WIP: bootstrap and partial real Solana watcher implementation

This commit is contained in:
2026-08-16 09:17:45 +00:00
commit dc23412c3f
7232 changed files with 1687637 additions and 0 deletions

418
tests/bot.test.ts Normal file
View 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);
});
});
});