370 lines
15 KiB
TypeScript
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 };
|
|
}
|
|
}
|