import assert from 'node:assert/strict'; import { Indicators } from '../src/utils/indicators.js'; import { DirectionTracker, SignalType } from '../src/strategies/directionTracker.js'; import { TrendBiasRule } from '../src/strategies/rules/TrendBiasRule.js'; import { MomentumRule } from '../src/strategies/rules/MomentumRule.js'; import { ZoneRule } from '../src/strategies/rules/ZoneRule.js'; import { SessionRule } from '../src/strategies/rules/SessionRule.js'; import { EntryTriggerRule } from '../src/strategies/rules/EntryTriggerRule.js'; import { RiskManagementRule } from '../src/strategies/rules/RiskManagementRule.js'; import { AIAnalysisRule } from '../src/strategies/rules/AIAnalysisRule.js'; import { ProStrategyEngine } from '../src/strategies/ProStrategyEngine.js'; import { SignalDirection, type MarketContext, type RuleResult } from '../src/strategies/rules/types.js'; import { config } from '../src/config/index.js'; import { RiskEngine } from '../src/services/riskEngine.js'; import { AutoTrader } from '../src/services/AutoTrader.js'; import { ExecutionManager } from '../src/services/executionManager.js'; import { supabaseService } from '../src/services/SupabaseService.js'; import { metrics, MetricsService } from '../src/services/MetricsService.js'; const makeCandle = (timestamp: number, open: number, high: number, low: number, close: number, volume: number = 100) => ({ timestamp, open, high, low, close, volume }); const buildSeries = (count: number, start: number, step: number) => { const out: Array> = []; for (let i = 0; i < count; i++) { const price = start + (step * i); out.push(makeCandle(1700000000000 + (i * 3600000), price - 1, price + 2, price - 2, price, 100 + i)); } return out; }; const makeContext = (overrides: Partial = {}): MarketContext => { const candles1h = overrides.candles1h || buildSeries(120, 100, 1); const candles4h = overrides.candles4h || buildSeries(320, 80, 1.5); const candles15m = overrides.candles15m || buildSeries(120, 102, 0.4); return { symbol: overrides.symbol || 'BTC/USD', candles4h, candles1h, candles15m, currentPrice: overrides.currentPrice ?? candles15m[candles15m.length - 1].close, change24h: overrides.change24h ?? 1.2, changeToday: overrides.changeToday ?? 0.5, session: overrides.session || 'LDN|NY', isMajorSession: overrides.isMajorSession ?? true, volatility: overrides.volatility || 'High', latestSignal: overrides.latestSignal || SignalDirection.NONE, ema20_1h: overrides.ema20_1h ?? 150, ema20_15m: overrides.ema20_15m ?? 149, ema50_4h: overrides.ema50_4h ?? 140, ema200_4h: overrides.ema200_4h ?? 120, rsi_1h: overrides.rsi_1h ?? 58, rsi_15m: overrides.rsi_15m ?? 56 }; }; async function testIndicatorsAndDirectionTracker() { assert.equal(Indicators.calculateEMA([], 10), 0, 'EMA should handle empty arrays'); assert.equal(Indicators.calculateRSI([1, 2, 3], 14), 50, 'RSI should return fallback for short series'); assert.equal(Indicators.calculateATR([], 14), 0, 'ATR should return 0 when candles are insufficient'); const atrCandles = buildSeries(20, 100, 1); const atr = Indicators.calculateATR(atrCandles, 14); assert(atr > 0, 'ATR should compute positive value for valid candles'); const tracker = new DirectionTracker(); const shortSeries = tracker.calculateDirection(buildSeries(10, 100, 0.2)); assert.equal(shortSeries.signal, SignalType.NONE); assert.equal(shortSeries.changed, false); const fromCloses = (closes: number[]) => closes.map((close, idx) => makeCandle(1700000000000 + (idx * 60000), close - 1, close + 1, close - 2, close, 100) ); const buySeries = tracker.calculateDirection(fromCloses([ 100, 99, 101, 100, 102, 101, 103, 102, 104, 103, 105, 104, 106, 105, 107, 106, 108, 107, 109, 108, 110, 109, 111, 110, 112, 111, 113, 112, 114, 113 ])); assert.equal(buySeries.signal, SignalType.BUY); assert.equal(buySeries.changed, true); const duplicateBuy = tracker.calculateDirection(fromCloses([ 102, 101, 103, 102, 104, 103, 105, 104, 106, 105, 107, 106, 108, 107, 109, 108, 110, 109, 111, 110, 112, 111, 113, 112, 114, 113, 115, 114, 116, 115 ])); assert.equal(duplicateBuy.signal, SignalType.BUY); assert.equal(duplicateBuy.changed, false, 'State machine should suppress duplicate signal transitions'); const sellSeries = tracker.calculateDirection(fromCloses([ 200, 201, 199, 200, 198, 199, 197, 198, 196, 197, 195, 196, 194, 195, 193, 194, 192, 193, 191, 192, 190, 191, 189, 190, 188, 189, 187, 188, 186, 187 ])); assert.equal(sellSeries.signal, SignalType.SELL); assert.equal(sellSeries.changed, true); } async function testRules() { const trendRule = new TrendBiasRule(); const trendInvalid = await trendRule.check(makeContext(), { fastPeriod: 200, slowPeriod: 50 }); assert.equal(trendInvalid.passed, false); const trendInsufficient = await trendRule.check(makeContext({ candles4h: buildSeries(10, 80, 1) })); assert.equal(trendInsufficient.passed, false); const trendBuy = await trendRule.check(makeContext({ currentPrice: 600, ema50_4h: 500, ema200_4h: 400 })); assert.equal(trendBuy.signal, SignalDirection.BUY); const trendSell = await trendRule.check(makeContext({ currentPrice: 60, candles4h: buildSeries(320, 500, -1) })); assert.equal(trendSell.signal, SignalDirection.SELL); const momentumRule = new MomentumRule(); const momentumShort = await momentumRule.check( makeContext({ candles1h: buildSeries(5, 100, 1), candles15m: buildSeries(5, 100, 1) }), { timeframe: '1h', rsiPeriod: 20 } ); assert.equal(momentumShort.passed, false); const momentumBuySeries = [ 100, 99, 101, 100, 102, 101, 103, 102, 104, 103, 105, 104, 106, 105, 107, 106, 108, 107, 109, 108, 110, 109, 111, 110, 112, 111, 113, 112, 114, 113, 115, 114, 116, 115, 117, 116, 118, 117, 119, 118 ].map((close, idx) => makeCandle(1700000000000 + (idx * 60000), close - 1, close + 1, close - 2, close, 100)); const momentumBuy = await momentumRule.check(makeContext({ isMajorSession: true, candles1h: momentumBuySeries }), { timeframe: '1h' }); assert.equal(momentumBuy.signal, SignalDirection.BUY); const momentumSellSeries = [ 200, 201, 199, 200, 198, 199, 197, 198, 196, 197, 195, 196, 194, 195, 193, 194, 192, 193, 191, 192, 190, 191, 189, 190, 188, 189, 187, 188, 186, 187, 185, 186, 184, 185, 183, 184, 182, 183, 181, 182 ].map((close, idx) => makeCandle(1700000000000 + (idx * 60000), close - 1, close + 1, close - 2, close, 100)); const momentumSell = await momentumRule.check(makeContext({ isMajorSession: false, candles1h: momentumSellSeries }), { timeframe: '1h' }); assert.equal(momentumSell.signal, SignalDirection.SELL); const zoneRule = new ZoneRule(); const zoneNoEma = await zoneRule.check(makeContext({ ema20_15m: 0 }), { timeframe: '15m' }); assert.equal(zoneNoEma.passed, false); const zonePass = await zoneRule.check(makeContext({ currentPrice: 101, ema20_1h: 100 }), { timeframe: '1h', zonePercent: 2 }); assert.equal(zonePass.passed, true); const zoneFail = await zoneRule.check(makeContext({ currentPrice: 110, ema20_1h: 100 }), { timeframe: '1h', zonePercent: 1 }); assert.equal(zoneFail.passed, false); const sessionRule = new SessionRule(); const sessionPass = await sessionRule.check(makeContext({ session: 'LDN|TOK', isMajorSession: true }), { sessions: 'LDN,NY' }); assert.equal(sessionPass.passed, true); const sessionFail = await sessionRule.check(makeContext({ session: 'TOK', isMajorSession: false }), { sessions: 'NY' }); assert.equal(sessionFail.passed, false); const sessionOff = await sessionRule.check(makeContext({ session: 'OFF', isMajorSession: false })); assert.equal(sessionOff.passed, false); const entryRule = new EntryTriggerRule(); const entryShort = await entryRule.check(makeContext({ candles1h: buildSeries(2, 100, 1) })); assert.equal(entryShort.passed, false); const entryNoEma = await entryRule.check(makeContext({ ema20_1h: 0 }), { timeframe: '1h' }); assert.equal(entryNoEma.passed, false); const bullishReclaimCtx = makeContext({ candles1h: [ makeCandle(1, 100, 101, 99, 99), makeCandle(2, 99, 101, 98, 98), makeCandle(3, 98, 105, 97, 104) ], ema20_1h: 100 }); const bullishReclaim = await entryRule.check(bullishReclaimCtx, { timeframe: '1h' }); assert.equal(bullishReclaim.signal, SignalDirection.BUY); const bearishReclaimCtx = makeContext({ candles1h: [ makeCandle(1, 100, 105, 99, 104), makeCandle(2, 104, 106, 102, 105), makeCandle(3, 105, 106, 95, 96) ], ema20_1h: 100 }); const bearishReclaim = await entryRule.check(bearishReclaimCtx, { timeframe: '1h', enableEmaReclaim: true, enableWickRejection: false }); assert.equal(bearishReclaim.signal, SignalDirection.SELL); const riskRule = new RiskManagementRule(); const riskShort = await riskRule.check(makeContext({ candles1h: buildSeries(10, 100, 1) })); assert.equal(riskShort.passed, false); const riskPass = await riskRule.check(makeContext(), { maxRisk: 10 }); assert.equal(riskPass.passed, true); const riskFail = await riskRule.check(makeContext({ currentPrice: 1, candles1h: buildSeries(120, 1, 1) }), { maxRisk: 0.1 }); assert.equal(riskFail.passed, false); } async function testAIAnalysisRule() { const aiRule = new AIAnalysisRule(); const aiRuleAsAny = aiRule as any; const originalFailOpen = config.AI.FAIL_OPEN; try { aiRuleAsAny.aiClient.generateAnalysis = async () => null; config.AI.FAIL_OPEN = true; const failOpenRes = await aiRule.check(makeContext()); assert.equal(failOpenRes.passed, true); assert.equal(failOpenRes.metadata.ai_fail_open, true); config.AI.FAIL_OPEN = false; const failClosedRes = await aiRule.check(makeContext()); assert.equal(failClosedRes.passed, false); aiRuleAsAny.aiClient.generateAnalysis = async () => '{"action":"BUY","confidence":0.82,"reasoning":"good"}'; config.AI.FAIL_OPEN = true; const parseOk = await aiRule.check(makeContext(), { minConfidence: 70 }); assert.equal(parseOk.passed, true); assert.equal(parseOk.signal, SignalDirection.BUY); const parsedInvalid = aiRuleAsAny.parseResponse('not-json'); assert.equal(parsedInvalid.action, 'HOLD'); const normalizedPct = aiRuleAsAny.normalizeConfidenceToPct(0.77); assert.equal(normalizedPct, 77); const normalizedClamped = aiRuleAsAny.normalizeConfidenceToPct(220); assert.equal(normalizedClamped, 100); const prompt = aiRuleAsAny.buildPrompt(makeContext()); assert(prompt.includes('Analyze the following Crypto Market Data')); } finally { config.AI.FAIL_OPEN = originalFailOpen; } } async function testRiskEngine() { const engine = new RiskEngine(); const noSignal = await engine.calculateRiskProfile('BTC/USD', SignalDirection.NONE, makeContext()); assert.equal(noSignal, null); const risk = await engine.calculateRiskProfile( 'BTC/USD', SignalDirection.BUY, makeContext(), { allocated_capital: 1000, risk_per_trade_percent: 1, strategy_config: { execution: { minQty: 0.001, maxQty: 5, qtyPrecision: 3, minNotionalUsd: 10, maxNotionalUsd: 10000 } } }, 500 ); assert(risk !== null); assert(risk!.positionSize > 0); const rejectMinQty = await engine.calculateRiskProfile( 'BTC/USD', SignalDirection.BUY, makeContext({ currentPrice: 50000 }), { allocated_capital: 100, risk_per_trade_percent: 0.1, strategy_config: { execution: { minQty: 5, maxQty: 10, qtyPrecision: 2, minNotionalUsd: 10, maxNotionalUsd: 1000 } } }, 5 ); assert.equal(rejectMinQty, null); } async function testProStrategyEngine() { const fakeExchange = { async fetchOHLCV(_symbol: string, timeframe: string) { if (timeframe === '4h') return buildSeries(320, 100, 1.2); if (timeframe === '1h') return buildSeries(140, 120, 0.8); return buildSeries(140, 121, 0.2); } } as any; const engine = new ProStrategyEngine(fakeExchange); const context = await engine.buildMarketContext('BTC/USD'); assert(context, 'buildMarketContext should return context with enough candles'); const fail = await engine.evaluateContext(makeContext(), [ { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 200, slowPeriod: 50 } } ]); assert.equal(fail?.passed, false); const pass = await engine.evaluateContext(makeContext({ currentPrice: 600, session: 'LDN|NY', isMajorSession: true }), [ { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, { ruleId: 'SessionRule', enabled: true, params: { sessions: 'LDN,NY' } }, { ruleId: 'AIAnalysisRule', enabled: false, params: {} } ]); assert(pass, 'evaluateContext should return a result object'); assert.equal(pass?.passed, true); assert(pass?.metadata?.ruleStatuses, 'Rule statuses should be included in pass response'); } async function testAutoTrader() { const originalEnableTrading = config.ENABLE_TRADING; const originalMaxOpen = config.MAX_OPEN_TRADES; const originalDailyLoss = (supabaseService as any).getProfileDailyLossUsd; const originalConsecutive = (supabaseService as any).getProfileConsecutiveLosses; const closeCalls: Array<{ symbol: string; reason: string; tradeId?: string }> = []; const openCalls: Array = []; const executor = { profileId: 'profile-1', positionsBySymbol: new Map(), checkCooldown: () => false, getOpenPositionCount: () => 0, getProfileId() { return this.profileId; }, getAllPositions: () => new Map(), getActivePositions(symbol: string) { return this.positionsBySymbol.get(symbol) || []; }, async closePosition(symbol: string, reason: string, tradeId?: string) { closeCalls.push({ symbol, reason, tradeId }); }, async openPosition(...args: any[]) { openCalls.push(args); return { success: true }; } } as any; const exchange = { async getPosition() { return null; } } as any; const trader = new AutoTrader(executor, exchange, async () => ({ allowed: true })); const resultBuy: RuleResult = { ruleName: 'x', passed: true, signal: SignalDirection.BUY }; const resultSell: RuleResult = { ruleName: 'x', passed: true, signal: SignalDirection.SELL }; try { (supabaseService as any).getProfileDailyLossUsd = async () => 0; (supabaseService as any).getProfileConsecutiveLosses = async () => 0; config.ENABLE_TRADING = false; await trader.handleSignal('BTC/USD', resultBuy, makeContext()); assert.equal(openCalls.length, 0); config.ENABLE_TRADING = true; await trader.handleSignal('BTC/USD', resultBuy, makeContext(), { symbols: 'ETH/USD' }); assert.equal(openCalls.length, 0, 'Symbol filtering should skip symbols not in profile list'); executor.positionsBySymbol.set('BTC/USD', [{ side: SignalDirection.BUY, entryPrice: 100, peakPrice: 100, tradeId: 't1' }]); await trader.handleSignal('BTC/USD', resultSell, makeContext({ currentPrice: 95 }), { strategy_config: { execution: { allowPyramiding: false } } }); assert.equal(closeCalls.length, 1, 'Opposite signal should close existing position'); executor.positionsBySymbol.clear(); await trader.handleSignal('BTC/USD', resultSell, makeContext(), { strategy_config: { execution: { entryMode: 'long_only' } } }); assert.equal(openCalls.length, 0, 'long_only should block SELL entries'); const blockedTrader = new AutoTrader(executor, exchange, async () => ({ allowed: false, reason: 'blocked' })); await blockedTrader.handleSignal('BTC/USD', resultBuy, makeContext()); assert.equal(openCalls.length, 0, 'Portfolio guard block should stop entries'); const maxOpenExecutor = { ...executor, getOpenPositionCount: () => 999 } as any; const maxOpenTrader = new AutoTrader(maxOpenExecutor, exchange); config.MAX_OPEN_TRADES = 1; await maxOpenTrader.handleSignal('BTC/USD', resultBuy, makeContext()); assert.equal(openCalls.length, 0, 'Max open trade guard should block entries'); const cooldownExecutor = { ...executor, checkCooldown: () => true } as any; const cooldownTrader = new AutoTrader(cooldownExecutor, exchange); await cooldownTrader.handleSignal('BTC/USD', resultBuy, makeContext()); assert.equal(openCalls.length, 0, 'Cooldown guard should block entries'); (supabaseService as any).getProfileDailyLossUsd = async () => 500; const riskBlocked = new AutoTrader(executor, exchange); await riskBlocked.handleSignal('BTC/USD', resultBuy, makeContext(), { strategy_config: { riskLimits: { maxDailyLossUsd: 100, maxConsecutiveLosses: 5 } } }); assert.equal(openCalls.length, 0, 'Runtime risk guard should block entries when daily loss breached'); (supabaseService as any).getProfileDailyLossUsd = async () => 0; (supabaseService as any).getProfileConsecutiveLosses = async () => 0; await trader.handleSignal('BTC/USD', resultBuy, makeContext(), { user_id: 'user-1', allocated_capital: 1000, risk_per_trade_percent: 1 }); assert.equal(openCalls.length, 1, 'Happy path should open new position'); } finally { config.ENABLE_TRADING = originalEnableTrading; config.MAX_OPEN_TRADES = originalMaxOpen; (supabaseService as any).getProfileDailyLossUsd = originalDailyLoss; (supabaseService as any).getProfileConsecutiveLosses = originalConsecutive; } } async function testExecutionManagerAndMetrics() { const originalEnableTrading = config.ENABLE_TRADING; const originalAllowLegacy = process.env.ALLOW_LEGACY_EXECUTION_MANAGER; const fakeExchange = { async getPosition() { return null; }, async placeOrder(_symbol: string, _side: 'buy' | 'sell', qty: number) { return { id: `ord-${qty}`, filled_avg_price: 123.45 }; } } as any; const apiServerMock = { updateOrders: (_orders: any[]) => undefined, addHistory: (_trade: any) => undefined } as any; try { process.env.ALLOW_LEGACY_EXECUTION_MANAGER = 'true'; config.ENABLE_TRADING = true; const manager = new ExecutionManager(fakeExchange, apiServerMock, 'global'); const ctx = makeContext({ currentPrice: 120 }); await manager.handleSignal('BTC/USD', { ruleName: 'r', passed: true, signal: SignalDirection.BUY }, ctx); assert.equal(manager.isSymbolLocked('BTC/USD'), true); await manager.handleSignal('BTC/USD', { ruleName: 'r', passed: true, signal: SignalDirection.SELL }, makeContext({ currentPrice: 130 })); assert.equal(manager.isSymbolLocked('BTC/USD'), false, 'Opposite signal should close and unlock symbol'); await manager.executeManualTrade('ETH/USD', 'buy', 1, 'market', 125); assert.equal(manager.getActivePosition('ETH/USD')?.side, SignalDirection.BUY); manager.markTradeComplete('ETH/USD', 130, 'manual test close'); assert.equal(manager.getActivePosition('ETH/USD'), null); const syncExchange = { async getPosition(symbol: string) { if (symbol.includes('SOL')) { return { side: 'long', avg_entry_price: '50', qty: '2' }; } return null; }, async placeOrder() { return { id: 'dummy' }; } } as any; const manager2 = new ExecutionManager(syncExchange, apiServerMock, 'global'); await manager2.syncPositions(['SOL/USD', 'BTC/USD']); assert.equal(manager2.isSymbolLocked('SOL/USD'), true); assert.equal(manager2.isSymbolLocked('BTC/USD'), false); // Metrics singleton calls metrics.operationalEventsTotal.labels({ severity: 'INFO', type: 'COVERAGE_TEST', profile_id: 'p1', symbol: 'BTC/USD' }).inc(); const svc = MetricsService.getInstance(); const exposition = await svc.getMetrics(); assert(exposition.includes('bytelyst_bot_operational_events_total')); assert.equal(typeof svc.getContentType(), 'string'); } finally { if (originalAllowLegacy === undefined) { delete process.env.ALLOW_LEGACY_EXECUTION_MANAGER; } else { process.env.ALLOW_LEGACY_EXECUTION_MANAGER = originalAllowLegacy; } config.ENABLE_TRADING = originalEnableTrading; } } async function run() { await testIndicatorsAndDirectionTracker(); await testRules(); await testAIAnalysisRule(); await testRiskEngine(); await testProStrategyEngine(); await testAutoTrader(); await testExecutionManagerAndMetrics(); console.log('[core-module-coverage] OK: core strategy, risk, trader, and legacy execution paths validated'); } await run();