learning_ai_invt_trdg/backend/src/services/executionManager.ts

398 lines
17 KiB
TypeScript

import { config } from '../config/index.js';
import { IExchangeConnector } from '../connectors/types.js';
import { RiskEngine, RiskProfile } from './riskEngine.js';
import { MarketContext, RuleResult, SignalDirection } from '../strategies/rules/types.js';
import logger from '../utils/logger.js';
import { Notifier } from './notifier.js';
import { ApiServer } from './apiServer.js';
import { SymbolMapper } from '../utils/symbolMapper.js';
import * as runtimeOrderRepository from './runtimeOrderRepository.js';
let deprecationWarned = false;
/**
* @deprecated Legacy execution manager retained only for backward compatibility.
* Use TradeExecutor + AutoTrader for all new execution flows.
*/
export class ExecutionManager {
private riskEngine: RiskEngine;
private notifier: Notifier;
private activeTraders: Map<string, any> = new Map(); // Symbol -> Position Details
private cooldowns: Map<string, number> = new Map(); // Symbol -> Timestamp of last exit
constructor(
private exchange: IExchangeConnector,
private apiServer?: ApiServer,
private userId: string = 'global' // Pass 'global' or user UUID
) {
const allowLegacy = String(process.env.ALLOW_LEGACY_EXECUTION_MANAGER || '').trim().toLowerCase() === 'true';
if (!allowLegacy) {
throw new Error(
'[ExecutionManager] CRITICAL: This class is DEPRECATED and disabled by default. ' +
'Set ALLOW_LEGACY_EXECUTION_MANAGER=true only for controlled test tooling. ' +
'Use TradeExecutor and AutoTrader for runtime flows.'
);
}
this.riskEngine = new RiskEngine();
this.notifier = new Notifier();
if (!deprecationWarned) {
logger.warn('[ExecutionManager] Legacy execution manager enabled for compatibility/testing only.');
deprecationWarned = true;
}
}
/**
* Attempts to execute a trade based on a strategy result.
*/
/**
* Attempts to execute a trade based on a strategy result.
*/
public async handleSignal(symbol: string, result: RuleResult, context: MarketContext) {
if (!config.ENABLE_TRADING) {
logger.info(`[Execution] 💡 Trading is DISABLED. Alert only for ${symbol}.`);
return;
}
const activePos = this.activeTraders.get(symbol);
// --- EXIT LOGIC (If in trade) ---
if (activePos) {
// 1. Exit on Signal Flip (Trend Reversal)
const isOpposite = (activePos.side === SignalDirection.BUY && result.signal === SignalDirection.SELL) ||
(activePos.side === SignalDirection.SELL && result.signal === SignalDirection.BUY);
if (isOpposite) {
await this.executeExit(symbol, context.currentPrice, 'Strategy Signal Flip');
return;
}
// 2. Exit on Trend Dissipation (Neutral after Profit Target)
const profitPercent = ((context.currentPrice - activePos.entryPrice) / activePos.entryPrice) * 100 * (activePos.side === SignalDirection.BUY ? 1 : -1);
if (profitPercent >= 1.0 && result.signal === SignalDirection.NONE) {
await this.executeExit(symbol, context.currentPrice, 'Trend Neutralized (Profit Target Met)');
return;
}
// Update price peaks for trailing logic
if (activePos.side === SignalDirection.BUY) {
activePos.peakPrice = Math.max(activePos.peakPrice || 0, context.currentPrice);
} else {
activePos.peakPrice = Math.min(activePos.peakPrice || 999999, context.currentPrice);
}
return; // Still in valid trade
}
if (result.signal === SignalDirection.NONE || !result.passed) {
return;
}
// --- ENTRY LOGIC ---
// 1. Check Global Max Open Trades Limit
if (this.activeTraders.size >= config.MAX_OPEN_TRADES) {
logger.info(`[Execution] 🛑 Max open trades reached (${config.MAX_OPEN_TRADES}). Skipping entry for ${symbol}.`);
return;
}
// 2. Check Cooldown (Default 1 hour after exit)
const lastExit = this.cooldowns.get(symbol) || 0;
const cooldownPeriod = 3600000; // 1 Hour
if (Date.now() - lastExit < cooldownPeriod) {
logger.info(`[Execution] 💤 ${symbol} is in cooldown. Skipping entry.`);
return;
}
// 3. Check for existing position on exchange (Safety Lock)
try {
const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER);
const position = await this.exchange.getPosition(tradeSymbol);
if (position) {
logger.warn(`[Execution] ⚠️ Found existing position on exchange for ${symbol} (Mapped: ${tradeSymbol}). Locking local state.`);
this.activeTraders.set(symbol, {
side: position.side?.toUpperCase(),
entryPrice: parseFloat(position.avg_entry_price),
size: parseFloat(position.qty),
stopLoss: 0,
takeProfit: 0,
peakPrice: parseFloat(position.avg_entry_price)
});
return;
}
} catch (e) {
logger.error(`[Execution] Error checking position for ${symbol}: ${e}`);
}
// 4. Calculate Risk-Adjusted Position
const riskProfile = await this.riskEngine.calculateRiskProfile(symbol, result.signal as SignalDirection, context);
if (!riskProfile) {
logger.warn(`[Execution] ❌ Failed to calculate risk profile for ${symbol}.`);
return;
}
// 5. Place Order
try {
const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER);
logger.info(`[Execution] 🚀 Executing ${riskProfile.action} for ${symbol} (Mapped: ${tradeSymbol})...`);
logger.info(`[Execution] Details: Qty=${riskProfile.positionSize.toFixed(4)}, SL=${riskProfile.stopLoss.toFixed(2)}, TP Trigger=${riskProfile.takeProfit.toFixed(2)}`);
const order = await this.exchange.placeOrder(
tradeSymbol,
riskProfile.action === SignalDirection.BUY ? 'buy' : 'sell',
riskProfile.positionSize,
'market',
undefined,
undefined,
undefined
);
if (order) {
const posData = {
side: riskProfile.action,
entryPrice: context.currentPrice,
size: riskProfile.positionSize,
stopLoss: riskProfile.stopLoss,
takeProfit: riskProfile.takeProfit, // Trigger for profit guard
peakPrice: context.currentPrice
};
this.activeTraders.set(symbol, posData);
// Log order to database
if (this.userId !== 'global') {
runtimeOrderRepository.logOrder({
user_id: this.userId,
order_id: order.id || undefined,
symbol,
type: 'Market',
side: riskProfile.action,
qty: riskProfile.positionSize,
price: context.currentPrice,
status: 'Filled',
timestamp: Date.now()
});
}
// Update Dashboard Orders
if (this.apiServer) {
this.apiServer.updateOrders([{
id: Math.random().toString(36).substring(7),
symbol: symbol,
type: 'Market',
side: riskProfile.action,
qty: riskProfile.positionSize,
price: context.currentPrice,
status: 'Filled',
timestamp: Date.now()
}]);
}
await this.notifier.sendAlert(`🚀 **TRADE EXECUTED**\nSymbol: ${symbol}\nAction: ${riskProfile.action}\nQty: ${riskProfile.positionSize.toFixed(4)}\nSL: ${riskProfile.stopLoss.toFixed(2)}\nMonitoring for 1%+ Runs...`);
}
} catch (error) {
logger.error(`[Execution] ❌ Failed to execute trade for ${symbol}: ${error}`);
await this.notifier.sendAlert(`❌ **EXECUTION FAILED**\nSymbol: ${symbol}\nError: ${error}`);
}
}
/**
* Forcefully exits a trade (Market Order).
*/
public async executeExit(symbol: string, currentPrice: number, reason: string = 'Target Reached') {
const pos = this.activeTraders.get(symbol);
if (!pos) return;
try {
const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER);
logger.info(`[Execution] 🚪 EXITING ${symbol} (Mapped: ${tradeSymbol}) | Reason: ${reason} | Price: ${currentPrice}`);
const order = await this.exchange.placeOrder(
tradeSymbol,
pos.side === SignalDirection.BUY ? 'sell' : 'buy',
pos.size,
'market',
undefined,
undefined,
undefined
);
if (order) {
await this.notifier.sendAlert(`🚪 **TRADE CLOSED**\nSymbol: ${symbol}\nReason: ${reason}\nPrice: ${currentPrice}`);
this.markTradeComplete(symbol, currentPrice, reason);
}
} catch (error) {
logger.error(`[Execution] ❌ Failed to execute exit for ${symbol}: ${error}`);
await this.notifier.sendAlert(`❌ **EXIT FAILED**\nSymbol: ${symbol}\nError: ${error}`);
}
}
/**
* Call this when a trade exit is detected (via TradeMonitor or Manual)
*/
public markTradeComplete(symbol: string, exitPrice?: number, reason: string = 'Target Reached') {
const pos = this.activeTraders.get(symbol);
if (pos && exitPrice && this.apiServer) {
const pnl = (exitPrice - pos.entryPrice) * pos.size * (pos.side === SignalDirection.BUY ? 1 : -1);
const pnlPercent = ((exitPrice - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1);
this.apiServer.addHistory({
symbol,
side: pos.side,
entryPrice: pos.entryPrice,
exitPrice,
size: pos.size,
pnl,
pnlPercent,
reason: reason,
timestamp: Date.now()
});
}
if (pos && exitPrice) {
const pnl = (exitPrice - pos.entryPrice) * pos.size * (pos.side === SignalDirection.BUY ? 1 : -1);
const pnlPercent = ((exitPrice - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1);
// Log to Supabase
if (this.userId !== 'global') {
runtimeOrderRepository.logTransaction({
user_id: this.userId,
symbol,
side: pos.side,
entry_price: pos.entryPrice,
exit_price: exitPrice,
size: pos.size,
pnl,
pnl_percent: pnlPercent,
reason,
timestamp: Date.now()
});
}
}
this.activeTraders.delete(symbol);
this.cooldowns.set(symbol, Date.now());
logger.info(`[Execution] ✅ Trade complete for ${symbol}. Cooldown started.`);
}
/**
* Synchronizes local state with exchange positions (Startup Recovery)
*/
public async syncPositions(symbols: string[]) {
logger.info(`[Execution] 🔄 Synchronizing positions on startup for ${symbols.length} symbols...`);
for (const symbol of symbols) {
try {
const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER);
logger.info(`[Execution] Checking exchange for ${symbol} (Mapped: ${tradeSymbol})...`);
const position = await this.exchange.getPosition(tradeSymbol);
if (position) {
const side = position.side?.toLowerCase();
const finalSide = (side === 'long' || side === 'buy') ? SignalDirection.BUY : SignalDirection.SELL;
logger.info(`[Execution] ✅ Found existing ${side} position for ${symbol}. Recovering as ${finalSide} | Price: ${position.avg_entry_price} | Qty: ${position.qty}`);
this.activeTraders.set(symbol, {
side: finalSide,
entryPrice: parseFloat(position.avg_entry_price),
size: parseFloat(position.qty),
stopLoss: 0, // Recalculated locally during loop
takeProfit: 0,
peakPrice: parseFloat(position.avg_entry_price)
});
} else {
logger.info(`[Execution] No active position found for ${symbol} on exchange.`);
}
} catch (e) {
logger.error(`[Execution] Failed to sync ${symbol}: ${e}`);
}
}
}
public isSymbolLocked(symbol: string): boolean {
return this.activeTraders.has(symbol);
}
public getActiveSymbols(): string[] {
return Array.from(this.activeTraders.keys());
}
/**
* Executes a manual trade triggered via API/Dashboard.
*/
public async executeManualTrade(symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit' = 'market', price?: number) {
logger.info(`[Execution] 🖐️ Manual Trade Request: ${side.toUpperCase()} ${symbol} | Qty: ${qty} | Type: ${type}`);
try {
const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER);
// Check for existing position if opening a new one
// (Optional: You might want to allow adding to a position manually, so we won't strictly block it, but we update tracking)
const order = await this.exchange.placeOrder(
tradeSymbol,
side,
qty,
type,
price,
undefined,
undefined,
undefined
);
if (order) {
const signalSide = side === 'buy' ? SignalDirection.BUY : SignalDirection.SELL;
// If this is an ENTRY or Adding to position
const currentPrice = price || order.filled_avg_price || 0; // Fallback if market order filling isn't instant
// Update local tracking
this.activeTraders.set(symbol, {
side: signalSide,
entryPrice: currentPrice, // Note: This might overwrite existing average entry price tracking, effectively averaging it loosely
size: qty, // Simplified: In a real system we'd sum it up
stopLoss: 0,
takeProfit: 0,
peakPrice: currentPrice
});
// Log to Supabase
if (this.userId !== 'global') {
runtimeOrderRepository.logOrder({
user_id: this.userId,
order_id: order.id,
symbol,
type: type === 'market' ? 'Market' : 'Limit',
side: signalSide,
qty: qty,
price: currentPrice,
status: 'Filled', // Simplified assumption for market orders
timestamp: Date.now()
});
}
// Update Dashboard
if (this.apiServer) {
this.apiServer.updateOrders([{
id: order.id || Math.random().toString(36).substring(7),
symbol: symbol,
type: type === 'market' ? 'Market' : 'Limit',
side: signalSide,
qty: qty,
price: currentPrice,
status: order.repliesstatus || 'Filled',
timestamp: Date.now()
}]);
}
await this.notifier.sendAlert(`🖐️ **MANUAL TRADE EXECUTED**\nSymbol: ${symbol}\nSide: ${side.toUpperCase()}\nQty: ${qty}`);
return { success: true, orderId: order.id };
}
} catch (error: any) {
logger.error(`[Execution] ❌ Manual Execution Failed: ${error.message}`);
return { success: false, error: error.message };
}
}
public getActivePosition(symbol: string): any {
return this.activeTraders.get(symbol) || null;
}
}