JSPM

backtest-kit

1.5.2
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 1064
  • Score
    100M100P100Q108203F
  • License MIT

A TypeScript library for trading system backtest

Package Exports

  • backtest-kit

Readme

🧿 Backtest Kit

A production-ready TypeScript framework for backtesting and live trading strategies with crash-safe state persistence, signal validation, and memory-optimized architecture.

Ask DeepWiki npm TypeScript

Build sophisticated trading systems with confidence. Backtest Kit empowers you to develop, test, and deploy algorithmic trading strategies with enterprise-grade reliability—featuring atomic state persistence, comprehensive validation, and memory-efficient execution. Whether you're backtesting historical data or running live strategies, this framework provides the tools you need to trade with precision.

📚 API Reference | 🌟 Quick Start

✨ Why Choose Backtest Kit?

  • 🚀 Production-Ready Architecture: Seamlessly switch between backtest and live modes with robust error recovery and graceful shutdown mechanisms. Your strategy code remains identical across environments.

  • 💾 Crash-Safe Persistence: Atomic file writes with automatic state recovery ensure no duplicate signals or lost data—even after crashes. Resume execution exactly where you left off.

  • Signal Validation: Comprehensive validation prevents invalid trades before execution. Catches price logic errors (TP/SL), throttles signal spam, and ensures data integrity. 🛡️

  • 🔄 Async Generator Architecture: Memory-efficient streaming for backtest and live execution. Process years of historical data without loading everything into memory. ⚡

  • 📊 VWAP Pricing: Volume-weighted average price from last 5 1-minute candles ensures realistic backtest results that match live execution. 📈

  • 🎯 Type-Safe Signal Lifecycle: State machine with compile-time guarantees (idle → scheduled → opened → active → closed/cancelled). No runtime state confusion. 🔒

  • 📈 Accurate PNL Calculation: Realistic profit/loss with configurable fees (0.1%) and slippage (0.1%). Track gross and net returns separately. 💰

  • Time-Travel Context: Async context propagation allows same strategy code to run in backtest (with historical time) and live (with real-time) without modifications. 🌐

  • 📝 Auto-Generated Reports: Markdown reports with statistics (win rate, avg PNL, Sharpe Ratio, standard deviation, certainty ratio, expected yearly returns, risk-adjusted returns). 📊

  • 📊 Revenue Profiling: Built-in performance tracking with aggregated statistics (avg, min, max, stdDev, P95, P99) for bottleneck analysis. ⚡

  • 🏃 Strategy Comparison (Walker): Compare multiple strategies in parallel with automatic ranking and statistical analysis. Find your best performer. 🏆

  • 🔥 Portfolio Heatmap: Multi-symbol performance analysis with extended metrics (Profit Factor, Expectancy, Win/Loss Streaks, Avg Win/Loss) sorted by Sharpe Ratio. 📉

  • 💰 Position Sizing Calculator: Built-in position sizing methods (Fixed Percentage, Kelly Criterion, ATR-based) with risk management constraints. 💵

  • 🛡️ Risk Management System: Portfolio-level risk controls with custom validation logic, concurrent position limits, and cross-strategy coordination. 🔐

  • 💾 Zero Data Download: Unlike Freqtrade, no need to download gigabytes of historical data—plug any data source (CCXT, database, API). 🚀

  • 🔌 Pluggable Persistence: Replace default file-based persistence with custom adapters (Redis, MongoDB, PostgreSQL) for distributed systems and high-performance scenarios.

  • 🔒 Safe Math & Robustness: All metrics protected against NaN/Infinity with unsafe numeric checks. Returns N/A for invalid calculations. ✨

  • 🤖 AI Strategy Optimizer: LLM-powered strategy generation from historical data. Train multiple strategy variants, compare performance, and auto-generate executable code. Supports Ollama integration with multi-timeframe analysis. 🧠

  • 🧪 Comprehensive Test Coverage: Unit and integration tests covering validation, PNL, callbacks, reports, performance tracking, walker, heatmap, position sizing, risk management, scheduled signals, crash recovery, optimizer, and event system.


🎳 Supported Order Types

Backtest Kit supports multiple execution styles to match real trading behavior:

  • Market — instant execution using current VWAP

  • Limit — entry at a specified priceOpen

  • Take Profit (TP) — automatic exit at the target price

  • Stop Loss (SL) — protective exit at the stop level

  • OCO (TP + SL) — linked exits; one cancels the other

  • Grid — auto-cancel if price never reaches entry point or hits SL before activation

🆕 Extendable Order Types

Easy to add without modifying the core:

  • Stop / Stop-Limit — entry triggered by triggerPrice

  • Trailing Stop — dynamic SL based on market movement

  • Conditional Entry — enter only if price breaks a level (above / below)

  • Post-Only / Reduce-Only — exchange-level execution flags


🚀 Getting Started

Installation

Get up and running in seconds:

npm install backtest-kit

Quick Example

Here's a taste of what backtest-kit can do—create a simple moving average crossover strategy with crash-safe persistence:

import {
  addExchange,
  addStrategy,
  addFrame,
  Backtest,
  listenSignalBacktest,
  listenError,
  listenDoneBacktest
} from "backtest-kit";
import ccxt from "ccxt";

// 1. Register exchange data source
addExchange({
  exchangeName: "binance",
  getCandles: async (symbol, interval, since, limit) => {
    const exchange = new ccxt.binance();
    const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
    return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({
      timestamp, open, high, low, close, volume
    }));
  },
  formatPrice: async (symbol, price) => price.toFixed(2),
  formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
});

// 2. Register trading strategy
addStrategy({
  strategyName: "sma-crossover",
  interval: "5m", // Throttling: signals generated max once per 5 minutes
  getSignal: async (symbol) => {
    const price = await getAveragePrice(symbol);
    return {
      position: "long",
      note: "BTC breakout",
      priceOpen: price,
      priceTakeProfit: price + 1_000,  // Must be > priceOpen for long
      priceStopLoss: price - 1_000,     // Must be < priceOpen for long
      minuteEstimatedTime: 60,
    };
  },
  callbacks: {
    onSchedule: (symbol, signal, currentPrice, backtest) => {
      console.log(`[${backtest ? "BT" : "LIVE"}] Scheduled signal created:`, signal.id);
    },
    onOpen: (symbol, signal, currentPrice, backtest) => {
      console.log(`[${backtest ? "BT" : "LIVE"}] Signal opened:`, signal.id);
    },
    onActive: (symbol, signal, currentPrice, backtest) => {
      console.log(`[${backtest ? "BT" : "LIVE"}] Signal active:`, signal.id);
    },
    onClose: (symbol, signal, priceClose, backtest) => {
      console.log(`[${backtest ? "BT" : "LIVE"}] Signal closed:`, priceClose);
    },
    onCancel: (symbol, signal, currentPrice, backtest) => {
      console.log(`[${backtest ? "BT" : "LIVE"}] Scheduled signal cancelled:`, signal.id);
    },
  },
});

// 3. Add timeframe generator
addFrame({
  frameName: "1d-backtest",
  interval: "1m",
  startDate: new Date("2024-01-01T00:00:00Z"),
  endDate: new Date("2024-01-02T00:00:00Z"),
});

// 4. Run backtest in background
Backtest.background("BTCUSDT", {
  strategyName: "sma-crossover",
  exchangeName: "binance",
  frameName: "1d-backtest"
});

// Listen to closed signals
listenSignalBacktest((event) => {
  if (event.action === "closed") {
    console.log("PNL:", event.pnl.pnlPercentage);
  }
});

// Listen to backtest completion
listenDoneBacktest((event) => {
  console.log("Backtest completed:", event.symbol);
  Backtest.dump(event.strategyName); // ./logs/backtest/sma-crossover.md
});

The feature of this library is dependency inversion for component injection. Exchanges, strategies, frames, and risk profiles are lazy-loaded during runtime, so you can declare them in separate modules and connect them with string constants 🧩

export enum ExchangeName {
  Binance = "binance",
  Bybit = "bybit",
}

export enum StrategyName {
  SMACrossover = "sma-crossover",
  RSIStrategy = "rsi-strategy",
}

export enum FrameName {
  OneDay = "1d-backtest",
  OneWeek = "1w-backtest",
}

// ...

addStrategy({
  strategyName: StrategyName.SMACrossover,
  interval: "5m",
  // ...
});

Backtest.background("BTCUSDT", {
  strategyName: StrategyName.SMACrossover,
  exchangeName: ExchangeName.Binance,
  frameName: FrameName.OneDay
});

🌟 Key Features

  • 🤝 Mode Switching: Seamlessly switch between backtest and live modes with identical strategy code. 🔄
  • 📜 Crash Recovery: Atomic persistence ensures state recovery after crashes—no duplicate signals. 🗂️
  • 🛠️ Custom Validators: Define validation rules with strategy-level throttling and price logic checks. 🔧
  • 🛡️ Signal Lifecycle: Type-safe state machine prevents invalid state transitions. 🚑
  • 📦 Dependency Inversion: Lazy-load components at runtime for modular, scalable designs. 🧩
  • 🔍 Schema Reflection: Runtime introspection with listExchanges(), listStrategies(), listFrames(). 📊
  • 🔬 Data Validation: Automatic detection and rejection of incomplete candles from Binance API with anomaly checks.

🎯 Use Cases

  • 📈 Algorithmic Trading: Backtest and deploy systematic trading strategies with confidence. 💹
  • 🤖 Strategy Development: Rapid prototyping with automatic validation and PNL tracking. 🛠️
  • 📊 Performance Analysis: Compare strategies with Walker and analyze portfolios with Heatmap. 📉
  • 💼 Portfolio Management: Multi-symbol trading with risk controls and position sizing. 🏦

📖 API Highlights

  • 🛠️ addExchange: Define exchange data sources (CCXT, database, API). 📡
  • 🤖 addStrategy: Create trading strategies with custom signals and callbacks. 💡
  • 🌐 addFrame: Configure timeframes for backtesting. 📅
  • 🔄 Backtest / Live: Run strategies in backtest or live mode (generator or background). ⚡
  • 📅 Schedule: Track scheduled signals and cancellation rate for limit orders. 📊
  • 📊 Partial: Access partial profit/loss statistics and reports for risk management. Track signals reaching milestone levels (10%, 20%, 30%, etc.). 💹
  • 🎯 Constant: Kelly Criterion-based constants for optimal take profit (TP_LEVEL1-3) and stop loss (SL_LEVEL1-2) levels. 📐
  • 🏃 Walker: Compare multiple strategies in parallel with ranking. 🏆
  • 🔥 Heat: Portfolio-wide performance analysis across multiple symbols. 📊
  • 💰 PositionSize: Calculate position sizes with Fixed %, Kelly Criterion, or ATR-based methods. 💵
  • 🛡️ addRisk: Portfolio-level risk management with custom validation logic. 🔐
  • 💾 PersistBase: Base class for custom persistence adapters (Redis, MongoDB, PostgreSQL).
  • 🔌 PersistSignalAdapter / PersistScheduleAdapter / PersistRiskAdapter / PersistPartialAdapter: Register custom adapters for signal, scheduled signal, risk, and partial state persistence.
  • 🤖 Optimizer: AI-powered strategy generation with LLM integration. Auto-generate strategies from historical data and export executable code. 🧠

Check out the sections below for detailed examples! 📚


🛠 Advanced Features

1. Register Exchange Data Source

You can plug any data source: CCXT for live data or a database for faster backtesting:

import { addExchange } from "backtest-kit";
import ccxt from "ccxt";

// Option 1: CCXT (live or historical)
addExchange({
  exchangeName: "binance",
  getCandles: async (symbol, interval, since, limit) => {
    const exchange = new ccxt.binance();
    const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
    return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({
      timestamp, open, high, low, close, volume
    }));
  },
  formatPrice: async (symbol, price) => price.toFixed(2),
  formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
});

// Option 2: Database (faster backtesting)
import { db } from "./database";

addExchange({
  exchangeName: "binance-db",
  getCandles: async (symbol, interval, since, limit) => {
    return await db.query(`
      SELECT timestamp, open, high, low, close, volume
      FROM candles
      WHERE symbol = $1 AND interval = $2 AND timestamp >= $3
      ORDER BY timestamp ASC
      LIMIT $4
    `, [symbol, interval, since, limit]);
  },
  formatPrice: async (symbol, price) => price.toFixed(2),
  formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
});

2. Register Trading Strategy

Define your signal generation logic with automatic validation:

import { addStrategy } from "backtest-kit";

addStrategy({
  strategyName: "my-strategy",
  interval: "5m", // Throttling: signals generated max once per 5 minutes
  getSignal: async (symbol) => {
    const price = await getAveragePrice(symbol);
    return {
      position: "long",
      note: "BTC breakout",
      priceOpen: price,
      priceTakeProfit: price + 1_000,  // Must be > priceOpen for long
      priceStopLoss: price - 1_000,     // Must be < priceOpen for long
      minuteEstimatedTime: 60,
    };
  },
  callbacks: {
    onOpen: (symbol, signal, currentPrice, backtest) => {
      console.log(`[${backtest ? "BT" : "LIVE"}] Signal opened:`, signal.id);
    },
    onClose: (symbol, signal, priceClose, backtest) => {
      console.log(`[${backtest ? "BT" : "LIVE"}] Signal closed:`, priceClose);
    },
  },
});

3. Run Backtest

Run strategies in background mode (infinite loop) or manually iterate with async generators:

import { Backtest, listenSignalBacktest, listenDoneBacktest } from "backtest-kit";

// Option 1: Background mode (recommended)
const stopBacktest = Backtest.background("BTCUSDT", {
  strategyName: "my-strategy",
  exchangeName: "binance",
  frameName: "1d-backtest"
});

listenSignalBacktest((event) => {
  if (event.action === "closed") {
    console.log("PNL:", event.pnl.pnlPercentage);
  }
});

listenDoneBacktest((event) => {
  console.log("Backtest completed:", event.symbol);
  Backtest.dump(event.strategyName); // ./logs/backtest/my-strategy.md
});

// Option 2: Manual iteration (for custom control)
for await (const result of Backtest.run("BTCUSDT", {
  strategyName: "my-strategy",
  exchangeName: "binance",
  frameName: "1d-backtest"
})) {
  console.log("PNL:", result.pnl.pnlPercentage);
  if (result.pnl.pnlPercentage < -5) break; // Early termination
}

4. Run Live Trading (Crash-Safe)

Live mode automatically persists state to disk with atomic writes:

import { Live, listenSignalLive } from "backtest-kit";

// Run live trading in background (infinite loop, crash-safe)
const stop = Live.background("BTCUSDT", {
  strategyName: "my-strategy",
  exchangeName: "binance"
});

listenSignalLive((event) => {
  if (event.action === "opened") {
    console.log("Signal opened:", event.signal.id);
  }

  if (event.action === "closed") {
    console.log("Signal closed:", {
      reason: event.closeReason,
      pnl: event.pnl.pnlPercentage,
    });
    Live.dump(event.strategyName); // Auto-save report
  }
});

// Stop when needed: stop();

Crash Recovery: If process crashes, restart with same code—state automatically recovered from disk (no duplicate signals).

5. Strategy Comparison with Walker

Walker runs multiple strategies in parallel and ranks them by a selected metric:

import { addWalker, Walker, listenWalkerComplete } from "backtest-kit";

// Register walker schema
addWalker({
  walkerName: "btc-walker",
  exchangeName: "binance",
  frameName: "1d-backtest",
  strategies: ["strategy-a", "strategy-b", "strategy-c"],
  metric: "sharpeRatio", // Metric to compare strategies
  callbacks: {
    onStrategyStart: (strategyName, symbol) => {
      console.log(`Starting strategy: ${strategyName}`);
    },
    onStrategyComplete: (strategyName, symbol, stats) => {
      console.log(`${strategyName} completed:`, stats.sharpeRatio);
    },
    onComplete: (results) => {
      console.log("Best strategy:", results.bestStrategy);
      console.log("Best metric:", results.bestMetric);
    },
  },
});

// Run walker in background
Walker.background("BTCUSDT", {
  walkerName: "btc-walker"
});

// Listen to walker completion
listenWalkerComplete((results) => {
  console.log("Walker completed:", results.bestStrategy);
  Walker.dump("BTCUSDT", results.walkerName); // Save report
});

// Get raw comparison data
const results = await Walker.getData("BTCUSDT", "btc-walker");
console.log(results);
// Returns:
// {
//   bestStrategy: "strategy-b",
//   bestMetric: 1.85,
//   strategies: [
//     { strategyName: "strategy-a", stats: { sharpeRatio: 1.23, ... }, metric: 1.23 },
//     { strategyName: "strategy-b", stats: { sharpeRatio: 1.85, ... }, metric: 1.85 },
//     { strategyName: "strategy-c", stats: { sharpeRatio: 0.98, ... }, metric: 0.98 }
//   ]
// }

// Generate markdown report
const markdown = await Walker.getReport("BTCUSDT", "btc-walker");
console.log(markdown);

Available metrics for comparison:

  • sharpeRatio - Risk-adjusted return (default)
  • winRate - Win percentage
  • avgPnl - Average PNL percentage
  • totalPnl - Total PNL percentage
  • certaintyRatio - avgWin / |avgLoss|

6. Portfolio Heatmap

Heat provides portfolio-wide performance analysis across multiple symbols:

import { Heat, Backtest } from "backtest-kit";

// Run backtests for multiple symbols
for (const symbol of ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"]) {
  for await (const _ of Backtest.run(symbol, {
    strategyName: "my-strategy",
    exchangeName: "binance",
    frameName: "2024-backtest"
  })) {}
}

// Get raw heatmap data
const stats = await Heat.getData("my-strategy");
console.log(stats);
// Returns:
// {
//   symbols: [
//     {
//       symbol: "BTCUSDT",
//       totalPnl: 15.5,           // Total profit/loss %
//       sharpeRatio: 2.10,        // Risk-adjusted return
//       profitFactor: 2.50,       // Wins / Losses ratio
//       expectancy: 1.85,         // Expected value per trade
//       winRate: 72.3,            // Win percentage
//       avgWin: 2.45,             // Average win %
//       avgLoss: -0.95,           // Average loss %
//       maxDrawdown: -2.5,        // Maximum drawdown %
//       maxWinStreak: 5,          // Consecutive wins
//       maxLossStreak: 2,         // Consecutive losses
//       totalTrades: 45,
//       winCount: 32,
//       lossCount: 13,
//       avgPnl: 0.34,
//       stdDev: 1.62
//     },
//     // ... more symbols sorted by Sharpe Ratio
//   ],
//   totalSymbols: 4,
//   portfolioTotalPnl: 45.3,      // Portfolio-wide total PNL
//   portfolioSharpeRatio: 1.85,   // Portfolio-wide Sharpe
//   portfolioTotalTrades: 120
// }

// Generate markdown report
const markdown = await Heat.getReport("my-strategy");
console.log(markdown);

// Save to disk (default: ./logs/heatmap/my-strategy.md)
await Heat.dump("my-strategy");

Heatmap Report Example:

# Portfolio Heatmap: my-strategy

**Total Symbols:** 4 | **Portfolio PNL:** +45.30% | **Portfolio Sharpe:** 1.85 | **Total Trades:** 120

| Symbol | Total PNL | Sharpe | PF | Expect | WR | Avg Win | Avg Loss | Max DD | W Streak | L Streak | Trades |
|--------|-----------|--------|-------|--------|-----|---------|----------|--------|----------|----------|--------|
| BTCUSDT | +15.50% | 2.10 | 2.50 | +1.85% | 72.3% | +2.45% | -0.95% | -2.50% | 5 | 2 | 45 |
| ETHUSDT | +12.30% | 1.85 | 2.15 | +1.45% | 68.5% | +2.10% | -1.05% | -3.10% | 4 | 2 | 38 |
| SOLUSDT | +10.20% | 1.65 | 1.95 | +1.20% | 65.2% | +1.95% | -1.15% | -4.20% | 3 | 3 | 25 |
| BNBUSDT | +7.30% | 1.40 | 1.75 | +0.95% | 62.5% | +1.75% | -1.20% | -3.80% | 3 | 2 | 12 |

Column Descriptions:

  • Total PNL - Total profit/loss percentage across all trades
  • Sharpe - Risk-adjusted return (higher is better)
  • PF - Profit Factor: sum of wins / sum of losses (>1.0 is profitable)
  • Expect - Expectancy: expected value per trade
  • WR - Win Rate: percentage of winning trades
  • Avg Win - Average profit on winning trades
  • Avg Loss - Average loss on losing trades
  • Max DD - Maximum drawdown (largest peak-to-trough decline)
  • W Streak - Maximum consecutive winning trades
  • L Streak - Maximum consecutive losing trades
  • Trades - Total number of trades for this symbol

7. Position Sizing Calculator

Position Sizing Calculator helps determine optimal position sizes based on risk management rules:

import { addSizing, PositionSize } from "backtest-kit";

// Fixed Percentage Risk - risk fixed % of account per trade
addSizing({
  sizingName: "conservative",
  note: "Conservative 2% risk per trade",
  method: "fixed-percentage",
  riskPercentage: 2,                // Risk 2% of account per trade
  maxPositionPercentage: 10,        // Max 10% of account in single position (optional)
  minPositionSize: 0.001,           // Min 0.001 BTC position (optional)
  maxPositionSize: 1.0,             // Max 1.0 BTC position (optional)
});

// Kelly Criterion - optimal bet sizing based on edge
addSizing({
  sizingName: "kelly-quarter",
  note: "Kelly Criterion with 25% multiplier for safety",
  method: "kelly-criterion",
  kellyMultiplier: 0.25,            // Use 25% of full Kelly (recommended for safety)
  maxPositionPercentage: 15,        // Cap position at 15% of account (optional)
  minPositionSize: 0.001,           // Min 0.001 BTC position (optional)
  maxPositionSize: 2.0,             // Max 2.0 BTC position (optional)
});

// ATR-based - volatility-adjusted position sizing
addSizing({
  sizingName: "atr-dynamic",
  note: "ATR-based sizing with 2x multiplier",
  method: "atr-based",
  riskPercentage: 2,                // Risk 2% of account
  atrMultiplier: 2,                 // Use 2x ATR as stop distance
  maxPositionPercentage: 12,        // Max 12% of account (optional)
  minPositionSize: 0.001,           // Min 0.001 BTC position (optional)
  maxPositionSize: 1.5,             // Max 1.5 BTC position (optional)
});

// Calculate position sizes
const quantity1 = await PositionSize.fixedPercentage(
  "BTCUSDT",
  10000,      // Account balance: $10,000
  50000,      // Entry price: $50,000
  49000,      // Stop loss: $49,000
  { sizingName: "conservative" }
);
console.log(`Position size: ${quantity1} BTC`);

const quantity2 = await PositionSize.kellyCriterion(
  "BTCUSDT",
  10000,      // Account balance: $10,000
  50000,      // Entry price: $50,000
  0.55,       // Win rate: 55%
  1.5,        // Win/loss ratio: 1.5
  { sizingName: "kelly-quarter" }
);
console.log(`Position size: ${quantity2} BTC`);

const quantity3 = await PositionSize.atrBased(
  "BTCUSDT",
  10000,      // Account balance: $10,000
  50000,      // Entry price: $50,000
  500,        // ATR: $500
  { sizingName: "atr-dynamic" }
);
console.log(`Position size: ${quantity3} BTC`);

When to Use Each Method:

  1. Fixed Percentage - Simple risk management, consistent risk per trade

    • Best for: Beginners, conservative strategies
    • Risk: Fixed 1-2% per trade
  2. Kelly Criterion - Optimal bet sizing based on win rate and win/loss ratio

    • Best for: Strategies with known edge, statistical advantage
    • Risk: Use fractional Kelly (0.25-0.5) to reduce volatility
  3. ATR-based - Volatility-adjusted sizing, accounts for market conditions

    • Best for: Swing trading, volatile markets
    • Risk: Position size scales with volatility

8. Risk Management

Risk Management provides portfolio-level risk controls across strategies:

import { addRisk } from "backtest-kit";

// Simple concurrent position limit
addRisk({
  riskName: "conservative",
  note: "Conservative risk profile with max 3 concurrent positions",
  validations: [
    ({ activePositionCount }) => {
      if (activePositionCount >= 3) {
        throw new Error("Maximum 3 concurrent positions allowed");
      }
    },
  ],
  callbacks: {
    onRejected: (symbol, params) => {
      console.warn(`Signal rejected for ${symbol}:`, params);
    },
    onAllowed: (symbol, params) => {
      console.log(`Signal allowed for ${symbol}`);
    },
  },
});

// Symbol-based filtering
addRisk({
  riskName: "no-meme-coins",
  note: "Block meme coins from trading",
  validations: [
    ({ symbol }) => {
      const memeCoins = ["DOGEUSDT", "SHIBUSDT", "PEPEUSDT"];
      if (memeCoins.includes(symbol)) {
        throw new Error(`Meme coin ${symbol} not allowed`);
      }
    },
  ],
});

// Time-based trading windows
addRisk({
  riskName: "trading-hours",
  note: "Only trade during market hours (9 AM - 5 PM UTC)",
  validations: [
    ({ timestamp }) => {
      const date = new Date(timestamp);
      const hour = date.getUTCHours();

      if (hour < 9 || hour >= 17) {
        throw new Error("Trading only allowed 9 AM - 5 PM UTC");
      }
    },
  ],
});

// Multi-strategy coordination with position inspection
addRisk({
  riskName: "strategy-coordinator",
  note: "Limit exposure per strategy and inspect active positions",
  validations: [
    ({ activePositions, strategyName, symbol }) => {
      // Count positions for this specific strategy
      const strategyPositions = activePositions.filter(
        (pos) => pos.strategyName === strategyName
      );

      if (strategyPositions.length >= 2) {
        throw new Error(`Strategy ${strategyName} already has 2 positions`);
      }

      // Check if we already have a position on this symbol
      const symbolPositions = activePositions.filter(
        (pos) => pos.symbol === symbol
      );

      if (symbolPositions.length > 0) {
        throw new Error(`Already have position on ${symbol}`);
      }
    },
  ],
});

// Use risk profile in strategy
addStrategy({
  strategyName: "my-strategy",
  interval: "5m",
  riskName: "conservative", // Apply risk profile
  getSignal: async (symbol) => {
    // Signal generation logic
    return { /* ... */ };
  },
});

9. Custom Persistence Adapters (Optional)

By default, backtest-kit uses file-based persistence with atomic writes. You can replace this with custom adapters (e.g., Redis, MongoDB, PostgreSQL) for distributed systems or high-performance scenarios.

Understanding the Persistence System

The library uses three persistence layers:

  1. PersistBase - Base class for all persistence operations (file-based by default)
  2. PersistSignalAdapter - Manages signal state persistence (used by Live mode)
  3. PersistRiskAdapter - Manages active positions for risk management

Default File-Based Persistence

By default, data is stored in JSON files:

./logs/data/
  signal/
    my-strategy/
      BTCUSDT.json      # Signal state for BTCUSDT
      ETHUSDT.json      # Signal state for ETHUSDT
  risk/
    conservative/
      positions.json     # Active positions for risk profile

Create Custom Adapter (Redis Example)

import { PersistBase, PersistSignalAdaper, PersistRiskAdapter } from "backtest-kit";
import Redis from "ioredis";

const redis = new Redis();

// Custom Redis-based persistence adapter
class RedisPersist extends PersistBase {
  // Initialize Redis connection
  async waitForInit(initial: boolean): Promise<void> {
    // Redis connection is already established
    console.log(`Redis persistence initialized for ${this.entityName}`);
  }

  // Read entity from Redis
  async readValue<T>(entityId: string | number): Promise<T> {
    const key = `${this.entityName}:${entityId}`;
    const data = await redis.get(key);

    if (!data) {
      throw new Error(`Entity ${this.entityName}:${entityId} not found`);
    }

    return JSON.parse(data) as T;
  }

  // Check if entity exists in Redis
  async hasValue(entityId: string | number): Promise<boolean> {
    const key = `${this.entityName}:${entityId}`;
    const exists = await redis.exists(key);
    return exists === 1;
  }

  // Write entity to Redis
  async writeValue<T>(entityId: string | number, entity: T): Promise<void> {
    const key = `${this.entityName}:${entityId}`;
    const serializedData = JSON.stringify(entity);
    await redis.set(key, serializedData);

    // Optional: Set TTL (time to live)
    // await redis.expire(key, 86400); // 24 hours
  }

  // Remove entity from Redis
  async removeValue(entityId: string | number): Promise<void> {
    const key = `${this.entityName}:${entityId}`;
    const result = await redis.del(key);

    if (result === 0) {
      throw new Error(`Entity ${this.entityName}:${entityId} not found for deletion`);
    }
  }

  // Remove all entities for this entity type
  async removeAll(): Promise<void> {
    const pattern = `${this.entityName}:*`;
    const keys = await redis.keys(pattern);

    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }

  // Iterate over all entity values
  async *values<T>(): AsyncGenerator<T> {
    const pattern = `${this.entityName}:*`;
    const keys = await redis.keys(pattern);

    // Sort keys alphanumerically
    keys.sort((a, b) => a.localeCompare(b, undefined, {
      numeric: true,
      sensitivity: "base"
    }));

    for (const key of keys) {
      const data = await redis.get(key);
      if (data) {
        yield JSON.parse(data) as T;
      }
    }
  }

  // Iterate over all entity IDs
  async *keys(): AsyncGenerator<string> {
    const pattern = `${this.entityName}:*`;
    const keys = await redis.keys(pattern);

    // Sort keys alphanumerically
    keys.sort((a, b) => a.localeCompare(b, undefined, {
      numeric: true,
      sensitivity: "base"
    }));

    for (const key of keys) {
      // Extract entity ID from key (remove prefix)
      const entityId = key.slice(this.entityName.length + 1);
      yield entityId;
    }
  }
}

// Register Redis adapter for signal persistence
PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);

// Register Redis adapter for risk persistence
PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);

Custom Adapter Registration (Before Running Strategies)

import { PersistSignalAdaper, PersistRiskAdapter, Live } from "backtest-kit";

// IMPORTANT: Register adapters BEFORE running any strategies
PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);

// Now run live trading with Redis persistence
Live.background("BTCUSDT", {
  strategyName: "my-strategy",
  exchangeName: "binance"
});

MongoDB Adapter Example

import { PersistBase } from "backtest-kit";
import { MongoClient, Collection } from "mongodb";

const client = new MongoClient("mongodb://localhost:27017");
const db = client.db("backtest-kit");

class MongoPersist extends PersistBase {
  private collection: Collection;

  constructor(entityName: string, baseDir: string) {
    super(entityName, baseDir);
    this.collection = db.collection(this.entityName);
  }

  async waitForInit(initial: boolean): Promise<void> {
    await client.connect();
    // Create index for faster lookups
    await this.collection.createIndex({ entityId: 1 }, { unique: true });
    console.log(`MongoDB persistence initialized for ${this.entityName}`);
  }

  async readValue<T>(entityId: string | number): Promise<T> {
    const doc = await this.collection.findOne({ entityId });

    if (!doc) {
      throw new Error(`Entity ${this.entityName}:${entityId} not found`);
    }

    return doc.data as T;
  }

  async hasValue(entityId: string | number): Promise<boolean> {
    const count = await this.collection.countDocuments({ entityId });
    return count > 0;
  }

  async writeValue<T>(entityId: string | number, entity: T): Promise<void> {
    await this.collection.updateOne(
      { entityId },
      { $set: { entityId, data: entity, updatedAt: new Date() } },
      { upsert: true }
    );
  }

  async removeValue(entityId: string | number): Promise<void> {
    const result = await this.collection.deleteOne({ entityId });

    if (result.deletedCount === 0) {
      throw new Error(`Entity ${this.entityName}:${entityId} not found for deletion`);
    }
  }

  async removeAll(): Promise<void> {
    await this.collection.deleteMany({});
  }

  async *values<T>(): AsyncGenerator<T> {
    const cursor = this.collection.find({}).sort({ entityId: 1 });

    for await (const doc of cursor) {
      yield doc.data as T;
    }
  }

  async *keys(): AsyncGenerator<string> {
    const cursor = this.collection.find({}, { projection: { entityId: 1 } }).sort({ entityId: 1 });

    for await (const doc of cursor) {
      yield String(doc.entityId);
    }
  }
}

// Register MongoDB adapter
PersistSignalAdaper.usePersistSignalAdapter(MongoPersist);
PersistRiskAdapter.usePersistRiskAdapter(MongoPersist);

Direct Persistence API Usage (Advanced)

You can also use PersistBase directly for custom data storage:

import { PersistBase } from "backtest-kit";

// Create custom persistence for trading logs
const tradingLogs = new PersistBase("trading-logs", "./logs/custom");

// Initialize
await tradingLogs.waitForInit(true);

// Write log entry
await tradingLogs.writeValue("log-1", {
  timestamp: Date.now(),
  message: "Strategy started",
  metadata: { symbol: "BTCUSDT", strategy: "sma-crossover" }
});

// Read log entry
const log = await tradingLogs.readValue("log-1");
console.log(log);

// Check if log exists
const exists = await tradingLogs.hasValue("log-1");
console.log(`Log exists: ${exists}`);

// Iterate over all logs
for await (const log of tradingLogs.values()) {
  console.log("Log:", log);
}

// Get all log IDs
for await (const logId of tradingLogs.keys()) {
  console.log("Log ID:", logId);
}

// Filter logs
for await (const log of tradingLogs.filter((l: any) => l.metadata.symbol === "BTCUSDT")) {
  console.log("BTC Log:", log);
}

// Take first 5 logs
for await (const log of tradingLogs.take(5)) {
  console.log("Recent Log:", log);
}

// Remove specific log
await tradingLogs.removeValue("log-1");

// Remove all logs
await tradingLogs.removeAll();

When to Use Custom Adapters

  1. Redis - Best for high-performance distributed systems with multiple instances

    • Fast read/write operations
    • Built-in TTL (automatic cleanup)
    • Pub/sub for real-time updates
  2. MongoDB - Best for complex queries and analytics

    • Rich query language
    • Aggregation pipelines
    • Scalable for large datasets
  3. PostgreSQL - Best for ACID transactions and relational data

    • Strong consistency guarantees
    • Complex joins and queries
    • Mature ecosystem
  4. File-based (default) - Best for single-instance deployments

    • No dependencies
    • Simple debugging (inspect JSON files)
    • Sufficient for most use cases

Testing Custom Adapters

import { test } from "worker-testbed";
import { PersistBase } from "backtest-kit";

test("Custom Redis adapter works correctly", async ({ pass, fail }) => {
  const persist = new RedisPersist("test-entity", "./logs/test");

  await persist.waitForInit(true);

  // Write
  await persist.writeValue("key1", { data: "value1" });

  // Read
  const value = await persist.readValue("key1");
  if (value.data === "value1") {
    pass("Redis adapter read/write works");
  } else {
    fail("Redis adapter failed");
  }

  // Cleanup
  await persist.removeValue("key1");
});

10. Partial Profit/Loss Tracking

Partial Profit/Loss system tracks signal performance at fixed percentage levels (10%, 20%, 30%, etc.) for risk management and position scaling strategies.

Understanding Partial Levels

The system automatically monitors profit/loss milestones and emits events when signals reach specific levels:

import {
  listenPartialProfit,
  listenPartialLoss,
  listenPartialProfitOnce,
  listenPartialLossOnce,
  Constant
} from "backtest-kit";

// Listen to all profit levels (10%, 20%, 30%, 40%, 50%, 60%, 70%, 80%, 90%, 100%)
listenPartialProfit(({ symbol, signal, price, level, backtest }) => {
  console.log(`${symbol} profit: ${level}% at ${price}`);

  // Close portions at Kelly-optimized levels
  if (level === Constant.TP_LEVEL3) {
    console.log("Close 33% at 25% profit");
  }
  if (level === Constant.TP_LEVEL2) {
    console.log("Close 33% at 50% profit");
  }
  if (level === Constant.TP_LEVEL1) {
    console.log("Close 34% at 100% profit");
  }
});

// Listen to all loss levels (10%, 20%, 30%, 40%, 50%, 60%, 70%, 80%, 90%, 100%)
listenPartialLoss(({ symbol, signal, price, level, backtest }) => {
  console.log(`${symbol} loss: -${level}% at ${price}`);

  // Close portions at stop levels
  if (level === Constant.SL_LEVEL2) {
    console.log("Close 50% at -50% loss");
  }
  if (level === Constant.SL_LEVEL1) {
    console.log("Close 50% at -100% loss");
  }
});

// Listen once to first profit level reached
listenPartialProfitOnce(
  () => true, // Accept any profit event
  ({ symbol, signal, price, level, backtest }) => {
    console.log(`First profit milestone: ${level}%`);
  }
);

// Listen once to first loss level reached
listenPartialLossOnce(
  () => true, // Accept any loss event
  ({ symbol, signal, price, level, backtest }) => {
    console.log(`First loss milestone: -${level}%`);
  }
);

Constant Utility - Kelly-Optimized Levels

The Constant class provides predefined Kelly Criterion-based levels for optimal position sizing:

import { Constant } from "backtest-kit";

// Take Profit Levels
console.log(Constant.TP_LEVEL1); // 100% (aggressive target)
console.log(Constant.TP_LEVEL2); // 50%  (moderate target)
console.log(Constant.TP_LEVEL3); // 25%  (conservative target)

// Stop Loss Levels
console.log(Constant.SL_LEVEL1); // 100% (maximum risk)
console.log(Constant.SL_LEVEL2); // 50%  (standard stop)

Use Case - Scale Out Strategy:

// Strategy: Close position in 3 tranches at optimal levels
listenPartialProfit(({ symbol, signal, price, level, backtest }) => {
  if (level === Constant.TP_LEVEL3) {
    // Close 33% at 25% profit (secure early gains)
    executePartialClose(symbol, signal.id, 0.33);
  }
  if (level === Constant.TP_LEVEL2) {
    // Close 33% at 50% profit (lock in medium gains)
    executePartialClose(symbol, signal.id, 0.33);
  }
  if (level === Constant.TP_LEVEL1) {
    // Close 34% at 100% profit (maximize winners)
    executePartialClose(symbol, signal.id, 0.34);
  }
});

Partial Reports and Statistics

The Partial utility provides access to accumulated partial profit/loss data:

import { Partial } from "backtest-kit";

// Get statistical data
const stats = await Partial.getData("BTCUSDT");
console.log(stats);
// Returns:
// {
//   totalEvents: 15,          // Total profit/loss events
//   totalProfit: 10,          // Number of profit events
//   totalLoss: 5,             // Number of loss events
//   eventList: [
//     {
//       timestamp: 1704370800000,
//       action: "PROFIT",       // PROFIT or LOSS
//       symbol: "BTCUSDT",
//       signalId: "abc123",
//       position: "LONG",       // or SHORT
//       level: 10,              // Percentage level reached
//       price: 51500.00,        // Current price at level
//       mode: "Backtest"        // or Live
//     },
//     // ... more events
//   ]
// }

// Generate markdown report
const markdown = await Partial.getReport("BTCUSDT");
console.log(markdown);

// Save report to disk (default: ./dump/partial/BTCUSDT.md)
await Partial.dump("BTCUSDT");

// Custom output path
await Partial.dump("BTCUSDT", "./reports/partial");

Partial Report Example:

# Partial Profit/Loss Report: BTCUSDT

| Action | Symbol | Signal ID | Position | Level % | Current Price | Timestamp | Mode |
| --- | --- | --- | --- | --- | --- | --- | --- |
| PROFIT | BTCUSDT | abc123 | LONG | +10% | 51500.00000000 USD | 2024-01-15T10:30:00.000Z | Backtest |
| PROFIT | BTCUSDT | abc123 | LONG | +20% | 53000.00000000 USD | 2024-01-15T11:15:00.000Z | Backtest |
| LOSS | BTCUSDT | def456 | SHORT | -10% | 51500.00000000 USD | 2024-01-15T14:00:00.000Z | Backtest |

**Total events:** 15
**Profit events:** 10
**Loss events:** 5

Strategy Callbacks

Partial profit/loss callbacks can also be configured at the strategy level:

import { addStrategy } from "backtest-kit";

addStrategy({
  strategyName: "my-strategy",
  interval: "5m",
  getSignal: async (symbol) => { /* ... */ },
  callbacks: {
    onPartialProfit: (symbol, data, currentPrice, revenuePercent, backtest) => {
      console.log(`Signal ${data.id} at ${revenuePercent.toFixed(2)}% profit`);
    },
    onPartialLoss: (symbol, data, currentPrice, lossPercent, backtest) => {
      console.log(`Signal ${data.id} at ${lossPercent.toFixed(2)}% loss`);
    },
  },
});

How Partial Levels Work

Architecture:

  1. ClientPartial - Tracks levels using Map<signalId, Set<level>> to prevent duplicates
  2. ClientStrategy - Calls partial.profit() / partial.loss() on every tick
  3. PartialMarkdownService - Accumulates events (max 250 per symbol) for reports
  4. State persisted to disk: ./dump/data/partial/{symbol}/levels.json

Level Detection:

// For LONG position at entry price 50000
// Current price = 55000 → revenue = 10%
// Levels triggered: 10%

// Current price = 61000 → revenue = 22%
// Levels triggered: 10%, 20% (only 20% event emitted if 10% already triggered)

// For SHORT position at entry price 50000
// Current price = 45000 → revenue = 10%
// Levels triggered: 10%

Deduplication Guarantee:

Each level is emitted exactly once per signal:

  • Uses Set<level> to track reached levels
  • Persisted to disk for crash recovery
  • Restored on system restart

Crash Recovery:

// Before crash:
// Signal opened at 50000, reached 10% and 20% profit
// State: { profitLevels: [10, 20], lossLevels: [] }
// Persisted to: ./dump/data/partial/BTCUSDT/levels.json

// After restart:
// State restored from disk
// Only new levels (30%, 40%, etc.) will emit events
// 10% and 20% won't fire again

Best Practices

  1. Use Constant for Kelly-Optimized Levels - Don't hardcode profit/loss levels
  2. Scale Out Gradually - Close positions in tranches (25%, 50%, 100%)
  3. Monitor Partial Statistics - Use Partial.getData() to track scaling effectiveness
  4. Filter Events - Use listenPartialProfitOnce for first-level-only logic
  5. Combine with Position Sizing - Scale out inversely to volatility
import { Constant, listenPartialProfit } from "backtest-kit";

// Advanced: Dynamic scaling based on level
listenPartialProfit(({ symbol, signal, price, level, backtest }) => {
  const percentToClose =
    level === Constant.TP_LEVEL3 ? 0.25 : // 25% at first level
    level === Constant.TP_LEVEL2 ? 0.35 : // 35% at second level
    level === Constant.TP_LEVEL1 ? 0.40 : // 40% at third level
    0;

  if (percentToClose > 0) {
    executePartialClose(symbol, signal.id, percentToClose);
  }
});

11. Scheduled Signal Persistence

The framework includes a separate persistence system for scheduled signals (PersistScheduleAdapter) that works independently from pending/active signal persistence (PersistSignalAdapter). This separation ensures crash-safe recovery of both signal types.

Understanding the Dual Persistence System

The library uses two independent persistence layers for signals:

  1. PersistSignalAdapter - Manages pending/active signals (signals that are already opened or waiting to reach TP/SL)
  2. PersistScheduleAdapter - Manages scheduled signals (signals waiting for entry price to activate)

This dual-layer architecture ensures that both signal types can be recovered independently after crashes, with proper callbacks (onActive for pending signals, onSchedule for scheduled signals).

Default Storage Structure

By default, scheduled signals are stored separately from pending signals:

./dump/data/
  signal/
    my-strategy/
      BTCUSDT.json      # Pending/active signal state
      ETHUSDT.json
  schedule/
    my-strategy/
      BTCUSDT.json      # Scheduled signal state
      ETHUSDT.json

How Scheduled Signal Persistence Works

During Normal Operation:

When a strategy generates a scheduled signal (limit order waiting for entry), the framework:

  1. Stores the signal to disk using atomic writes: ./dump/data/schedule/{strategyName}/{symbol}.json
  2. Monitors price movements for activation
  3. When price reaches entry point OR cancellation condition occurs:
    • Deletes scheduled signal from storage
    • Optionally creates pending signal in PersistSignalAdapter

After System Crash:

When the system restarts:

  1. Framework checks for stored scheduled signals during initialization
  2. Validates exchange name and strategy name match (security protection)
  3. Restores scheduled signal to memory (_scheduledSignal)
  4. Calls onSchedule() callback to notify about restored signal
  5. Continues monitoring from where it left off

Crash Recovery Flow:

// Before crash:
// 1. Strategy generates signal with priceOpen = 50000 (current price = 49500)
// 2. Signal stored to ./dump/data/schedule/my-strategy/BTCUSDT.json
// 3. System waits for price to reach 50000
// 4. CRASH OCCURS at current price = 49800

// After restart:
// 1. System reads ./dump/data/schedule/my-strategy/BTCUSDT.json
// 2. Validates exchangeName and strategyName
// 3. Restores signal to _scheduledSignal
// 4. Calls onSchedule() callback with restored signal
// 5. Continues monitoring for price = 50000
// 6. When price reaches 50000, signal activates normally

Scheduled Signal Data Structure

interface IScheduledSignalRow {
  id: string;                    // Unique signal ID
  position: "long" | "short";
  priceOpen: number;             // Entry price (trigger price for scheduled signal)
  priceTakeProfit: number;
  priceStopLoss: number;
  minuteEstimatedTime: number;
  exchangeName: string;          // Used for validation during restore
  strategyName: string;          // Used for validation during restore
  timestamp: number;
  pendingAt: number;
  scheduledAt: number;
  symbol: string;
  _isScheduled: true;            // Marker for scheduled signals
  note?: string;
}

Integration with ClientStrategy

The ClientStrategy class uses setScheduledSignal() method to ensure all scheduled signal changes are persisted:

// WRONG - Direct assignment (not persisted)
this._scheduledSignal = newSignal;

// CORRECT - Using setScheduledSignal() method (persisted)
await this.setScheduledSignal(newSignal);

Automatic Persistence Locations:

All scheduled signal state changes are automatically persisted:

  • Signal generation (new scheduled signal created)
  • Signal activation (scheduled → pending transition)
  • Signal cancellation (timeout or stop loss hit before activation)
  • Manual signal clearing

BACKTEST Mode Exception:

In backtest mode, persistence is skipped for performance reasons:

public async setScheduledSignal(scheduledSignal: IScheduledSignalRow | null) {
  this._scheduledSignal = scheduledSignal;

  if (this.params.execution.context.backtest) {
    return; // Skip persistence in backtest mode
  }

  await PersistScheduleAdapter.writeScheduleData(
    this._scheduledSignal,
    this.params.strategyName,
    this.params.execution.context.symbol
  );
}

Custom Scheduled Signal Adapters

You can replace file-based scheduled signal persistence with custom adapters (Redis, MongoDB, etc.):

import { PersistScheduleAdapter, PersistBase } from "backtest-kit";
import Redis from "ioredis";

const redis = new Redis();

class RedisSchedulePersist extends PersistBase {
  async waitForInit(initial: boolean): Promise<void> {
    console.log(`Redis scheduled signal persistence initialized for ${this.entityName}`);
  }

  async readValue<T>(entityId: string | number): Promise<T> {
    const key = `schedule:${this.entityName}:${entityId}`;
    const data = await redis.get(key);

    if (!data) {
      throw new Error(`Scheduled signal ${this.entityName}:${entityId} not found`);
    }

    return JSON.parse(data) as T;
  }

  async hasValue(entityId: string | number): Promise<boolean> {
    const key = `schedule:${this.entityName}:${entityId}`;
    const exists = await redis.exists(key);
    return exists === 1;
  }

  async writeValue<T>(entityId: string | number, entity: T): Promise<void> {
    const key = `schedule:${this.entityName}:${entityId}`;
    const serializedData = JSON.stringify(entity);
    await redis.set(key, serializedData);

    // Optional: Set TTL for scheduled signals (e.g., 24 hours)
    await redis.expire(key, 86400);
  }

  async removeValue(entityId: string | number): Promise<void> {
    const key = `schedule:${this.entityName}:${entityId}`;
    const result = await redis.del(key);

    if (result === 0) {
      throw new Error(`Scheduled signal ${this.entityName}:${entityId} not found for deletion`);
    }
  }

  async removeAll(): Promise<void> {
    const pattern = `schedule:${this.entityName}:*`;
    const keys = await redis.keys(pattern);

    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }

  async *values<T>(): AsyncGenerator<T> {
    const pattern = `schedule:${this.entityName}:*`;
    const keys = await redis.keys(pattern);

    keys.sort((a, b) => a.localeCompare(b, undefined, {
      numeric: true,
      sensitivity: "base"
    }));

    for (const key of keys) {
      const data = await redis.get(key);
      if (data) {
        yield JSON.parse(data) as T;
      }
    }
  }

  async *keys(): AsyncGenerator<string> {
    const pattern = `schedule:${this.entityName}:*`;
    const keys = await redis.keys(pattern);

    keys.sort((a, b) => a.localeCompare(b, undefined, {
      numeric: true,
      sensitivity: "base"
    }));

    for (const key of keys) {
      const entityId = key.slice(`schedule:${this.entityName}:`.length);
      yield entityId;
    }
  }
}

// Register Redis adapter for scheduled signal persistence
PersistScheduleAdapter.usePersistScheduleAdapter(RedisSchedulePersist);

Best Practices

  1. Always use setScheduledSignal() - Never assign _scheduledSignal directly (except in waitForInit for restoration)

  2. Validate signal metadata - Always store exchangeName and strategyName with signals for validation

  3. Handle empty storage gracefully - Don't crash when readScheduleData() returns null

  4. Test crash recovery - Write E2E tests that simulate system crashes and verify restoration

  5. Choose persistence adapter wisely:

    • Use file-based for single-instance deployments
    • Use Redis for distributed systems with multiple instances
    • Use MongoDB for analytics and complex queries
  6. Monitor persistence operations - Use callbacks to track storage operations:

addStrategy({
  strategyName: "my-strategy",
  interval: "5m",
  getSignal: async (symbol) => { /* ... */ },
  callbacks: {
    onSchedule: (symbol, signal, price, backtest) => {
      console.log(`Scheduled signal created/restored: ${signal.id}`);
      // Signal was either:
      // 1. Newly generated and persisted
      // 2. Restored from storage after crash
    },
    onCancel: (symbol, signal, price, backtest) => {
      console.log(`Scheduled signal cancelled: ${signal.id}`);
      // Signal was removed from storage
    },
  },
});

🤖 AI Strategy Optimizer

The Optimizer uses LLM (Large Language Models) to generate trading strategies from historical data. It automates the process of analyzing backtest results, generating strategy logic, and creating executable code.

How It Works

  1. Data Collection: Fetch historical data from multiple sources (backtest results, market data, indicators)
  2. LLM Training: Build conversation context with data for each training period
  3. Strategy Generation: LLM analyzes patterns and generates strategy logic
  4. Code Export: Auto-generate complete executable code with Walker for testing

Basic Example

import { addOptimizer, Optimizer } from "backtest-kit";

// Register optimizer configuration
addOptimizer({
  optimizerName: "btc-optimizer",

  // Training periods (multiple strategies generated)
  rangeTrain: [
    {
      note: "Bull market Q1 2024",
      startDate: new Date("2024-01-01T00:00:00Z"),
      endDate: new Date("2024-03-31T23:59:59Z"),
    },
    {
      note: "Consolidation Q2 2024",
      startDate: new Date("2024-04-01T00:00:00Z"),
      endDate: new Date("2024-06-30T23:59:59Z"),
    },
  ],

  // Testing period (Walker validates strategies)
  rangeTest: {
    note: "Validation Q3 2024",
    startDate: new Date("2024-07-01T00:00:00Z"),
    endDate: new Date("2024-09-30T23:59:59Z"),
  },

  // Data sources for strategy generation
  source: [
    {
      name: "backtest-results",
      fetch: async ({ symbol, startDate, endDate, limit, offset }) => {
        // Fetch closed signals from your backtest database
        return await db.getBacktestResults({
          symbol,
          startDate,
          endDate,
          limit,
          offset,
        });
      },
    },
    {
      name: "market-indicators",
      fetch: async ({ symbol, startDate, endDate, limit, offset }) => {
        // Fetch RSI, MACD, volume data, etc.
        return await db.getIndicators({
          symbol,
          startDate,
          endDate,
          limit,
          offset,
        });
      },
    },
  ],

  // LLM prompt generation from conversation history
  getPrompt: async (symbol, messages) => {
    // Analyze messages and create strategy prompt
    return `
      Based on the historical data, create a strategy that:
      - Uses multi-timeframe analysis (1h, 15m, 5m, 1m)
      - Identifies high-probability entry points
      - Uses proper risk/reward ratios (min 1.5:1)
      - Adapts to market conditions
    `;
  },
});

// Generate strategies and export code
await Optimizer.dump("BTCUSDT", {
  optimizerName: "btc-optimizer"
}, "./generated");

// Output: ./generated/btc-optimizer_BTCUSDT.mjs

Generated Code Structure

The Optimizer auto-generates a complete executable file with:

  1. Imports - All necessary dependencies (backtest-kit, ollama, ccxt)
  2. Helper Functions:
    • text() - LLM text generation for analysis
    • json() - Structured signal generation with schema validation
    • dumpJson() - Debug logging to ./dump/strategy
  3. Exchange Configuration - CCXT Binance integration
  4. Frame Definitions - Training and testing timeframes
  5. Strategy Implementations - One per training range with multi-timeframe analysis
  6. Walker Setup - Automatic strategy comparison on test period
  7. Event Listeners - Progress tracking and result collection

Advanced Configuration

Custom Message Templates

Override default LLM message formatting:

addOptimizer({
  optimizerName: "custom-optimizer",
  rangeTrain: [...],
  rangeTest: {...},
  source: [...],
  getPrompt: async (symbol, messages) => "...",

  // Custom templates
  template: {
    getUserMessage: async (symbol, data, sourceName) => {
      return `Analyze ${sourceName} data for ${symbol}:\n${JSON.stringify(data, null, 2)}`;
    },
    getAssistantMessage: async (symbol, data, sourceName) => {
      return `Data from ${sourceName} analyzed successfully`;
    },
  },
});

Lifecycle Callbacks

Monitor optimizer operations:

addOptimizer({
  optimizerName: "monitored-optimizer",
  rangeTrain: [...],
  rangeTest: {...},
  source: [...],
  getPrompt: async (symbol, messages) => "...",

  callbacks: {
    onSourceData: async (symbol, sourceName, data, startDate, endDate) => {
      console.log(`Fetched ${data.length} rows from ${sourceName}`);
    },
    onData: async (symbol, strategyData) => {
      console.log(`Generated ${strategyData.length} strategies for ${symbol}`);
    },
    onCode: async (symbol, code) => {
      console.log(`Generated ${code.length} bytes of code`);
    },
    onDump: async (symbol, filepath) => {
      console.log(`Saved strategy to ${filepath}`);
    },
  },
});

Multiple Data Sources

Combine different data types for comprehensive analysis:

addOptimizer({
  optimizerName: "multi-source-optimizer",
  rangeTrain: [...],
  rangeTest: {...},

  source: [
    // Source 1: Backtest results
    {
      name: "backtest-signals",
      fetch: async (args) => await getBacktestSignals(args),
    },

    // Source 2: Market indicators
    {
      name: "technical-indicators",
      fetch: async (args) => await getTechnicalIndicators(args),
    },

    // Source 3: Volume profile
    {
      name: "volume-analysis",
      fetch: async (args) => await getVolumeProfile(args),
    },

    // Source 4: Order book depth
    {
      name: "order-book",
      fetch: async (args) => await getOrderBookData(args),
    },
  ],

  getPrompt: async (symbol, messages) => {
    // LLM has full context from all sources
    return `Create strategy using all available data sources`;
  },
});

API Methods

// Get strategy metadata (no code generation)
const strategies = await Optimizer.getData("BTCUSDT", {
  optimizerName: "my-optimizer"
});

// strategies[0].messages - LLM conversation history
// strategies[0].strategy - Generated strategy prompt

// Generate executable code
const code = await Optimizer.getCode("BTCUSDT", {
  optimizerName: "my-optimizer"
});

// Save to file
await Optimizer.dump("BTCUSDT", {
  optimizerName: "my-optimizer"
}, "./output"); // Default: "./"

LLM Integration

The Optimizer uses Ollama for LLM inference:

# Set up Ollama API
export OLLAMA_API_KEY=your-api-key

# Run generated strategy
node generated/btc-optimizer_BTCUSDT.mjs

Generated strategies use:

  • Model: gpt-oss:20b (configurable in templates)
  • Multi-timeframe analysis: 1h, 15m, 5m, 1m candles
  • Structured output: JSON schema with signal validation
  • Debug logging: Saves conversations to ./dump/strategy

Best Practices

  1. Training Periods: Use 2-4 diverse market conditions (bull, bear, sideways)
  2. Data Quality: Ensure data sources have unique IDs for deduplication
  3. Pagination: Sources automatically paginated (25 records per request)
  4. Testing: Always validate generated strategies on unseen data (rangeTest)
  5. Monitoring: Use callbacks to track data fetching and code generation

📐 Architecture Overview

The framework follows clean architecture with:

  • Client Layer - Pure business logic without DI (ClientStrategy, ClientExchange, ClientFrame)
  • Service Layer - DI-based services organized by responsibility
    • Schema Services - Registry pattern for configuration
    • Connection Services - Memoized client instance creators
    • Global Services - Context wrappers for public API
    • Logic Services - Async generator orchestration (backtest/live)
  • Persistence Layer - Crash-safe atomic file writes with PersistSignalAdapter

✅ Signal Validation

All signals are validated automatically before execution:

// ✅ Valid long signal
{
  position: "long",
  priceOpen: 50000,
  priceTakeProfit: 51000,  // ✅ 51000 > 50000
  priceStopLoss: 49000,     // ✅ 49000 < 50000
  minuteEstimatedTime: 60,  // ✅ positive
}

// ❌ Invalid long signal - throws error
{
  position: "long",
  priceOpen: 50000,
  priceTakeProfit: 49000,  // ❌ 49000 < 50000 (must be higher for long)
  priceStopLoss: 51000,    // ❌ 51000 > 50000 (must be lower for long)
}

// ✅ Valid short signal
{
  position: "short",
  priceOpen: 50000,
  priceTakeProfit: 49000,  // ✅ 49000 < 50000 (profit goes down for short)
  priceStopLoss: 51000,    // ✅ 51000 > 50000 (stop loss goes up for short)
}

Validation errors include detailed messages for debugging.


🧠 Interval Throttling

Prevent signal spam with automatic throttling:

addStrategy({
  strategyName: "my-strategy",
  interval: "5m", // Signals generated max once per 5 minutes
  getSignal: async (symbol) => {
    // This function will be called max once per 5 minutes
    // Even if tick() is called every second
    return signal;
  },
});

Supported intervals: "1m", "3m", "5m", "15m", "30m", "1h"


📝 Markdown Reports

Generate detailed trading reports with statistics:

Backtest Reports

import { Backtest } from "backtest-kit";

// Get raw statistical data (Controller)
const stats = await Backtest.getData("my-strategy");
console.log(stats);
// Returns:
// {
//   signalList: [...],           // All closed signals
//   totalSignals: 10,
//   winCount: 7,
//   lossCount: 3,
//   winRate: 70.0,               // Percentage (higher is better)
//   avgPnl: 1.23,                // Average PNL % (higher is better)
//   totalPnl: 12.30,             // Total PNL % (higher is better)
//   stdDev: 2.45,                // Standard deviation (lower is better)
//   sharpeRatio: 0.50,           // Risk-adjusted return (higher is better)
//   annualizedSharpeRatio: 9.55, // Sharpe × √365 (higher is better)
//   certaintyRatio: 1.75,        // avgWin / |avgLoss| (higher is better)
//   expectedYearlyReturns: 156   // Estimated yearly trades (higher is better)
// }

// Generate markdown report (View)
const markdown = await Backtest.getReport("my-strategy");

// Save to disk (default: ./logs/backtest/my-strategy.md)
await Backtest.dump("my-strategy");

Live Trading Reports

import { Live } from "backtest-kit";

// Get raw statistical data (Controller)
const stats = await Live.getData("my-strategy");
console.log(stats);
// Returns:
// {
//   eventList: [...],            // All events (idle, scheduled, opened, active, closed, cancelled)
//   totalEvents: 15,
//   totalClosed: 5,
//   winCount: 3,
//   lossCount: 2,
//   winRate: 60.0,               // Percentage (higher is better)
//   avgPnl: 1.23,                // Average PNL % (higher is better)
//   totalPnl: 6.15,              // Total PNL % (higher is better)
//   stdDev: 1.85,                // Standard deviation (lower is better)
//   sharpeRatio: 0.66,           // Risk-adjusted return (higher is better)
//   annualizedSharpeRatio: 12.61,// Sharpe × √365 (higher is better)
//   certaintyRatio: 2.10,        // avgWin / |avgLoss| (higher is better)
//   expectedYearlyReturns: 365   // Estimated yearly trades (higher is better)
// }

// Generate markdown report (View)
const markdown = await Live.getReport("my-strategy");

// Save to disk (default: ./logs/live/my-strategy.md)
await Live.dump("my-strategy");

Scheduled Signals Reports

import { Schedule } from "backtest-kit";

// Get raw scheduled signals data (Controller)
const stats = await Schedule.getData("my-strategy");
console.log(stats);
// Returns:
// {
//   eventList: [...],            // All scheduled/cancelled events
//   totalEvents: 8,
//   totalScheduled: 6,           // Number of scheduled signals
//   totalCancelled: 2,           // Number of cancelled signals
//   cancellationRate: 33.33,     // Percentage (lower is better)
//   avgWaitTime: 45.5,           // Average wait time for cancelled signals in minutes
// }

// Generate markdown report (View)
const markdown = await Schedule.getReport("my-strategy");

// Save to disk (default: ./logs/schedule/my-strategy.md)
await Schedule.dump("my-strategy");

// Clear accumulated data
await Schedule.clear("my-strategy");

Scheduled Signals Report Example:

# Scheduled Signals Report: my-strategy

| Timestamp | Action | Symbol | Signal ID | Position | Note | Current Price | Entry Price | Take Profit | Stop Loss | Wait Time (min) |
|-----------|--------|--------|-----------|----------|------|---------------|-------------|-------------|-----------|-----------------|
| 2024-01-15T10:30:00Z | SCHEDULED | BTCUSDT | sig-001 | LONG | BTC breakout | 42150.50 USD | 42000.00 USD | 43000.00 USD | 41000.00 USD | N/A |
| 2024-01-15T10:35:00Z | CANCELLED | BTCUSDT | sig-002 | LONG | BTC breakout | 42350.80 USD | 10000.00 USD | 11000.00 USD | 9000.00 USD | 60 |

**Total events:** 8
**Scheduled signals:** 6
**Cancelled signals:** 2
**Cancellation rate:** 33.33% (lower is better)
**Average wait time (cancelled):** 45.50 minutes

🎧 Event Listeners

Listen to All Signals (Backtest + Live)

import { listenSignal } from "backtest-kit";

// Listen to both backtest and live signals
listenSignal((event) => {
  console.log(`[${event.backtest ? "BT" : "LIVE"}] ${event.action}:`, event.signal.id);

  if (event.action === "closed") {
    console.log("PNL:", event.pnl.pnlPercentage);
    console.log("Close reason:", event.closeReason);
  }
});

Listen to Partial Profit/Loss Events

import {
  listenPartialProfit,
  listenPartialLoss,
  listenPartialProfitOnce,
  listenPartialLossOnce,
  Constant
} from "backtest-kit";

// Listen to all profit milestones
listenPartialProfit(({ symbol, signal, price, level, backtest }) => {
  console.log(`${symbol} reached ${level}% profit at ${price}`);

  // Scale out at Kelly-optimized levels
  if (level === Constant.TP_LEVEL3) {
    console.log("Close 33% at 25% profit");
  }
  if (level === Constant.TP_LEVEL2) {
    console.log("Close 33% at 50% profit");
  }
  if (level === Constant.TP_LEVEL1) {
    console.log("Close 34% at 100% profit");
  }
});

// Listen to all loss milestones
listenPartialLoss(({ symbol, signal, price, level, backtest }) => {
  console.log(`${symbol} reached -${level}% loss at ${price}`);

  // Scale out at stop levels
  if (level === Constant.SL_LEVEL2) {
    console.log("Close 50% at -50% loss");
  }
  if (level === Constant.SL_LEVEL1) {
    console.log("Close 50% at -100% loss");
  }
});

// Listen once to first profit level
listenPartialProfitOnce(
  () => true, // Accept any profit event
  ({ symbol, signal, price, level, backtest }) => {
    console.log(`First profit milestone: ${level}%`);
  }
);

// Listen once to first loss level
listenPartialLossOnce(
  () => true, // Accept any loss event
  ({ symbol, signal, price, level, backtest }) => {
    console.log(`First loss milestone: -${level}%`);
  }
);

Listen Once with Filter

import { listenSignalOnce, listenSignalLiveOnce } from "backtest-kit";

// Listen once with filter
listenSignalOnce(
  (event) => event.action === "closed" && event.pnl.pnlPercentage > 5,
  (event) => {
    console.log("Big win detected:", event.pnl.pnlPercentage);
  }
);

// Listen once for specific symbol in live mode
listenSignalLiveOnce(
  (event) => event.signal.symbol === "BTCUSDT" && event.action === "opened",
  (event) => {
    console.log("BTC signal opened:", event.signal.id);
  }
);

Listen to Background Completion

import { listenDoneBacktest, listenDoneLive, listenDoneWalker } from "backtest-kit";

// Backtest completion
listenDoneBacktest((event) => {
  console.log("Backtest completed:", event.strategyName);
  console.log("Symbol:", event.symbol);
  console.log("Exchange:", event.exchangeName);
});

// Live trading completion
listenDoneLive((event) => {
  console.log("Live trading stopped:", event.strategyName);
});

// Walker completion
listenDoneWalker((event) => {
  console.log("Walker completed:", event.strategyName);
  console.log("Best strategy:", event.bestStrategy);
});

⚙️ Global Configuration

You can customize framework behavior using the setConfig() function. This allows you to adjust global parameters without modifying the source code.

Available Configuration Options

import { setConfig } from "backtest-kit";

// Configure global parameters
await setConfig({
  // Time to wait for scheduled signal activation (in minutes)
  // If a scheduled signal doesn't activate within this time, it will be cancelled
  // Default: 120 minutes
  CC_SCHEDULE_AWAIT_MINUTES: 90,

  // Number of candles to use for average price calculation (VWAP)
  // Used in both backtest and live modes for price calculations
  // Default: 5 candles (last 5 minutes when using 1m interval)
  CC_AVG_PRICE_CANDLES_COUNT: 10,
});

Configuration Parameters

CC_SCHEDULE_AWAIT_MINUTES

Controls how long scheduled signals wait for activation before being cancelled.

  • Default: 120 minutes (2 hours)
  • Use case: Adjust based on market volatility and strategy timeframe
  • Example: Lower for scalping strategies (30-60 min), higher for swing trading (180-360 min)
// For scalping strategies with tight entry windows
await setConfig({
  CC_SCHEDULE_AWAIT_MINUTES: 30,
});

// For swing trading with wider entry windows
await setConfig({
  CC_SCHEDULE_AWAIT_MINUTES: 240,
});

CC_AVG_PRICE_CANDLES_COUNT

Controls the number of 1-minute candles used for VWAP (Volume Weighted Average Price) calculations.

  • Default: 5 candles (5 minutes of data)
  • Use case: Adjust for more stable (higher) or responsive (lower) price calculations
  • Impact: Affects entry/exit prices in both backtest and live modes
// More responsive to recent price changes (3 minutes)
await setConfig({
  CC_AVG_PRICE_CANDLES_COUNT: 3,
});

// More stable, less sensitive to spikes (10 minutes)
await setConfig({
  CC_AVG_PRICE_CANDLES_COUNT: 10,
});

When to Call setConfig()

Always call setConfig() before running any strategies to ensure configuration is applied:

import { setConfig, Backtest, Live } from "backtest-kit";

// 1. Configure framework first
await setConfig({
  CC_SCHEDULE_AWAIT_MINUTES: 90,
  CC_AVG_PRICE_CANDLES_COUNT: 7,
});

// 2. Then run strategies
Backtest.background("BTCUSDT", {
  strategyName: "my-strategy",
  exchangeName: "binance",
  frameName: "1d-backtest"
});

Live.background("ETHUSDT", {
  strategyName: "my-strategy",
  exchangeName: "binance"
});

Partial Configuration

You can update individual parameters without specifying all of them:

// Only change candle count, keep other defaults
await setConfig({
  CC_AVG_PRICE_CANDLES_COUNT: 8,
});

// Later, only change timeout
await setConfig({
  CC_SCHEDULE_AWAIT_MINUTES: 60,
});

✅ Tested & Reliable

backtest-kit comes with 217 unit and integration tests covering:

  • Signal validation and throttling
  • PNL calculation with fees and slippage
  • Crash recovery and state persistence
  • Dual-layer persistence (pending signals and scheduled signals)
  • Crash recovery validation (exchange/strategy name mismatch protection)
  • Callback execution order (onSchedule, onOpen, onActive, onClose, onCancel)
  • Markdown report generation (backtest, live, scheduled signals)
  • Walker strategy comparison
  • Heatmap portfolio analysis
  • Position sizing calculations
  • Risk management validation
  • Scheduled signals lifecycle and cancellation tracking
  • Event system

🤝 Contribute

We'd love your input! Fork the repo, submit a PR, or open an issue on GitHub. 🙌

📜 License

MIT © tripolskypetr 🖋️