This document details the production-ready features of the backtest-kit framework that enable reliable algorithmic trading strategy development and deployment. Each feature is explained with its corresponding code implementation and architectural role.
For information about the overall architecture and layer separation, see Architecture. For implementation details of specific components, see Core Business Logic and Service Layer.
The framework supports two distinct execution modes with shared business logic but different orchestration patterns: backtesting for historical analysis and live trading for production deployment.
Backtesting executes strategies against historical data using BacktestLogicPrivateService to orchestrate timeframe iteration:
Backtest Flow Characteristics:
| Feature | Implementation |
|---|---|
| Timeframe Generation | ClientFrame.getTimeframe() creates timestamp array with configured interval |
| Context Injection | ExecutionContextService sets when to historical timestamp, backtest=true |
| Fast-Forward Simulation | ClientStrategy.backtest() processes future candles without tick iteration |
| Memory Efficiency | Generator yields only closed signals, no accumulation |
| Early Termination | User can break from async iterator at any time |
Live trading runs an infinite loop with 1-minute intervals, monitoring active signals in real-time:
Live Trading Characteristics:
| Feature | Implementation |
|---|---|
| Infinite Generator | LiveLogicPrivateService.execute() loops with while (true) |
| Real-Time Context | ExecutionContextService sets when=Date.now(), backtest=false |
| State Persistence | PersistSignalAdapter.writeSignalData() before every state change |
| Crash Recovery | ClientStrategy.waitForInit() loads last known state on restart |
| Interval Control | sleep(60000 + 1ms) ensures 1-minute tick rate |
| Filtered Output | Only opened and closed yielded, active filtered |
Live trading uses atomic file writes to persist signal state before every state transition, enabling crash recovery without signal duplication or data loss.
The PersistSignalAdapter ensures atomicity through temporary file writes:
| Step | Operation | Purpose |
|---|---|---|
| 1 | Write to .tmp file |
Prevent corruption if crash during write |
| 2 | Sync to disk | Ensure OS flushes write buffer |
| 3 | Rename .tmp to final |
Atomic filesystem operation (all-or-nothing) |
| 4 | Sync directory | Ensure directory entry is persisted |
Key Code Locations:
FilePersist.writeValue)setPendingSignal)waitForInit){strategyName}_{symbol}.json convention in PersistSignalAdapter.getEntityId()Every signal generated by getSignal() is validated before execution to prevent invalid trades. Validation failures throw descriptive errors. The framework provides configurable validation parameters to protect against common trading mistakes.
The VALIDATE_SIGNAL_FN function enforces the following constraints:
Configurable via setConfig() from src/config/params.ts:1-35:
| Parameter | Default | Purpose |
|---|---|---|
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
0.1% | Ensures TP covers trading fees (prevents micro-profits eaten by costs) |
CC_MAX_STOPLOSS_DISTANCE_PERCENT |
20% | Prevents catastrophic losses from extreme SL values |
CC_MAX_SIGNAL_LIFETIME_MINUTES |
1440 (1 day) | Prevents eternal signals blocking risk limits |
CC_SCHEDULE_AWAIT_MINUTES |
120 (2 hours) | Maximum wait time for scheduled signal activation |
Validation Error Examples:
| Invalid Signal | Error Message |
|---|---|
| Long with TP below open | Long: priceTakeProfit (49000) must be > priceOpen (50000) |
| Long with SL above open | Long: priceStopLoss (51000) must be < priceOpen (50000) |
| Short with TP above open | Short: priceTakeProfit (51000) must be < priceOpen (50000) |
| Short with SL below open | Short: priceStopLoss (49000) must be > priceOpen (50000) |
| Negative price | priceOpen must be positive, got -50000 |
| Zero time | minuteEstimatedTime must be positive, got 0 |
| TP too close | TakeProfit distance (0.05%) below minimum (0.1%) |
| SL too far | StopLoss distance (25%) exceeds maximum (20%) |
| Excessive lifetime | Signal lifetime (2000min) exceeds maximum (1440min) |
Both backtest and live execution use async generators (AsyncIterableIterator) to stream results without accumulating data in memory, enabling processing of arbitrarily large datasets.
| Aspect | Backtest Generator | Live Generator |
|---|---|---|
| Termination | Finite (timeframe exhausted) | Infinite (while (true)) |
| Yield Condition | Only closed results |
opened and closed (filters active) |
| Context Setting | Historical timestamp from timeframe | Date.now() on each iteration |
| Sleep Interval | None (fast iteration) | 60000ms + 1ms between ticks |
| Fast-Forward | Yes (backtest() method) |
No (real-time only) |
| Cancellation | break from iterator |
cancel() function returned by background() |
Prototype Methods: All client classes use prototype functions instead of arrow functions
public methodName = async () => {} pattern)Memoization: Connection services cache client instances
memoize() from functools-kit on getStrategy(), getExchange(), getFrame()Streaming Accumulation: Markdown services accumulate passively
getReport()Lazy Initialization: Services created only when needed
Signals follow a deterministic state machine with discriminated union types for type-safe handling. The framework supports both market orders (immediate execution) and limit orders (scheduled execution).
The discriminated union IStrategyTickResult enables type narrowing:
// Example usage (not actual code, just illustration)
const result = await strategy.tick();
if (result.action === "idle") {
// TypeScript knows: result.signal === null
console.log(result.currentPrice);
}
if (result.action === "scheduled") {
// TypeScript knows: result.signal is ISignalRow with scheduledAt
console.log(result.signal.priceOpen);
console.log(result.signal.scheduledAt);
}
if (result.action === "cancelled") {
// TypeScript knows: result has closeReason, closeTimestamp
console.log(result.closeReason); // "timeout" | "stop_loss"
console.log(result.closeTimestamp);
}
if (result.action === "opened") {
// TypeScript knows: result.signal is ISignalRow with pendingAt
console.log(result.signal.priceOpen);
console.log(result.signal.pendingAt);
}
if (result.action === "active") {
// TypeScript knows: result.signal is ISignalRow
// result.currentPrice is available
}
if (result.action === "closed") {
// TypeScript knows: result has pnl, closeReason, closeTimestamp
console.log(result.pnl.pnlPercentage);
console.log(result.closeReason); // "take_profit" | "stop_loss" | "time_expired"
}
| State | Entry Point | Exit Point | Notes |
|---|---|---|---|
| idle | src/client/ClientStrategy.ts:306-322 | getSignal() returns non-null |
No active signal |
| scheduled | Signal generation with future priceOpen |
Price activation or timeout | Limit order waiting |
| cancelled | Timeout or SL before activation | Return to idle | Scheduled signal not filled |
| opened | src/client/ClientStrategy.ts:275-291 | Next tick iteration | Position activated |
| active | src/client/ClientStrategy.ts:447-463 | TP/SL/time condition met | Position monitoring |
| closed | src/client/ClientStrategy.ts:416-435 | setPendingSignal(null) |
Final state with PNL |
Profit and loss calculations include realistic trading costs (fees and slippage) for accurate backtesting:
| Cost Type | Value | Application |
|---|---|---|
| Fee | 0.1% (0.001) | Applied to both entry and exit |
| Slippage | 0.1% (0.001) | Simulates market impact |
| Total Cost | 0.2% per side | 0.4% round-trip (0.2% entry + 0.2% exit) |
Long Position:
priceOpenWithCosts = priceOpen × (1 + slippage + fee)
priceCloseWithCosts = priceClose × (1 - slippage - fee)
pnl% = (priceCloseWithCosts - priceOpenWithCosts) / priceOpenWithCosts × 100
Short Position:
priceOpenWithCosts = priceOpen × (1 - slippage + fee)
priceCloseWithCosts = priceClose × (1 + slippage + fee)
pnl% = (priceOpenWithCosts - priceCloseWithCosts) / priceOpenWithCosts × 100
Example Calculation (Long Position):
| Parameter | Value |
|---|---|
| Entry Price | $50,000 |
| Exit Price | $51,000 |
| Adjusted Entry | $50,000 × 1.002 = $50,100 |
| Adjusted Exit | $51,000 × 0.998 = $50,898 |
| PNL % | ($50,898 - $50,100) / $50,100 × 100 = +1.59% |
Without costs, this would be +2.0%. The 0.41% difference represents realistic trading costs.
Signal generation is throttled at the strategy level to prevent spam and ensure consistent signal spacing:
| Interval | Minutes | Use Case |
|---|---|---|
"1m" |
1 | High-frequency strategies |
"3m" |
3 | Short-term signals |
"5m" |
5 | Medium-frequency trading |
"15m" |
15 | Moderate signals |
"30m" |
30 | Low-frequency strategies |
"1h" |
60 | Hourly signals |
The throttling check occurs at src/client/ClientStrategy.ts:94-106:
// Pseudocode representation (not actual code)
const intervalMinutes = INTERVAL_MINUTES[interval]; // e.g., "5m" → 5
const intervalMs = intervalMinutes × 60 × 1000;
if (lastSignalTimestamp !== null &&
currentTime - lastSignalTimestamp < intervalMs) {
return null; // Too soon, throttle
}
lastSignalTimestamp = currentTime; // Update for next check
All price monitoring uses Volume-Weighted Average Price (VWAP) calculated from the last CC_AVG_PRICE_CANDLES_COUNT (default: 5) one-minute candles, providing more accurate price discovery than simple close prices:
| Context | Method | Purpose |
|---|---|---|
| Live Tick | ClientStrategy.tick() |
Check TP/SL against current VWAP |
| Live Idle | ClientStrategy.tick() (no signal) |
Report current market price |
| Backtest | ClientStrategy.backtest() |
Check TP/SL on each candle's VWAP |
| Public API | getAveragePrice(symbol) |
Expose VWAP to user strategies |
Edge Case: If total volume is zero, fallback to simple average of close prices:
// Pseudocode (not actual code)
if (totalVolume === 0) {
return candles.reduce((sum, c) => sum + c.close, 0) / candles.length;
}
The framework generates detailed markdown reports with statistics for backtest, live trading, and scheduled signals:
Backtest Report Metrics (BacktestMarkdownService):
Live Report Metrics (LiveMarkdownService):
Schedule Report Metrics (ScheduleMarkdownService):
| Method | Backtest | Live | Schedule | Purpose |
|---|---|---|---|---|
getData(strategy) |
✓ | ✓ | ✓ | Get statistics object |
getReport(strategy) |
✓ | ✓ | ✓ | Generate markdown string |
dump(strategy, path?) |
✓ | ✓ | ✓ | Save to disk (default: ./logs/{mode}/{strategy}.md) |
clear(strategy?) |
✓ | ✓ | ✓ | Clear accumulated data |
The framework uses a registry pattern with dependency injection to support custom implementations of exchanges, strategies, and timeframes:
| Schema | Required Methods | Purpose |
|---|---|---|
| IStrategySchema | getSignal(symbol) |
Define signal generation logic |
| IExchangeSchema | getCandles(symbol, interval, since, limit) |
Provide market data |
| IFrameSchema | startDate, endDate, interval |
Define backtest period |
Connection services use memoization to ensure single instance per schema name:
Users can implement custom exchanges by providing a schema:
// Example pattern (not actual code)
addExchange({
exchangeName: "custom-db",
getCandles: async (symbol, interval, since, limit) => {
// Fetch from PostgreSQL, MongoDB, etc.
const rows = await db.query(`SELECT * FROM candles WHERE ...`);
return rows.map(row => ({
timestamp: row.time,
open: row.open,
high: row.high,
low: row.low,
close: row.close,
volume: row.volume
}));
},
formatPrice: async (symbol, price) => price.toFixed(8),
formatQuantity: async (symbol, qty) => qty.toFixed(8)
});
| Feature | Key Components | Primary Benefit |
|---|---|---|
| Multi-Mode Execution | BacktestLogicPrivateService, LiveLogicPrivateService |
Single codebase for research and production |
| Crash-Safe Persistence | PersistSignalAdapter, FilePersist |
Zero data loss in production crashes |
| Signal Validation | VALIDATE_SIGNAL_FN, GET_SIGNAL_FN |
Prevents invalid trades at source |
| Async Generators | execute() generator methods |
Constant memory usage, early termination |
| Signal Lifecycle | IStrategyTickResult discriminated union |
Type-safe state handling |
| Accurate PNL | toProfitLossDto() |
Realistic performance metrics |
| Interval Throttling | _lastSignalTimestamp check |
Controlled signal frequency |
| VWAP Pricing | GET_AVG_PRICE_FN, getAveragePrice() |
Better price discovery |
| Markdown Reports | BacktestMarkdownService, LiveMarkdownService |
Performance analysis and auditing |
| Plugin Architecture | Schema services, connection services | Easy integration with custom data sources |