Core Concepts

This page introduces the fundamental architectural patterns and execution models of backtest-kit. Understanding these core concepts is essential before using the framework:

  • Execution Modes: Three distinct modes (Backtest, Live, Walker) that share components but differ in timing and data models
  • Signal Lifecycle: Type-safe state machine governing position entry, monitoring, and exit
  • Component Registration: Schema-based architecture with lazy instantiation and memoization
  • Context Propagation: Implicit parameter passing via di-scoped for clean API design
  • Event System: Pub-sub architecture for observability and real-time monitoring

For 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.

Mermaid Diagram

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:

  1. Command Service Layer: Entry point with validation (*CommandService)
  2. Logic Public Service Layer: API contract definition (*LogicPublicService)
  3. Logic Private Service Layer: Implementation with core service coordination (*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:

  • Backtest: { symbol, when: timestamp_from_array, backtest: true }
  • Live: { symbol, when: new Date(), backtest: false }
  • Walker: Delegates context to BacktestLogicPublicService per strategy

The backtest flag controls behavior throughout the system:

  • VWAP calculation source (historical candles vs real-time API)
  • Signal persistence (disabled in backtest, enabled in live)
  • Event emitter routing (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 only
  • signalLiveEmitter: Live signals only

Lifecycle Events:

  • doneBacktestSubject: Backtest completion
  • doneLiveSubject: Live completion
  • doneWalkerSubject: Walker completion

Progress 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 rejections
  • performanceEmitter: Execution timing metrics

Error Events:

  • errorEmitter: Recoverable errors (execution continues)
  • exitEmitter: Fatal errors (execution terminates)
  • validationSubject: Risk validation errors

All 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:

  • Events are processed in order received
  • No concurrent execution of callback functions
  • Async callbacks complete before next event is processed

Mermaid Diagram

Markdown Services subscribe to events for statistics aggregation and report generation:

  • BacktestMarkdownService: Subscribes to signalBacktestEmitter for closed signals
  • LiveMarkdownService: Subscribes to signalLiveEmitter for all signal states
  • PartialMarkdownService: Subscribes to partialProfitSubject and partialLossSubject
  • RiskMarkdownService: Subscribes to riskSubject for rejection tracking
  • WalkerMarkdownService: Subscribes to walkerEmitter for strategy comparison
  • PerformanceMarkdownService: Subscribes to performanceEmitter for timing metrics

These 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.

Mermaid Diagram

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:

Signals track two critical timestamps for accurate duration calculation:

The 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.

Mermaid Diagram

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:

Mermaid Diagram

This multi-tier architecture (Schema → Validation → Connection → Client) enables:

  1. Validation at registration time: Schema structure is validated immediately via *ValidationService.validate() using memoized checks
  2. Lazy instantiation: Client instances are only created when first used, reducing memory overhead
  3. Instance reuse: Memoization ensures one Client per schema name, preventing state duplication
  4. Crash recovery: Live mode can restore persisted state via ClientStrategy.waitForInit() before first operation

Connection 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 system

MethodContextService wraps IMethodContext:

  • strategyName: Which IStrategySchema to retrieve
  • exchangeName: Which IExchangeSchema to retrieve
  • frameName: 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:

  • What to execute (MethodContext: which schemas)
  • When/How to execute (ExecutionContext: runtime parameters)

Mermaid Diagram

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.