learning_ai_invt_trdg/backend/src/services/AutoTrader.ts

370 lines
15 KiB
TypeScript

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> | 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<AutoTraderExecutionOutcome> {
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<PortfolioGuardResult> {
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 };
}
}