learning_ai_invt_trdg/backend/testCoreModuleCoverage.ts

506 lines
22 KiB
TypeScript

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<ReturnType<typeof makeCandle>> = [];
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> = {}): 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<any> = [];
const executor = {
profileId: 'profile-1',
positionsBySymbol: new Map<string, any[]>(),
checkCooldown: () => false,
getOpenPositionCount: () => 0,
getProfileId() { return this.profileId; },
getAllPositions: () => new Map<string, any>(),
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();