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.
Features
- 🚀 Production-Ready Architecture - Backtest/live mode, robust error recovery
- 💾 Crash-Safe Persistence - Atomic file writes with automatic state recovery
- ✅ Signal Validation - Comprehensive validation prevents invalid trades
- 🔄 Async Generators - Memory-efficient streaming for backtest and live execution
- 📊 VWAP Pricing - Volume-weighted average price from last 5 1m candles
- 🎯 Signal Lifecycle - Type-safe state machine (idle → opened → active → closed)
- 📉 Accurate PNL - Calculation with fees (0.1%) and slippage (0.1%)
- 🧠 Interval Throttling - Prevents signal spam at strategy level
- ⚡ Memory Optimized - Prototype methods + memoization + streaming
- 🔌 Flexible Architecture - Plug your own exchanges and strategies
- 📝 Markdown Reports - Auto-generated trading reports with statistics (win rate, avg PNL)
- 🛑 Graceful Shutdown - Live.background() waits for open positions to close before stopping
Installation
npm install backtest-kitQuick Start
1. Register Exchange Data Source
import { addExchange } from "backtest-kit";
import ccxt from "ccxt"; // Example using CCXT library
addExchange({
exchangeName: "binance",
// Fetch historical candles
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,
}));
},
// Format price according to exchange rules (e.g., 2 decimals for BTC)
formatPrice: async (symbol, price) => {
const exchange = new ccxt.binance();
const market = exchange.market(symbol);
return exchange.priceToPrecision(symbol, price);
},
// Format quantity according to exchange rules (e.g., 8 decimals)
formatQuantity: async (symbol, quantity) => {
const exchange = new ccxt.binance();
return exchange.amountToPrecision(symbol, quantity);
},
});Alternative: Database implementation
import { addExchange } from "backtest-kit";
import { db } from "./database"; // Your database client
addExchange({
exchangeName: "binance-db",
getCandles: async (symbol, interval, since, limit) => {
// Fetch from database for faster backtesting
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
import { addStrategy } from "backtest-kit";
addStrategy({
strategyName: "my-strategy",
interval: "5m", // Throttling: signals generated max once per 5 minutes
getSignal: async (symbol) => {
// Your signal generation logic
// Validation happens automatically (prices, TP/SL logic, timestamps)
return {
position: "long",
note: "BTC breakout",
priceOpen: 50000,
priceTakeProfit: 51000, // Must be > priceOpen for long
priceStopLoss: 49000, // Must be < priceOpen for long
minuteEstimatedTime: 60, // Signal duration in minutes
timestamp: Date.now(),
};
},
callbacks: {
onOpen: (backtest, symbol, signal) => {
console.log(`[${backtest ? "BT" : "LIVE"}] Signal opened:`, signal.id);
},
onClose: (backtest, symbol, priceClose, signal) => {
console.log(`[${backtest ? "BT" : "LIVE"}] Signal closed:`, priceClose);
},
},
});3. Add Timeframe Generator
import { addFrame } from "backtest-kit";
addFrame({
frameName: "1d-backtest",
interval: "1m",
startDate: new Date("2024-01-01T00:00:00Z"),
endDate: new Date("2024-01-02T00:00:00Z"),
callbacks: {
onTimeframe: (timeframe, startDate, endDate, interval) => {
console.log(`Generated ${timeframe.length} timeframes from ${startDate} to ${endDate}`);
},
},
});4. Run Backtest
import { Backtest, listenSignalBacktest, listenError, listenDone } from "backtest-kit";
// Run backtest in background
const stopBacktest = Backtest.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
});
// Listen to closed signals
listenSignalBacktest((event) => {
if (event.action === "closed") {
console.log("PNL:", event.pnl.pnlPercentage);
}
});
// Listen to errors
listenError((error) => {
console.error("Error:", error.message);
});
// Listen to completion
listenDone((event) => {
if (event.backtest) {
console.log("Backtest completed:", event.symbol);
// Generate and save report
Backtest.dump(event.strategyName); // ./logs/backtest/my-strategy.md
}
});5. Run Live Trading (Crash-Safe)
import { Live, listenSignalLive, listenError, listenDone } from "backtest-kit";
// Run live trading in background (infinite loop, crash-safe)
const stop = Live.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance"
});
// Listen to all signal events
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,
});
// Auto-save report
Live.dump(event.strategyName);
}
});
// Listen to errors
listenError((error) => {
console.error("Error:", error.message);
});
// Listen to completion
listenDone((event) => {
if (!event.backtest) {
console.log("Live trading stopped:", event.symbol);
}
});
// Stop when needed: stop();Crash Recovery: If process crashes, restart with same code - state automatically recovered from disk (no duplicate signals).
6. Alternative: Async Generators (Optional)
For manual control over execution flow:
import { Backtest, Live } from "backtest-kit";
// Manual backtest iteration
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
}
// Manual live iteration (infinite loop)
for await (const result of Live.run("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance"
})) {
if (result.action === "closed") {
console.log("PNL:", result.pnl.pnlPercentage);
}
}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
PersistSignalAdaper
See ARCHITECTURE.md for detailed documentation.
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
timestamp: Date.now(), // ✅ 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.
Custom Persistence Adapter
By default, signals are persisted to disk using atomic file writes (./logs/data/signal/). You can override the persistence layer with a custom adapter (e.g., Redis, MongoDB):
import { PersistBase, PersistSignalAdaper, ISignalData, EntityId } from "backtest-kit";
import Redis from "ioredis";
// Create custom Redis adapter
class RedisPersist extends PersistBase {
private redis = new Redis({
host: "localhost",
port: 6379,
});
async waitForInit(initial: boolean): Promise<void> {
// Initialize Redis connection if needed
await this.redis.ping();
}
async readValue(entityId: EntityId): Promise<ISignalData> {
const key = `${this.entityName}:${entityId}`;
const data = await this.redis.get(key);
if (!data) {
throw new Error(`Entity ${this.entityName}:${entityId} not found`);
}
return JSON.parse(data);
}
async hasValue(entityId: EntityId): Promise<boolean> {
const key = `${this.entityName}:${entityId}`;
const exists = await this.redis.exists(key);
return exists === 1;
}
async writeValue(entityId: EntityId, entity: ISignalData): Promise<void> {
const key = `${this.entityName}:${entityId}`;
await this.redis.set(key, JSON.stringify(entity));
}
}
// Register custom adapter
PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
// Now all signal persistence uses Redis
Live.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance"
});Key methods to implement:
waitForInit(initial)- Initialize storage connectionreadValue(entityId)- Read entity from storagehasValue(entityId)- Check if entity existswriteValue(entityId, entity)- Write entity to storage
The adapter is registered globally and applies to all strategies.
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";
// Run backtest
const stopBacktest = Backtest.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
});
// Generate markdown report
const markdown = await Backtest.getReport("my-strategy");
console.log(markdown);
// Save to disk (default: ./logs/backtest/my-strategy.md)
await Backtest.dump("my-strategy");
// Save to custom path
await Backtest.dump("my-strategy", "./custom/path");Report includes:
- Total closed signals
- All signal details (prices, TP/SL, PNL, duration, close reason)
- Timestamps for each signal
Live Trading Reports
import { Live } from "backtest-kit";
// Generate live trading report
const markdown = await Live.getReport("my-strategy");
// Save to disk (default: ./logs/live/my-strategy.md)
await Live.dump("my-strategy");Report includes:
- Total events (idle, opened, active, closed)
- Closed signals count
- Win rate (% wins, wins/losses)
- Average PNL percentage
- Signal-by-signal details with current state
Report example:
# Live Trading Report: my-strategy
Total events: 15
Closed signals: 5
Win rate: 60.00% (3W / 2L)
Average PNL: +1.23%
| Timestamp | Action | Symbol | Signal ID | Position | ... | PNL (net) | Close Reason |
|-----------|--------|--------|-----------|----------|-----|-----------|--------------|
| ... | CLOSED | BTCUSD | abc-123 | LONG | ... | +2.45% | take_profit |Event Listeners
Subscribe to signal events with filtering support. Useful for running strategies in background while reacting to specific events.
Background Execution with Event Listeners
import { Backtest, listenSignalBacktest } from "backtest-kit";
// Run backtest in background (doesn't yield results)
Backtest.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
});
// Listen to all backtest events
const unsubscribe = listenSignalBacktest((event) => {
if (event.action === "closed") {
console.log("Signal closed:", {
pnl: event.pnl.pnlPercentage,
reason: event.closeReason
});
}
});
// Stop listening when done
// unsubscribe();Listen Once with Filter
import { Backtest, listenSignalBacktestOnce } from "backtest-kit";
// Run backtest in background
Backtest.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
});
// Wait for first take profit event
listenSignalBacktestOnce(
(event) => event.action === "closed" && event.closeReason === "take_profit",
(event) => {
console.log("First take profit hit!", event.pnl.pnlPercentage);
// Automatically unsubscribes after first match
}
);Live Trading with Event Listeners
import { Live, listenSignalLive, listenSignalLiveOnce } from "backtest-kit";
// Run live trading in background (infinite loop)
const cancel = Live.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance"
});
// Listen to all live events
listenSignalLive((event) => {
if (event.action === "opened") {
console.log("Signal opened:", event.signal.id);
}
if (event.action === "closed") {
console.log("Signal closed:", event.pnl.pnlPercentage);
}
});
// React to first stop loss once
listenSignalLiveOnce(
(event) => event.action === "closed" && event.closeReason === "stop_loss",
(event) => {
console.error("Stop loss hit!", event.pnl.pnlPercentage);
// Send alert, dump report, etc.
}
);
// Stop live trading after some condition
// cancel();Listen to All Signals (Backtest + Live)
import { listenSignal, listenSignalOnce, Backtest, Live } from "backtest-kit";
// Listen to both backtest and live events
listenSignal((event) => {
console.log("Event:", event.action, event.strategyName);
});
// Wait for first loss from any source
listenSignalOnce(
(event) => event.action === "closed" && event.pnl.pnlPercentage < 0,
(event) => {
console.log("First loss detected:", event.pnl.pnlPercentage);
}
);
// Run both modes
Backtest.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
});
Live.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance"
});Available event listeners:
listenSignal(callback)- Subscribe to all signal events (backtest + live)listenSignalOnce(filter, callback)- Subscribe once with filter predicatelistenSignalBacktest(callback)- Subscribe to backtest signals onlylistenSignalBacktestOnce(filter, callback)- Subscribe to backtest signals oncelistenSignalLive(callback)- Subscribe to live signals onlylistenSignalLiveOnce(filter, callback)- Subscribe to live signals oncelistenError(callback)- Subscribe to background execution errorslistenDone(callback)- Subscribe to background completion eventslistenDoneOnce(filter, callback)- Subscribe to background completion once
All listeners return an unsubscribe function. All callbacks are processed sequentially using queued async execution.
Listen to Background Completion
import { listenDone, listenDoneOnce, Backtest, Live } from "backtest-kit";
// Listen to all completion events
listenDone((event) => {
console.log("Execution completed:", {
mode: event.backtest ? "backtest" : "live",
symbol: event.symbol,
strategy: event.strategyName,
exchange: event.exchangeName,
});
// Auto-generate report on completion
if (event.backtest) {
Backtest.dump(event.strategyName);
} else {
Live.dump(event.strategyName);
}
});
// Wait for specific backtest to complete
listenDoneOnce(
(event) => event.backtest && event.symbol === "BTCUSDT",
(event) => {
console.log("BTCUSDT backtest finished");
// Start next backtest or live trading
Live.background(event.symbol, {
strategyName: event.strategyName,
exchangeName: event.exchangeName,
});
}
);
// Run backtests
Backtest.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
});API Reference
High-Level Functions
Schema Registration
// Register exchange
addExchange(exchangeSchema: IExchangeSchema): void
// Register strategy
addStrategy(strategySchema: IStrategySchema): void
// Register timeframe generator
addFrame(frameSchema: IFrameSchema): voidExchange Data
// Get historical candles
const candles = await getCandles("BTCUSDT", "1h", 5);
// Returns: [
// { timestamp: 1704067200000, open: 42150.5, high: 42380.2, low: 42100.0, close: 42250.8, volume: 125.43 },
// { timestamp: 1704070800000, open: 42250.8, high: 42500.0, low: 42200.0, close: 42450.3, volume: 98.76 },
// { timestamp: 1704074400000, open: 42450.3, high: 42600.0, low: 42400.0, close: 42580.5, volume: 110.22 },
// { timestamp: 1704078000000, open: 42580.5, high: 42700.0, low: 42550.0, close: 42650.0, volume: 95.18 },
// { timestamp: 1704081600000, open: 42650.0, high: 42750.0, low: 42600.0, close: 42720.0, volume: 102.35 }
// ]
// Get VWAP from last 5 1m candles
const vwap = await getAveragePrice("BTCUSDT");
// Returns: 42685.34
// Get current date in execution context
const date = await getDate();
// Returns: 2024-01-01T12:00:00.000Z (in backtest mode, returns frame's current timestamp)
// Returns: 2024-01-15T10:30:45.123Z (in live mode, returns current wall clock time)
// Get current mode
const mode = await getMode();
// Returns: "backtest" or "live"
// Format price/quantity for exchange
const price = await formatPrice("BTCUSDT", 42685.3456789);
// Returns: "42685.35" (formatted to exchange precision)
const quantity = await formatQuantity("BTCUSDT", 0.123456789);
// Returns: "0.12345" (formatted to exchange precision)Service APIs
Backtest API
import { Backtest } from "backtest-kit";
// Stream backtest results
Backtest.run(
symbol: string,
context: {
strategyName: string;
exchangeName: string;
frameName: string;
}
): AsyncIterableIterator<IStrategyTickResultClosed>
// Run in background without yielding results
Backtest.background(
symbol: string,
context: { strategyName, exchangeName, frameName }
): Promise<() => void> // Returns cancellation function
// Generate markdown report
Backtest.getReport(strategyName: string): Promise<string>
// Save report to disk
Backtest.dump(strategyName: string, path?: string): Promise<void>Live Trading API
import { Live } from "backtest-kit";
// Stream live results (infinite)
Live.run(
symbol: string,
context: {
strategyName: string;
exchangeName: string;
}
): AsyncIterableIterator<IStrategyTickResult>
// Run in background without yielding results
Live.background(
symbol: string,
context: { strategyName, exchangeName }
): Promise<() => void> // Returns cancellation function
// Generate markdown report
Live.getReport(strategyName: string): Promise<string>
// Save report to disk
Live.dump(strategyName: string, path?: string): Promise<void>Type Definitions
Signal Data
interface ISignalRow {
id: string; // Auto-generated
position: "long" | "short";
note: string;
priceOpen: number;
priceTakeProfit: number;
priceStopLoss: number;
minuteEstimatedTime: number;
timestamp: number;
}Tick Results (Discriminated Union)
type IStrategyTickResult =
| { action: "idle"; signal: null }
| { action: "opened"; signal: ISignalRow }
| { action: "active"; signal: ISignalRow; currentPrice: number }
| {
action: "closed";
signal: ISignalRow;
currentPrice: number;
closeReason: "take_profit" | "stop_loss" | "time_expired";
closeTimestamp: number;
pnl: {
priceOpenWithCosts: number;
priceCloseWithCosts: number;
pnlPercentage: number;
};
};PNL Calculation
// Constants
PERCENT_SLIPPAGE = 0.1% // 0.001
PERCENT_FEE = 0.1% // 0.001
// 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 * 100Production Readiness
✅ Production-Ready Features
- Crash-Safe Persistence - Atomic file writes with automatic recovery
- Signal Validation - Comprehensive validation prevents invalid trades
- Type Safety - Discriminated unions eliminate runtime type errors
- Memory Efficiency - Prototype methods + async generators + memoization
- Interval Throttling - Prevents signal spam
- Live Trading Ready - Full implementation with real-time progression
- Error Recovery - Stateless process with disk-based state
File Structure
src/
├── client/ # Pure business logic (no DI)
│ ├── ClientStrategy.ts # Signal lifecycle + validation + persistence
│ ├── ClientExchange.ts # VWAP calculation
│ └── ClientFrame.ts # Timeframe generation
├── classes/
│ └── Persist.ts # Atomic file persistence
├── function/ # High-level API
│ ├── add.ts # addStrategy, addExchange, addFrame
│ ├── exchange.ts # getCandles, getAveragePrice, getDate, getMode
│ └── run.ts # DEPRECATED - use logic services instead
├── interfaces/ # TypeScript interfaces
│ ├── Strategy.interface.ts
│ ├── Exchange.interface.ts
│ └── Frame.interface.ts
├── lib/
│ ├── core/ # DI container
│ ├── services/
│ │ ├── base/ # LoggerService
│ │ ├── context/ # ExecutionContext, MethodContext
│ │ ├── connection/ # Client instance creators
│ │ ├── global/ # Context wrappers
│ │ ├── schema/ # Registry services
│ │ └── logic/
│ │ └── private/ # Async generator orchestration
│ │ ├── BacktestLogicPrivateService.ts
│ │ └── LiveLogicPrivateService.ts
│ └── index.ts # Public API
└── helpers/
└── toProfitLossDto.ts # PNL calculationAdvanced Examples
Custom Persistence Adapter
import { PersistSignalAdaper, PersistBase } from "backtest-kit";
class RedisPersist extends PersistBase {
async readValue(entityId) {
return JSON.parse(await redis.get(entityId));
}
async writeValue(entityId, entity) {
await redis.set(entityId, JSON.stringify(entity));
}
}
PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);Multi-Symbol Live Trading
import { Live } from "backtest-kit";
const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"];
// Run all symbols in parallel
await Promise.all(
symbols.map(async (symbol) => {
for await (const result of Live.run(symbol, {
strategyName: "my-strategy",
exchangeName: "binance"
})) {
console.log(`[${symbol}]`, result.action);
// Generate reports periodically
if (result.action === "closed") {
await Live.dump("my-strategy");
}
}
})
);Early Termination
Using async generator with break:
import { Backtest } from "backtest-kit";
for await (const result of Backtest.run("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
})) {
if (result.closeReason === "stop_loss") {
console.log("Stop loss hit - terminating backtest");
// Save final report before exit
await Backtest.dump("my-strategy");
break; // Generator stops immediately
}
}Using background mode with stop() function:
import { Backtest, Live, listenSignalLiveOnce } from "backtest-kit";
// Backtest.background returns a stop function
const stopBacktest = await Backtest.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
});
// Stop backtest after some condition
setTimeout(() => {
console.log("Stopping backtest...");
stopBacktest(); // Stops the background execution
}, 5000);
// Live.background also returns a stop function
const stopLive = Live.background("BTCUSDT", {
strategyName: "my-strategy",
exchangeName: "binance"
});
// Stop live trading after detecting stop loss
listenSignalLiveOnce(
(event) => event.action === "closed" && event.closeReason === "stop_loss",
(event) => {
console.log("Stop loss detected - stopping live trading");
stopLive(); // Stops the infinite loop
}
);Use Cases
- Algorithmic Trading - Backtest and deploy strategies with crash recovery
- Strategy Research - Test hypotheses on historical data
- Signal Generation - Use with ML models or technical indicators
- Portfolio Management - Track multiple strategies across symbols
- Educational Projects - Learn trading system architecture
Contributing
Pull requests are welcome. For major changes, please open an issue first.
License
MIT