246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { config } from '../src/config/index.js';
|
|
import { AIClient } from '../src/services/aiClient.js';
|
|
import { AlpacaConnector } from '../src/connectors/alpaca.js';
|
|
import { CCXTConnector } from '../src/connectors/ccxt.js';
|
|
import { ConnectorFactory } from '../src/connectors/factory.js';
|
|
import type { Candle } from '../src/connectors/types.js';
|
|
|
|
async function* generateBars(rows: any[]) {
|
|
for (const row of rows) {
|
|
yield row;
|
|
}
|
|
}
|
|
|
|
async function testAIClient() {
|
|
const originalAiConfig = {
|
|
OPENAI_API_KEY: config.AI.OPENAI_API_KEY,
|
|
PERPLEXITY_API_KEY: config.AI.PERPLEXITY_API_KEY,
|
|
GEMINI_API_KEY: config.AI.GEMINI_API_KEY,
|
|
FALLBACK_LIST: [...config.AI.FALLBACK_LIST],
|
|
MODEL: config.AI.MODEL
|
|
};
|
|
|
|
try {
|
|
config.AI['OPENAI_API_KEY'] = '';
|
|
config.AI['PERPLEXITY_API_KEY'] = '';
|
|
config.AI['GEMINI_API_KEY'] = '';
|
|
config.AI.FALLBACK_LIST = ['openai', 'perplexity', 'gemini'];
|
|
|
|
const clientNoKeys = new AIClient();
|
|
const noResult = await clientNoKeys.generateAnalysis('prompt');
|
|
assert.equal(noResult, null, 'generateAnalysis should return null when no provider keys are configured');
|
|
|
|
const noProbeHealth = await clientNoKeys.getProviderHealth(false);
|
|
assert.equal(noProbeHealth.length, 3);
|
|
assert(noProbeHealth.every((item) => item.status === 'missing_key'));
|
|
|
|
// Override provider call paths to avoid network and validate fallback routing
|
|
const clientWithMocks = new AIClient() as any;
|
|
config.AI['OPENAI_API_KEY'] = 'key-openai';
|
|
config.AI['PERPLEXITY_API_KEY'] = 'key-perplexity';
|
|
config.AI['GEMINI_API_KEY'] = 'key-gemini';
|
|
config.AI.FALLBACK_LIST = ['unsupported' as any, 'openai', 'perplexity', 'gemini'];
|
|
|
|
clientWithMocks.callOpenAI = async () => '{"action":"BUY","confidence":75,"reasoning":"ok"}';
|
|
clientWithMocks.callPerplexity = async () => '{"action":"SELL","confidence":65,"reasoning":"ok"}';
|
|
clientWithMocks.callGemini = async () => '{"action":"HOLD","confidence":50,"reasoning":"ok"}';
|
|
|
|
const mockedResult = await clientWithMocks.generateAnalysis('probe prompt');
|
|
assert.equal(typeof mockedResult, 'string');
|
|
assert((mockedResult as string).includes('BUY'));
|
|
|
|
const probeHealth = await clientWithMocks.getProviderHealth(true);
|
|
assert.equal(probeHealth.length, 3);
|
|
assert(probeHealth.every((item: any) => item.status === 'ok'));
|
|
|
|
// probeProvider unsupported branch
|
|
await assert.rejects(async () => {
|
|
await clientWithMocks.probeProvider('unsupported');
|
|
});
|
|
} finally {
|
|
config.AI['OPENAI_API_KEY'] = originalAiConfig.OPENAI_API_KEY;
|
|
config.AI['PERPLEXITY_API_KEY'] = originalAiConfig.PERPLEXITY_API_KEY;
|
|
config.AI['GEMINI_API_KEY'] = originalAiConfig.GEMINI_API_KEY;
|
|
config.AI.FALLBACK_LIST = originalAiConfig.FALLBACK_LIST;
|
|
config.AI.MODEL = originalAiConfig.MODEL;
|
|
}
|
|
}
|
|
|
|
async function testAlpacaConnector() {
|
|
const originalAssetClass = config.ASSET_CLASS;
|
|
|
|
try {
|
|
const connector = new AlpacaConnector('k', 's') as any;
|
|
|
|
const bars = [
|
|
{ Timestamp: '2026-02-16T00:00:00Z', OpenPrice: 100, HighPrice: 101, LowPrice: 99, ClosePrice: 100, Volume: 10 },
|
|
{ Timestamp: '2026-02-16T01:00:00Z', OpenPrice: 101, HighPrice: 102, LowPrice: 100, ClosePrice: 101, Volume: 11 },
|
|
{ Timestamp: '2026-02-16T02:00:00Z', OpenPrice: 102, HighPrice: 103, LowPrice: 101, ClosePrice: 102, Volume: 12 },
|
|
{ Timestamp: '2026-02-16T03:00:00Z', OpenPrice: 103, HighPrice: 104, LowPrice: 102, ClosePrice: 103, Volume: 13 }
|
|
];
|
|
|
|
connector.client = {
|
|
getBarsV2: (_symbol: string, _opts: any) => generateBars(bars),
|
|
createOrder: async (opts: any) => ({ id: 'alp-1', ...opts }),
|
|
getOrder: async (_id: string) => ({ id: 'alp-1', status: 'filled' }),
|
|
cancelOrder: async (_id: string) => undefined,
|
|
getPosition: async (_symbol: string) => ({ side: 'long', avg_entry_price: '100', qty: '2' }),
|
|
getClock: async () => ({ is_open: true })
|
|
};
|
|
|
|
const mappedTf = connector.mapTimeframe('1m');
|
|
assert.equal(mappedTf, '1Min');
|
|
assert.equal(connector.mapTimeframe('unknown'), 'unknown');
|
|
|
|
const aggregated = connector.aggregateBars([
|
|
{ timestamp: 1, open: 1, high: 2, low: 0.5, close: 1.5, volume: 5 },
|
|
{ timestamp: 2, open: 1.5, high: 3, low: 1, close: 2.5, volume: 7 }
|
|
], 2);
|
|
assert.equal(aggregated.length, 1);
|
|
assert.equal(aggregated[0].high, 3);
|
|
assert.equal(aggregated[0].volume, 12);
|
|
|
|
const candles4h = await connector.fetchOHLCV('BTC/USD', '4h', 1);
|
|
assert.equal(candles4h.length, 1);
|
|
|
|
config.ASSET_CLASS = 'us_equity';
|
|
const orderWithBracket = await connector.placeOrder('AAPL', 'buy', 1, 'limit', 200, 190, 220);
|
|
assert.equal(orderWithBracket.order_class, 'bracket');
|
|
|
|
config.ASSET_CLASS = 'crypto';
|
|
const cryptoOrder = await connector.placeOrder('BTC/USD', 'sell', 0.5, 'market');
|
|
assert.equal(cryptoOrder.side, 'sell');
|
|
|
|
const order = await connector.getOrder('ord-1');
|
|
assert.equal(order.status, 'filled');
|
|
|
|
const cancelOk = await connector.cancelOrder('ord-1');
|
|
assert.equal(cancelOk, true);
|
|
|
|
const position = await connector.getPosition('BTC/USD');
|
|
assert.equal(position.qty, '2');
|
|
|
|
const marketOpen = await connector.isTradingWindowOpen();
|
|
assert.equal(marketOpen, true);
|
|
|
|
connector.client.getOrder = async () => { throw new Error('boom'); };
|
|
const missingOrder = await connector.getOrder('ord-2');
|
|
assert.equal(missingOrder, null);
|
|
|
|
connector.client.cancelOrder = async () => { throw new Error('boom'); };
|
|
const cancelFail = await connector.cancelOrder('ord-2');
|
|
assert.equal(cancelFail, false);
|
|
|
|
connector.client.getPosition = async () => { throw new Error('404 not found'); };
|
|
const noPosition = await connector.getPosition('ETH/USD');
|
|
assert.equal(noPosition, null);
|
|
|
|
connector.client.getPosition = async () => { throw new Error('500 server'); };
|
|
await assert.rejects(async () => {
|
|
await connector.getPosition('ETH/USD');
|
|
});
|
|
|
|
config.ASSET_CLASS = 'us_equity';
|
|
connector.client.getClock = async () => ({ is_open: false });
|
|
const marketClosed = await connector.isTradingWindowOpen();
|
|
assert.equal(marketClosed, false);
|
|
|
|
connector.client.getClock = async () => ({ nope: true });
|
|
const marketUnknown = await connector.isTradingWindowOpen();
|
|
assert.equal(marketUnknown, null);
|
|
|
|
connector.client.getClock = async () => { throw new Error('clock-fail'); };
|
|
const clockFail = await connector.isTradingWindowOpen();
|
|
assert.equal(clockFail, null);
|
|
} finally {
|
|
config.ASSET_CLASS = originalAssetClass;
|
|
}
|
|
}
|
|
|
|
async function testCCXTConnectorAndFactory() {
|
|
const originalExchange = config.EXCHANGE;
|
|
const originalProvider = config.PROVIDER;
|
|
|
|
try {
|
|
// Unsupported exchange branch
|
|
config.EXCHANGE = 'definitely_unsupported_exchange';
|
|
await assert.rejects(async () => {
|
|
new CCXTConnector('k', 's');
|
|
});
|
|
|
|
config.EXCHANGE = 'binance';
|
|
const ccxtConnector = new CCXTConnector('k', 's') as any;
|
|
ccxtConnector.client = {
|
|
fetchOHLCV: async (_symbol: string, _tf: string, _since: any, _limit: number) => [
|
|
[1, 100, 110, 90, 105, 1000],
|
|
[2, 105, 112, 100, 111, 1200]
|
|
],
|
|
createOrder: async (_symbol: string, _type: string, _side: string, _qty: number, _price: number | undefined) => ({ id: 'ccxt-1' }),
|
|
fetchOrder: async (_id: string, _symbol?: string) => ({ id: 'ccxt-1', status: 'closed' }),
|
|
fetchPositions: async (_symbols: string[]) => [{ symbol: 'BTC/USDT', contracts: 1 }],
|
|
cancelOrder: async (_id: string, _symbol?: string) => undefined
|
|
};
|
|
|
|
const candles = await ccxtConnector.fetchOHLCV('BTC/USDT', '1Hour', 2);
|
|
assert.equal(candles.length, 2);
|
|
assert.equal((candles[0] as Candle).close, 105);
|
|
|
|
const order = await ccxtConnector.placeOrder('BTC/USDT', 'buy', 1, 'market');
|
|
assert.equal(order.id, 'ccxt-1');
|
|
|
|
const fetchedOrder = await ccxtConnector.getOrder('ccxt-1', 'BTC/USDT');
|
|
assert.equal(fetchedOrder.status, 'closed');
|
|
|
|
const position = await ccxtConnector.getPosition('BTC/USDT');
|
|
assert.equal(position.symbol, 'BTC/USDT');
|
|
|
|
const isOpen = await ccxtConnector.isTradingWindowOpen();
|
|
assert.equal(isOpen, true);
|
|
|
|
const cancelOk = await ccxtConnector.cancelOrder('ccxt-1', 'BTC/USDT');
|
|
assert.equal(cancelOk, true);
|
|
|
|
ccxtConnector.client.fetchOrder = async () => { throw new Error('fetch-order-fail'); };
|
|
const missingOrder = await ccxtConnector.getOrder('ccxt-2', 'BTC/USDT');
|
|
assert.equal(missingOrder, null);
|
|
|
|
ccxtConnector.client.fetchPositions = async () => { throw new Error('position-fail'); };
|
|
await assert.rejects(async () => {
|
|
await ccxtConnector.getPosition('BTC/USDT');
|
|
});
|
|
|
|
ccxtConnector.client.cancelOrder = async () => { throw new Error('cancel-fail'); };
|
|
const cancelFail = await ccxtConnector.cancelOrder('ccxt-2', 'BTC/USDT');
|
|
assert.equal(cancelFail, false);
|
|
|
|
// Factory branches
|
|
config.PROVIDER = 'alpaca';
|
|
const defaultConnector = ConnectorFactory.getConnector();
|
|
assert(defaultConnector instanceof AlpacaConnector);
|
|
|
|
const customCcxt = ConnectorFactory.getCustomConnector('ccxt', 'k', 's');
|
|
assert(customCcxt instanceof CCXTConnector);
|
|
|
|
const customAlpaca = ConnectorFactory.getCustomConnector('alpaca', 'k', 's');
|
|
assert(customAlpaca instanceof AlpacaConnector);
|
|
|
|
await assert.rejects(async () => {
|
|
ConnectorFactory.getCustomConnector('unknown-provider');
|
|
});
|
|
} finally {
|
|
config.EXCHANGE = originalExchange;
|
|
config.PROVIDER = originalProvider;
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
await testAIClient();
|
|
await testAlpacaConnector();
|
|
await testCCXTConnectorAndFactory();
|
|
|
|
console.log('[connector-ai-coverage] OK: connector and AI client branches validated');
|
|
}
|
|
|
|
await run();
|