This document describes the overall architecture of backtest-kit, including its layered design, dependency injection system, context propagation mechanisms, and event-driven patterns. The architecture is designed to support three execution modes (Backtest, Live, Walker) while maintaining temporal isolation, crash recovery, and clean separation of concerns.
For detailed information about specific architectural components:
backtest-kit implements a layered service architecture with dependency injection and context propagation. The system consists of approximately 50+ services organized into distinct layers, each with specific responsibilities. Services are instantiated lazily via a custom DI container and communicate through well-defined interfaces.
The architecture supports three primary execution modes:
The system uses a custom dependency injection container that maps TYPES symbols to service factory functions. All services are registered at module load time and instantiated lazily on first access.
Example Registration:
// In provide.ts
provide(TYPES.strategySchemaService, () => new StrategySchemaService());
provide(TYPES.strategyConnectionService, () => new StrategyConnectionService());
// In index.ts
const strategySchemaService = inject<StrategySchemaService>(TYPES.strategySchemaService);
const strategyConnectionService = inject<StrategyConnectionService>(TYPES.strategyConnectionService);
Each layer has specific responsibilities and communicates only with adjacent layers. This enforces separation of concerns and makes the system easier to test and maintain.
| Layer | Responsibility | Examples | Communication |
|---|---|---|---|
| Public API | User-facing functions | addStrategy(), listenSignal() |
Calls Validation + Schema |
| Utility Classes | Execution control | Backtest, Live, Walker |
Calls Command Services |
| Command | Workflow orchestration | BacktestCommandService |
Calls Logic Public |
| Logic Public | API wrappers with validation | BacktestLogicPublicService |
Calls Logic Private |
| Logic Private | Internal algorithms | BacktestLogicPrivateService |
Calls Global + Core + Context |
| Global | Subsystem facades | RiskGlobalService |
Calls Connection + Validation |
| Core | Domain logic | StrategyCoreService |
Calls Connection |
| Connection | Factory + Memoization | StrategyConnectionService |
Creates Clients |
| Schema | Configuration storage | StrategySchemaService |
ToolRegistry pattern |
| Validation | Business rules | StrategyValidationService |
Enforces constraints |
| Markdown | Report generation | BacktestMarkdownService |
Subscribes to events |
| Client | Business logic execution | ClientStrategy |
Uses Context |
| Context | Implicit parameters | ExecutionContextService |
AsyncLocalStorage |
Connection services use factory pattern to create client instances. Memoization ensures proper instance isolation based on composite keys.
Key Construction Examples:
"BTCUSDT:my-strategy:true""BTCUSDT:my-strategy:false""ETHUSDT:my-strategy:true" (separate instance)This ensures that:
Two scoped services provide implicit parameter passing without manual threading:
ExecutionContextService provides runtime parameters:
symbol: Trading pair (e.g., "BTCUSDT")when: Current timestamp for operationsbacktest: Boolean flag for mode detectionMethodContextService provides schema selection:
strategyName: Which strategy to useexchangeName: Which exchange to useframeName: Which frame to use (empty for live)This pattern eliminates the need to pass these parameters explicitly through every function call.
The system uses RxJS Subjects as a central event bus for decoupled communication between components.
Event Hierarchy:
signalEmitter: Broadcasts ALL signals (backtest + live)signalBacktestEmitter: Backtest-only signalssignalLiveEmitter: Live-only signalsThis allows subscribers to listen at different granularities without tight coupling to execution logic.
Queued Processing:
All listener callbacks are wrapped with queued() from functools-kit, ensuring sequential execution even for async handlers. This prevents race conditions in event processing.
The following diagram shows how data flows through the system during a backtest execution:
Key Observations:
ExecutionContextService enforces temporal isolation by controlling which timestamp is "current" for all operations. During backtesting, when is set to the candle timestamp being processed. During live trading, when is set to Date.now().
ClientExchange.getCandles() uses the context's when value to fetch historical candles:
Date.now()This ensures strategies cannot access "future" data during backtesting, making backtest results realistic.
PersistBase abstract class provides atomic file writes using the temp-rename pattern:
signal.json.tmpfsync() to ensure disk writesignal.jsonMultiple persistence adapters extend PersistBase:
Each adapter has separate file paths to prevent cross-contamination. On restart, waitForInit() loads state from disk files.
Signal state machine uses TypeScript discriminated unions for type-safe state handling:
type IStrategyTickResult =
| IStrategyTickResultIdle // action: "idle"
| IStrategyTickResultScheduled // action: "scheduled"
| IStrategyTickResultOpened // action: "opened"
| IStrategyTickResultActive // action: "active"
| IStrategyTickResultClosed // action: "closed"
| IStrategyTickResultCancelled // action: "cancelled"
Each state has distinct properties. TypeScript narrows the type based on the action discriminator:
if (result.action === "closed") {
// TypeScript knows result is IStrategyTickResultClosed
console.log(result.pnl.pnlPercentage); // OK
console.log(result.closeReason); // OK
}
This prevents accessing properties that don't exist in the current state, catching bugs at compile time.
Connection services use memoization to ensure singleton behavior per composite key:
// StrategyConnectionService.getStrategy() pseudo-code
getStrategy(symbol: string, strategyName: string, backtest: boolean) {
const key = `${symbol}:${strategyName}:${backtest}`;
if (!this.cache.has(key)) {
const schema = this.schemaService.retrieve(strategyName);
const instance = new ClientStrategy({
...schema,
logger: this.logger,
execution: this.executionContextService,
// ... other dependencies
});
this.cache.set(key, instance);
}
return this.cache.get(key);
}
This pattern:
The following diagram shows how risk management integrates across layers:
Key Interactions:
addRisk() → validates → stores in SchemaServicewaitForInit() → PersistRiskAdapter loads from diskThe backtest-kit architecture is characterized by:
This architecture enables the framework to support complex trading workflows while maintaining testability, extensibility, and reliability. The layered design ensures that changes to one component (e.g., persistence implementation) do not cascade to unrelated components (e.g., signal generation logic).