This document covers advanced integration patterns and customization techniques for the backtest-kit framework. It explains how to implement custom exchange data sources, integrate alternative persistence backends, and design multi-symbol trading strategies with shared state management.
For basic usage of the public API, see Public API Reference. For service architecture patterns, see Service Layer. For signal lifecycle fundamentals, see Signal Lifecycle.
Custom exchange implementations allow integration with any data source including REST APIs, WebSocket streams, database queries, or CSV files. All custom exchanges must implement the IExchangeSchema interface and provide candle data, price formatting, and quantity formatting functions.
The IExchangeSchema interface defines the contract for custom exchange implementations:
| Method | Purpose | Required |
|---|---|---|
exchangeName |
Unique identifier for the exchange | Yes |
getCandles(symbol, interval, since, limit) |
Fetch OHLCV candle data | Yes |
formatPrice(symbol, price) |
Format price with exchange precision | Yes |
formatQuantity(symbol, quantity) |
Format quantity with exchange precision | Yes |
callbacks |
Optional lifecycle event callbacks | No |
To integrate with a REST API exchange, implement the IExchangeSchema interface with HTTP client calls:
// Reference pattern from README.md:32-52
// User implements getCandles() to fetch from external API
// User implements formatPrice() and formatQuantity() for precision rules
Key considerations:
ICandleData structureThe ClientExchange class will delegate all getCandles() calls to your implementation, as shown in types.d.ts:1438-1477.
Database-backed exchanges query historical candle data from local storage:
Architectural flow:
getCandles() to query database with SQL/NoSQLICandleData[] formatClientExchange receives standardized candle dataDatabase schema requirements:
timestamp, open, high, low, close, volumePrecision formatting:
symbol_info)File-based exchanges read historical data from disk:
Implementation approach:
getCandles() callPerformance optimizations:
Precision formatting:
All exchange implementations must return data in the ICandleData format:
// Reference: types.d.ts:104-118
// timestamp: Unix milliseconds (e.g., 1704067200000)
// open, high, low, close, volume: positive numbers
Common transformations:
| Source Format | Target Format | Conversion |
|---|---|---|
| ISO 8601 string | Unix milliseconds | new Date(isoString).getTime() |
| Unix seconds | Unix milliseconds | unixSeconds * 1000 |
| String numbers | Number | parseFloat(stringPrice) |
| Scientific notation | Decimal | Native JavaScript handling |
Validation rules:
timestamp must be positive integerhigh >= open, low, close (high is maximum)low <= open, high, close (low is minimum)volume >= 0 (can be zero for illiquid pairs)Optional callbacks provide observability into exchange operations:
// Reference: types.d.ts:131-135
// onCandleData(symbol, interval, since, limit, data)
// Called after every successful getCandles() invocation
Use cases:
The callback is invoked by ClientExchange.getCandles() after successful data fetch, as shown in types.d.ts:1448-1449.
The default persistence implementation uses atomic file writes to local disk. Custom persistence backends enable integration with distributed databases, in-memory caches, or cloud storage systems for production deployments.
Custom persistence adapters must implement the IPersistBase interface:
| Method | Purpose | Atomicity Required |
|---|---|---|
waitForInit(initial) |
Initialize storage connection/schema | No |
readValue(entityId) |
Read signal data for strategy+symbol | No |
hasValue(entityId) |
Check if signal exists | No |
writeValue(entityId, entity) |
Write signal data atomically | Yes |
Critical requirement: The writeValue() method must be atomic to prevent signal duplication during crashes. If the write operation cannot complete atomically, implement two-phase commit or use database transactions.
Redis provides in-memory persistence with optional disk durability:
Implementation pattern:
PersistBase classreadValue() to call redis.get()writeValue() to call redis.set()hasValue() to call redis.exists()redis.multi() for atomic multi-key operations if neededEntity ID format:
${strategyName}:${symbol} (e.g., "my-strategy:BTCUSDT")_getFilePath() methodAtomicity considerations:
SET commands are atomic in RedisSET with NX flag to prevent race conditionsMULTI/EXEC) for complex operationsConnection management:
waitForInit()PostgreSQL provides ACID-compliant persistence with strong consistency:
Schema design:
-- Reference implementation approach
-- Table: signal_data
-- Columns: strategy_name, symbol, signal_json, updated_at
-- Primary Key: (strategy_name, symbol)
-- Index: strategy_name for efficient lookups
Implementation details:
ISignalRow to JSON before storageINSERT ... ON CONFLICT UPDATE for upsertsAtomicity guarantees:
INSERT/UPDATE statements are atomicREAD COMMITTED or higherPerformance optimizations:
(strategy_name, symbol) for fast lookupsMongoDB provides document-based persistence with flexible schema:
Collection design:
// Reference schema structure
// Collection: signal_data
// Document: { _id: "strategy:symbol", strategyName, symbol, signalRow, updatedAt }
Implementation approach:
findOne() for readsupdateOne() with upsert: true for writes_id field to ${strategyName}:${symbol} for deterministic keyssignalRow as nested document (not JSON string)Atomicity guarantees:
findOneAndUpdate() for atomic read-modify-writeConnection handling:
S3 provides object storage with eventual consistency:
Implementation considerations:
| Aspect | Approach |
|---|---|
| Object key | ${strategyName}/${symbol}.json |
| Content-Type | application/json |
| Atomicity | Use S3 conditional writes (If-None-Match) |
| Read consistency | S3 is now strongly consistent for new objects |
| Permissions | Use IAM roles, not access keys |
Atomicity challenges:
Performance characteristics:
Cost optimization:
Custom persistence adapters are registered globally before running strategies:
// Reference: README.md:676-690
// PersistSignalAdaper.usePersistSignalAdapter(CustomPersistClass)
// Must be called before first strategy execution
Registration flow:
PersistBase subclassusePersistSignalAdapter() with constructorImportant notes:
Backtest.run() or Live.run() callAll persistence implementations must store and retrieve ISignalData objects:
// Reference: types.d.ts:896-903
// interface ISignalData {
// signalRow: ISignalRow | null
// }
Storage format:
{ signalRow: { id, position, priceOpen, ... } }{ signalRow: null }Entity ID structure:
${strategyName}:${symbol}"my-strategy:BTCUSDT" → my-strategy/BTCUSDT.jsonCrash recovery:
ClientStrategy.waitForInit() calls readSignalData()Multi-symbol strategies execute the same trading logic across multiple symbols simultaneously. The framework supports both isolated state (independent signals per symbol) and shared state (portfolio-level decisions) patterns.
The isolated state pattern runs independent strategy instances per symbol with separate persistence:
Characteristics:
| Aspect | Behavior |
|---|---|
| Signal generation | Independent per symbol |
| Persistence | Separate files/keys per symbol |
| Crash recovery | Per-symbol state restoration |
| Position sizing | Fixed per symbol |
| Risk management | Symbol-level only |
Implementation approach:
// Reference: README.md:693-715
// Promise.all() with multiple Live.run() calls
// Each symbol gets isolated execution context
Parallel execution:
Promise.all() to run symbols concurrentlyUse cases:
The shared state pattern maintains global state across all symbols for portfolio-level decisions:
Implementation requirements:
// User implements custom state management
// Example: Map<symbol, ISignalRow> for tracking all active signals
// Store in closure or external state management library
// User implements custom persistence for shared state
// Single persistence key for entire portfolio
// Atomic updates to prevent partial state writes
// User implements portfolio-level position sizing
// Calculate available capital across all symbols
// Enforce maximum open positions limit
// Balance capital allocation dynamically
Synchronization concerns:
Portfolio-level position limits prevent over-exposure:
Implementation pattern:
// Inside getSignal() implementation:
// 1. Check global position counter
// 2. If at max limit, return null (no new signal)
// 3. If below limit, generate signal and increment counter
// 4. On signal close, decrement counter
State tracking:
Example limits:
Multi-symbol strategies require portfolio-level risk management:
Correlation analysis:
Capital allocation:
Stop-loss coordination:
Exposure limits:
Event listeners aggregate signals across all symbols:
// Reference: README.md:336-461
// listenSignal() receives events from all symbols
// Filter by symbol, strategyName, or other criteria
Global monitoring:
result.signal.symbol for per-symbol logicPer-symbol monitoring:
listenSignalOnce() for one-time alertsBackground execution runs strategies silently with event-driven reactions:
// Reference: README.md:341-362
// Backtest.background() returns cancellation function
// Live.background() returns cancellation function
// Use event listeners to react to signals
Benefits:
Cancellation handling:
// Store cancellation functions in array
// Call all cancellation functions to stop portfolio
// Example: stops[] = [stopBTC, stopETH, stopSOL]
// stops.forEach(stop => stop())
Multi-symbol execution impacts system resources:
Memory usage:
| Pattern | Memory Per Symbol | Total for 10 Symbols |
|---|---|---|
| Isolated (streaming) | ~1-2 MB | ~10-20 MB |
| Isolated (accumulated) | ~10-50 MB | ~100-500 MB |
| Shared state | ~1-2 MB + shared | ~10-20 MB + shared |
CPU usage:
Network usage:
Recommendations:
Custom loggers integrate with framework internals:
// Reference: types.d.ts:32-49
// setLogger({ log, debug, info, warn })
// All internal services will use custom logger
// Automatic context injection (strategyName, exchangeName, symbol, when, backtest)
Context injection:
MethodContextService provides strategyName, exchangeName, frameNameExecutionContextService provides symbol, when, backtest flagLoggerService automatically appends context to all log calls"tick my-strategy binance BTCUSDT 2024-01-01T12:00:00Z backtest"Use cases:
Context services enable implicit parameter passing through the call stack:
// Reference: types.d.ts:84-95 (ExecutionContextService)
// Reference: types.d.ts:344-350 (MethodContextService)
// Uses di-scoped for async-local-storage-like behavior
ExecutionContextService:
{ symbol, when, backtest } through executionMethodContextService:
{ strategyName, exchangeName, frameName } through executionBenefits:
Extend framework services to add custom functionality:
Extension points:
ClientStrategy.getSignal() for complex signal logicClientExchange.getCandles() for custom data sourcesClientFrame.getTimeframe() for dynamic timeframesPersistBase for custom persistence backendsDependency injection:
Architecture constraints: