import { config } from '../config/index.js'; import { MarketContext, RuleResult, SignalDirection } from '../strategies/rules/types.js'; import { RiskEngine } from './riskEngine.js'; import { TradeExecutor } from './TradeExecutor.js'; import logger from '../utils/logger.js'; import { SymbolMapper } from '../utils/symbolMapper.js'; import { IExchangeConnector } from '../connectors/types.js'; import { healthTracker } from './healthTracker.js'; import { getProfileConsecutiveLosses, getProfileDailyLossUsd, getProfileDailyNetPnlUsd, } from './tradeHistoryRepository.js'; export interface PortfolioGuardInput { symbol: string; profileSettings?: any; signal?: SignalDirection; } export interface PortfolioGuardResult { allowed: boolean; reason?: string; } export type AutoTraderExecutionStatus = 'EXECUTED' | 'BLOCKED' | 'SKIPPED'; export interface AutoTraderExecutionOutcome { status: AutoTraderExecutionStatus; code: string; reason: string; orderId?: string; } export class AutoTrader { private riskEngine: RiskEngine; constructor( private executor: TradeExecutor, private exchange: IExchangeConnector, // Needed for pre-check private portfolioGuard?: (input: PortfolioGuardInput) => Promise | PortfolioGuardResult ) { this.riskEngine = new RiskEngine(); } private outcome( status: AutoTraderExecutionStatus, code: string, reason: string, orderId?: string ): AutoTraderExecutionOutcome { return { status, code, reason, orderId }; } private evaluateProfileSymbolScope(symbol: string, profileSettings?: any): { allowed: boolean; reason?: string } { if (!profileSettings || profileSettings.symbols === undefined || profileSettings.symbols === null) { return { allowed: true }; } const rawSymbols = Array.isArray(profileSettings.symbols) ? profileSettings.symbols : String(profileSettings.symbols).split(','); const allowedSymbols = rawSymbols .map((value: string) => String(value).trim()) .filter(Boolean); if (!allowedSymbols.length) { return { allowed: false, reason: 'Profile watchlist is empty.' }; } const executionSymbol = SymbolMapper.toExecutionScopeSymbol(symbol, config.EXECUTION_PROVIDER); const normalizedAllowed = allowedSymbols.map((item: string) => SymbolMapper.toExecutionScopeSymbol(item, config.EXECUTION_PROVIDER) ); if (normalizedAllowed.includes(executionSymbol)) { return { allowed: true }; } return { allowed: false, reason: `${symbol} is outside profile watchlist (${allowedSymbols.join(', ')})` }; } public async handleSignal( symbol: string, result: RuleResult, context: MarketContext, profileSettings?: any ): Promise { if (!config.ENABLE_TRADING) { logger.info(`[AutoTrader] Trading disabled. Alert-only mode for ${symbol}.`); return this.outcome('SKIPPED', 'trading_disabled', 'Trading is disabled globally.'); } // --- Profile Level Filtering --- const scopeCheck = this.evaluateProfileSymbolScope(symbol, profileSettings); if (!scopeCheck.allowed) { return this.outcome( 'BLOCKED', 'symbol_not_in_profile_scope', scopeCheck.reason || `Symbol ${symbol} not allowed by profile watchlist.` ); } const activePositions = this.executor.getActivePositions(symbol); const hasActivePositions = activePositions.length > 0; // --- EXIT LOGIC --- if (hasActivePositions) { const hasOpposite = activePositions.some((pos) => (pos.side === SignalDirection.BUY && result.signal === SignalDirection.SELL) || (pos.side === SignalDirection.SELL && result.signal === SignalDirection.BUY) ); if (hasOpposite) { for (const pos of activePositions) { await this.executor.closePosition(symbol, 'Strategy Signal Flip', pos.tradeId); } return this.outcome( 'EXECUTED', 'exit_on_signal_flip', `Closed ${activePositions.length} position(s) on signal flip.` ); } const profitExitThreshold = profileSettings?.strategy_config?.execution?.profitExitPercent ?? config.PROFIT_EXIT_PERCENT; let closedForNeutral = 0; for (const activePos of activePositions) { const profitPercent = ((context.currentPrice - activePos.entryPrice) / activePos.entryPrice) * 100 * (activePos.side === SignalDirection.BUY ? 1 : -1); if (profitPercent >= profitExitThreshold && result.signal === SignalDirection.NONE) { await this.executor.closePosition(symbol, `Trend Neutralized (>${profitExitThreshold}% profit)`, activePos.tradeId); closedForNeutral += 1; } } if (closedForNeutral > 0) { return this.outcome( 'EXECUTED', 'exit_on_neutral_profit', `Closed ${closedForNeutral} position(s) on neutralized trend profit exit.` ); } for (const activePos of activePositions) { if (activePos.side === SignalDirection.BUY) { activePos.peakPrice = Math.max(activePos.peakPrice, context.currentPrice); } else { activePos.peakPrice = Math.min(activePos.peakPrice, context.currentPrice); } } const allowPyramiding = profileSettings?.strategy_config?.execution?.allowPyramiding !== false; if (!allowPyramiding) { return this.outcome( 'SKIPPED', 'pyramiding_disabled', 'Active position exists and pyramiding is disabled.' ); } const hasSameDirectionExposure = activePositions.some((pos) => pos.side === result.signal); if (!hasSameDirectionExposure) { return this.outcome( 'SKIPPED', 'active_position_no_same_direction', 'Active position exists in opposite direction; waiting for flip/exit handling.' ); } } // --- ENTRY LOGIC --- if (result.signal === SignalDirection.NONE || !result.passed) { return this.outcome( 'SKIPPED', 'no_actionable_signal', result.reason || 'No actionable entry signal.' ); } if (healthTracker.isPaused()) { logger.info(`[AutoTrader] Entry blocked for ${symbol}: bot is paused by control plane.`); return this.outcome('BLOCKED', 'trading_paused', 'Trading is paused by control plane.'); } const entryMode = this.resolveEntryMode(profileSettings); if (entryMode === 'long_only' && result.signal === SignalDirection.SELL) { logger.info(`[AutoTrader] Entry blocked for ${symbol}: profile is long_only and signal is SELL.`); return this.outcome('BLOCKED', 'long_only_sell_block', 'Profile is long_only; SELL entries are blocked.'); } if (this.portfolioGuard) { const portfolioCheck = await this.portfolioGuard({ symbol, profileSettings, signal: result.signal }); if (!portfolioCheck.allowed) { logger.info(`[AutoTrader] Portfolio guard blocked ${symbol}: ${portfolioCheck.reason || 'guard rejection'}`); return this.outcome( 'BLOCKED', 'portfolio_guard_blocked', portfolioCheck.reason || 'Portfolio guard rejected entry.' ); } } // 1. Checks // Max open trades: per-profile > global config const maxOpenTrades = profileSettings?.strategy_config?.riskLimits?.maxOpenTrades ?? config.MAX_OPEN_TRADES; if (this.executor.getOpenPositionCount() >= maxOpenTrades) { logger.info(`[AutoTrader] Max open trades reached (${maxOpenTrades}).`); return this.outcome( 'BLOCKED', 'max_open_trades_reached', `Max open trades reached (${maxOpenTrades}).` ); } // Cooldown: per-profile > global config const cooldownMs = profileSettings?.strategy_config?.execution?.cooldownMinutes ? profileSettings.strategy_config.execution.cooldownMinutes * 60_000 : config.COOLDOWN_MS; if (this.executor.checkCooldown(symbol, cooldownMs)) { return this.outcome('BLOCKED', 'cooldown_active', 'Symbol is in cooldown window.'); } const riskLimitGuard = await this.checkRuntimeRiskLimits(profileSettings); if (!riskLimitGuard.allowed) { logger.warn(`[AutoTrader] Runtime risk guard blocked ${symbol}: ${riskLimitGuard.reason || 'risk limit exceeded'}`); return this.outcome( 'BLOCKED', 'runtime_risk_limit_blocked', riskLimitGuard.reason || 'Runtime risk limit exceeded.' ); } // 2. Safety Lock (Exchange Check) const profileId = this.executor.getProfileId(); const isDedicatedProfileScope = !!profileId && profileId !== 'global' && !profileId.startsWith('default-'); try { const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); const position = await this.exchange.getPosition(tradeSymbol); if (position && !isDedicatedProfileScope) { logger.warn(`[AutoTrader] Existing position found on exchange for ${symbol}. Syncing.`); await this.executor.syncPositions([symbol]); return this.outcome( 'BLOCKED', 'exchange_position_already_open', 'Exchange already has an open position; synced local state and skipped entry.' ); } if (position && isDedicatedProfileScope) { logger.info(`[AutoTrader] Exchange already has ${symbol}. Continuing with profile-scoped virtual sub-position flow for ${profileId}.`); } } catch (e) { logger.error(`[AutoTrader] Position check failed for ${symbol}. Skipping entry to avoid duplicate exposure.`, e); return this.outcome( 'BLOCKED', 'exchange_position_check_failed', 'Exchange position check failed; entry skipped to avoid duplicate exposure.' ); } // 3. Risk Calculation (PASS PROFILE OVERRIDES + estimated available capital) const allocatedCapital = profileSettings?.allocated_capital || config.TOTAL_CAPITAL; const committedCapital = Array.from(this.executor.getAllPositions().values()) .reduce((sum, pos) => sum + (pos.size * pos.entryPrice), 0); const availableCapital = Math.max(0, allocatedCapital - committedCapital); const riskProfile = await this.riskEngine.calculateRiskProfile( symbol, result.signal as SignalDirection, context, profileSettings, availableCapital ); if (!riskProfile) { return this.outcome('BLOCKED', 'risk_profile_unavailable', 'Risk profile calculation returned no executable sizing.'); } // 4. Execute const execution = await this.executor.openPosition( symbol, riskProfile.action, riskProfile.positionSize, 'market', context.currentPrice, riskProfile.stopLoss, riskProfile.takeProfit, profileSettings?.user_id // Ensure order is attributed to the user/profile owner ); if (!execution.success) { return this.outcome( 'BLOCKED', 'executor_rejected_entry', execution.error || 'Trade executor rejected entry.' ); } return this.outcome( 'EXECUTED', 'entry_submitted', 'Entry submitted to execution engine.', execution.orderId ); } private resolveEntryMode(profileSettings?: any): 'both' | 'long_only' { const execution = profileSettings?.strategy_config?.execution; const rawEntryMode = String( execution?.entryMode ?? (execution?.longOnly ? 'long_only' : 'both') ).toLowerCase(); if (rawEntryMode === 'long_only' || rawEntryMode === 'longonly' || rawEntryMode === 'buy_only') { return 'long_only'; } return 'both'; } private async checkRuntimeRiskLimits(profileSettings?: any): Promise { const profileId = this.executor.getProfileId(); if (!profileId || profileId === 'global' || profileId.startsWith('default-')) { return { allowed: true }; } const riskLimits = profileSettings?.strategy_config?.riskLimits; if (!riskLimits) { return { allowed: true }; } const maxDailyLossUsd = Number(riskLimits.maxDailyLossUsd); if (Number.isFinite(maxDailyLossUsd) && maxDailyLossUsd > 0) { const dailyLossUsd = await getProfileDailyLossUsd(profileId); if (dailyLossUsd >= maxDailyLossUsd) { return { allowed: false, reason: `maxDailyLossUsd reached (${dailyLossUsd.toFixed(2)} / ${maxDailyLossUsd.toFixed(2)})` }; } } const dailyProfitTargetUsd = Number(riskLimits.dailyProfitTargetUsd); if (Number.isFinite(dailyProfitTargetUsd) && dailyProfitTargetUsd > 0) { const dailyNetPnl = await getProfileDailyNetPnlUsd(profileId); if (dailyNetPnl >= dailyProfitTargetUsd) { return { allowed: false, reason: `Daily profit target reached ($${dailyNetPnl.toFixed(2)} / $${dailyProfitTargetUsd.toFixed(2)})` }; } } const maxConsecutiveLosses = Number(riskLimits.maxConsecutiveLosses); if (Number.isFinite(maxConsecutiveLosses) && maxConsecutiveLosses > 0) { const consecutiveLosses = await getProfileConsecutiveLosses(profileId, 100); if (consecutiveLosses >= maxConsecutiveLosses) { return { allowed: false, reason: `maxConsecutiveLosses reached (${consecutiveLosses} / ${maxConsecutiveLosses})` }; } } return { allowed: true }; } }