import { describe, it, expect, beforeEach } from 'vitest'; import { loadConfig } from '../src/config/index.js'; import { SimpleCopyStrategy } from '../src/strategy/index.js'; import { SimulationExecutor } from '../src/execution/simulationExecutor.js'; import { DeterministicMarketDataProvider } from '../src/simulation/marketData.js'; import { SimulationScheduler } from '../src/simulation/scheduler.js'; import { EventJournalService } from '../src/persistence/eventJournal.js'; import { MockMetricsProvider } from '../src/metrics/index.js'; import { Application } from '../src/application/index.js'; import { InMemoryCopySignalRepository, InMemoryTradeRepository, InMemorySimulationExecutionRepository, InMemoryEventJournalRepository, } from './mocks/InMemoryRepositories.js'; import { CopySignal } from '../src/domain/models.js'; import { solToLamports, lamportsToSol, tokenToBaseUnits, baseUnitsToToken, calculateEntryPricePenalty, calculateLatencyImpact, calculatePnl, calculateExcursion, } from '../src/domain/numeric.js'; describe('Solana Copy-Trading Bot Unit Tests', () => { // Config parsing tests describe('Configuration Parsing', () => { it('should load default configuration values and validate them', () => { const config = loadConfig({ DATABASE_URL: 'postgres://localhost:5432/test_db', WATCHED_WALLETS: '3yF9asA9B7G3Y1G7as78gHJKa7A,4yF9asA9B7G3Y1G7as78gHJKa7B', LATENCY_SCENARIOS: '0,1000,5000', }); expect(config.DATABASE_URL).toBe('postgres://localhost:5432/test_db'); expect(config.WATCHED_WALLETS).toEqual([ '3yF9asA9B7G3Y1G7as78gHJKa7A', '4yF9asA9B7G3Y1G7as78gHJKa7B', ]); expect(config.LATENCY_SCENARIOS).toEqual([0, 1000, 5000]); expect(config.CONFIGURATION_HASH).toContain('conf-'); }); }); // Strategy Evaluation describe('Strategy Evaluation', () => { it('should copy buy signals and exit on sell signals', async () => { const strategy = new SimpleCopyStrategy(); const buySignal: CopySignal = { id: '1', correlationId: 'corr-1', sourceWallet: 'wallet-1', tokenMint: 'token-1', side: 'buy', sourceTransactionSignature: 'sig-1', sourceTransactionTimestamp: new Date(), detectedTimestamp: new Date(), detectedSlot: 1000, sourceAmountSol: 0.05, tokenAmount: 100, sourcePrice: 0.0005, metadata: {}, }; const evalBuy = await strategy.evaluate(buySignal); expect(evalBuy.shouldCopy).toBe(true); const sellSignal: CopySignal = { ...buySignal, side: 'sell', }; const evalSell = await strategy.evaluate(sellSignal); expect(evalSell.shouldCopy).toBe(true); }); }); // Simulation Executor and Market Data Provider describe('Simulation Executor & Market Data', () => { it('should resolve predictable, deterministic quotes', async () => { const marketData = new DeterministicMarketDataProvider(); marketData.setBasePrice('token-1', 0.005); const timestamp = new Date(1710000000000); // fixed timestamp const quote = await marketData.getQuote('token-1', timestamp); expect(quote.tokenMint).toBe('token-1'); const sinPart = Math.sin(1710000000000 / 30000.0) * 0.15; const cosPart = Math.cos(1710000000000 / 10000.0) * 0.05; expect(quote.priceSol).toBeCloseTo(0.005 * (1 + sinPart + cosPart)); }); it('should correctly calculate entry prices and token amounts', async () => { const marketData = new DeterministicMarketDataProvider(); marketData.setBasePrice('token-1', 0.005); const executor = new SimulationExecutor(marketData); const signal: CopySignal = { id: '1', correlationId: 'corr-1', sourceWallet: 'wallet-1', tokenMint: 'token-1', side: 'buy', sourceTransactionSignature: 'sig-1', sourceTransactionTimestamp: new Date(1710000000000), detectedTimestamp: new Date(1710000000000), detectedSlot: 1000, sourceAmountSol: 0.05, tokenAmount: 100, sourcePrice: 0.005, metadata: {}, }; const res = await executor.executeEntry(signal, 1000, 0.05); expect(res.amountSol).toBe(0.05); expect(res.priceSol).toBeGreaterThan(0); expect(res.tokenAmount).toBe(0.05 / res.priceSol); }); }); // Simulation Scheduler, Latency, and Accounting describe('Simulation Scheduler & Latency Processing', () => { let signalRepo: InMemoryCopySignalRepository; let tradeRepo: InMemoryTradeRepository; let execRepo: InMemorySimulationExecutionRepository; let journalRepo: InMemoryEventJournalRepository; let marketData: DeterministicMarketDataProvider; let metrics: MockMetricsProvider; let journal: EventJournalService; let scheduler: SimulationScheduler; beforeEach(() => { signalRepo = new InMemoryCopySignalRepository(); tradeRepo = new InMemoryTradeRepository(); execRepo = new InMemorySimulationExecutionRepository(); journalRepo = new InMemoryEventJournalRepository(); marketData = new DeterministicMarketDataProvider({ 'token-1': 0.002, }); metrics = new MockMetricsProvider(); journal = new EventJournalService(journalRepo, '1.0.0', 'test-commit', 'test-hash'); scheduler = new SimulationScheduler( tradeRepo, execRepo, signalRepo, marketData, metrics, journal, 1.0, // Starting balance 0.05, // Position size [0, 1000], // Latency scenarios '1.0.0', 'test-commit', 'test-hash', ); }); it('should schedule entry executions and compute latency offsets without blocking', async () => { const signal: CopySignal = { id: 'sig-1', correlationId: 'corr-123', sourceWallet: 'wallet-1', tokenMint: 'token-1', side: 'buy', sourceTransactionSignature: 'sig-1', sourceTransactionTimestamp: new Date(1710000000000), detectedTimestamp: new Date(1710000000000), detectedSlot: 1000, sourceAmountSol: 0.05, tokenAmount: 100, sourcePrice: 0.002, metadata: {}, }; await signalRepo.saveSignal(signal); await scheduler.scheduleEntry(signal); // Verify that two executions are scheduled expect(execRepo.executions.length).toBe(2); expect(execRepo.executions[0]?.latencyScenario).toBe(0); expect(execRepo.executions[1]?.latencyScenario).toBe(1000); expect(execRepo.executions[0]?.executionTimestamp.getTime()).toBe(1710000000000); expect(execRepo.executions[1]?.executionTimestamp.getTime()).toBe(1710000001000); // Verify no trade is open yet before processing expect(tradeRepo.trades.length).toBe(0); // Process at timestamp of scenario 0 const processedCount = await scheduler.processPending(new Date(1710000000000)); expect(processedCount).toBe(1); // Only 0ms scenario runs // Scenario 0ms trade is now open expect(tradeRepo.trades.length).toBe(1); const trade0 = tradeRepo.trades[0]; expect(trade0?.status).toBe('open'); expect(trade0?.latencyScenario).toBe(0); // Process at timestamp of scenario 1000ms const processedCount2 = await scheduler.processPending(new Date(1710000001000)); expect(processedCount2).toBe(1); // 1000ms scenario runs // Scenario 1000ms trade is now open expect(tradeRepo.trades.length).toBe(2); const trade1000 = tradeRepo.trades.find((t) => t.latencyScenario === 1000); expect(trade1000?.status).toBe('open'); // Verify simulated accounting balance (Starting balance: 1.0, positions size: 0.05) const balance0 = await scheduler.calculatePortfolioBalance(0); expect(balance0).toBeCloseTo(0.95); }); it('should execute sells, calculate PnL, MFE, and MAE deterministically', async () => { // Setup: Open a trade in 0ms scenario const buySignal: CopySignal = { id: 'sig-buy', correlationId: 'corr-abc', sourceWallet: 'wallet-1', tokenMint: 'token-1', side: 'buy', sourceTransactionSignature: 'sig-buy-tx', sourceTransactionTimestamp: new Date(1710000000000), detectedTimestamp: new Date(1710000000000), detectedSlot: 1000, sourceAmountSol: 0.05, tokenAmount: 25, sourcePrice: 0.002, metadata: {}, }; await signalRepo.saveSignal(buySignal); await scheduler.scheduleEntry(buySignal); await scheduler.processPending(new Date(1710000000000)); // executes 0ms entry const openTrade = await tradeRepo.getTradeByCorrelationIdAndScenario('corr-abc', 0); expect(openTrade).not.toBeNull(); expect(openTrade?.status).toBe('open'); // Now create sell signal const sellSignal: CopySignal = { id: 'sig-sell', correlationId: 'corr-xyz', sourceWallet: 'wallet-1', tokenMint: 'token-1', side: 'sell', sourceTransactionSignature: 'sig-sell-tx', sourceTransactionTimestamp: new Date(1710000030000), // 30s later detectedTimestamp: new Date(1710000030000), detectedSlot: 1006, sourceAmountSol: null, tokenAmount: 25, sourcePrice: 0.003, metadata: {}, }; await signalRepo.saveSignal(sellSignal); await scheduler.scheduleExit(sellSignal); await scheduler.processPending(new Date(1710000030000)); // executes 0ms exit const closedTrade = await tradeRepo.getTradeByCorrelationIdAndScenario('corr-abc', 0); expect(closedTrade?.status).toBe('closed'); expect(closedTrade?.exitPriceSol).toBeGreaterThan(0); expect(closedTrade?.pnlSol).not.toBeNull(); expect(closedTrade?.mfeSol).toBeGreaterThanOrEqual(0); expect(closedTrade?.maeSol).toBeLessThanOrEqual(0); const finalBalance = await scheduler.calculatePortfolioBalance(0); expect(finalBalance).toBeCloseTo(1.0 + (closedTrade?.pnlSol ?? 0)); }); }); describe('Numeric Precision Correctness', () => { it('should convert SOL and tokens to base units safely without float error', () => { expect(solToLamports(0.1)).toBe(100000000n); expect(solToLamports(0.000000001)).toBe(1n); expect(lamportsToSol(100000000n)).toBe(0.1); expect(tokenToBaseUnits(123.456, 6)).toBe(123456000n); expect(baseUnitsToToken(123456000n, 6)).toBe(123.456); }); it('should calculate entry price penalty, latency impact and PnL safely with BigInt precision', () => { const penalty = calculateEntryPricePenalty(0.0055, 0.005); expect(penalty).toBeCloseTo(0.0005); const impact = calculateLatencyImpact(0.0055, 0.005, 10, 9); expect(impact).toBeCloseTo(0.005); // (0.0055 - 0.005) * 10 = 0.005 SOL const pnl = calculatePnl(10, 0.006, 0.05, 9); expect(pnl).toBeCloseTo(0.01); // 10 * 0.006 - 0.05 = 0.01 SOL const excursion = calculateExcursion(0.005, 0.008, 10, 9); expect(excursion).toBeCloseTo(0.03); // (0.008 - 0.005) * 10 = 0.03 SOL }); }); describe('Latency Scenario Isolation', () => { let signalRepo: InMemoryCopySignalRepository; let tradeRepo: InMemoryTradeRepository; let execRepo: InMemorySimulationExecutionRepository; let journalRepo: InMemoryEventJournalRepository; let marketData: DeterministicMarketDataProvider; let metrics: MockMetricsProvider; let journal: EventJournalService; let scheduler: SimulationScheduler; beforeEach(() => { signalRepo = new InMemoryCopySignalRepository(); tradeRepo = new InMemoryTradeRepository(); execRepo = new InMemorySimulationExecutionRepository(); journalRepo = new InMemoryEventJournalRepository(); marketData = new DeterministicMarketDataProvider({ 'token-1': 0.002, }); metrics = new MockMetricsProvider(); journal = new EventJournalService(journalRepo, '1.0.0', 'test-commit', 'test-hash'); scheduler = new SimulationScheduler( tradeRepo, execRepo, signalRepo, marketData, metrics, journal, 1.0, // Starting balance 0.05, // Position size [0, 5000], // 0ms and 5000ms universes '1.0.0', 'test-commit', 'test-hash', ); }); it('should guarantee absolute simulation isolation between latency scenarios', async () => { const buySignal: CopySignal = { id: 'sig-buy', correlationId: 'corr-isolate', sourceWallet: 'wallet-1', tokenMint: 'token-1', side: 'buy', sourceTransactionSignature: 'sig-buy-tx', sourceTransactionTimestamp: new Date(1710000000000), detectedTimestamp: new Date(1710000000000), detectedSlot: 1000, sourceAmountSol: 0.05, tokenAmount: 25, sourcePrice: 0.002, metadata: {}, }; await signalRepo.saveSignal(buySignal); await scheduler.scheduleEntry(buySignal); // Execute scenario 0ms only (which is at time 1710000000000) await scheduler.processPending(new Date(1710000000000)); const trade0 = await tradeRepo.getTradeByCorrelationIdAndScenario('corr-isolate', 0); const trade5000 = await tradeRepo.getTradeByCorrelationIdAndScenario('corr-isolate', 5000); expect(trade0).not.toBeNull(); expect(trade0?.status).toBe('open'); // 5000ms scenario should have NO trade created yet expect(trade5000).toBeNull(); // Check balance of both universes: scenario 0ms spent 0.05 SOL, scenario 5000ms spent 0.00 SOL const balance0 = await scheduler.calculatePortfolioBalance(0); const balance5000 = await scheduler.calculatePortfolioBalance(5000); expect(balance0).toBeCloseTo(0.95); expect(balance5000).toBeCloseTo(1.0); // Now execute 5000ms entry at 1710000005000 await scheduler.processPending(new Date(1710000005000)); const trade5000After = await tradeRepo.getTradeByCorrelationIdAndScenario('corr-isolate', 5000); expect(trade5000After).not.toBeNull(); expect(trade5000After?.status).toBe('open'); const balance5000After = await scheduler.calculatePortfolioBalance(5000); expect(balance5000After).toBeCloseTo(0.95); }); }); describe('Graceful Shutdown Behavior', () => { it('should cleanly stop watcher, clear intervals, close metrics, and resolve successfully without throwing', async () => { const app = new Application(); let stoppedWatching = false; app.watcher = { async startWatching() {}, async stopWatching() { stoppedWatching = true; }, onObservation() {}, } as any; let closedMetrics = false; app.metricsProvider = { async close() { closedMetrics = true; } } as any; // Mock set interval app.simulationIntervalId = setInterval(() => {}, 10000); // Perform shutdown await app.shutdown('SIGINT'); expect(app.isShuttingDown).toBe(true); expect(app.simulationIntervalId).toBeNull(); expect(stoppedWatching).toBe(true); expect(closedMetrics).toBe(true); }); }); });