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