WIP: bootstrap and partial real Solana watcher implementation
This commit is contained in:
418
tests/bot.test.ts
Normal file
418
tests/bot.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user