This page introduces the fundamental architectural patterns and execution models of backtest-kit. Understanding these core concepts is essential before using the framework:
di-scoped for clean API designFor detailed API documentation, see Public API Reference. For implementation details of specific components, see Component Types. For service layer architecture, see Architecture.
backtest-kit provides three execution modes that share strategy and exchange components but differ in timing models, data sources, and orchestration logic. Each mode is implemented as an async generator for memory-efficient streaming.
| Mode | Time Source | Data Flow | State Persistence | Signal Yield | Loop Type |
|---|---|---|---|---|---|
| Backtest | IFrameSchema.getTimeframe() array |
Historical candles via ExchangeCoreService.getCandles() |
None (in-memory only) | Closed signals only | Finite iteration |
| Live | new Date() on each tick |
Real-time VWAP via ExchangeCoreService.getAveragePrice() |
PersistSignalAdapter (atomic file writes) |
All states (idle/opened/active/closed) | Infinite while(true) |
| Walker | Delegates to BacktestLogicPublicService |
Shared IFrameSchema across strategies |
None (uses backtest mode) | WalkerContract with strategy stats |
Sequential strategy iteration |
Each mode follows the same service layer pattern:
*CommandService)*LogicPublicService)*LogicPrivateService)The Command Services validate schema existence before delegating to Logic Services:
// BacktestCommandService validates strategyName, exchangeName, frameName
// LiveCommandService validates strategyName, exchangeName
// WalkerCommandService validates walkerName
Each mode sets different IExecutionContext values:
{ symbol, when: timestamp_from_array, backtest: true }{ symbol, when: new Date(), backtest: false }BacktestLogicPublicService per strategyThe backtest flag controls behavior throughout the system:
signalBacktestEmitter vs signalLiveEmitter)backtest-kit uses a pub-sub event system for observability, real-time monitoring, and progress tracking. All events are processed sequentially via queued async wrappers to prevent race conditions.
The framework provides typed event emitters using Subject from functools-kit:
Signal Events:
signalEmitter: All signals (backtest + live)signalBacktestEmitter: Backtest signals onlysignalLiveEmitter: Live signals onlyLifecycle Events:
doneBacktestSubject: Backtest completiondoneLiveSubject: Live completiondoneWalkerSubject: Walker completionProgress Events:
progressBacktestEmitter: Backtest progress (frames processed)progressWalkerEmitter: Walker progress (strategies tested)progressOptimizerEmitter: Optimizer progress (data sources)Monitoring Events:
partialProfitSubject: Profit level milestones (10%, 20%, 30%, etc)partialLossSubject: Loss level milestones (10%, 20%, 30%, etc)riskSubject: Risk validation rejectionsperformanceEmitter: Execution timing metricsError Events:
errorEmitter: Recoverable errors (execution continues)exitEmitter: Fatal errors (execution terminates)validationSubject: Risk validation errorsAll event listeners use the queued() wrapper from functools-kit to ensure sequential processing:
// listenSignal implementation pattern
export function listenSignal(fn: (event: IStrategyTickResult) => void) {
return signalEmitter.subscribe(queued(async (event) => fn(event)));
}
This guarantees:
Markdown Services subscribe to events for statistics aggregation and report generation:
BacktestMarkdownService: Subscribes to signalBacktestEmitter for closed signalsLiveMarkdownService: Subscribes to signalLiveEmitter for all signal statesPartialMarkdownService: Subscribes to partialProfitSubject and partialLossSubjectRiskMarkdownService: Subscribes to riskSubject for rejection trackingWalkerMarkdownService: Subscribes to walkerEmitter for strategy comparisonPerformanceMarkdownService: Subscribes to performanceEmitter for timing metricsThese services maintain internal state and provide getData() methods for report generation via Backtest.getData(), Live.getData(), etc.
Signals progress through a state machine implemented as a discriminated union of result types. The action field serves as the discriminator for type-safe state handling. The lifecycle is managed by ClientStrategy methods.
The framework uses TypeScript discriminated unions for type-safe signal state handling:
type IStrategyTickResult =
| IStrategyTickResultIdle
| IStrategyTickResultScheduled
| IStrategyTickResultOpened
| IStrategyTickResultActive
| IStrategyTickResultClosed
| IStrategyTickResultCancelled;
Each state type has a unique action discriminator:
priceOpen, waiting for price to reach entry point - src/interfaces/Strategy.interface.ts:181-194Signals track two critical timestamps for accurate duration calculation:
priceOpen (updated on scheduled signal activation) - src/interfaces/Strategy.interface.ts:56-56The minuteEstimatedTime countdown uses pendingAt, not scheduledAt, ensuring scheduled signals don't count waiting time toward expiration - src/client/ClientStrategy.ts:681-683.
backtest-kit uses a registration-based architecture where components are defined as schemas and instantiated on-demand via dependency injection.
The framework provides six registrable component types, each with a dedicated schema interface:
| Component | Schema Interface | Purpose | Registration Function |
|---|---|---|---|
| Strategy | IStrategySchema |
Signal generation logic via getSignal() |
addStrategy() |
| Exchange | IExchangeSchema |
Market data via getCandles(), price formatting |
addExchange() |
| Frame | IFrameSchema |
Backtest timeframe generation via getTimeframe() |
addFrame() |
| Risk | IRiskSchema |
Portfolio-level position limits and validations | addRisk() |
| Sizing | ISizingSchema |
Position size calculation (fixed, Kelly, ATR) | addSizing() |
| Walker | IWalkerSchema |
Multi-strategy comparison configuration | addWalker() |
Each schema is stored in a corresponding *SchemaService using the ToolRegistry pattern - src/lib/services/schema/StrategySchemaService.ts, src/lib/services/schema/ExchangeSchemaService.ts, etc.
The framework uses memoized Connection Services to lazily instantiate Client classes:
This multi-tier architecture (Schema → Validation → Connection → Client) enables:
*ValidationService.validate() using memoized checksClientStrategy.waitForInit() before first operationConnection Services use memoize() from functools-kit to cache Client instances by schema name:
// StrategyConnectionService.getStrategy() implementation pattern:
const memoizedFactory = memoize((strategyName: string) => {
const schema = this.strategySchemaService.getSchema(strategyName);
return new ClientStrategy({ ...schema, logger, execution, ... });
});
// First call with "my-strategy" creates instance
// Subsequent calls return cached instance
This pattern applies to all Connection Services:
StrategyConnectionService.getStrategy(strategyName)ExchangeConnectionService.getExchange(exchangeName)FrameConnectionService.getFrame(frameName)RiskConnectionService.getRisk(riskName)PartialConnectionService.getPartial(symbol)The framework uses di-scoped library to propagate execution context implicitly without explicit parameter passing. This enables clean strategy code that doesn't need to know about framework internals.
Two context services manage different aspects of execution:
ExecutionContextService wraps IExecutionContext:
symbol: Trading pair (e.g., "BTCUSDT")when: Current timestamp (historical for backtest, Date.now() for live)backtest: Boolean flag controlling behavior throughout systemMethodContextService wraps IMethodContext:
strategyName: Which IStrategySchema to retrieveexchangeName: Which IExchangeSchema to retrieveframeName: Which IFrameSchema to retrieve (empty string for live mode)Both services use di-scoped for async-safe context propagation:
// ExecutionContextService extends di-scoped pattern
export const ExecutionContextService = scopedClass<IExecutionContext>();
// Usage in Logic Services
ExecutionContextService.runInContext(async () => {
// All code here can access executionContextService.context
await strategy.tick(symbol);
}, { symbol, when, backtest });
Connection Services access both contexts to route requests:
// ExchangeConnectionService.getExchange() pattern
const methodContext = this.methodContextService.context;
const executionContext = this.executionContextService.context;
// Use methodContext.exchangeName to get schema
const schema = this.exchangeSchemaService.getSchema(methodContext.exchangeName);
// Pass executionContext to Client constructor for runtime behavior
return new ClientExchange({ ...schema, execution: executionContext });
This two-context design separates:
This pattern enables clean strategy code without framework boilerplate:
// Strategy author writes:
const candles = await getCandles(symbol, interval, limit);
// Framework automatically injects:
// - executionContext.when (which timestamp to query)
// - methodContext.exchangeName (which exchange to use)
// - executionContext.backtest (historical vs real-time data)
For detailed context propagation mechanics, see Context Propagation.