Exhaustive, source-accurate API reference for backtest-kit
v13.6.0. This document is written for language models and machine consumption: every symbol below is exported frombacktest-kitand verified against./src. The human-facing narrative lives in README.md; this file is the dense, complete specification.
backtest-kit is a TypeScript framework for backtesting and live trading strategies on multi-asset markets (crypto, forex, DEX/peer-to-peer, spot, futures) with crash-safe persistence, signal validation, transactional broker integration, virtual-time scheduling, and AI/LLM optimization. It is not a data-processing library β it is a time execution engine: an async stream of virtual (backtest) or real (live) time, where your strategy is evaluated tick by tick, and the same strategy code runs unchanged in both modes.
Think of the engine as an async stream of time. Each emitted moment is a tick. On each tick the engine:
CC_AVG_PRICE_CANDLES_COUNT 1-minute candles).interval).idle, scheduled, waiting, opened, active, closed, cancelled) plus ping events (idlePing, schedulePing, activePing).The headline guarantees:
Backtest (historical, virtual time) and Live (real time). The only difference is the clock source β handled by the framework via AsyncLocalStorage.when. It is structurally impossible for a strategy to read a candle from its own future. See Β§11.Broker adapter intercepts every position mutation before internal state changes; an exchange rejection rolls back the operation atomically and the engine retries on the next tick (Β§17).NaN/Infinity; invalid computations surface as null / N/A rather than poisoning a report.backtest-kit has 775+ unit and integration tests covering validation, recovery, reports, events, walker, heatmap, position sizing, risk, scheduled signals, partials, breakeven, trailing, DCA, cron, sync, and broker.
The why behind these design choices β look-ahead bias as an architectural constraint, second-order chaos, the zero-expectation trap, AI-driven strategy development, the monorepo/parallel model β is laid out in Β§36 Framework philosophy & further reading.
npm install backtest-kit ccxt ollama uuid
backtest-kit has only four runtime dependencies (di-kit, di-scoped, di-singleton, functools-kit, get-moment-stamp) and requires typescript ^5.0.0 as a peer dependency. ccxt, ollama, uuid are your peers for data fetching and LLM inference β not required by the core.
# Zero-boilerplate: all wiring stays inside the CLI package
npx @backtest-kit/cli --init --output backtest-kit-project
cd backtest-kit-project && npm install && npm start
# Full-control eject: exchange/frame/risk/strategy/runner all live as editable files
npx -y @backtest-kit/sidekick my-trading-bot
cd my-trading-bot && npm start
# Docker workspace with auto-restart
npx @backtest-kit/cli --docker
cd backtest-kit-docker
MODE=live SYMBOL=TRXUSDT STRATEGY_FILE=./content/feb_2026/feb_2026.strategy.ts docker-compose up -d
| Package | Purpose |
|---|---|
@backtest-kit/cli |
Zero-boilerplate CLI runner. --backtest / --paper / --live, auto candle cache, --ui, --telegram, monorepo isolation. |
@backtest-kit/sidekick |
Project scaffolder β the "eject" of --init; all boilerplate as editable source files. |
@backtest-kit/pinets |
Run TradingView Pine Script v5/v6 strategies in Node via the PineTS runtime; 60+ built-in indicators. |
@backtest-kit/graph |
Compose computations as a typed DAG (sourceNode + outputNode), resolved in topological order with Promise.all parallelism. |
@backtest-kit/ui |
Full-stack visualization: Node backend + React dashboard, candlestick charts, signal tracking, risk analysis. |
@backtest-kit/mongo |
MongoDB source-of-truth + Redis O(1) cache replacing file-based ./dump/; all 15 persist adapters implemented. |
@backtest-kit/ollama |
Multi-provider LLM inference (OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, HuggingFace, Ollama) with structured JSON output and token rotation. |
@backtest-kit/signals |
50+ technical indicators across 4 timeframes; markdown reports formatted for LLM context injection. |
Full per-package API reference (verified against each
src/index.ts) is in Β§33.
Community templates: backtest-monorepo-parallel (9 symbols in one process on Mongo+Redis), backtest-ollama-crontab (Telegram-ingested signals + LLM risk filter), backtest-kit-redis-mongo-docker (production persistence stack), uzse-backtest-app (regional stock exchanges via Pine Script).
Vector/quant math companions, plugging into the Exchange schema with no Python runtime: garch (conditional variance β TP/SL corridor, via getCandles), pump-anomaly (coordinated-speculation detection, via getRawCandles), volume-anomaly (order-flow intensity, via getAggregatedTrades).
import { setLogger, setConfig } from "backtest-kit";
setLogger({
log: console.log,
debug: console.debug,
info: console.info,
warn: console.warn,
});
// Optional β see Β§26 for all ~40 keys. Call before running any strategy.
setConfig({
CC_PERCENT_SLIPPAGE: 0.1, // % slippage per side
CC_PERCENT_FEE: 0.1, // % fee per side
CC_SCHEDULE_AWAIT_MINUTES: 120, // pending (scheduled) signal timeout
});
setLogger/setConfigare synchronous inv13.6.0(the README'sawait setConfig(...)still works becauseawaiton a non-promise is a no-op, but it is not required).setConfigvalidates the merged config and rolls back + rethrows on failure.
import ccxt from "ccxt";
import {
addExchangeSchema, addStrategySchema, addFrameSchema, addRiskSchema,
} from "backtest-kit";
// Exchange (data source)
addExchangeSchema({
exchangeName: "binance",
getCandles: async (symbol, interval, since, limit, backtest) => {
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: (symbol, price) => price.toFixed(2),
formatQuantity: (symbol, quantity) => quantity.toFixed(8),
});
// Risk profile (portfolio-level validations)
addRiskSchema({
riskName: "demo",
validations: [
// TP at least 1%
({ currentSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, position } = currentSignal;
const tpDistance = position === "long"
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
if (tpDistance < 1) throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
},
// Reward/Risk at least 2:1
({ currentSignal }) => {
const { priceOpen, priceTakeProfit, priceStopLoss, position } = currentSignal;
const reward = position === "long" ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
const risk = position === "long" ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
if (reward / risk < 2) throw new Error("Poor R/R ratio");
},
],
});
// Time frame (backtest period)
addFrameSchema({
frameName: "1d-test",
interval: "1m",
startDate: new Date("2025-12-01"),
endDate: new Date("2025-12-02"),
});
Note on the risk validation payload. In
v13.6.0the validation function receives anIRiskValidationPayloadwhose signal field iscurrentSignal(anIRiskSignalRow, withpriceOpenalways present), plusactivePositionCountandactivePositions. Earlier docs referencedpendingSignalβ usecurrentSignal. See Β§16.
import { v4 as uuid } from "uuid";
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from "backtest-kit";
import { json } from "./utils/json.mjs"; // your LLM wrapper
import { getMessages } from "./utils/messages.mjs"; // market-data prep
addStrategySchema({
strategyName: "llm-strategy",
interval: "5m",
riskName: "demo",
getSignal: async (symbol, when, currentPrice) => {
const candles1h = await getCandles(symbol, "1h", 24);
const candles15m = await getCandles(symbol, "15m", 48);
const candles5m = await getCandles(symbol, "5m", 60);
const candles1m = await getCandles(symbol, "1m", 60);
const messages = await getMessages(symbol, { candles1h, candles15m, candles5m, candles1m });
const resultId = uuid();
const signal = await json(messages); // LLM returns { position, priceTakeProfit, priceStopLoss, ... }
await dumpAgentAnswer({
dumpId: "position-context",
bucketName: "multi-timeframe-strategy",
messages,
description: "agent reasoning for this signal",
});
await dumpRecord({
dumpId: "position-entry",
bucketName: "multi-timeframe-strategy",
record: signal,
description: "signal entry parameters",
});
return { ...signal, id: resultId };
},
});
getSignalsignature changed. Inv13.6.0getSignalis(symbol, when, currentPrice) => Promise<ISignalDto | null>. Thewhen: DateandcurrentPrice: numberarguments are passed by the engine; you no longer have to callgetDate()/getAveragePrice()just to obtain them (though those functions still exist). Returningnullmeans "no signal this tick".
import { Backtest, listenSignalBacktest, listenDoneBacktest } from "backtest-kit";
Backtest.background("BTCUSDT", {
strategyName: "llm-strategy",
exchangeName: "binance",
frameName: "1d-test",
});
listenSignalBacktest((event) => console.log(event));
listenDoneBacktest(async (event) => {
await Backtest.dump(event.symbol, event.strategyName); // β ./dump/backtest/llm-strategy.md
});
import { Live, listenSignalLive } from "backtest-kit";
Live.background("BTCUSDT", {
strategyName: "llm-strategy",
exchangeName: "binance",
});
listenSignalLive((event) => console.log(event));
A minimal, fully self-contained backtest with a deterministic synthetic exchange β useful as a smoke test or a starting skeleton:
import {
addExchangeSchema, addStrategySchema, addFrameSchema,
Backtest, listenSignalBacktest, listenDoneBacktest, listenError,
} from "backtest-kit";
// 1) Exchange β synthetic candles around a slowly drifting base price.
addExchangeSchema({
exchangeName: "sim",
getCandles: async (symbol, interval, since, limit) => {
const base = 50_000;
return Array.from({ length: limit }, (_, i) => {
const t = since.getTime() + i * 60_000;
const p = base + Math.sin(t / 6e6) * 500;
return { timestamp: t, open: p, high: p + 25, low: p - 25, close: p, volume: 10 };
});
},
formatPrice: (s, price) => price.toFixed(2),
formatQuantity: (s, qty) => qty.toFixed(6),
});
// 2) Strategy β open a LONG immediately when flat, scale out at +1%, trail at +2%.
addStrategySchema({
strategyName: "demo",
interval: "5m",
getSignal: async (symbol, when, currentPrice) => {
if (!(await hasNoPendingSignal(symbol))) return null;
return {
position: "long",
priceTakeProfit: currentPrice * 1.03,
priceStopLoss: currentPrice * 0.98,
minuteEstimatedTime: 240,
};
},
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
const pct = await getPositionPnlPercent(symbol, currentPrice);
if (pct !== null && pct >= 1) await commitPartialProfit(symbol, 50);
if (pct !== null && pct >= 2) await commitTrailingStop(symbol, 1, currentPrice);
},
onClose: (symbol, data, priceClose, when) =>
console.log(`closed ${symbol} @ ${priceClose} pnl=${data.pnl.pnlPercentage.toFixed(2)}%`),
},
});
// 3) Frame β one day at 1-minute granularity.
addFrameSchema({
frameName: "1d",
interval: "1m",
startDate: new Date("2025-12-01T00:00:00Z"),
endDate: new Date("2025-12-02T00:00:00Z"),
});
// 4) Run + report.
listenError((e) => console.error("engine error:", e));
listenSignalBacktest((e) => { if (e.action === "closed") console.log("PNL%", e.pnl.pnlPercentage); });
listenDoneBacktest(async (e) => { await Backtest.dump(e.symbol, e.strategyName); });
Backtest.background("BTCUSDT", { strategyName: "demo", exchangeName: "sim", frameName: "1d" });
Imports of
hasNoPendingSignal,getPositionPnlPercent,commitPartialProfit,commitTrailingStopcome frombacktest-kit(omitted above for brevity). They are valid insidegetSignal/callbacks because the engine has an active context there.
Exchanges, strategies, frames, risk profiles, sizing profiles, walkers, and actions are registered under string identifiers and lazily resolved at runtime. Declare them in separate modules, wire them with constants:
export enum ExchangeName { Binance = "binance", Bybit = "bybit" }
export enum StrategyName { SMA = "sma-crossover", RSI = "rsi-strategy" }
export enum FrameName { Day = "1d", Week = "1w" }
addStrategySchema({ strategyName: StrategyName.SMA, interval: "5m", getSignal: async () => { /* β¦ */ } });
Backtest.background("BTCUSDT", {
strategyName: StrategyName.SMA,
exchangeName: ExchangeName.Binance,
frameName: FrameName.Day,
});
All name types (ExchangeName, StrategyName, FrameName, RiskName, SizingName, WalkerName, ActionName) are string aliases β use plain strings or enums interchangeably.
context objectEvery runner method takes (symbol, context). The shape depends on the runner:
{ strategyName, exchangeName, frameName }{ strategyName, exchangeName } (no frame β live uses the wall clock){ walkerName } (the walker schema already names the exchange + frame + strategy list)Both consume the same engine with the same guarantees; only the consumption model differs.
Event-driven (background): for production bots, monitoring, long-running processes.
Backtest.background("BTCUSDT", config);
listenSignalBacktest((event) => { /* handle every lifecycle event */ });
listenDoneBacktest((event) => { /* finalize / dump report */ });
Async iterator (pull-based): for research, scripting, tests, and LLM agents.
for await (const event of Backtest.run("BTCUSDT", config)) {
// backtest yields closed/cancelled/opened/scheduled/active results
}
background(...) returns a cancellation closure (graceful stop β lets the current position finish). run(...) returns an async generator.
getSignal returns an ISignalDto:
priceOpen is provided, the signal is scheduled β it waits for the market to reach priceOpen (a limit/grid-style entry). It is auto-cancelled after CC_SCHEDULE_AWAIT_MINUTES, or if SL is hit before activation.priceOpen is omitted, the signal opens immediately at the current VWAP.Direction rules (validated automatically): for long, priceTakeProfit > priceOpen and priceStopLoss < priceOpen; for short, the inverse.
With per-entry PNL, peak profit, and max drawdown tracking:
commitAverageBuy)minuteEstimatedTime: Infinity)backtest-kit uses Node's AsyncLocalStorage to propagate two contexts through the entire async call tree without threading parameters:
{ symbol, when: Date, backtest: boolean }. The clock.{ strategyName, exchangeName, frameName }. The identity.Almost every public function (getCandles, getAveragePrice, commitPartialProfit, getPositionPnlPercent, β¦) reads these contexts internally. They throw if called outside an active context β i.e. you can only call them from inside getSignal or a strategy callback (onActivePing, onClose, β¦), not at module top-level. Use hasTradeContext() to test for an active context before calling.
getMode() returns "backtest" | "live"; getDate() returns the current when; getSymbol() returns the symbol; getRuntimeInfo() returns the full { symbol, context, backtest, range, currentPrice, info, when } snapshot (Β§28).
The engine's "current price" is the VWAP of the last CC_AVG_PRICE_CANDLES_COUNT (default 5) one-minute candles:
TypicalPrice = (high + low + close) / 3
VWAP = Ξ£(TypicalPrice Γ volume) / Ξ£(volume)
If total volume is zero, the engine falls back to the simple average of close prices. The same VWAP is used in backtest and live so results are comparable. Obtain it with getAveragePrice(symbol).
A candle's timestamp is its openTime, never its closeTime. Close time = timestamp + stepMs where stepMs is the interval duration (e.g. 60000 for "1m").
All timestamps are aligned down to the interval boundary (e.g. for 15m: 00:17 β 00:15, 00:44 β 00:30), in UTC (Unix epoch). For a 4h interval the boundaries are 00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC β they will look "uneven" if printed in a non-UTC-multiple local zone; use toISOString()/toUTCString() in callbacks to see true aligned boundaries.
The fetch functions (Β§11) all compute timestamps relative to the current virtual when and exclude the in-progress (pending) candle:
getCandles returns the half-open range [since, alignedWhen) β the candle at alignedWhen is not returned (it is still open).getNextCandles (backtest only) returns [alignedWhen, β¦) going forward β throws in live mode to prevent look-ahead.getRawCandles supports flexible (limit, sDate, eDate) combinations, all validated so eDate <= when.It is therefore structurally impossible for a strategy to observe data from after its current tick.
getSignal is throttled to the strategy interval (default "1m"; one of "1m" | "3m" | "5m" | "15m" | "30m" | "1h"). Even if the engine ticks every minute, getSignal is invoked at most once per interval window. Ping callbacks (onActivePing, onSchedulePing, onIdlePing) fire every minute regardless of interval, so position management can be finer-grained than signal generation.
All addXxxSchema functions register a configuration object under a string name. Each has a matching overrideXxxSchema (replace an existing registration), getXxxSchema (retrieve the raw schema), and listXxxSchema (list registered names). Registration is idempotent on the name.
| Domain | Add | Override | Get | List |
|---|---|---|---|---|
| Exchange | addExchangeSchema |
overrideExchangeSchema |
getExchangeSchema |
listExchangeSchema |
| Strategy | addStrategySchema |
overrideStrategySchema |
getStrategySchema |
listStrategySchema |
| Frame | addFrameSchema |
overrideFrameSchema |
getFrameSchema |
listFrameSchema |
| Risk | addRiskSchema |
overrideRiskSchema |
getRiskSchema |
listRiskSchema |
| Sizing | addSizingSchema |
overrideSizingSchema |
getSizingSchema |
listSizingSchema |
| Walker | addWalkerSchema |
overrideWalkerSchema |
getWalkerSchema |
listWalkerSchema |
| Action | addActionSchema |
overrideActionSchema |
getActionSchema |
β |
addExchangeSchema(schema: IExchangeSchema)The data source. Only exchangeName and getCandles are required; everything else has defaults.
interface IExchangeSchema {
exchangeName: ExchangeName; // unique id
note?: string;
// REQUIRED β fetch OHLCV; backtest flag tells you whether you may use sliced historical data
getCandles: (symbol: string, interval: CandleInterval, since: Date, limit: number, backtest: boolean)
=> Promise<IPublicCandleData[]>;
// OPTIONAL β default Binance precision (2 dp price, 8 dp quantity) if omitted
formatPrice?: (symbol: string, price: number, backtest: boolean) => Promise<string>;
formatQuantity?: (symbol: string, quantity: number, backtest: boolean) => Promise<string>;
// OPTIONAL β throw-if-called if omitted
getOrderBook?: (symbol: string, depth: number, from: Date, to: Date, backtest: boolean) => Promise<IOrderBookData>;
getAggregatedTrades?: (symbol: string, from: Date, to: Date, backtest: boolean) => Promise<IAggregatedTradeData[]>;
callbacks?: Partial<{
onCandleData: (symbol, interval, since, limit, data) => void | Promise<void>;
}>;
}
CandleInterval = "1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h" | "1d".
Adapter contract for getCandles (enforced by validation):
timestamp must equal the aligned since.limit candles must be returned.since + i * stepMs for i = 0 β¦ limit-1.Data types:
interface IPublicCandleData { timestamp; open; high; low; close; volume; } // all number | undefined
interface ICandleData { timestamp: number; open; high; low; close; volume: number; } // all required
interface IBidData { price: string; quantity: string; }
interface IOrderBookData { symbol: string; bids: IBidData[]; asks: IBidData[]; }
interface IAggregatedTradeData { id: string; price: number; qty: number; timestamp: number; isBuyerMaker: boolean; }
formatPrice/formatQuantity may return synchronously or as a Promise β both are accepted.
addStrategySchema(schema: IStrategySchema)interface IStrategySchema {
strategyName: StrategyName; // unique id
note?: string;
interval?: SignalInterval; // throttle for getSignal; default "1m"
// Returns a signal DTO or null. `when` and `currentPrice` are supplied by the engine.
getSignal?: (symbol: string, when: Date, currentPrice: number) => Promise<ISignalDto | null>;
callbacks?: Partial<IStrategyCallbacks>;
riskName?: RiskName; // single risk profile
riskList?: RiskName[]; // multiple risk profiles (all must pass)
actions?: ActionName[]; // attached action handlers (see Β§22)
info?: RuntimeData; // arbitrary Record<string, unknown> surfaced in getRuntimeInfo()
}
type SignalInterval = "1m" | "3m" | "5m" | "15m" | "30m" | "1h";
type RuntimeData = Record<string, unknown>;
ISignalDto β what getSignal returns:
interface ISignalDto {
id?: string; // auto-generated UUID v4 if omitted
symbol?: string;
position: "long" | "short";
note?: string;
priceOpen?: number; // provided β scheduled entry; omitted β immediate at VWAP
priceTakeProfit: number; // long: > priceOpen ; short: < priceOpen
priceStopLoss: number; // long: < priceOpen ; short: > priceOpen
minuteEstimatedTime?: number; // Infinity = no timeout; default CC_MAX_SIGNAL_LIFETIME_MINUTES (1440)
cost?: number; // entry cost in USD; default CC_POSITION_ENTRY_COST (100)
}
IStrategyCallbacks β all optional, all receive when: Date and backtest: boolean. The argument order in v13.6.0 is shown below (note: when precedes backtest, and partial callbacks pass the percent before currentPrice):
interface IStrategyCallbacks {
onTick: (symbol, result: IStrategyTickResult, currentPrice, when, backtest) => void | Promise<void>;
onOpen: (symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onActive: (symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onIdle: (symbol, currentPrice, when, backtest) => void | Promise<void>;
onClose: (symbol, data: IPublicSignalRow, priceClose, when, backtest) => void | Promise<void>;
onSchedule:(symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onCancel: (symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onWrite: (symbol, data: ISignalRow | null, currentPrice, when, backtest) => void;
onPartialProfit: (symbol, data, revenuePercent, currentPrice, when, backtest) => void | Promise<void>;
onPartialLoss: (symbol, data, lossPercent, currentPrice, when, backtest) => void | Promise<void>;
onBreakeven: (symbol, data, currentPrice, when, backtest) => void | Promise<void>;
// Fire EVERY minute regardless of `interval` β the right place for dynamic management:
onSchedulePing: (symbol, data, currentPrice, when, backtest) => void | Promise<void>;
onActivePing: (symbol, data, currentPrice, when, backtest) => void | Promise<void>;
}
Use onActivePing to call commitAverageBuy / commitPartialProfit / commitTrailingStop / commitBreakeven against the live position (Β§9).
addFrameSchema(schema: IFrameSchema)Defines the backtest period. (Live mode ignores frames β it uses the wall clock.)
interface IFrameSchema {
frameName: FrameName; // unique id
note?: string;
interval?: FrameInterval; // tick granularity; default "1m"
startDate: Date; // inclusive
endDate: Date; // inclusive
callbacks?: Partial<{
onTimeframe: (timeframe: Date[], startDate: Date, endDate: Date, interval: FrameInterval) => void | Promise<void>;
}>;
}
type FrameInterval =
"1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h" | "12h" | "1d";
The number of generated ticks corresponds to (endDate - startDate) / interval. For a 1-day frame at 1m granularity that is ~1440 ticks. The interval here is the engine's step size, independent of any getCandles interval you request inside getSignal.
A signal moves through a type-safe state machine. Each tick yields exactly one IStrategyTickResult discriminated on action:
idle ββgetSignalβββΆ scheduled ββprice reaches priceOpenβββΆ opened βββΆ active βββΆ closed
β β β²
β βββ timeout / SL-before-entry ββΆ cancelled β
βββ getSignal (no priceOpen) ββββββββββββββββββββββββΆ opened βββββββββββββββββββββ
idle β no active or scheduled signal this tick.scheduled β a limit/grid signal was just created and is waiting for priceOpen.waiting β emitted on subsequent ticks while a scheduled signal is still waiting (distinct from the one-time scheduled).opened β a position just became active (either immediate, or a scheduled signal that activated).active β an open position is being monitored (carries percentTp, percentSl, pnl).closed β position exited (carries closeReason, closeTimestamp, pnl).cancelled β a scheduled signal never activated (carries reason).type IStrategyTickResult =
| IStrategyTickResultIdle // { action: "idle"; signal: null; currentPrice; β¦ }
| IStrategyTickResultScheduled // { action: "scheduled"; signal: IPublicSignalRow; β¦ }
| IStrategyTickResultWaiting // { action: "waiting"; signal; percentTp:0; percentSl:0; pnl; β¦ }
| IStrategyTickResultOpened // { action: "opened"; signal; currentPrice; β¦ }
| IStrategyTickResultActive // { action: "active"; signal; percentTp; percentSl; pnl; β¦ }
| IStrategyTickResultClosed // { action: "closed"; signal; closeReason; closeTimestamp; pnl; β¦ }
| IStrategyTickResultCancelled; // { action: "cancelled"; signal; reason; closeTimestamp; β¦ }
type StrategyCloseReason = "time_expired" | "take_profit" | "stop_loss" | "closed";
type StrategyCancelReason = "timeout" | "price_reject" | "user";
Every variant carries strategyName, exchangeName, frameName, symbol, currentPrice, backtest, and createdAt. Use a type guard on action for type-safe field access:
for await (const result of Backtest.run("BTCUSDT", config)) {
if (result.action === "closed") {
console.log(result.closeReason, result.pnl.pnlPercentage);
}
}
Backtest.run yields opened | scheduled | active | closed | cancelled (an active result only appears when the frame is exhausted while a minuteEstimatedTime: Infinity position is still open). Live.run yields the full set including idle.
IStrategyPnLinterface IStrategyPnL {
pnlPercentage: number; // e.g. 1.5 = +1.5%
priceOpen: number; // entry adjusted for slippage + fees
priceClose: number; // exit adjusted for slippage + fees
pnlCost: number; // absolute USD P/L = pnlPercentage/100 * pnlEntries
pnlEntries: number; // total invested capital in USD (sum of all entry costs)
}
IPublicSignalRow β the signal object surfaced everywhereISignalDto (your input) is augmented into ISignalRow (internal) and exposed as IPublicSignalRow in events, callbacks, and analytics. Key public fields beyond the DTO:
interface IPublicSignalRow extends ISignalRow {
cost: number; // cost of the initial entry (not DCA)
originalPriceOpen: number; // entry at creation (unchanged by averaging)
originalPriceStopLoss: number; // SL at creation (unchanged by trailing)
originalPriceTakeProfit: number;// TP at creation (unchanged by trailing)
partialExecuted: number; // 0β100, sum of all partial-close percentages
totalEntries: number; // _entry.length (1 = no DCA)
totalPartials: number; // _partial.length (0 = no partial closes)
pnl: IStrategyPnL; // unrealized PNL at emission
peakProfit: IStrategyPnL; // best favorable excursion so far
maxDrawdown: IStrategyPnL; // worst adverse excursion so far
}
Internal _-prefixed fields (also present, useful when persisting/inspecting): _entry[] (DCA history { price, cost, timestamp }), _partial[] (partial-close history { type, percent, currentPrice, costBasisAtClose, entryCountAtClose, timestamp }), _trailingPriceStopLoss, _trailingPriceTakeProfit, _peak, _fall, pendingAt, scheduledAt.
Every signal is validated automatically before it is opened/scheduled. Failures throw with a detailed message (surfaced via listenError / listenValidation). The exported validators (validateSignal, validateCommonSignal, validatePendingSignal, validateScheduledSignal) implement these rules and can also be called standalone.
Common rules (validateCommonSignal) β applied to every signal:
priceOpen, priceTakeProfit, priceStopLoss must each be a finite, positive number.priceTakeProfit > priceOpen and priceStopLoss < priceOpen.priceTakeProfit < priceOpen and priceStopLoss > priceOpen.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT (default 0.5%) β must exceed slippage+fees so a trade can be net-profitable.CC_MIN_STOPLOSS_DISTANCE_PERCENT (0.5%) β avoids instant stop-out on noise.CC_MAX_STOPLOSS_DISTANCE_PERCENT (20%) β caps catastrophic single-signal loss.Immediate (pending) signals (validatePendingSignal) β when priceOpen is omitted and the position opens now at currentPrice:
currentPrice <= priceStopLoss (would instantly stop) or currentPrice >= priceTakeProfit (would instantly take profit).currentPrice >= priceStopLoss or currentPrice <= priceTakeProfit.Scheduled signals (validateScheduledSignal) β when priceOpen is provided:
priceOpen must lie strictly between SL and TP so activation would not immediately close the position:
priceStopLoss < priceOpen < priceTakeProfit.priceTakeProfit < priceOpen < priceStopLoss.// β
valid LONG
{ position: "long", priceOpen: 50000, priceTakeProfit: 51000, priceStopLoss: 49000 }
// β invalid LONG β throws (TP below open, SL above open)
{ position: "long", priceOpen: 50000, priceTakeProfit: 49000, priceStopLoss: 51000 }
// β
valid SHORT
{ position: "short", priceOpen: 50000, priceTakeProfit: 49000, priceStopLoss: 51000 }
validateCandles (and the internal fetch path) reject incomplete/anomalous candles from the data source:
CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN (default 5) candles a median reference price is used; below that, a simple average β then any candle whose price is more than CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR (default 1000Γ) below the reference is rejected as an anomaly.getCandles, getNextCandles, getRawCandles, and the cache layer.getCandles retries up to CC_GET_CANDLES_RETRY_COUNT (3) times with CC_GET_CANDLES_RETRY_DELAY_MS (5000 ms) between attempts; requests larger than CC_MAX_CANDLES_PER_REQUEST (1000) are paginated.
These are called from inside getSignal or a strategy callback. They read the execution + method context automatically (no strategyName/exchangeName arguments) and throw if no context is active. Import them as named functions from backtest-kit.
| Function | Signature | Returns |
|---|---|---|
getPendingSignal |
(symbol) => Promise<IPublicSignalRow | null> |
The active open position, or null. |
getScheduledSignal |
(symbol) => Promise<IPublicSignalRow | null> |
The waiting scheduled signal, or null. |
hasNoPendingSignal |
(symbol) => Promise<boolean> |
true if no open position. |
hasNoScheduledSignal |
(symbol) => Promise<boolean> |
true if no scheduled signal. |
getBreakeven |
(symbol, currentPrice) => Promise<boolean> |
true if price has cleared the breakeven threshold (covers fees+slippage). |
getStrategyStatus |
(symbol) => Promise<StrategyStatus> |
Deferred-state snapshot (commit queue, created/closed/cancelled/activated signal, pendingSignalId). |
getTotalPercentClosed |
(symbol) => Promise<number> |
% of position still held (100 = full, 0 = fully closed), DCA-aware. |
getTotalCostClosed |
(symbol) => Promise<number> |
USD cost basis still held, DCA-aware. |
getLatestSignal |
(symbol) => Promise<IPublicSignalRow | null> |
Most recent signal (pending or closed) β useful for cooldown logic. |
getMinutesSinceLatestSignalCreated |
(symbol) => Promise<number | null> |
Whole minutes since the latest signal was created. |
// Guard pattern inside getSignal:
addStrategySchema({
strategyName: "guarded",
getSignal: async (symbol, when, currentPrice) => {
if (!(await hasNoPendingSignal(symbol))) return null; // one position at a time
const minutes = await getMinutesSinceLatestSignalCreated(symbol);
if (minutes !== null && minutes < 240) return null; // 4h cooldown after last signal
return { position: "long", priceTakeProfit: currentPrice * 1.03, priceStopLoss: currentPrice * 0.99 };
},
});
| Function | Signature | Notes |
|---|---|---|
hasTradeContext |
() => boolean |
true if both execution + method contexts are active. |
getDate |
() => Promise<Date> |
Current virtual (backtest) or real (live) time. |
getTimestamp |
() => Promise<number> |
Same as getDate().getTime(), via the time-meta service. |
getMode |
() => Promise<"backtest" | "live"> |
|
getSymbol |
() => Promise<string> |
Current symbol. |
getContext |
() => Promise<{ strategyName; exchangeName; frameName }> |
Method context. |
getRuntimeInfo |
<Data>() => Promise<IRuntimeInfo<Data>> |
Full snapshot: { symbol, context, backtest, range, currentPrice, info, when }. |
createSignalState β typed per-signal accumulator (recommended)Returns a bound [getState, setState] tuple scoped to a bucket and an active signal. Both resolve the signal and backtest flag from context β no signalId argument. Ideal for capitulation logic that accumulates per-trade metrics across onActivePing ticks.
import { createSignalState } from "backtest-kit";
const [getTradeState, setTradeState] = createSignalState({
bucketName: "trade",
initialValue: { peakPercent: 0, minutesOpen: 0 },
});
// inside onActivePing:
await setTradeState((s) => ({
peakPercent: Math.max(s.peakPercent, currentUnrealisedPercent),
minutesOpen: s.minutesOpen + 1,
}));
const { peakPercent, minutesOpen } = await getTradeState();
if (minutesOpen >= 15 && peakPercent < 0.3) await commitClosePending(symbol); // capitulate
getSignalState(symbol, { bucketName, initialValue })andsetSignalState(symbol, dispatch, { bucketName, initialValue })are the lower-level equivalents and are deprecated in favour ofcreateSignalState.
The position-mutation API. Called from onActivePing (or other callbacks) with await. All read context automatically. Mutations are queued and applied transactionally; in live mode each is intercepted by the Broker adapter before internal state changes (Β§17).
| Function | Signature | Effect |
|---|---|---|
commitCreateSignal |
(symbol, currentPrice, dto: ISignalDto) => Promise<void> |
Queue a user-supplied signal for the next tick instead of getSignal. |
commitClosePending |
(symbol, payload?: Partial<CommitPayload>) => Promise<void> |
Close the open position now (closeReason: "closed"). |
commitCancelScheduled |
(symbol, payload?: Partial<CommitPayload>) => Promise<void> |
Cancel the waiting scheduled signal (reason: "user"). |
commitActivateScheduled |
(symbol, payload?: Partial<CommitPayload>) => Promise<void> |
Force-activate the scheduled signal at the current price without waiting for priceOpen. |
commitSignalNotify |
(symbol, payload: SignalNotificationPayload) => Promise<void> |
Emit a user notification tied to the signal. |
CommitPayload = { id: string; note: string } (both optional via Partial).
commitAverageBuy(symbol: string, cost?: number): Promise<boolean>
Adds a new entry to the open position. cost defaults to CC_POSITION_ENTRY_COST ($100). Default acceptance rule: the entry is accepted only when currentPrice beats the all-time extreme since entry β
currentPrice is a new low (below every prior entry price);currentPrice is a new high (above every prior entry price).This prevents averaging up (into a losing direction the wrong way). When rejected it returns false silently. Set CC_ENABLE_DCA_EVERYWHERE: true to relax the rule to "any price still beyond priceOpen" rather than a new extreme. Each accepted entry shifts the effective priceOpen (harmonic/cost-basis mean β see Β§12), which in turn changes whether the next commitAverageBuy is accepted.
| Function | Signature | Effect |
|---|---|---|
commitPartialProfit |
(symbol, percentToClose: number) => Promise<boolean> |
Close percentToClose % (0β100) of the position at profit. Throws if price is not in profit direction. |
commitPartialLoss |
(symbol, percentToClose: number) => Promise<boolean> |
Close percentToClose % at loss. |
commitPartialProfitCost |
(symbol, dollarAmount: number) => Promise<boolean> |
Close a USD dollarAmount worth at profit. |
commitPartialLossCost |
(symbol, dollarAmount: number) => Promise<boolean> |
Close a USD dollarAmount worth at loss. |
Returns false (skips) if closing would exceed 100% total closed, or if the precondition fails. By default a partial-profit only succeeds when price is moving toward TP and a partial-loss only when moving toward SL; set CC_ENABLE_PPPL_EVERYWHERE: true to allow mixing.
addStrategySchema({
strategyName: "scale-out",
getSignal,
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
const pct = await getPositionPnlPercent(symbol, currentPrice);
if (pct !== null && pct >= 3) await commitPartialProfit(symbol, 33);
if (pct !== null && pct >= 6) await commitPartialProfit(symbol, 33);
},
},
});
| Function | Signature | Effect |
|---|---|---|
commitTrailingStop |
(symbol, percentShift: number, currentPrice: number) => Promise<boolean> |
Move SL to a trailing distance percentShift % behind currentPrice (ratchets one way only). |
commitTrailingTake |
(symbol, percentShift: number, currentPrice: number) => Promise<boolean> |
Adjust TP by percentShift % relative to currentPrice. |
commitTrailingStopCost |
(symbol, newStopLossPrice: number) => Promise<boolean> |
Set the trailing SL to an absolute price. |
commitTrailingTakeCost |
(symbol, newTakeProfitPrice: number) => Promise<boolean> |
Set the trailing TP to an absolute price. |
commitBreakeven |
(symbol) => Promise<boolean> |
Move SL to entry (breakeven) once getBreakeven(symbol, currentPrice) would return true. |
The trailing SL never moves against the position (for LONG it only moves up, for SHORT only down). The original SL/TP are preserved in originalPriceStopLoss/originalPriceTakeProfit; the trailing values override them for exit evaluation. Set CC_ENABLE_TRAILING_EVERYWHERE: true to activate trailing without absorption conditions.
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
if (await getBreakeven(symbol, currentPrice)) await commitBreakeven(symbol);
await commitTrailingStop(symbol, 1.0, currentPrice); // 1% trailing stop
},
}
A large family of read-only getPosition* functions describing the current open position. All read context automatically, take (symbol) (a few also take currentPrice), and return null when there is no pending signal. They are also available as methods on Backtest/Live (with explicit (symbol, [currentPrice,] context)), which is how you query a position from outside a strategy callback.
| Function | Returns | Meaning |
|---|---|---|
getPositionEffectivePrice |
number | null |
Weighted-average (cost-basis) entry price across all DCA entries. |
getPositionInvestedCount |
number | null |
Total base-asset units held (sum across DCA entries). |
getPositionInvestedCost |
number | null |
Total USD cost invested (sum of entry costs). |
getPositionEntries |
Array<{ price; cost; timestamp }> | null |
All entries; [0] is the original priceOpen. |
getPositionLevels |
number[] | null |
Just the entry prices; single-element [priceOpen] if no DCA. |
getPositionPartials |
Array<{ type:"profit"|"loss"; percent; currentPrice; costBasisAtClose; entryCountAtClose; timestamp }> | null |
Partial-close history. |
| Function | Signature | Returns |
|---|---|---|
getPositionPnlPercent |
(symbol, currentPrice) |
Unrealized PNL % vs effective entry (fees/slippage/partials aware). |
getPositionPnlCost |
(symbol, currentPrice) |
Unrealized PNL in USD. |
| Function | Returns | Meaning |
|---|---|---|
getPositionEstimateMinutes |
number | null |
Original minuteEstimatedTime. |
getPositionCountdownMinutes |
number | null |
Remaining minutes before time_expired (clamped to 0). |
getPositionActiveMinutes |
number | null |
Minutes the position has been open. |
getPositionWaitingMinutes |
number | null |
Minutes a scheduled signal has been waiting for activation. |
| Function | Returns |
|---|---|
getPositionHighestProfitPrice |
Best price seen in the profit direction. |
getPositionHighestProfitTimestamp |
When that peak occurred. |
getPositionHighestPnlPercentage |
Peak unrealized PNL %. |
getPositionHighestPnlCost |
Peak unrealized PNL in USD. |
getPositionHighestProfitMinutes |
Minutes from open to the peak. |
getPositionHighestProfitBreakeven |
Whether the peak ever cleared the breakeven threshold. |
| Function | Returns |
|---|---|
getPositionDrawdownMinutes |
Minutes spent below the effective entry. |
getPositionMaxDrawdownPrice |
Worst price seen in the loss direction. |
getPositionMaxDrawdownTimestamp |
When that trough occurred. |
getPositionMaxDrawdownMinutes |
Minutes from open to the trough. |
getPositionMaxDrawdownPnlPercentage |
Worst unrealized PNL %. |
getPositionMaxDrawdownPnlCost |
Worst unrealized PNL in USD. |
| Function | Meaning |
|---|---|
getPositionHighestMaxDrawdownPnlPercentage / β¦PnlCost |
The worst drawdown that occurred after the highest profit. |
getPositionHighestProfitDistancePnlPercentage / β¦PnlCost |
Distance between peak profit and the current/closing point. |
getMaxDrawdownDistancePnlPercentage / getMaxDrawdownDistancePnlCost |
Distance from the max-drawdown point. |
| Function | Meaning |
|---|---|
getPositionEntryOverlap |
(symbol, currentPrice, ladder?) => Promise<boolean> β true if currentPrice falls within the spacing band of an existing DCA entry. |
getPositionPartialOverlap |
(symbol, currentPrice, ladder?) => Promise<boolean> β same, for partial-close prices. |
ladder is an IPositionOverlapLadder ({ upperPercent, lowerPercent }, default POSITION_OVERLAP_LADDER_DEFAULT). This is the DCA-ladder spacing guard: before adding a rung, check getPositionEntryOverlap and skip if it returns true, so entries are spaced at least lowerPercent/upperPercent apart (see the ladder recipe in Β§22.5 and the Mar/Apr 2026 examples in Β§34).
These functions fetch market data from the registered exchange, always relative to the current virtual when, always look-ahead-safe. Import them from backtest-kit; call from inside a strategy/callback (active context required).
| Function | Signature | Notes |
|---|---|---|
getCandles |
(symbol, interval, limit) => Promise<ICandleData[]> |
limit candles backwards from aligned when. Range [since, alignedWhen). |
getNextCandles |
(symbol, interval, limit) => Promise<ICandleData[]> |
limit candles forwards from aligned when. Backtest only β throws in live (look-ahead). Range [alignedWhen, β¦). |
getRawCandles |
(symbol, interval, limit?, sDate?, eDate?) => Promise<ICandleData[]> |
Flexible date/limit combos (see below). |
getAveragePrice |
(symbol) => Promise<number> |
VWAP of last CC_AVG_PRICE_CANDLES_COUNT 1-minute candles. |
getClosePrice |
(symbol, interval) => Promise<number> |
Close of the last completed candle for interval. |
getOrderBook |
(symbol, depth?) => Promise<IOrderBookData> |
Depth defaults to CC_ORDER_BOOK_MAX_DEPTH_LEVELS. |
getAggregatedTrades |
(symbol, limit?) => Promise<IAggregatedTradeData[]> |
No limit β one CC_AGGREGATED_TRADES_MAX_MINUTES window; with limit β paginates backwards then slices to most-recent limit. |
formatPrice |
(symbol, price) => Promise<string> |
Exchange precision. |
formatQuantity |
(symbol, quantity) => Promise<string> |
Exchange precision. |
hasTradeContext |
() => boolean |
Guard before calling any of the above. |
getRawCandles parameter combinationsAll combinations validate eDate <= when (look-ahead protection). sDate/eDate are epoch milliseconds.
(limit) β since = alignedWhen - limit*stepMs, range [since, alignedWhen).(limit, sDate) β since = align(sDate), limit candles forward, range [since, since + limit*stepMs).(limit, undefined, eDate) β since = align(eDate) - limit*stepMs, range [since, eDate) (eDate exclusive).(undefined, sDate, eDate) β limit computed from range, sDate inclusive, eDate exclusive, range [sDate, eDate).(limit, sDate, eDate) β since = align(sDate), limit candles, sDate inclusive.// 15-minute interval, when = 00:12:00
stepMs = 15 * 60000 = 900000
alignedWhen = floor(when / stepMs) * stepMs = 00:00:00
// getCandles("BTCUSDT","15m",4):
since = alignedWhen - 4*stepMs = 23:00:00 (prev day)
// returns timestamps: 23:00, 23:15, 23:30, 23:45 β the 00:00 candle is EXCLUDED (still open)
Why exclude the pending candle: at when = 00:12, the 00:00 candle covers [00:00, 00:15) and is incomplete; its OHLCV would distort indicators. Only fully-closed candles are returned. Validation (first-timestamp + count) is applied uniformly across getCandles, getNextCandles, getRawCandles, and the cache layer.
Order book uses a configurable time offset rather than candle intervals:
offsetMs = CC_ORDER_BOOK_TIME_OFFSET_MINUTES * 60000 // default 10 min
alignedTo = floor(when / offsetMs) * offsetMs
to = alignedTo ; from = alignedTo - offsetMs
// adapter receives (symbol, depth, from, to, backtest)
Most exchanges expose only the current book (Binance GET /api/v3/depth), so for backtest you supply your own snapshot storage; live adapters may ignore from/to.
Aggregated trades are aligned to the 1-minute boundary; to = align(when, 1m), window = CC_AGGREGATED_TRADES_MAX_MINUTES. With a limit, the engine paginates backwards in window-sized chunks until limit is collected, then slices to the most recent limit. Compatible with garch and volume-anomaly which accept the same from/to format.
warmCandles, checkCandles, cacheCandles (exported) pre-warm and validate a persistent candle cache. The cache uses the identical timestamp math as the runtime fetch path: a lookup computes the expected since + i*stepMs timestamps and returns all candles if present, null on any miss. CC_ENABLE_CANDLE_FETCH_MUTEX (default true) serializes concurrent fetches of the same candles to avoid redundant API calls.
No mathematical knowledge is required to use the framework β this section documents the internal model so generated code and reports can be reasoned about precisely.
To reduce position linearity, each DCA entry is by default a fixed $100 unit (CC_POSITION_ENTRY_COST, overridable per-entry via ISignalDto.cost or per-call via commitAverageBuy(symbol, cost)).
Three public functions drive position management dynamically:
commitAverageBuy β adds a DCA entry (default: only when price beats the all-time extreme since entry).commitPartialProfit β closes X% at profit (locks gains, keeps exposure).commitPartialLoss β closes X% at loss (cuts exposure before SL).priceOpenpriceOpen is the cost-basis (harmonic) mean of all accepted DCA entries. After every partial close the remaining cost basis is carried forward into the mean for subsequent entries, so the effective priceOpen shifts after each partial β which feeds back into whether the next commitAverageBuy is accepted. The physical entry prices are never altered by sells (getEffectivePriceOpen exposes the computation; costBasisAtClose is the accounting snapshot stored on each partial).
Scenario: LONG entry @ 1000, 4 DCA attempts (1 rejected), 3 partials, closed at TP. totalInvested = $400 (4 Γ $100; the rejected attempt is not counted).
entry#1 @ 1000 β 0.10000 coins
commitPartialProfit(30%) @ 1150 β entryCountAtClose = 1
entry#2 @ 950 β 0.10526 coins
entry#3 @ 880 β 0.11364 coins
commitPartialLoss(20%) @ 860 β entryCountAtClose = 3
entry#4 @ 920 β 0.10870 coins
commitPartialProfit(40%) @ 1050 β entryCountAtClose = 4
entry#5 @ 980 β REJECTED (980 > effectivePriceβ β 929.92)
Partial #1 β profit @ 1150, 30%, cnt=1
effectivePrice = 1000 ; costBasis = $100
partialDollarValue = 30% Γ 100 = $30 β weight = 30/400 = 0.075
pnl = (1150β1000)/1000 Γ 100 = +15.00%
costBasis β $70 ; coins sold 0.03000 ; remaining 0.07000
After #1: entry#2 @ 950 (β <1000), entry#3 @ 880 (β <1000). coins = 0.07000 + 0.10526 + 0.11364 = 0.28890
Partial #2 β loss @ 860, 20%, cnt=3
costBasis = 70 + 100 + 100 = $270 ; effectivePriceβ = 270/0.28890 β 934.58
partialDollarValue = 20% Γ 270 = $54 β weight = 54/400 = 0.135
pnl = (860β934.58)/934.58 Γ 100 β β7.98%
costBasis β $216 ; remaining 0.23112
After #2: entry#4 @ 920 (β <934.58). coins = 0.23112 + 0.10870 = 0.33982
Partial #3 β profit @ 1050, 40%, cnt=4
costBasis = 216 + 100 = $316 ; effectivePriceβ = 316/0.33982 β 929.92
partialDollarValue = 40% Γ 316 = $126.4 β weight = 126.4/400 = 0.316
pnl = (1050β929.92)/929.92 Γ 100 β +12.91%
costBasis β $189.6 ; remaining 0.20389
entry#5 @ 980 rejected (980 > 929.92).
Close at TP @ 1200
effectivePrice_final = 929.92 (no new entries) ; remaining dollar value = 400β30β54β126.4 = $189.6
weight = 189.6/400 = 0.474 ; pnl = (1200β929.92)/929.92 Γ 100 β +29.04%
Result (toProfitLossDto):
0.075 Γ (+15.00) = +1.125
0.135 Γ (β7.98) = β1.077
0.316 Γ (+12.91) = +4.080
0.474 Γ (+29.04) = +13.765
ββββββββββββββββββββββββββββ
β +17.89%
Cross-check (coins): 34.50 + 49.69 + 142.72 + 244.67 = $471.58 β (471.58β400)/400 β +17.90% β
The weighted PNL is Ξ£(weightα΅’ Γ pnlα΅’). Weights come from a running cost-basis replay through all partials in order:
costBasis = 0
for each partial[i]:
newEntries = entryCountAtClose[i] - entryCountAtClose[i-1] // 0 for i = 0
costBasis += newEntries * CC_POSITION_ENTRY_COST
dollarValue = (percent[i] / 100) * costBasis // correct running basis
costBasis *= (1 - percent[i] / 100) // reduce after each close
weightα΅’ = dollarValueα΅’ / totalInvested
The remaining (final) close gets weight = remainingDollarValue / totalInvested. Helpers toProfitLossDto, getEffectivePriceOpen, getTotalClosed, getPriceScale, and computeEffectivePriceAtPartial (internal) implement this. Fees (CC_PERCENT_FEE, default 0.1% per side) and slippage (CC_PERCENT_SLIPPAGE, default 0.1% per side) are applied to entry and exit prices in IStrategyPnL.priceOpen/priceClose.
BacktestSingleton with per-(symbol, strategy, exchange, frame) memoized instances.
| Method | Signature | Notes |
|---|---|---|
run |
(symbol, { strategyName, exchangeName, frameName }) => AsyncGenerator<IStrategyBacktestResult> |
Pull-based; validates schemas, clears prior state, yields each closed/cancelled/opened result. |
background |
(symbol, ctx) => () => void |
Fire-and-forget; returns a graceful-stop closure. Throws if already running for this key. |
stop |
(symbol, strategyName) => Promise<void> |
Graceful stop: current position completes, no new signals, then listenDoneBacktest fires. |
list |
() => Promise<Array<{ id; symbol; strategyName; exchangeName; frameName; status }>> |
All instances; status β "ready" | "pending" | "fulfilled" | "rejected". |
getStatus |
(per instance) | Single instance status. |
getData |
(strategyName) => Promise<BacktestStatisticsModel> |
Raw statistics. |
getReport |
(strategyName) => Promise<string> |
Markdown report. |
dump |
(symbol, { strategyName, exchangeName, frameName }, path?, columns?) => Promise<void> |
Write report to disk (default ./dump/backtest/{strategyName}.md). Takes the full context object, not just the strategy name. |
Backtest also exposes the full position-query family as methods (explicit context form): getPendingSignal(symbol, currentPrice, ctx), getScheduledSignal, hasNoPendingSignal(symbol, ctx), hasNoScheduledSignal, getBreakeven(symbol, currentPrice, ctx), getTotalPercentClosed, getTotalCostClosed, and every getPosition* from Β§10. Use these to inspect a running backtest from outside a callback.
import { Backtest, listenDoneBacktest } from "backtest-kit";
const stop = Backtest.background("BTCUSDT", { strategyName: "my", exchangeName: "binance", frameName: "1d" });
listenDoneBacktest(async (e) =>
await Backtest.dump(e.symbol, { strategyName: e.strategyName, exchangeName: e.exchangeName, frameName: e.frameName }));
// later β graceful early exit:
await Backtest.stop("BTCUSDT", "my");
dumptakes the context object.Backtest.dump,Live.dump,Partial.dump,Risk.dump,Schedule.dump,Breakeven.dump, etc. all take(symbol, { strategyName, exchangeName, frameName }, path?). ThelistenDone*/listen*event objects carry exactly those fields, so the idiom is to spread them straight from the event. (Earlier examples in this document showingdump(symbol, strategyName)are shorthand β the real call passes the context object.)
BacktestStatisticsModel (getData):
{
signalList: IStrategyTickResultClosed[]; // all closed signals
totalSignals: number; winCount: number; lossCount: number;
winRate: number | null; // %
avgPnl: number | null; // %
totalPnl: number | null; // %
stdDev: number | null;
sharpeRatio: number | null; // avgPnl / stdDev
annualizedSharpeRatio: number | null; // sharpe Γ βtradesPerYear
certaintyRatio: number | null; // avgWin / |avgLoss|
expectedYearlyReturns: number | null;
}
LiveSame surface as Backtest but context is { strategyName, exchangeName } (no frame), run is an infinite async generator with crash recovery, and reports live at ./dump/live/{strategyName}.md.
| Method | Signature |
|---|---|
run |
(symbol, { strategyName, exchangeName }) => AsyncGenerator<IStrategyTickResult> |
background |
(symbol, ctx) => () => void |
stop |
(symbol, strategyName) => Promise<void> |
list |
() => Promise<Array<{ id; symbol; strategyName; exchangeName; status }>> |
getData |
(strategyName) => Promise<LiveStatisticsModel> |
getReport / dump |
as Backtest |
LiveStatisticsModel adds eventList: TickEvent[], totalEvents, totalClosed to the same win/PNL/Sharpe fields. Crash recovery: if the process dies, restarting with the same code restores pending + scheduled signals, partial levels, breakeven flags, and the strategy commit queue from disk β no duplicate signals.
getStatus() returns { id, symbol, strategyName, exchangeName, status }. The position-query family is also available as Live.getPositionPnlPercent(symbol, currentPrice, { strategyName, exchangeName }) etc.
A/B-tests multiple strategies on the same symbol/exchange/frame and ranks them.
interface IWalkerSchema {
walkerName: WalkerName;
note?: string;
exchangeName: ExchangeName;
frameName: FrameName;
strategies: StrategyName[]; // must be registered
metric?: WalkerMetric; // default "sharpeRatio"
callbacks?: Partial<{
onStrategyStart: (strategyName, symbol) => void | Promise<void>;
onStrategyComplete: (strategyName, symbol, stats: BacktestStatisticsModel, metric: number | null) => void | Promise<void>;
onStrategyError: (strategyName, symbol, error) => void | Promise<void>;
onComplete: (results: IWalkerResults) => void | Promise<void>;
}>;
}
type WalkerMetric =
| "sharpeRatio" // default
| "annualizedSharpeRatio"
| "winRate"
| "totalPnl"
| "certaintyRatio"
| "avgPnl"
| "expectedYearlyReturns"; // higher is always better β the metric is maximized
| Method | Signature |
|---|---|
run |
(symbol, { walkerName }) => AsyncGenerator<β¦> |
background |
(symbol, { walkerName }) => () => void |
stop |
(symbol, walkerName) => Promise<void> (early termination: current strategy finishes, remaining skipped, listenWalkerComplete fires with partial results) |
getData |
(symbol, walkerName) => Promise<IWalkerResults> |
getReport |
(symbol, walkerName) => Promise<string> |
dump |
(symbol, walkerName, path?) => Promise<void> |
list |
() => Promise<Array<{ symbol; walkerName; status }>> |
IWalkerResults: { bestStrategy, bestMetric, strategies: IWalkerStrategyResult[], symbol, exchangeName, walkerName, frameName }, where each IWalkerStrategyResult = { strategyName, stats: BacktestStatisticsModel, metric: number | null, rank: number } (rank 1 = best). The comparison table shows the top CC_WALKER_MARKDOWN_TOP_N (default 10) strategies.
addWalkerSchema({
walkerName: "btc-walker",
exchangeName: "binance",
frameName: "1d",
strategies: ["strategy-a", "strategy-b", "strategy-c"],
metric: "sharpeRatio",
callbacks: {
onStrategyComplete: async (name, symbol, stats) => {
if ((stats.sharpeRatio ?? 0) > 2.0) await Walker.stop("BTCUSDT", "btc-walker"); // good enough
},
onComplete: (r) => console.log("best:", r.bestStrategy, r.bestMetric),
},
});
Walker.background("BTCUSDT", { walkerName: "btc-walker" });
listenWalkerComplete((r) => Walker.dump("BTCUSDT", r.walkerName));
Backtest.stop / Live.stop / Walker.stop are graceful: the current signal/strategy completes naturally (callbacks fire, state persists in live), no forced close, then the corresponding listenDone* / listenWalkerComplete event fires and the task status transitions pending β fulfilled. list() lets you build a monitoring dashboard. The top-level shutdown() function (Β§23) waits until no Backtest/Live task is pending, then emits the shutdown event for cleanup β the clean way to handle SIGINT.
process.on("SIGINT", () => shutdown());
Every analytics class follows the same trio: getData(...) β model, getReport(...) β markdown string, dump(..., path?) β writes file. Default dump paths are under ./dump/<domain>/.
The complete catalog of all 13 markdown reports β exact titles, default paths, what feeds each, and row caps β is in Β§37.
Heat β portfolio heatmap across symbolsHeat.getData(strategyName), Heat.getReport(strategyName), Heat.dump(strategyName, path?). Run a backtest per symbol first, then aggregate.
The per-symbol row (IHeatmapRow) is far richer than win/loss basics β it includes (all number | null unless noted):
totalPnl, sharpeRatio, maxDrawdown, totalTrades, winCount, lossCount, winRate, avgPnl, stdDev, profitFactor, avgWin, avgLoss, maxWinStreak, maxLossStreak, expectancy, avgPeakPnl, avgFallPnl, peakProfitPnl, maxDrawdownPnl, avgDuration, medianPnl, avgConsecutiveWinPnl, avgConsecutiveLossPnl, avgWinDuration, avgLossDuration, sortinoRatio, calmarRatio, recoveryFactor, annualizedSharpeRatio, certaintyRatio, expectedYearlyReturns, tradesPerYear, medianStepSize, buyerPressure, sellerPressure, buyerStrength, sellerStrength, pressureImbalance, trend ("bullish"|"bearish"|"sideways"|"neutral"|null), trendStrength, trendConfidence.
The aggregate (IHeatmapStatistics) wraps symbols: IHeatmapRow[] plus totalSymbols, portfolioTotalPnl, portfolioSharpeRatio, portfolioTotalTrades. Symbols are sorted by Sharpe.
for (const symbol of ["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT"]) {
for await (const _ of Backtest.run(symbol, { strategyName: "my", exchangeName: "binance", frameName: "2024" })) {}
}
await Heat.dump("my"); // β ./dump/heatmap/my.md
Selected column meanings: Profit Factor = Ξ£ wins / Ξ£ losses (>1 profitable); Expectancy = (winRateΒ·avgWin) β (lossRateΒ·|avgLoss|); Sortino = avgPnl / downside-deviation; Calmar / Recovery = totalPnl / maxDrawdown; trend is a bivariate (slope Γ RΒ²) classification of the log-price regression with trendStrength (%/day slope) and trendConfidence (RΒ²).
Schedule β scheduled-signal statsSchedule.getData(strategyName), getReport, dump, clear(strategyName).
ScheduleStatisticsModel: { eventList: ScheduledEvent[], totalEvents, totalScheduled, totalOpened, totalCancelled, cancellationRate (%, null β lower is better), activationRate (%, null β higher is better), avgWaitTime (min, null β for cancelled signals), avgActivationTime (min, null β for opened signals) }. Each ScheduledEvent.action is "scheduled" | "opened" | "cancelled" ("opened" = a scheduled signal that activated). The report tabulates these events with entry/TP/SL and wait/activation time.
Partial β partial profit/loss milestone statsPartial.getData(symbol), getReport, dump(symbol, path?) (default ./dump/partial/{symbol}.md).
PartialStatisticsModel: { eventList: PartialEvent[], totalEvents, totalProfit, totalLoss }. Each PartialEvent records { timestamp, action: "PROFIT"|"LOSS", symbol, signalId, position, level, price, mode }. Milestone levels are emitted exactly once per signal (deduplicated, crash-safe). Max CC_MAX_PARTIAL_MARKDOWN_ROWS (default 250) events retained.
Report samples (markdown produced by getReport/dump):
Heatmap (Heat):
# 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 |
Partial (Partial):
# Partial Profit/Loss Report: BTCUSDT
| Action | Symbol | Signal ID | Position | Level % | Current Price | Timestamp | Mode |
| --- | --- | --- | --- | --- | --- | --- | --- |
| PROFIT | BTCUSDT | abc123 | LONG | +10% | 51500.00 USD | 2024-01-15T10:30:00.000Z | Backtest |
| LOSS | BTCUSDT | def456 | SHORT | -10% | 51500.00 USD | 2024-01-15T14:00:00.000Z | Backtest |
**Total events:** 15 **Profit events:** 10 **Loss events:** 5
Scheduled signals (Schedule):
# Scheduled Signals Report: my-strategy
| Timestamp | Action | Symbol | Signal ID | Position | Current Price | Entry Price | Take Profit | Stop Loss | Wait Time (min) |
|-----------|--------|--------|-----------|----------|---------------|-------------|-------------|-----------|-----------------|
| 2024-01-15T10:30:00Z | SCHEDULED | BTCUSDT | sig-001 | LONG | 42150.50 USD | 42000.00 USD | 43000.00 USD | 41000.00 USD | N/A |
| 2024-01-15T10:35:00Z | CANCELLED | BTCUSDT | sig-002 | LONG | 42350.80 USD | 42000.00 USD | 43000.00 USD | 41000.00 USD | 60 |
**Scheduled:** 6 **Cancelled:** 2 **Cancellation rate:** 33.33% (lower is better) **Avg wait (cancelled):** 45.50 min
Position β signal-DTO builders + open-position snapshotPosition has two roles. First, static signal-DTO factories used inside getSignal to derive priceTakeProfit/priceStopLoss from a percentage and the current price (you spread the result into your returned ISignalDto):
Position.bracket({ position, currentPrice, percentTakeProfit, percentStopLoss })
// β { position, priceTakeProfit, priceStopLoss } with both legs set from percentages
Position.moonbag({ position, currentPrice, percentStopLoss })
// β same shape but TP fixed at +50% (a "let it run" wide target; you exit via trailing/close logic)
For long, priceTakeProfit = currentPriceΒ·(1 + pct/100) and priceStopLoss = currentPriceΒ·(1 β pct/100); for short the signs invert. moonbag is the idiom across the reference strategies β open with a far TP + a hard SL, then manage the exit dynamically in listenActivePing (trailing take, target PNL, sentiment flip).
return {
...Position.moonbag({ position: "long", currentPrice, percentStopLoss: 1.0 }),
minuteEstimatedTime: Infinity,
};
Second, like Β§10, Position also exposes the getPosition* analytics as a standalone utility for querying a live position outside a strategy callback.
HighestProfit & MaxDrawdown β excursion statsBoth follow getData / getReport / dump. HighestProfit tracks the best favorable excursion per signal (HighestProfitStatisticsModel, HighestProfitEvent); MaxDrawdown tracks the worst adverse excursion (MaxDrawdownStatisticsModel, MaxDrawdownEvent). Listen live via listenHighestProfit / listenMaxDrawdown. Retained rows: CC_MAX_HIGHEST_PROFIT_MARKDOWN_ROWS / CC_MAX_MAX_DRAWDOWN_MARKDOWN_ROWS (250 each).
Risk β risk-rejection statsRisk.getData, getReport, dump. Records every rejection (RiskStatisticsModel, RiskEvent). Listen live via listenRisk / listenRiskOnce. Retained: CC_MAX_RISK_MARKDOWN_ROWS (250).
Performance β revenue profilingPerformance aggregates timing metrics (avg, min, max, stdDev, P95, P99) for bottleneck analysis (PerformanceStatisticsModel, MetricStats, PerformanceMetricType). Listen via listenPerformance. Retained: CC_MAX_PERFORMANCE_MARKDOWN_ROWS (10000 β higher because metrics are lightweight and benefit from larger samples).
Sync β order-sync stats β see Β§19Lookup β parallel-run coordinationLookup tracks active (symbol, context, backtest) activities and exposes Lookup.isParallel (whether more than one workload is active). It drives the cooperative CC_ENABLE_BACKTEST_PARALLEL_SPIN interleaving so parallel backtests progress round-robin instead of one monopolizing the event loop.
Register sizing profiles with addSizingSchema, then compute sizes with the static PositionSize methods. Three methods, discriminated by method:
type ISizingSchema =
| ISizingSchemaFixedPercentage // method: "fixed-percentage"; riskPercentage: number
| ISizingSchemaKelly // method: "kelly-criterion"; kellyMultiplier?: number (default 0.25)
| ISizingSchemaATR; // method: "atr-based"; riskPercentage: number; atrMultiplier?: number
interface ISizingSchemaBase {
sizingName: SizingName;
note?: string;
maxPositionPercentage?: number; // cap as % of account (0β100)
minPositionSize?: number; // absolute floor
maxPositionSize?: number; // absolute cap
callbacks?: Partial<{ onCalculate: (quantity: number, params: ISizingCalculateParams) => void | Promise<void> }>;
}
addSizingSchema({ sizingName: "conservative", method: "fixed-percentage", riskPercentage: 2, maxPositionPercentage: 10 });
addSizingSchema({ sizingName: "kelly-quarter", method: "kelly-criterion", kellyMultiplier: 0.25, maxPositionPercentage: 15 });
addSizingSchema({ sizingName: "atr-dynamic", method: "atr-based", riskPercentage: 2, atrMultiplier: 2 });
PositionSize static methods (each takes the symbol, balance, prices/edge inputs, and { sizingName }):
PositionSize.fixedPercentage(symbol, accountBalance, priceOpen, priceStopLoss, { sizingName }): Promise<number>
PositionSize.kellyCriterion(symbol, accountBalance, priceOpen, winRate, winLossRatio, { sizingName }): Promise<number>
PositionSize.atrBased(symbol, accountBalance, priceOpen, atr, { sizingName }): Promise<number>
const qty = await PositionSize.fixedPercentage("BTCUSDT", 10_000, 50_000, 49_000, { sizingName: "conservative" });
const qtyK = await PositionSize.kellyCriterion("BTCUSDT", 10_000, 50_000, 0.55, 1.5, { sizingName: "kelly-quarter" });
const qtyA = await PositionSize.atrBased("BTCUSDT", 10_000, 50_000, 500, { sizingName: "atr-dynamic" });
When to use: Fixed % β simple, consistent risk per trade (beginners, conservative). Kelly β optimal sizing given a measured edge; use fractional (0.25β0.5) to tame volatility. ATR-based β volatility-adjusted sizing for swing/volatile markets.
Portfolio-level controls evaluated before a signal is opened. Attach via riskName (single) or riskList (multiple β all must pass) on the strategy schema.
interface IRiskSchema {
riskName: RiskName;
note?: string;
validations: (IRiskValidation | IRiskValidationFn)[]; // throw or return rejection to block
callbacks?: Partial<{
onRejected: (symbol, params: IRiskCheckArgs) => void | Promise<void>;
onAllowed: (symbol, params: IRiskCheckArgs) => void | Promise<void>;
}>;
}
// A validation is either a bare function or { validate, note }:
type IRiskValidationFn = (payload: IRiskValidationPayload) => RiskRejection | Promise<RiskRejection>;
type RiskRejection = void | IRiskRejectionResult | string | null; // throw/return-truthy/return-string β reject
IRiskValidationPayload (what each validation receives):
{
symbol: string;
currentSignal: IRiskSignalRow; // the candidate signal; priceOpen always present
strategyName; exchangeName; riskName; frameName;
currentPrice: number;
timestamp: number;
activePositionCount: number; // across ALL strategies sharing this risk profile
activePositions: IRiskActivePosition[]; // each: { strategyName, exchangeName, frameName, symbol, position, priceOpen, priceStopLoss, priceTakeProfit, minuteEstimatedTime, openTimestamp }
}
To reject: throw new Error(reason), or return a truthy string/IRiskRejectionResult. Returning void/null allows the signal.
// Concurrent-position cap
addRiskSchema({
riskName: "conservative",
validations: [
({ activePositionCount }) => { if (activePositionCount >= 3) throw new Error("Max 3 concurrent positions"); },
],
});
// Symbol filter
addRiskSchema({
riskName: "no-meme",
validations: [
({ symbol }) => { if (["DOGEUSDT","SHIBUSDT","PEPEUSDT"].includes(symbol)) throw new Error(`${symbol} blocked`); },
],
});
// Trading-hours window
addRiskSchema({
riskName: "hours",
validations: [
({ timestamp }) => { const h = new Date(timestamp).getUTCHours(); if (h < 9 || h >= 17) throw new Error("Outside 9β17 UTC"); },
],
});
// Cross-strategy coordination
addRiskSchema({
riskName: "coordinator",
validations: [
({ activePositions, strategyName, symbol }) => {
if (activePositions.filter(p => p.strategyName === strategyName).length >= 2) throw new Error("Strategy at cap");
if (activePositions.some(p => p.symbol === symbol)) throw new Error(`Already in ${symbol}`);
},
],
});
Risk.getData/getReport/dump (Β§14.6) report all rejections. Concurrency note: risk uses an internal checkSignalAndReserve that atomically validates and reserves a slot, so parallel strategies sharing a profile cannot all pass a count check before any of them registers β preventing limit overshoot.
Broker.useBrokerAdapter(adapter) connects a real exchange (ccxt/Binance/etc.) to the framework with transaction safety. Every commit method fires before internal position state mutates. If the exchange rejects, the fill times out, or the network fails, the adapter throws β the mutation is skipped β backtest-kit retries on the next tick. Call Broker.enable() once at startup. Broker.disable() tears it down.
Signal open/close events are routed automatically via an internal event bus after Broker.enable() β no manual wiring. All other operations (partialProfit, partialLoss, trailingStop, trailingTake, breakeven, averageBuy) are intercepted explicitly before the corresponding state mutation.
IBroker interface & payloadsinterface IBroker {
waitForInit(): Promise<void>;
onSignalOpenCommit(p: BrokerSignalOpenPayload): Promise<void>;
onSignalCloseCommit(p: BrokerSignalClosePayload): Promise<void>;
onOrderPing(p: BrokerSignalPendingPayload): Promise<void>; // confirm order still open; throw if gone
onPartialProfitCommit(p: BrokerPartialProfitPayload): Promise<void>;
onPartialLossCommit(p: BrokerPartialLossPayload): Promise<void>;
onTrailingStopCommit(p: BrokerTrailingStopPayload): Promise<void>;
onTrailingTakeCommit(p: BrokerTrailingTakePayload): Promise<void>;
onBreakevenCommit(p: BrokerBreakevenPayload): Promise<void>;
onAverageBuyCommit(p: BrokerAverageBuyPayload): Promise<void>;
}
Every payload carries symbol, signalId, and position: "long" | "short". Additional fields per payload:
| Payload | Extra fields |
|---|---|
BrokerSignalOpenPayload |
cost, priceOpen, priceTakeProfit, priceStopLoss |
BrokerSignalClosePayload |
cost, currentPrice, priceOpen, priceTakeProfit, priceStopLoss |
BrokerSignalPendingPayload |
currentPrice, priceOpen, priceTakeProfit, priceStopLoss |
BrokerPartialProfitPayload / BrokerPartialLossPayload |
percentToClose, cost, currentPrice, priceTakeProfit, priceStopLoss |
BrokerTrailingStopPayload |
currentPrice, newStopLossPrice |
BrokerTrailingTakePayload |
currentPrice, newTakeProfitPrice |
BrokerBreakevenPayload |
currentPrice, newStopLossPrice, newTakeProfitPrice |
BrokerAverageBuyPayload |
currentPrice, cost, priceTakeProfit, priceStopLoss |
BrokerBase is an abstract base you can extend; TBrokerCtor is the constructor type. useBrokerAdapter accepts a class (TBrokerCtor) or a partial instance (Partial<IBroker>).
import ccxt from "ccxt";
import { singleshot, sleep } from "functools-kit";
import {
Broker, IBroker,
BrokerSignalOpenPayload, BrokerSignalClosePayload,
BrokerPartialProfitPayload, BrokerPartialLossPayload,
BrokerTrailingStopPayload, BrokerTrailingTakePayload,
BrokerBreakevenPayload, BrokerAverageBuyPayload,
} from "backtest-kit";
const FILL_POLL_INTERVAL_MS = 10_000;
const FILL_POLL_ATTEMPTS = 10;
const CANCEL_SETTLE_MS = 2_000; // let Binance settle a cancellation before reading balance
const STOP_LIMIT_SLIPPAGE = 0.995; // limit slightly below stopPrice so it fills on a gap down
const getSpotExchange = singleshot(async () => {
const exchange = new ccxt.binance({
apiKey: process.env.BINANCE_API_KEY, secret: process.env.BINANCE_API_SECRET,
options: { defaultType: "spot", adjustForTimeDifference: true, recvWindow: 60000 },
enableRateLimit: true,
});
await exchange.loadMarkets();
return exchange;
});
const getBase = (ex, symbol) => ex.markets[symbol].base; // safe for any quote (USDT/USDC/FDUSD)
const truncateQty = (ex, symbol, qty) => parseFloat(ex.amountToPrecision(symbol, qty, ex.TRUNCATE)); // round down
async function fetchFreeQty(ex, symbol) {
const balance = await ex.fetchBalance();
return parseFloat(String(balance?.free?.[getBase(ex, symbol)] ?? 0));
}
async function cancelAllOrders(ex, orders, symbol) {
await Promise.allSettled(orders.map((o) => ex.cancelOrder(o.id, symbol)));
}
async function createStopLossOrder(ex, symbol, qty, stopPrice) {
const limitPrice = parseFloat(ex.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
await ex.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
}
// Place a limit order, poll until filled; on timeout cancel, roll back any partial fill via market,
// restore SL/TP on the remainder, then throw so backtest-kit retries.
async function createLimitOrderAndWait(ex, symbol, side, qty, price, restore) {
const order = await ex.createOrder(symbol, "limit", side, qty, price);
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
await sleep(FILL_POLL_INTERVAL_MS);
if ((await ex.fetchOrder(order.id, symbol)).status === "closed") return;
}
await ex.cancelOrder(order.id, symbol);
await sleep(CANCEL_SETTLE_MS);
const final = await ex.fetchOrder(order.id, symbol);
const filledQty = final.filled ?? 0;
if (filledQty > 0) {
await ex.createOrder(symbol, "market", side === "buy" ? "sell" : "buy", filledQty);
}
if (restore) {
const remainingQty = truncateQty(ex, symbol, await fetchFreeQty(ex, symbol));
if (remainingQty > 0) {
await ex.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
await createStopLossOrder(ex, symbol, remainingQty, restore.slPrice);
}
}
throw new Error(`Limit ${order.id} not filled β partial rolled back, will retry`);
}
Broker.useBrokerAdapter(class implements IBroker {
async waitForInit() { await getSpotExchange(); }
async onSignalOpenCommit({ symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position }: BrokerSignalOpenPayload) {
if (position === "short") throw new Error(`Spot has no short selling (${symbol})`);
const ex = await getSpotExchange();
const qty = truncateQty(ex, symbol, cost / priceOpen);
if (qty <= 0) throw new Error(`Computed qty zero for ${symbol}`);
const openPrice = parseFloat(ex.priceToPrecision(symbol, priceOpen));
const tpPrice = parseFloat(ex.priceToPrecision(symbol, priceTakeProfit));
const slPrice = parseFloat(ex.priceToPrecision(symbol, priceStopLoss));
await createLimitOrderAndWait(ex, symbol, "buy", qty, openPrice);
try {
await ex.createOrder(symbol, "limit", "sell", qty, tpPrice);
await createStopLossOrder(ex, symbol, qty, slPrice);
} catch (err) { await ex.createOrder(symbol, "market", "sell", qty); throw err; } // unprotected β market close
}
async onSignalCloseCommit({ symbol, currentPrice, priceTakeProfit, priceStopLoss }: BrokerSignalClosePayload) {
const ex = await getSpotExchange();
await cancelAllOrders(ex, await ex.fetchOpenOrders(symbol), symbol);
await sleep(CANCEL_SETTLE_MS);
const qty = truncateQty(ex, symbol, await fetchFreeQty(ex, symbol));
if (qty === 0) return; // already closed by exchange SL/TP β commit succeeds
await createLimitOrderAndWait(ex, symbol, "sell", qty,
parseFloat(ex.priceToPrecision(symbol, currentPrice)),
{ tpPrice: parseFloat(ex.priceToPrecision(symbol, priceTakeProfit)),
slPrice: parseFloat(ex.priceToPrecision(symbol, priceStopLoss)) });
}
async onPartialProfitCommit(p: BrokerPartialProfitPayload) { /* cancel orders, sell percentToClose%, restore SL/TP on remainder */ }
async onPartialLossCommit(p: BrokerPartialLossPayload) { /* symmetric to partial profit */ }
async onTrailingStopCommit({ symbol, newStopLossPrice }: BrokerTrailingStopPayload) {
const ex = await getSpotExchange();
const orders = await ex.fetchOpenOrders(symbol);
const slOrder = orders.find(o => o.side === "sell" && ["stop_loss_limit","stop","STOP_LOSS_LIMIT"].includes(o.type ?? "")) ?? null;
if (slOrder) { await ex.cancelOrder(slOrder.id, symbol); await sleep(CANCEL_SETTLE_MS); }
const qty = truncateQty(ex, symbol, await fetchFreeQty(ex, symbol));
if (qty === 0) throw new Error(`TrailingStop skipped: no position for ${symbol}`);
await createStopLossOrder(ex, symbol, qty, parseFloat(ex.priceToPrecision(symbol, newStopLossPrice)));
}
async onTrailingTakeCommit(p: BrokerTrailingTakePayload) { /* cancel TP limit, re-place at new price */ }
async onBreakevenCommit(p: BrokerBreakevenPayload) { /* cancel SL, re-place stop_loss_limit at entry */ }
async onAverageBuyCommit({ symbol, currentPrice, cost, priceTakeProfit, priceStopLoss }: BrokerAverageBuyPayload) {
const ex = await getSpotExchange();
await cancelAllOrders(ex, await ex.fetchOpenOrders(symbol), symbol);
await sleep(CANCEL_SETTLE_MS);
const existing = await fetchFreeQty(ex, symbol);
const minNotional = ex.markets[symbol].limits?.cost?.min ?? 1;
if (existing * currentPrice < minNotional) throw new Error(`AverageBuy skipped: no position for ${symbol}`); // ghost-position guard
const qty = truncateQty(ex, symbol, cost / currentPrice);
if (qty <= 0) throw new Error(`Computed qty zero for ${symbol}`);
const tpPrice = parseFloat(ex.priceToPrecision(symbol, priceTakeProfit));
const slPrice = parseFloat(ex.priceToPrecision(symbol, priceStopLoss));
await createLimitOrderAndWait(ex, symbol, "buy", qty, parseFloat(ex.priceToPrecision(symbol, currentPrice)), { tpPrice, slPrice });
const totalQty = truncateQty(ex, symbol, await fetchFreeQty(ex, symbol)); // refetch after fill
try {
await ex.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
await createStopLossOrder(ex, symbol, totalQty, slPrice);
} catch (err) { await ex.createOrder(symbol, "market", "sell", totalQty); throw err; }
}
});
Broker.enable();
The futures adapter (Binance USD-M via ccxt) mirrors the spot logic with these structural differences:
options.defaultType: "future" and await exchange.setLeverage(FUTURES_LEVERAGE, symbol) before the first open (e.g. 3Γ).entrySide/exitSide derive from position; positionSide ("LONG"/"SHORT") is forwarded on every order (required in hedge mode to avoid Binance error -4061; ignored in one-way mode).exchange.fetchPositions([symbol]) + findPosition(positions, symbol, side) (handles both one-way and hedge mode), reading pos.contracts.reduceOnly: true on all exit/rollback orders so qty drift can never accidentally reverse the position.stop_market orders for SL (with { stopPrice, reduceOnly: true, positionSide }) instead of spot's stop_loss_limit.createLimitOrderAndWait closes the partial fill via a reduceOnly market order with the correct positionSide.Filtering existing SL/TP for cancellation uses o.reduceOnly && ["stop_market","stop","STOP_MARKET"].includes(o.type) for SL and o.reduceOnly && ["limit","LIMIT"].includes(o.type) for TP. The DCA (onAverageBuyCommit) ghost-position guard compares notional (existing * currentPrice < minNotional) rather than raw contracts to avoid the float-=== 0 trap.
Complete futures reference implementation:
import ccxt from "ccxt";
import { singleshot, sleep } from "functools-kit";
import {
Broker, IBroker,
BrokerSignalOpenPayload, BrokerSignalClosePayload,
BrokerPartialProfitPayload, BrokerPartialLossPayload,
BrokerTrailingStopPayload, BrokerTrailingTakePayload,
BrokerBreakevenPayload, BrokerAverageBuyPayload,
} from "backtest-kit";
const FILL_POLL_INTERVAL_MS = 10_000;
const FILL_POLL_ATTEMPTS = 10;
const CANCEL_SETTLE_MS = 2_000;
const FUTURES_LEVERAGE = 3; // conservative for ~$1000 fiat; applied per-symbol on first open
const getFuturesExchange = singleshot(async () => {
const exchange = new ccxt.binance({
apiKey: process.env.BINANCE_API_KEY, secret: process.env.BINANCE_API_SECRET,
options: { defaultType: "future", adjustForTimeDifference: true, recvWindow: 60000 },
enableRateLimit: true,
});
await exchange.loadMarkets();
return exchange;
});
const truncateQty = (ex, symbol, qty) => parseFloat(ex.amountToPrecision(symbol, qty, ex.TRUNCATE));
const toPositionSide = (position: "long" | "short") => (position === "long" ? "LONG" : "SHORT");
// Resolve position for symbol/side β safe in both one-way and hedge mode
function findPosition(positions, symbol, side: "long" | "short") {
const hedged = positions.find((p) => p.symbol === symbol && p.side === side);
if (hedged) return hedged;
const pos = positions.find((p) => p.symbol === symbol) ?? null;
if (pos && pos.side && pos.side !== side) {
console.warn(`findPosition: expected side="${side}" got "${pos.side}" for ${symbol} β one-way/hedge mismatch`);
}
return pos;
}
async function fetchContractsQty(ex, symbol, side: "long" | "short") {
const positions = await ex.fetchPositions([symbol]);
return Math.abs(parseFloat(String(findPosition(positions, symbol, side)?.contracts ?? 0)));
}
async function cancelAllOrders(ex, orders, symbol) {
await Promise.allSettled(orders.map((o) => ex.cancelOrder(o.id, symbol)));
}
// Place limit order, poll, roll back partial fill via reduceOnly market on timeout, restore SL/TP, throw.
async function createLimitOrderAndWait(ex, symbol, side, qty, price, params = {}, restore?) {
const order = await ex.createOrder(symbol, "limit", side, qty, price, params);
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
await sleep(FILL_POLL_INTERVAL_MS);
if ((await ex.fetchOrder(order.id, symbol)).status === "closed") return;
}
await ex.cancelOrder(order.id, symbol);
await sleep(CANCEL_SETTLE_MS);
const final = await ex.fetchOrder(order.id, symbol);
const filledQty = final.filled ?? 0;
if (filledQty > 0) {
const rollbackSide = side === "buy" ? "sell" : "buy";
const rollbackPositionSide = params.positionSide ?? (restore ? toPositionSide(restore.positionSide) : undefined);
await ex.createOrder(symbol, "market", rollbackSide, filledQty, undefined, {
reduceOnly: true, ...(rollbackPositionSide ? { positionSide: rollbackPositionSide } : {}),
});
}
if (restore) {
const remainingQty = truncateQty(ex, symbol, await fetchContractsQty(ex, symbol, restore.positionSide));
if (remainingQty > 0) {
await ex.createOrder(symbol, "limit", restore.exitSide, remainingQty, restore.tpPrice, { reduceOnly: true });
await ex.createOrder(symbol, "stop_market", restore.exitSide, remainingQty, undefined, { stopPrice: restore.slPrice, reduceOnly: true });
}
}
throw new Error(`Limit ${order.id} not filled β partial rolled back, will retry`);
}
Broker.useBrokerAdapter(class implements IBroker {
async waitForInit() { await getFuturesExchange(); }
async onSignalOpenCommit({ symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position }: BrokerSignalOpenPayload) {
const ex = await getFuturesExchange();
await ex.setLeverage(FUTURES_LEVERAGE, symbol);
const qty = truncateQty(ex, symbol, cost / priceOpen);
if (qty <= 0) throw new Error(`Computed qty zero for ${symbol}`);
const openPrice = parseFloat(ex.priceToPrecision(symbol, priceOpen));
const tpPrice = parseFloat(ex.priceToPrecision(symbol, priceTakeProfit));
const slPrice = parseFloat(ex.priceToPrecision(symbol, priceStopLoss));
const entrySide = position === "long" ? "buy" : "sell";
const exitSide = position === "long" ? "sell" : "buy";
const positionSide = toPositionSide(position);
await createLimitOrderAndWait(ex, symbol, entrySide, qty, openPrice, { positionSide });
try {
await ex.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
await ex.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
} catch (err) {
await ex.createOrder(symbol, "market", exitSide, qty, undefined, { reduceOnly: true, positionSide });
throw err;
}
}
async onSignalCloseCommit({ symbol, position, currentPrice, priceTakeProfit, priceStopLoss }: BrokerSignalClosePayload) {
const ex = await getFuturesExchange();
await cancelAllOrders(ex, await ex.fetchOpenOrders(symbol), symbol);
await sleep(CANCEL_SETTLE_MS);
const qty = truncateQty(ex, symbol, await fetchContractsQty(ex, symbol, position));
const exitSide = position === "long" ? "sell" : "buy";
if (qty === 0) throw new Error(`SignalClose skipped: no position for ${symbol} β let backtest-kit reconcile`);
await createLimitOrderAndWait(ex, symbol, exitSide, qty,
parseFloat(ex.priceToPrecision(symbol, currentPrice)),
{ reduceOnly: true },
{ exitSide, tpPrice: parseFloat(ex.priceToPrecision(symbol, priceTakeProfit)),
slPrice: parseFloat(ex.priceToPrecision(symbol, priceStopLoss)), positionSide: position });
}
async onPartialProfitCommit(p: BrokerPartialProfitPayload) { /* cancel; reduceOnly limit close percentToClose%; restore SL/TP stop_market+limit on remainder */ }
async onPartialLossCommit(p: BrokerPartialLossPayload) { /* symmetric */ }
async onTrailingStopCommit({ symbol, newStopLossPrice, position }: BrokerTrailingStopPayload) {
const ex = await getFuturesExchange();
const slOrder = (await ex.fetchOpenOrders(symbol)).find(o => !!o.reduceOnly && ["stop_market","stop","STOP_MARKET"].includes(o.type ?? "")) ?? null;
if (slOrder) { await ex.cancelOrder(slOrder.id, symbol); await sleep(CANCEL_SETTLE_MS); }
const qty = truncateQty(ex, symbol, await fetchContractsQty(ex, symbol, position));
const exitSide = position === "long" ? "sell" : "buy";
if (qty === 0) throw new Error(`TrailingStop skipped: no position for ${symbol}`);
await ex.createOrder(symbol, "stop_market", exitSide, qty, undefined,
{ stopPrice: parseFloat(ex.priceToPrecision(symbol, newStopLossPrice)), reduceOnly: true, positionSide: toPositionSide(position) });
}
async onTrailingTakeCommit(p: BrokerTrailingTakePayload) { /* cancel reduceOnly limit TP; re-place reduceOnly limit at new price */ }
async onBreakevenCommit(p: BrokerBreakevenPayload) { /* cancel reduceOnly stop_market SL; re-place at entry */ }
async onAverageBuyCommit({ symbol, currentPrice, cost, position, priceTakeProfit, priceStopLoss }: BrokerAverageBuyPayload) {
const ex = await getFuturesExchange();
await cancelAllOrders(ex, await ex.fetchOpenOrders(symbol), symbol);
await sleep(CANCEL_SETTLE_MS);
const existing = await fetchContractsQty(ex, symbol, position);
const minNotional = ex.markets[symbol].limits?.cost?.min ?? 1;
if (existing * currentPrice < minNotional) throw new Error(`AverageBuy skipped: no position for ${symbol}`);
const qty = truncateQty(ex, symbol, cost / currentPrice);
if (qty <= 0) throw new Error(`Computed qty zero for ${symbol}`);
const tpPrice = parseFloat(ex.priceToPrecision(symbol, priceTakeProfit));
const slPrice = parseFloat(ex.priceToPrecision(symbol, priceStopLoss));
const positionSide = toPositionSide(position);
const entrySide = position === "long" ? "buy" : "sell";
const exitSide = position === "long" ? "sell" : "buy";
await createLimitOrderAndWait(ex, symbol, entrySide, qty, parseFloat(ex.priceToPrecision(symbol, currentPrice)),
{ positionSide }, { exitSide, tpPrice, slPrice, positionSide: position });
const totalQty = truncateQty(ex, symbol, await fetchContractsQty(ex, symbol, position));
try {
await ex.createOrder(symbol, "limit", exitSide, totalQty, tpPrice, { reduceOnly: true, positionSide });
await ex.createOrder(symbol, "stop_market", exitSide, totalQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
} catch (err) {
await ex.createOrder(symbol, "market", exitSide, totalQty, undefined, { reduceOnly: true, positionSide });
throw err;
}
}
});
Broker.enable();
onOrderPing (optional)onOrderPing(payload: BrokerSignalPendingPayload) fires on every live tick while a pending signal is monitored, before TP/SL/time evaluation, to confirm the order still exists. Throw only when the order is confirmed not-found by id (filled/cancelled/liquidated externally) β the framework then closes the position with closeReason: "closed". Swallow transient/network errors (timeout, 5xx, rate-limit, disconnect) β returning normally β or a connectivity blip would wrongly close an open position.
Cron is a periodic / fire-once scheduler that runs in virtual time β the same time stream strategies see in backtest. Handlers fire on candle-interval boundaries (1m, 5m, 1h, 1d, β¦) and are coordinated across parallel Backtest.background(symbol, β¦) runs so the same boundary never produces two concurrent invocations.
interface CronEntry {
name: string; // unique; must not contain ':' (reserved key separator)
interval?: CandleInterval; // omit β fire-once mode
symbols?: string[]; // omit/empty β global; non-empty β per-symbol fan-out
handler: (info: IRuntimeInfo) => void | Promise<void>;
}
Cron.register(entry: CronEntry): CronHandle // returns a disposer; re-registering a name replaces it
Cron.unregister(name: string): void
Cron.enable(): () => void // subscribe to engine lifecycle; singleshot, returns disposer
Cron.disable(): void // tear down subscriptions (safe before enable / repeatedly)
Cron.clear(symbol?: string): void // clear fire-once marks (symbol β fan-out marks for that symbol)
Cron.dispose(): void // hard reset: disable + wipe entries/marks/watermarks
CronHandle is () => void (calling it = unregister(name)). The handler receives IRuntimeInfo ({ symbol, context, backtest, range, currentPrice, info, when }).
interval set) β fires once per boundary of that interval.interval omitted) β fires on the first matching tick, never again until clear()/unregister/re-register. A failed handler is not marked fired, so it retries.symbols empty) β fires once per boundary across all parallel backtests; first symbol to reach the boundary opens the slot, others await the same promise.symbols non-empty) β fires once per boundary per whitelisted symbol; each symbol has its own slot.import { Cron, Backtest } from "backtest-kit";
// Global hourly job β once per virtual hour across all parallel backtests
Cron.register({ name: "tg-signal-parser", interval: "1h",
handler: async ({ when }) => { await parseTelegramSignalsToMongo(when); } });
// Per-symbol fan-out β once per hour per whitelisted symbol
Cron.register({ name: "fetch-funding", interval: "1h", symbols: ["BTCUSDT","ETHUSDT"],
handler: async ({ symbol, when }) => { await fetchFundingRate(symbol, when); } });
// Fire-once global warm-up
Cron.register({ name: "warm-cache", handler: async () => { await warmupCache(); } });
Cron.enable(); // wire once at startup; after this every strategy tick feeds Cron automatically
for (const symbol of ["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","TRXUSDT"]) {
Backtest.background(symbol, { strategyName, exchangeName, frameName });
}
// On shutdown: Cron.disable();
enable() subscribes a single singlerun-wrapped handler to four lifecycle subjects (beforeStart, idlePing, activePing, schedulePing), merging them into one serial queue so concurrent ticks on the same (symbol, virtual-minute) cannot race to open a slot. Each tick is base-aligned to the 1-minute boundary first. Coordination keys are ${name}:${alignedMs}:${symbol?}:g${generation}; parallel backtests hitting the same key share one in-flight promise (mutex). Periodic entries use a watermark (_lastBoundary): they fire when the tick's aligned boundary is strictly greater than the last fired β so a virtual-time jump that skips clean over a boundary (e.g. a 5m loop going 00:14 β 00:29 missing the 15m 00:15 boundary) still fires once, at the newest crossed boundary (catch-up). A failed periodic handler rolls back the watermark so the boundary retries. A 120s watchdog warns (does not interrupt) if a handler stalls. The generation suffix isolates re-registrations so a late write from an old in-flight handler never collides with the new entry.
Sync records and reports order-synchronization events between the framework and the broker/exchange. It is driven by the broker/sync contracts (SignalSyncContract, SignalOpenContract, SignalCloseContract, SignalPingContract). Listen live with listenSync / listenSyncOnce. Reporting trio: Sync.getData, Sync.getReport, Sync.dump (SyncStatisticsModel, SyncEvent; retained rows CC_MAX_SYNC_MARKDOWN_ROWS, default 250).
These contracts back the broker auto-routing in Β§17: when the framework wants to open or close a position it emits a SignalSyncContract (action: "signal-open" | "signal-close"); a listener (or broker adapter) that throws rejects the operation and the framework retries on the next tick.
Five scoped key-value subsystems. Memory / State are scoped to the active signal (resolved from context); Session is scoped to (symbol, strategy, exchange, frame); Storage / Recent are general-purpose utilities. All persist across candles, survive live restarts, and respect virtual time.
All functions resolve the active pending or scheduled signal from context, and throw if neither exists. Object-DTO call style:
writeMemory<T>({ bucketName, memoryId, value: T, description }): Promise<void>
readMemory<T>({ bucketName, memoryId }): Promise<T> // throws if not found
listMemory<T>({ bucketName }): Promise<Array<{ memoryId; content: T }>>
removeMemory({ bucketName, memoryId }): Promise<void>
searchMemory<T>({ bucketName, query }): Promise<Array<{ memoryId; score; content: T }>> // BM25 full-text, sorted by relevance
The description field is indexed for BM25 search. Use it for cross-candle recall of LLM context tied to a signal:
await writeMemory({ bucketName: "ctx", memoryId: "thesis",
value: { trend: "up", confidence: 0.9 }, description: "bullish breakout thesis at entry" });
const hits = await searchMemory({ bucketName: "ctx", query: "bullish trend" });
Memory (and MemoryLive/MemoryBacktest variants, MemoryBacktestAdapter/MemoryLiveAdapter, IMemoryInstance, TMemoryInstanceCtor) are exported for advanced use.
Prefer createSignalState({ bucketName, initialValue }) β [getState, setState] (Β§8.3). The lower-level getSignalState(symbol, { bucketName, initialValue }) / setSignalState(symbol, dispatch, { bucketName, initialValue }) are deprecated. setState accepts a value or an updater (current) => next. Exported: State, StateLive, StateBacktest, StateBacktestAdapter, StateLiveAdapter, IStateInstance, TStateInstanceCtor.
Not tied to a signal β survives across signals within a run, and across restarts in live. Ideal for caching LLM inference results or indicator state.
getSessionData<T>(symbol): Promise<T | null>
setSessionData<T>(symbol, value: T | null): Promise<void> // null clears
const session = await getSessionData<{ lastLlmSignal: string }>("BTCUSDT");
if (session?.lastLlmSignal === "buy") { /* reuse cached LLM result */ }
await setSessionData("BTCUSDT", { lastLlmSignal: "buy" });
Exported: Session, SessionLive, SessionBacktest, ISessionInstance, TSessionInstanceCtor.
Storage (and StorageLive/StorageBacktest, IStorageUtils, TStorageUtilsCtor) is a general-purpose persisted key-value utility. Recent (and RecentLive/RecentBacktest, IRecentUtils, TRecentUtilsCtor) tracks recent signals and powers getLatestSignal / getMinutesSinceLatestSignalCreated (Β§8.1).
dump* functions capture artifacts scoped to the active signal (pending or scheduled, resolved from context) into a per-bucket markdown/record store under ./dump/. Their description is indexed for Memory-style search. All use object-DTO call style and throw if no signal is active.
dumpAgentAnswer({ bucketName, dumpId, messages: MessageModel[], description }): Promise<void> // full LLM chat history
dumpRecord({ bucketName, dumpId, record: Record<string,unknown>, description }): Promise<void> // flat KV β markdown table / SQL
dumpTable({ bucketName, dumpId, rows: Record<string,unknown>[], description }): Promise<void> // array β table (union of keys)
dumpText({ bucketName, dumpId, content: string, description }): Promise<void> // raw text
dumpError({ bucketName, dumpId, content: string, description }): Promise<void> // error description
dumpJson({ bucketName, dumpId, json: object, description }): Promise<void> // DEPRECATED β prefer dumpRecord
MessageModel (MessageRole, MessageToolCall) is the chat-message shape for dumpAgentAnswer. Typical use is inside an LLM getSignal to record the reasoning and the resulting signal alongside the trade for later audit (see Β§3.3).
The lower-level Dump class (IDumpInstance, IDumpContext, TDumpInstanceCtor) backs these functions.
Actions attach custom handler classes to a strategy (via actions: [actionName] on the schema). Each action instance is created per strategy-frame pair and receives every event the strategy emits β ideal for state managers (Redux/Zustand), notifications (Telegram/Discord), logging, analytics, and external-system writes.
interface IActionSchema {
actionName: ActionName;
note?: string;
handler: TActionCtor | Partial<IPublicAction>; // class or instance
callbacks?: Partial<IActionCallbacks>;
}
// Constructor receives identity + mode:
type TActionCtor = new (strategyName, frameName, actionName, backtest: boolean) => Partial<IPublicAction>;
IAction event methods (all optional via IPublicAction, plus an init?() lifecycle hook):
signal (all modes), signalLive, signalBacktest (one mode), breakevenAvailable, partialProfitAvailable, partialLossAvailable, pingScheduled, pingActive, pingIdle, riskRejection, signalSync (deprecated β use signal())*, orderPing (deprecated β use Broker.onOrderPing), dispose.
The same hooks are available as callbacks on the schema (onSignal, onSignalLive, onSignalBacktest, onBreakevenAvailable, onPartialProfitAvailable, onPartialLossAvailable, onPingScheduled, onPingActive, onPingIdle, onRiskRejection, onSignalSync, onOrderPing, plus onInit/onDispose), each receiving (event, actionName, strategyName, frameName, backtest).
class TelegramNotifier implements Partial<IPublicAction> {
constructor(private strategyName: string, private frameName: string, private actionName: string, private backtest: boolean) {}
async init() { /* connect bot */ }
async signal(event: IStrategyTickResult) {
if (!this.backtest && event.action === "opened") await sendTelegram(`Signal opened: ${event.signal.id}`);
}
async dispose() { /* disconnect */ }
}
addActionSchema({ actionName: "telegram", handler: TelegramNotifier });
addStrategySchema({ strategyName: "my", actions: ["telegram"], getSignal });
dispose() is guaranteed to run exactly once (singleshot). ActionBase is an exported base class. Exceptions from signalSync/orderPing are not swallowed (they reject the operation) β all other action callbacks swallow exceptions.
Complete, copy-pastable patterns built only from verified API. All assume the relevant functions are imported from backtest-kit.
import { listenPartialProfitAvailable, Constant, commitPartialProfit } from "backtest-kit";
listenPartialProfitAvailable(async ({ symbol, level }) => {
if (level === Constant.TP_LEVEL1) await commitPartialProfit(symbol, 33); // +3%
if (level === Constant.TP_LEVEL2) await commitPartialProfit(symbol, 33); // +6%
if (level === Constant.TP_LEVEL3) await commitPartialProfit(symbol, 34); // +9%
});
addStrategySchema({
strategyName: "protect",
getSignal,
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
if (await getBreakeven(symbol, currentPrice)) await commitBreakeven(symbol);
const pct = await getPositionPnlPercent(symbol, currentPrice);
if (pct !== null && pct >= 2) await commitTrailingStop(symbol, 1, currentPrice); // 1% trail past +2%
},
},
});
addStrategySchema({
strategyName: "long-dca",
interval: "5m",
getSignal: async (symbol, when, currentPrice) => {
if (!(await hasNoPendingSignal(symbol))) return null;
return { position: "long", priceTakeProfit: currentPrice * 1.10, priceStopLoss: currentPrice * 0.85, minuteEstimatedTime: Infinity };
},
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
// commitAverageBuy is auto-rejected unless currentPrice is a NEW LOW since entry (default rule),
// so simply attempting on every ping naturally builds the ladder only on genuine dips.
const entries = await getPositionInvestedCount(symbol);
if ((entries ?? 0) < 10) await commitAverageBuy(symbol, 100); // up to 10 rungs of $100
const pct = await getPositionPnlPercent(symbol, currentPrice);
if (pct !== null && pct >= 3) await commitClosePending(symbol); // 3% blended target
},
},
});
Identical structure with position: "short"; commitAverageBuy is auto-rejected unless currentPrice is a new high since entry. Close at a small blended profit (e.g. 0.5%) via commitClosePending.
getSignal: async (symbol, when, currentPrice) => {
const minutes = await getMinutesSinceLatestSignalCreated(symbol);
if (minutes !== null && minutes < 240) return null; // 4h cooldown
// ... generate signal
}
getSignal: async (symbol, when, currentPrice) => ({
position: "long",
priceOpen: currentPrice * 0.99, // provided β scheduled; waits for a 1% dip
priceTakeProfit: currentPrice * 1.02,
priceStopLoss: currentPrice * 0.96,
});
// Auto-cancelled after CC_SCHEDULE_AWAIT_MINUTES, or if SL is hit before activation.
// Force early activation from a callback: await commitActivateScheduled(symbol);
// Cancel manually: await commitCancelScheduled(symbol);
const [getTradeState, setTradeState] = createSignalState({
bucketName: "trade", initialValue: { peakPercent: 0, minutesOpen: 0 },
});
addStrategySchema({
strategyName: "llm-capitulation",
getSignal,
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
const pct = (await getPositionPnlPercent(symbol, currentPrice)) ?? 0;
const { peakPercent, minutesOpen } = await setTradeState((s) => ({
peakPercent: Math.max(s.peakPercent, pct),
minutesOpen: s.minutesOpen + 1,
}));
if (minutesOpen >= 15 && peakPercent < 0.3) await commitClosePending(symbol); // thesis not confirmed
},
},
});
Global event listeners. Each returns an unsubscribe closure. *Once variants take a (filter, handler) pair and auto-unsubscribe after the first matching event. Import from backtest-kit.
| Listener | Fires on |
|---|---|
listenSignal / listenSignalOnce |
Every tick result, both modes. event.backtest distinguishes. |
listenSignalBacktest / listenSignalBacktestOnce |
Backtest tick results only. |
listenSignalLive / listenSignalLiveOnce |
Live tick results only. |
listenSignalNotify / listenSignalNotifyOnce |
User notifications emitted via commitSignalNotify. |
listenSignal((event) => {
if (event.action === "closed") console.log(event.closeReason, event.pnl.pnlPercentage);
});
listenSignalOnce(
(e) => e.action === "closed" && e.pnl.pnlPercentage > 5,
(e) => console.log("big win", e.pnl.pnlPercentage),
);
| Listener | Fires on |
|---|---|
listenDoneBacktest / β¦Once |
A backtest completes/stops ({ symbol, strategyName, exchangeName, frameName, backtest }). |
listenDoneLive / β¦Once |
Live trading stops. |
listenDoneWalker / β¦Once |
Walker completes (carries bestStrategy). |
listenWalkerComplete |
Walker results (IWalkerResults). |
listenWalker / β¦Once |
Per-strategy walker progress events. |
listenWalkerProgress |
Walker progress ticks. |
listenBacktestProgress |
Backtest progress ticks. |
| Listener | Fires on |
|---|---|
listenIdlePing / β¦Once |
Every minute while idle. |
listenSchedulePing / β¦Once |
Every minute while a scheduled signal waits. |
listenActivePing / β¦Once |
Every minute while a position is active. |
listenPartialProfitAvailable / β¦Once |
A profit milestone (10/20/β¦/100%) reached (deduplicated). |
listenPartialLossAvailable / β¦Once |
A loss milestone reached. |
listenBreakevenAvailable / β¦Once |
Breakeven threshold reached. |
listenHighestProfit / β¦Once |
Peak-profit update. |
listenMaxDrawdown / β¦Once |
Max-drawdown update. |
import { listenPartialProfitAvailable, Constant } from "backtest-kit";
listenPartialProfitAvailable(({ symbol, signal, price, level, backtest }) => {
if (level === Constant.TP_LEVEL1) { /* +3% β close 33% */ }
if (level === Constant.TP_LEVEL2) { /* +6% β close 33% */ }
if (level === Constant.TP_LEVEL3) { /* +9% β close 34% */ }
});
The *Once filtered form:
listenPartialProfitAvailableOnce(() => true, ({ level }) => console.log("first profit milestone", level));
| Listener | Fires on |
|---|---|
listenError |
An uncaught engine error ((error) => β¦). |
listenExit |
Process-exit signal. |
listenValidation |
A signal validation event. |
listenRisk / β¦Once |
A risk rejection. |
listenStrategyCommit / β¦Once |
A strategy commit (partial/trailing/breakeven/average-buy). |
listenSync / β¦Once |
An order-sync event. |
listenPerformance |
A performance metric sample. |
listenBeforeStart / β¦Once |
First lifecycle event of a run. |
listenAfterEnd / β¦Once |
After a run ends. |
shutdown() (Β§13.4) is the orchestrated stop: it waits until no Backtest/Live task is pending, then emits the shutdown event so subscribers can clean up.
import { listenError, shutdown } from "backtest-kit";
let lastError;
const unError = listenError((e) => { lastError = e; });
process.on("SIGINT", () => shutdown());
The raw subjects are also exported under emitters (import { emitters } from "backtest-kit") for advanced wiring.
Notification (and NotificationLive/NotificationBacktest, INotificationUtils, TNotificationUtilsCtor) emit and persist typed notifications. The exported NotificationModel discriminated union covers every event type:
CriticalErrorNotification, InfoErrorNotification, PartialLossAvailableNotification, PartialProfitAvailableNotification, BreakevenAvailableNotification, PartialProfitCommitNotification, PartialLossCommitNotification, BreakevenCommitNotification, ActivateScheduledCommitNotification, TrailingStopCommitNotification, TrailingTakeCommitNotification, RiskRejectionNotification, SignalCancelledNotification, SignalClosedNotification, SignalOpenedNotification, SignalScheduledNotification, ValidationErrorNotification, AverageBuyCommitNotification, SignalSyncCloseNotification, SignalSyncOpenNotification, CancelScheduledCommitNotification, ClosePendingCommitNotification, SignalInfoNotification.
Up to CC_MAX_NOTIFICATIONS (default 500) notifications are retained. @backtest-kit/ui consumes these for its real-time notification panel.
Default persistence is file-based with atomic writes under ./dump/. There are 15 independent persistence domains, each with its own adapter you can replace (Redis, MongoDB, PostgreSQL, β¦) for distributed or high-performance deployments. In backtest mode persistence is skipped for performance; it is active in live mode for crash recovery.
Each domain X exports: a data type XData, an adapter namespace PersistXAdapter (with usePersistXAdapter(ctor)), an instance interface IPersistXInstance, a default instance class PersistXInstance, and a constructor type TPersistXInstanceCtor.
| # | Domain | Purpose |
|---|---|---|
| 1 | Signal |
Pending/active signals (live recovery). SignalData, PersistSignalAdapter, β¦ |
| 2 | Schedule |
Scheduled (waiting) signals β independent of Signal so both recover separately. |
| 3 | Risk |
Active positions for portfolio risk. RiskData, PersistRiskAdapter. |
| 4 | Strategy |
Deferred strategy state (commit queue, created/closed/cancelled/activated signal). |
| 5 | Partial |
Per-signal partial milestone levels (dedup, crash-safe). |
| 6 | Breakeven |
Per-signal breakeven flags. |
| 7 | Candle |
OHLCV cache. PersistCandleAdapter. |
| 8 | Storage |
General key-value store (Β§20.4). |
| 9 | Notification |
Notification log (Β§24). |
| 10 | Log |
Log lines (capped at CC_MAX_LOG_LINES). |
| 11 | Measure |
Performance metric samples. |
| 12 | Memory |
BM25-searchable per-signal memory (Β§20.1). |
| 13 | Interval |
Interval/throttle bookkeeping. |
| 14 | Recent |
Recent-signal tracking (Β§20.4). |
| 15 | State |
Per-signal typed state (Β§20.2). |
| (+) | Session |
Per-context session store (Β§20.3) β SessionData, PersistSessionAdapter, β¦ |
./dump/data/
signal/{strategyName}/{symbol}.json # pending/active signal
schedule/{strategyName}/{symbol}.json # scheduled signal
risk/{riskName}/positions.json # active positions
partial/{symbol}/levels.json # partial milestone levels
...
./dump/backtest/{strategyName}.md # reports
./dump/live/{strategyName}.md
./dump/heatmap/{strategyName}.md
./dump/partial/{symbol}.md
./dump/schedule/{strategyName}.md
PersistBase β the base contractclass PersistBase {
constructor(entityName: string, baseDir: string);
waitForInit(initial: boolean): Promise<void>;
readValue<T>(entityId: string | number): Promise<T>; // throw if missing
hasValue(entityId: string | number): Promise<boolean>;
writeValue<T>(entityId: string | number, entity: T): Promise<void>;
removeValue(entityId: string | number): Promise<void>; // throw if missing
removeAll(): Promise<void>;
values<T>(): AsyncGenerator<T>; // sorted alphanumerically
keys(): AsyncGenerator<string>; // sorted alphanumerically
// plus filter(predicate) and take(n) iterators
}
Exported helpers: SignalData, EntityId, PersistBase, TPersistBase, IPersistBase, TPersistBaseCtor, plus the full PersistXAdapter / IPersistXInstance / PersistXInstance / TPersistXInstanceCtor / XData set for all 15 domains (+ Session).
import { PersistBase, PersistSignalAdapter, PersistRiskAdapter, PersistScheduleAdapter } from "backtest-kit";
import Redis from "ioredis";
const redis = new Redis();
class RedisPersist extends PersistBase {
async waitForInit() { /* connection already established */ }
async readValue<T>(id) {
const data = await redis.get(`${this.entityName}:${id}`);
if (!data) throw new Error(`${this.entityName}:${id} not found`);
return JSON.parse(data) as T;
}
async hasValue(id) { return (await redis.exists(`${this.entityName}:${id}`)) === 1; }
async writeValue<T>(id, entity: T) { await redis.set(`${this.entityName}:${id}`, JSON.stringify(entity)); }
async removeValue(id) { if ((await redis.del(`${this.entityName}:${id}`)) === 0) throw new Error("not found"); }
async removeAll() { const k = await redis.keys(`${this.entityName}:*`); if (k.length) await redis.del(...k); }
async *values<T>() {
const keys = (await redis.keys(`${this.entityName}:*`)).sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}));
for (const key of keys) { const d = await redis.get(key); if (d) yield JSON.parse(d) as T; }
}
async *keys() {
const keys = (await redis.keys(`${this.entityName}:*`)).sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}));
for (const key of keys) yield key.slice(this.entityName.length + 1);
}
}
// Register BEFORE running any strategy:
PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
The complete production stack (all 15 domains on MongoDB + Redis O(1) cache, atomic
findOneAndUpdateupserts, look-ahead-protectedwhencolumns) is@backtest-kit/mongo/backtest-kit-redis-mongo-dockerβ drop-in, strategy code unchanged.
import { PersistBase, PersistSignalAdapter, PersistRiskAdapter } 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() {
await client.connect();
await this.collection.createIndex({ entityId: 1 }, { unique: true });
}
async readValue<T>(entityId) {
const doc = await this.collection.findOne({ entityId });
if (!doc) throw new Error(`${this.entityName}:${entityId} not found`);
return doc.data as T;
}
async hasValue(entityId) { return (await this.collection.countDocuments({ entityId })) > 0; }
async writeValue<T>(entityId, entity: T) {
await this.collection.updateOne({ entityId },
{ $set: { entityId, data: entity, updatedAt: new Date() } }, { upsert: true });
}
async removeValue(entityId) {
if ((await this.collection.deleteOne({ entityId })).deletedCount === 0) throw new Error("not found");
}
async removeAll() { await this.collection.deleteMany({}); }
async *values<T>() { for await (const doc of this.collection.find({}).sort({ entityId: 1 })) yield doc.data as T; }
async *keys() { for await (const doc of this.collection.find({}, { projection: { entityId: 1 } }).sort({ entityId: 1 })) yield String(doc.entityId); }
}
PersistSignalAdapter.usePersistSignalAdapter(MongoPersist);
PersistRiskAdapter.usePersistRiskAdapter(MongoPersist);
When to choose: Redis β high-performance distributed systems, multiple instances, TTL cleanup, pub/sub. MongoDB β rich queries, aggregation pipelines, large datasets. PostgreSQL β ACID, complex joins. File (default) β single instance, zero deps, inspectable JSON.
PersistScheduleAdapter is fully independent from PersistSignalAdapter, so scheduled (waiting) signals recover separately from pending/active ones. The scheduled-signal row (IScheduledSignalRow) carries exchangeName and strategyName so the framework can validate the restored signal belongs to the right strategy/exchange before reinstating it.
Before crash:
1. getSignal returns { priceOpen: 50000 } at current price 49500 β scheduled
2. written atomically to ./dump/data/schedule/my-strategy/BTCUSDT.json
3. engine waits for price to reach 50000
4. CRASH at price 49800
After restart (same code):
1. framework reads the scheduled JSON during waitForInit
2. validates exchangeName + strategyName match (security)
3. restores it to _scheduledSignal and fires onSchedule()
4. continues monitoring; activates normally when price reaches 50000
The same dual-layer recovery applies to partial milestone levels (PersistPartial), breakeven flags (PersistBreakeven), risk positions (PersistRisk), and the deferred strategy commit queue (PersistStrategy) β so a live process can die mid-trade and resume with the exact in-flight state.
import { PersistBase } from "backtest-kit";
const logs = new PersistBase("trading-logs", "./dump/custom");
await logs.waitForInit(true);
await logs.writeValue("log-1", { timestamp: Date.now(), message: "started" });
const log = await logs.readValue("log-1");
for await (const l of logs.values()) { /* β¦ */ }
for await (const l of logs.filter((x:any) => x.symbol === "BTCUSDT")) { /* β¦ */ }
Set with setConfig(partial) before running any strategy. getConfig() returns a copy of the current config; getDefaultConfig() returns the frozen defaults; GlobalConfig is the exported type. For where in the engine each key is actually consumed (the consumer file/behavior), see Β§40. setColumns/getColumns/getDefaultColumns + ColumnConfig/ColumnModel customize markdown report columns (setColumns({ backtest_columns: [...] })).
| Key | Default | Meaning |
|---|---|---|
CC_AVG_PRICE_CANDLES_COUNT |
5 |
1-minute candles used for VWAP. Lower = responsive, higher = stable. |
CC_PERCENT_SLIPPAGE |
0.1 |
% slippage per side (applied at entry and exit). |
CC_PERCENT_FEE |
0.1 |
% fee per side (total ~0.2% round-trip). |
CC_POSITION_ENTRY_COST |
100 |
Default USD cost per entry (used for DCA units and sizing). |
| Key | Default | Meaning |
|---|---|---|
CC_SCHEDULE_AWAIT_MINUTES |
120 |
Minutes a scheduled signal waits for activation before auto-cancel. |
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
0.5 |
Minimum TP distance from priceOpen (must exceed slippage+fees). |
CC_MIN_STOPLOSS_DISTANCE_PERCENT |
0.5 |
Minimum SL distance (avoids instant stop-out on noise). |
CC_MAX_STOPLOSS_DISTANCE_PERCENT |
20 |
Maximum SL distance (caps single-signal loss). |
CC_MAX_SIGNAL_LIFETIME_MINUTES |
1440 |
Max signal lifetime; also default minuteEstimatedTime. Use Infinity for no timeout. |
CC_MAX_SIGNAL_GENERATION_SECONDS |
180 |
Max time getSignal may run before being aborted. |
CC_BREAKEVEN_THRESHOLD |
0.2 |
Min profit distance from entry to enable breakeven (above cost coverage). |
| Key | Default | Meaning |
|---|---|---|
CC_GET_CANDLES_RETRY_COUNT |
3 |
Retries for getCandles. |
CC_GET_CANDLES_RETRY_DELAY_MS |
5000 |
Delay between retries. |
CC_MAX_CANDLES_PER_REQUEST |
1000 |
Pagination threshold per API call. |
CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR |
1000 |
Reject candles whose price is this factor below the reference (catches Binance incomplete-candle ~0 prices). |
CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN |
5 |
Below this count, use average instead of median for anomaly reference. |
CC_ENABLE_CANDLE_FETCH_MUTEX |
true |
Serialize concurrent fetches of identical candles. |
CC_ENABLE_BACKTEST_PARALLEL_SPIN |
true |
Cooperative round-robin interleaving of parallel backtests after each fetch (skipped when single workload or mutex off). |
| Key | Default | Meaning |
|---|---|---|
CC_ORDER_BOOK_TIME_OFFSET_MINUTES |
10 |
Time-window size/offset for getOrderBook. |
CC_ORDER_BOOK_MAX_DEPTH_LEVELS |
1000 |
Default depth levels. |
CC_AGGREGATED_TRADES_MAX_MINUTES |
60 |
Window size for getAggregatedTrades pagination (Binance constraint). |
| Key | Default | Meaning |
|---|---|---|
CC_ENABLE_DCA_EVERYWHERE |
false |
Allow commitAverageBuy when price is below priceOpen even if not a new extreme. |
CC_ENABLE_PPPL_EVERYWHERE |
false |
Allow partial profit/loss even when it mixes exit directions. |
CC_ENABLE_TRAILING_EVERYWHERE |
false |
Activate trailing without absorption conditions. |
CC_ENABLE_LONG_SIGNAL |
true |
Permit long signals. |
CC_ENABLE_SHORT_SIGNAL |
true |
Permit short signals. |
CC_MAX_*_MARKDOWN_ROWS cap how many events each report retains (FIFO eviction). All default to 250 except where noted: CC_MAX_BACKTEST_MARKDOWN_ROWS, CC_MAX_BREAKEVEN_MARKDOWN_ROWS, CC_MAX_HEATMAP_MARKDOWN_ROWS, CC_MAX_HIGHEST_PROFIT_MARKDOWN_ROWS, CC_MAX_MAX_DRAWDOWN_MARKDOWN_ROWS, CC_MAX_LIVE_MARKDOWN_ROWS, CC_MAX_PARTIAL_MARKDOWN_ROWS, CC_MAX_RISK_MARKDOWN_ROWS, CC_MAX_SCHEDULE_MARKDOWN_ROWS, CC_MAX_STRATEGY_MARKDOWN_ROWS, CC_MAX_SYNC_MARKDOWN_ROWS. Larger caps: CC_MAX_PERFORMANCE_MARKDOWN_ROWS = 10000, plus storage caps CC_MAX_NOTIFICATIONS = 500, CC_MAX_SIGNALS = 50, CC_MAX_LOG_LINES = 1000, and CC_WALKER_MARKDOWN_TOP_N = 10. CC_REPORT_SHOW_SIGNAL_NOTE = false toggles the "Note" column across all report tables.
setConfig({
CC_SCHEDULE_AWAIT_MINUTES: 90,
CC_AVG_PRICE_CANDLES_COUNT: 7,
CC_ENABLE_DCA_EVERYWHERE: true,
});
Exported pure functions (no context required):
| Function | Purpose |
|---|---|
percentDiff(a, b) |
Percentage difference between two values. |
percentValue(value, percent) |
value Γ percent/100. |
investedCostToPercent(...) |
Convert invested cost to a percentage of basis. |
slPriceToPercentShift / tpPriceToPercentShift |
Convert an SL/TP price to a % distance from entry. |
slPercentShiftToPrice / tpPercentShiftToPrice |
The inverse β % distance β absolute SL/TP price. |
percentToCloseCost(...) |
Convert a close-percentage to a USD cost amount. |
roundTicks(...) |
Round a price to the exchange tick size. |
alignToInterval(date, interval) |
Align a date down to an interval boundary (the core look-ahead primitive). |
intervalStepMs(interval) |
Milliseconds per interval step. |
waitForCandle(...) |
Await the next candle boundary (live). |
getBacktestTimeframe(...) |
Generate the array of tick timestamps for a frame. |
parseArgs(...) |
CLI-style argument parser. |
beginContext(...) / beginTime(...) |
Manually establish execution/method context (advanced/testing). |
runInMockContext(...) |
Run a function inside a synthetic context (testing). |
get(obj, path) / set(obj, path, value) |
Safe deep get/set. |
validate / validateSignal / validateCandles / validateCommonSignal / validatePendingSignal / validateScheduledSignal |
Standalone validators. |
toProfitLossDto / getEffectivePriceOpen / getTotalClosed / getPriceScale / toPlainString |
PNL/DCA helpers (Β§12). |
waitForReady |
Await framework initialization. |
Constant exposes Kelly-tuned partial levels (verified values in v13.6.0):
Constant.TP_LEVEL1 // 30 β triggers at +3% profit (30% of TP distance)
Constant.TP_LEVEL2 // 60 β +6% profit
Constant.TP_LEVEL3 // 90 β +9% profit
Constant.SL_LEVEL1 // 40 β -2% loss
Constant.SL_LEVEL2 // 80 β -4% loss
These are the level thresholds used by the partial-milestone system, not close-percentages. They differ from older docs that listed 100/50/25 β always reference
Constant.*rather than hardcoding.
Cache and Interval (exported classes) back the candle cache and interval throttling, and both expose a memoize-per-interval wrapper used throughout the reference strategies:
Cache.fn(run, { interval: CandleInterval, key?: (args) => K }) // memoize a fetch; recomputes once per interval boundary
Cache.file(run, { interval, name: string, key? }) // same, persisted to a named file (survives restarts)
Interval.fn(run, { interval: CandleInterval, key? }) // throttle a fn to at most once per interval
Each returns the wrapped function augmented with .clear(), .gc(), and .hasValue(...args). Cache.fn(getPrediction, { interval: "8h" }) runs an expensive model train/inference at most once per 8h boundary across all ticks; Interval.fn(getSignal, { interval: "15m" }) gates signal generation. They are the building blocks behind the strategy examples in Β§34 and compose with @backtest-kit/graph sourceNodes. System, Log (ILog, TLogCtor), Markdown, Report, MarkdownWriter/ReportWriter and the writer base classes (MarkdownFileBase, MarkdownFolderBase, ReportBase, β¦) support custom report generation. Strategy, Exchange, Breakeven are the lower-level client wrappers. The raw orchestration container is exported as lib (import { lib } from "backtest-kit") for deep integration.
Reflect plus the listXxxSchema functions enable runtime introspection of every registered component:
import { listExchangeSchema, listStrategySchema, listFrameSchema,
listRiskSchema, listSizingSchema, listWalkerSchema, Reflect } from "backtest-kit";
listStrategySchema(); // β registered strategy names
getStrategySchema(name);// β raw IStrategySchema
getRuntimeInfo<Data>() (from inside a context) returns the full IRuntimeInfo<Data>:
interface IRuntimeInfo<Data = RuntimeData> {
symbol: string;
context: { strategyName; exchangeName; frameName };
backtest: boolean;
range: IRuntimeRange | null; // backtest frame { from, to }; null in live
currentPrice: number;
info: Data | null; // IStrategySchema.info payload
when: Date;
}
This is exactly the object passed to Cron handlers (Β§18).
The Optimizer (@backtest-kit/ollama: addOptimizerSchema, Optimizer) uses an LLM to generate strategies from historical data and emit executable code.
Flow: (1) fetch historical data from your sources; (2) build LLM conversation context per training period; (3) LLM analyzes patterns and produces strategy logic; (4) export complete executable code with a Walker for validation on an unseen test period.
import { addOptimizerSchema, Optimizer } from "@backtest-kit/ollama";
addOptimizerSchema({
optimizerName: "btc-optimizer",
rangeTrain: [
{ note: "Bull Q1", startDate: new Date("2024-01-01"), endDate: new Date("2024-03-31") },
{ note: "Consolidation Q2", startDate: new Date("2024-04-01"), endDate: new Date("2024-06-30") },
],
rangeTest: { note: "Validation Q3", startDate: new Date("2024-07-01"), endDate: new Date("2024-09-30") },
source: [
{ name: "backtest-results", fetch: async ({ symbol, startDate, endDate, limit, offset }) => db.getBacktestResults({ symbol, startDate, endDate, limit, offset }) },
{ name: "market-indicators", fetch: async (a) => db.getIndicators(a) },
],
getPrompt: async (symbol, messages) => `Create a multi-timeframe strategy with R/R β₯ 1.5:1 β¦`,
template: {
getUserMessage: async (symbol, data, sourceName) => `Analyze ${sourceName}:\n${JSON.stringify(data)}`,
getAssistantMessage: async (symbol, data, sourceName) => `Analyzed ${sourceName}`,
},
callbacks: {
onSourceData: async (symbol, sourceName, data) => console.log(`fetched ${data.length} from ${sourceName}`),
onData: async (symbol, strategies) => console.log(`generated ${strategies.length}`),
onCode: async (symbol, code) => console.log(`${code.length} bytes`),
onDump: async (symbol, filepath) => console.log(`saved ${filepath}`),
},
});
await Optimizer.dump("BTCUSDT", { optimizerName: "btc-optimizer" }, "./generated");
// β ./generated/btc-optimizer_BTCUSDT.mjs
API: Optimizer.getData(symbol, { optimizerName }) (metadata + LLM conversation), Optimizer.getCode(symbol, { optimizerName }) (string), Optimizer.dump(symbol, { optimizerName }, path?). Sources are auto-paginated (25 records/request). Generated files default to model gpt-oss:20b, multi-timeframe (1h/15m/5m/1m), structured JSON output, and debug logging to ./dump/strategy. Best practice: 2β4 diverse training regimes, always validate on rangeTest, ensure source rows have unique IDs for dedup.
Reference implementations in example/content/:
| Strategy | Mechanism |
|---|---|
| Neural Network (Oct 2021) | TensorFlow FFN (8β6β4β1) retrained every 8h predicts next-candle close; LONG with 1% trailing TP when price < prediction. |
| Pine Script Range Breakout (Dec 2025) | @backtest-kit/pinets runs btc_dec2025_range.pine on 1h candles; fires on confirmed BB/range/volume breakouts. |
| Signal Inversion (Jan 2026) | Takes a real Telegram channel's signals, enters the same zone/time but inverts direction to fade the crowd. |
| AI News Sentiment (Feb 2026) | Every 4β8h fetches news via Tavily, asks Ollama for bullish/bearish/wait, flips positions on conflict. +16.99% during a β16.4% month. |
| SHORT DCA Ladder (Mar 2026) | SHORT on every pending signal, adds up to 10 rungs on upward spikes outside a Β±1β5% band; closes at 0.5% blended profit. |
| LONG DCA Ladder (Apr 2026) | LONG-biased, 3% target. ~2.4 entries/trade avg; +67.85% PNL on deployed capital, drawdown β2.59% vs β3.99% without DCA. |
| Python EMA Crossover (Feb 2021) | WASI/WebAssembly Python strategy; EMA(9)ΓEMA(21) crossover confirmed by 4h range midpoint. |
The recommended starting point is the reference implementation β a complete news-sentiment AI system with LLM forecasting, multi-timeframe data, and a documented Feb 2026 backtest.
Full annotated source + documented results for all eight examples is in Β§34.
Clean-architecture layering:
ClientStrategy, ClientExchange, ClientFrame, ClientRisk, ClientSizing, ClientPartial, ClientBreakeven, ClientAction.add*/get*/list*).TimeMetaService, RuntimeMetaService, etc.ExecutionContextService (clock) + MethodContextService (identity) over AsyncLocalStorage.Two equivalent run forms over the same engine: event-driven background(...) + listeners, or pull-based for await (... of run(...)).
Everything below is exported from the backtest-kit package root.
Schema registration: addExchangeSchema addStrategySchema addFrameSchema addRiskSchema addSizingSchema addWalkerSchema addActionSchema Β· overrideExchangeSchema overrideStrategySchema overrideFrameSchema overrideRiskSchema overrideSizingSchema overrideWalkerSchema overrideActionSchema Β· getStrategySchema getExchangeSchema getFrameSchema getWalkerSchema getSizingSchema getRiskSchema getActionSchema Β· listExchangeSchema listStrategySchema listFrameSchema listWalkerSchema listSizingSchema listRiskSchema
Runners & analytics: Backtest Live Walker Heat Schedule Partial Position HighestProfit MaxDrawdown Risk Performance Sync Lookup PositionSize Reflect Constant
Commit functions: commitCreateSignal commitClosePending commitCancelScheduled commitActivateScheduled commitAverageBuy commitSignalNotify commitPartialProfit commitPartialLoss commitPartialProfitCost commitPartialLossCost commitTrailingStop commitTrailingTake commitTrailingStopCost commitTrailingTakeCost commitBreakeven
Strategy context queries: getPendingSignal getScheduledSignal hasNoPendingSignal hasNoScheduledSignal getBreakeven getStrategyStatus getTotalPercentClosed getTotalCostClosed getLatestSignal getMinutesSinceLatestSignalCreated
Position analytics: getPositionEffectivePrice getPositionInvestedCount getPositionInvestedCost getPositionPnlPercent getPositionPnlCost getPositionLevels getPositionPartials getPositionEntries getPositionEstimateMinutes getPositionCountdownMinutes getPositionActiveMinutes getPositionWaitingMinutes getPositionHighestProfitPrice getPositionHighestProfitTimestamp getPositionHighestPnlPercentage getPositionHighestPnlCost getPositionHighestProfitBreakeven getPositionHighestProfitMinutes getPositionDrawdownMinutes getPositionMaxDrawdownMinutes getPositionMaxDrawdownPrice getPositionMaxDrawdownTimestamp getPositionMaxDrawdownPnlPercentage getPositionMaxDrawdownPnlCost getPositionHighestMaxDrawdownPnlCost getPositionHighestMaxDrawdownPnlPercentage getPositionHighestProfitDistancePnlCost getPositionHighestProfitDistancePnlPercentage getPositionEntryOverlap getPositionPartialOverlap getMaxDrawdownDistancePnlCost getMaxDrawdownDistancePnlPercentage
Exchange data: getCandles getRawCandles getNextCandles getAveragePrice getClosePrice getAggregatedTrades getOrderBook formatPrice formatQuantity hasTradeContext Β· cache: warmCandles checkCandles cacheCandles
Meta / context: getDate getTimestamp getMode getContext getSymbol getRuntimeInfo Β· createSignalState getSignalState setSignalState getSessionData setSessionData runInMockContext
Memory / dump: writeMemory readMemory removeMemory searchMemory listMemory Β· dumpAgentAnswer dumpRecord dumpTable dumpText dumpError dumpJson
Control & setup: stopStrategy shutdown waitForReady setLogger setConfig getConfig getDefaultConfig setColumns getColumns getDefaultColumns
Events: listenSignal(+Once/Backtest/Live) listenError listenExit listenDoneLive listenDoneBacktest listenDoneWalker listenBacktestProgress listenPerformance listenWalker(+Once/Complete/Progress) listenValidation listenPartialLossAvailable(+Once) listenPartialProfitAvailable(+Once) listenBreakevenAvailable(+Once) listenRisk(+Once) listenSchedulePing(+Once) listenActivePing(+Once) listenIdlePing(+Once) listenStrategyCommit(+Once) listenSync(+Once) listenHighestProfit(+Once) listenMaxDrawdown(+Once) listenSignalNotify(+Once) listenBeforeStart(+Once) listenAfterEnd(+Once) Β· emitters
Broker / Cron / persistence: Broker BrokerBase IBroker TBrokerCtor + all Broker*Payload types Β· Cron (CronEntry CronHandle CronCallback) Β· PersistBase + the 15-domain Persist*Adapter / IPersist*Instance / Persist*Instance / *Data sets Β· Session/Storage/Recent/Memory/State/Notification/Dump utility classes & variants
Models & types: BacktestStatisticsModel LiveStatisticsModel HeatmapStatisticsModel ScheduleStatisticsModel PerformanceStatisticsModel WalkerStatisticsModel PartialStatisticsModel HighestProfitStatisticsModel MaxDrawdownStatisticsModel RiskStatisticsModel BreakevenStatisticsModel StrategyStatisticsModel Β· NotificationModel (+ all notification variants) Β· MessageModel Β· ColumnModel Β· all I*Schema / I* interfaces and *Name aliases listed throughout this document.
Math & utils: percentDiff percentValue investedCostToPercent slPriceToPercentShift tpPriceToPercentShift slPercentShiftToPrice tpPercentShiftToPrice percentToCloseCost Β· alignToInterval intervalStepMs waitForCandle roundTicks parseArgs beginContext beginTime get set getBacktestTimeframe Β· validate validateSignal validateCandles validateCommonSignal validatePendingSignal validateScheduledSignal Β· toProfitLossDto toPlainString getEffectivePriceOpen getTotalClosed getPriceScale Β· lib
backtest-kit@13.6.0. Verify the installed version before relying on a signature; the API has grown substantially across major versions.getCandles, getAveragePrice, commit*, getPosition*, dump*, memory/state/session) throw outside an active execution+method context. Only call them from inside getSignal or a strategy callback. Guard with hasTradeContext() when unsure.getSignal signature: (symbol, when, currentPrice) => Promise<ISignalDto | null>. Return null for no-op ticks.currentSignal (an IRiskSignalRow), not pendingSignal.Constant levels are 30/60/90 (TP) and 40/80 (SL) β never hardcode partial levels.CC_ENABLE_DCA_EVERYWHERE../dump/<domain>/; persistence data to ./dump/data/.setConfig/register adapters before running any strategy.Each package below is a separate npm module that builds on backtest-kit. The exports listed are verified against each package's src/index.ts.
@backtest-kit/pinets β Pine Script v5/v6 runtimeRun TradingView Pine Script in Node via the PineTS runtime β no rewrite, 1:1 syntax, 60+ built-in indicators (ta.rsi, ta.macd, ta.ema, ta.atr, ta.crossover, β¦). getCandles is wired to backtest-kit's temporal context, so look-ahead protection still applies.
npm install @backtest-kit/pinets pinets backtest-kit
Exports: Code File Β· run getSignal extract extractRows Β· usePine useIndicator setLogger dumpPlotData toMarkdown markdown toSignalDto Β· CandleModel PlotModel PlotRecord SymbolInfoModel Β· ILogger IPine/TPineCtor IIndicator/TIndicatorCtor IProvider AXIS_SYMBOL Β· lib.
Source loaders: File.fromPath(path) (cached file read) or Code.fromString(code) (inline).
import { File, getSignal } from "@backtest-kit/pinets";
import { addStrategySchema } from "backtest-kit";
addStrategySchema({
strategyName: "pine-ema-cross",
interval: "5m",
riskName: "demo",
getSignal: async (symbol) =>
getSignal(File.fromPath("strategy.pine"), { symbol, timeframe: "1h", limit: 100 }),
});
getSignal(source, { symbol, timeframe, limit }) runs the script and maps required plots to an ISignalDto. The Pine Script must plot() these names:
| Plot | Value | Meaning |
|---|---|---|
"Signal" |
1 / -1 / 0 |
long / short / no-signal |
"Close" |
price | entry price |
"StopLoss" |
price | SL level |
"TakeProfit" |
price | TP level |
"EstimatedTime" |
minutes | hold duration (optional, default 240) |
run(source, opts) β plots returns raw plot data. extract(plots, mapping) pulls the latest bar (missing β 0); extractRows(plots, mapping) returns every bar as { timestamp, β¦ } rows (missing β null). Mapping entries are either a plot name or { plot, barsBack?, transform? }:
const data = await extract(plots, {
rsi: "RSI",
prevRsi: { plot: "RSI", barsBack: 1 },
trend: { plot: "ADX", transform: (v) => (v > 25 ? "strong" : "weak") },
});
dumpPlotData(id, plots, name, "./dump/ta") writes plot data to markdown for debugging. usePine(Pine) registers a custom Pine constructor; toSignalDto(id, extracted, β¦) converts extracted values to an ISignalDto.
@backtest-kit/graph β typed DAG of computationsCompose computations as a directed acyclic graph; nodes resolve bottom-up in topological order with Promise.all parallelism. TypeScript infers each node's value type through the graph.
npm install @backtest-kit/graph backtest-kit
Exports: sourceNode outputNode resolve deepFlat serialize deserialize Β· INode TypedNode IFlatNode Value (Value = string | number | boolean | null).
sourceNode(fetch) β leaf node. fetch receives (symbol, when, currentPrice, exchangeName) from the execution context.outputNode(compute, ...nodes) β combines children; compute(values) is typed by position from nodes.resolve(node) β resolves the graph inside a backtest-kit strategy.import { sourceNode, outputNode, resolve } from "@backtest-kit/graph";
const close = sourceNode(async (symbol, when, price, ex) => (await getCandles(symbol, "1h", 1))[0].close);
const volume = sourceNode(async (symbol, when, price, ex) => (await getCandles(symbol, "1h", 1))[0].volume);
const vwap = outputNode(([c, v]) => c * v, close, volume); // c,v inferred number
addStrategySchema({ strategyName: "graph", getSignal: () => resolve(vwap) });
A multi-timeframe Pine filter composes naturally β a 4h sourceNode (trend) + a 15m sourceNode (entry) combined in an outputNode that returns null when the trend disagrees:
const mtfSignal = outputNode(
async ([higher, lower]) => {
if (higher.noTrades || lower.position === 0) return null;
if (higher.allowShort && lower.position === 1) return null;
if (higher.allowLong && lower.position === -1) return null;
return toSignalDto(randomString(), lower, null);
},
higherTimeframe, lowerTimeframe,
);
serialize(roots) β IFlatNode[] flattens the graph for DB storage (replacing object refs with nodeIds); deserialize(flat) β INode[] rebuilds it (you re-attach fetch/compute afterward β they are not serialized). deepFlat(nodes) returns all nodes in topological order, deduplicated. INode is the untyped runtime/storage shape; TypedNode is the authoring union with full inference.
@backtest-kit/ollama β multi-provider LLM + OptimizerUnified higher-order-function wrapper over 13 providers, structured JSON output via agent-swarm-kit outlines, plus the AI strategy Optimizer.
npm install @backtest-kit/ollama backtest-kit agent-swarm-kit
Provider HOFs β (fn, model, apiKey?) => fn (wraps an async fn with inference context via di-scoped):
| Function | Provider | Base URL |
|---|---|---|
gpt5 |
OpenAI | api.openai.com/v1/ |
claude |
Anthropic | api.anthropic.com/v1/ |
deepseek |
DeepSeek | api.deepseek.com/ |
grok |
xAI | api.x.ai/v1/ |
groq |
Groq | (Groq cloud) |
mistral |
Mistral | api.mistral.ai/v1/ |
perplexity |
Perplexity | api.perplexity.ai/ |
cohere |
Cohere | api.cohere.ai/compatibility/v1/ |
alibaba |
Alibaba | dashscope-intl.aliyuncs.com/compatible-mode/v1/ |
hf |
HuggingFace | router.huggingface.co/v1/ |
ollama |
Ollama | localhost:11434/ |
glm4 |
Zhipu AI | open.bigmodel.cn/api/paas/v4/ |
(groq is exported but omitted from the README provider table.) Pass an array of keys for automatic token rotation: ollama(fn, "llama3.3:70b", ["k1","k2","k3"]).
Prompt assembly: Module.fromPath(path, baseDir?) (default baseDir {cwd}/config/prompt/) loads a .cjs prompt module; Prompt.fromPrompt(source) takes an inline PromptModel; commitPrompt(source, history) appends the assembled system/user messages to a MessageModel[].
type PromptModel = { system?: string[] | SystemPromptFn; user: string | UserPromptFn };
// both fns receive (symbol, strategyName, exchangeName, frameName, backtest)
import { deepseek, Module, commitPrompt, MessageModel } from "@backtest-kit/ollama";
import { json } from "agent-swarm-kit";
const signalModule = Module.fromPath("./signal.prompt.cjs");
const getSignal = async () => {
const messages: MessageModel[] = [];
await commitPrompt(signalModule, messages);
const { data } = await json("SignalOutline", messages); // outline registered via addOutline
return data;
};
addStrategySchema({ strategyName: "llm-signal", interval: "5m",
getSignal: deepseek(getSignal, "deepseek-chat", process.env.DEEPSEEK_API_KEY) });
Structured output uses agent-swarm-kit's addOutline({ outlineName, completion: CompletionName.RunnerOutlineCompletion, format, getOutlineHistory, validations }) with either a Zod zodResponseFormat(...) or a plain JSON IOutlineFormat. CompletionName is the exported completion-name enum.
Optimizer (also re-summarized in Β§29) β addOptimizerSchema(schema: IOptimizerSchema), Optimizer.getData/getCode/dump, getOptimizerSchema, listOptimizerSchema, listenOptimizerProgress, ProgressOptimizerContract. Interfaces: IOptimizerSchema IOptimizerSource IOptimizerRange IOptimizerStrategy IOptimizerData IOptimizerTemplate IOptimizerCallbacks IOptimizerFetchArgs IOptimizerFilterArgs. Also exported: dumpSignalData, validate, setLogger, lib.
@backtest-kit/signals β 50+ indicators across 4 timeframesComputes multi-timeframe technical analysis and emits markdown reports formatted for LLM context injection.
npm install @backtest-kit/signals backtest-kit
Exports: orchestrators commitHistorySetup commitBookDataReport Β· histories commitOneMinuteHistory commitFifteenMinuteHistory commitThirtyMinuteHistory commitHourHistory Β· indicator maths commitMicroTermMath commitShortTermMath commitSwingTermMath commitLongTermMath Β· setLogger Β· lib. Each commit* appends a markdown report to a MessageModel[].
import { commitHistorySetup } from "@backtest-kit/signals";
const messages = [{ role: "system", content: "You are a trading bot." }];
await commitHistorySetup("BTCUSDT", messages); // order book + 1m/15m/30m/1h candles + all 4 indicator sets
const signal = await llm(messages);
Timeframe tiers: MicroTerm (1m, 60 candles β scalping), ShortTerm (15m, 144 β day trading), SwingTerm (30m, 96 β swing), LongTerm (1h, 100 β trend). Indicators include RSI, MACD, Bollinger, Stochastic, ADX, ATR, CCI, Fibonacci, support/resistance, volume trend, squeeze, order-book imbalance = (bidVol β askVol)/(bidVol + askVol). Per-timeframe caching (1m β 1 min TTL β¦ 1h β 30 min), cleared automatically on error.
@backtest-kit/mongo β MongoDB + Redis persistenceReplaces file-based ./dump/ with all 15 persist adapters on MongoDB (source of truth) + Redis (O(1) _id cache). Strategy code unchanged.
npm install @backtest-kit/mongo backtest-kit mongoose ioredis
Exports: setup(config?) install() setConfig(config) getConfig() setLogger(logger) getMongo() getRedis() waitForInit() BaseCRUD BaseMap.
import { setup } from "@backtest-kit/mongo";
setup(); // reads env vars; call once before any trading op
// or explicit:
setup({ CC_MONGO_CONNECTION_STRING: "mongodb://mongo:27017/db", CC_REDIS_HOST: "redis", CC_REDIS_PORT: 6379, CC_REDIS_PASSWORD: "secret" });
setup() configures and registers all adapters; install() registers only (when config came from env/setConfig). Env vars: CC_MONGO_CONNECTION_STRING (default mongodb://localhost:27017/backtest-kit?wtimeoutMS=15000), CC_REDIS_HOST (127.0.0.1), CC_REDIS_PORT (6379), CC_REDIS_USER, CC_REDIS_PASSWORD. Explicit args override env.
Collections & unique indexes: candle-items (symbol+interval+timestamp, immutable via $setOnInsert), signal-items/schedule-items (symbol+strategyName+exchangeName), risk-items (riskName+exchangeName), partial-items/breakeven-items (β¦+signalId), storage-items (backtest+signalId), notification-items (backtest+notificationId), log-items (entryId), measure-items/interval-items (bucket+entryKey), memory-items (signalId+bucketName+memoryId), recent-items (symbol+strategyName+exchangeName+frameName+backtest), state-items (signalId+bucketName), session-items (strategyName+exchangeName+frameName). Writes are atomic findOneAndUpdate(..., { upsert: true, new: true }) then Redis SET, guaranteeing read-after-write. Measure/Interval/Memory use soft delete (removed: true). Signal-affecting domains store when: Number for look-ahead protection (Measure is exempt β it caches LLM/API responses).
@backtest-kit/ui β full-stack dashboardNode backend + React 18 / MUI 5 / Lightweight-Charts dashboard for signals, candles, risk, notifications.
npm install @backtest-kit/ui backtest-kit ccxt
Exports: serve(host?, port?) getRouter() setLogger(logger) SymbolModel getModulesPath() getPublicPath() lib.
import { serve } from "@backtest-kit/ui";
serve("0.0.0.0", 60050); // dashboard at http://localhost:60050
getRouter() returns an Express-compatible router for embedding in your own server. Views: Signal Opened/Closed/Scheduled/Cancelled, Risk Rejection, Partial Profit/Loss, Trailing Stop/Take, Breakeven β each with a detail form, 1m/15m/1h charts, and JSON export.
Dashboard revenue math β revenue per window is the dollar sum Ξ£ signal.pnl.pnlCost over closed signals, where pnlCost = pnlPercentage/100 Γ pnlEntries and pnlEntries = Ξ£ entry.cost. The anchor is the latest updatedAt in backtest mode, Date.now() in live. Windows: Today, Yesterday, 7d, 31d. Effective entry through DCA + partials uses the same cost-basis replay as Β§12 (effectivePrice = Ξ£cost / Ξ£(cost/price)), and the per-partial fee/slippage-weighted PNL of toProfitLossDto.
@backtest-kit/cli β zero-boilerplate runnerThe lightest possible runner for a solo quant and a monorepo-grade runner for a desk of strategies β the same tool, no rewrite when the business scales. Point it at a strategy entry file, choose a mode, and it resolves exchange connectivity, candle caching, the UI dashboard, Telegram alerts, broker wiring, persistence, and graceful shutdown for you. The strategy file only registers schemas via backtest-kit; the CLI is purely the runner.
npm install @backtest-kit/cli backtest-kit ccxt
# or run once without installing:
npx @backtest-kit/cli --backtest ./src/index.mjs --symbol BTCUSDT
npx @backtest-kit/cli --init --output backtest-kit-project # scaffold
npx @backtest-kit/cli --docker # docker workspace
The bin is backtest-kit (so npx @backtest-kit/cli β‘ the backtest-kit executable). Library exports (src/index.ts, for embedding the runner): Setup, setLogger, run, cli (DI container), and the ILogger / ILoader / IBabel / ExchangeName / FrameName types.
Exactly one positional argument β the path to the strategy entry file β is required (set once in package.json scripts). Each mode maps to a main/* handler:
| Mode | Flag | Description |
|---|---|---|
| Backtest | --backtest |
Run on historical candles using a registered FrameSchema. |
| Walker | --walker |
A/B-compare multiple strategy files on the same history; ranked report. |
| Paper | --paper |
Live prices, no real orders (identical code path to live). |
| Live | --live |
Real trades via the exchange API + broker adapter. |
| Main | --main |
Run a custom entry point with the full prepared environment but no trading harness. |
| UI | --ui |
Start @backtest-kit/ui at http://localhost:60050. |
| Telegram | --telegram |
HTML trade notifications with 1m/15m/1h charts. |
| PineScript | --pine |
Run a local .pine indicator against exchange data. |
| Pine Editor | --editor |
Browser-based Pine Script editor (?pine=1 on the UI server). |
| Candle Dump | --dump |
Fetch + save raw OHLCV to a file. |
| PnL Debug | --pnldebug |
Simulate per-minute PnL for an entry price + direction. |
| Broker Debug | --brokerdebug |
Fire a single broker commit against the live adapter (--commit, default signal-open). |
| Flush | --flush |
Delete report/log/markdown/agent folders from the dump dir. |
| Init | --init |
Scaffold a new project. |
| Docker | --docker |
Generate a docker-compose workspace. |
--symbol (default BTCUSDT), --strategy / --exchange / --frame (default: first registered), --cacheInterval (default "1m, 15m, 30m, 4h"), --ui, --telegram, --verbose (log each candle fetch), --noCache (skip cache warming), --noFlush (keep output folders). If no exchange is registered, the CLI auto-registers a CCXT Binance schema.
{
"scripts": {
"backtest": "npx @backtest-kit/cli --backtest ./src/index.mjs",
"paper": "npx @backtest-kit/cli --paper ./src/index.mjs",
"start": "npx @backtest-kit/cli --live --ui ./src/index.mjs"
}
}
Before a backtest the CLI removes report/log/markdown/agent from the strategy's dump/, then warms the candle cache for every --cacheInterval; subsequent runs use cached data with no API calls.
Each positional argument is a separate strategy entry file; addWalkerSchema is called automatically using the exchange + frame the files register (falls back to the last 31 days if no frame). Walker-specific flags: --output (base name, default walker_{SYMBOL}_{TIMESTAMP}), --json (save Walker.getData() to ./dump/<output>.json), --markdown (save Walker.getReport() to ./dump/<output>.md); no flag β print Markdown to stdout.
npx @backtest-kit/cli --walker --symbol BTCUSDT --noCache --markdown --output cmp \
./content/v1.strategy.ts ./content/v2.strategy.ts ./content/v3.strategy.ts # β ./dump/cmp.md
--entry (multi-symbol fan-out)--main <entry> loads the full environment (.env, config/setup.config, config/loader.config, ./modules/main.module, cwd β entry dir, SIGINT wiring) but starts no harness β your entry decides what to run. Any Backtest/Live/Walker.background() your code launches is still managed (auto-exit on listenDone*, first Ctrl+C stops all via *.list()/*.stop(), second force-quits).
--entry <file> is a modifier combined with exactly one of --backtest/--live/--paper/--walker: the CLI does only the boilerplate (Setup, providers, the matching ./modules/<mode>.module, SIGINT, shutdown()), and you pick the symbol set + call *.background() per symbol β the way to fan one strategy across many symbols in one process:
// src/multi-symbol.mjs β run with: npx @backtest-kit/cli --backtest --entry ./src/multi-symbol.mjs
import { addExchangeSchema, addFrameSchema, addStrategySchema, Backtest } from "backtest-kit";
// β¦ register schemas β¦
for (const symbol of ["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT"]) {
Backtest.background(symbol, { strategyName: "my-strategy", exchangeName: "binance", frameName: "feb-2026" });
}
When the CLI loads an entry file it process.chdir()s to that file's directory, loads the root .env then the strategy .env (override). So dump/, modules/, template/, and config/ resolve inside the strategy folder. Each strategy gets an isolated candle cache (./dump/data/candle/), reports (./dump/), broker modules (./modules/{live,paper,backtest,walker,main,brokerdebug}.module.{ts,mjs,cjs}), Telegram templates (./template/*.mustache), and env.
Every top-level folder in process.cwd() becomes a bare import alias inside strategy files β no config: import { calcRSI } from "math/rsi" resolves <cwd>/math/rsi.ts, import { research } from "logic" resolves <cwd>/logic/index.ts (barrel + deep subpaths supported). Add matching paths to tsconfig.json for editor resolution.
A mode-specific side-effect module registers a Broker adapter before the run starts; .ts/.mjs/.cjs tried in order, missing is a soft warning:
| Flag | Module file | Loaded before |
|---|---|---|
--live |
./modules/live.module |
Live.background() |
--paper |
./modules/paper.module |
Live.background() (paper) |
--backtest |
./modules/backtest.module |
Backtest.background() |
--walker |
./modules/walker.module |
Walker.background() |
--main |
./modules/main.module |
the custom entry |
--brokerdebug |
./modules/brokerdebug.module |
the broker commit test |
--pine / --editor |
./modules/pine.module / editor.module |
exchange registration for Pine runs |
The module calls Broker.useBrokerAdapter(MyBroker); Broker.enable(); (Β§17). In backtest mode broker calls are skipped automatically.
config/*.config)Loaded from the project root, each tried as .ts/.cjs/.mjs/.js:
config/setup.config β side-effect, loaded once before everything. When present, the CLI skips its own default persistence adapter registration β your config owns the persistence layer. Typical use: import { setup } from "@backtest-kit/mongo"; setup();.config/loader.config β loaded after setup.config, awaited. Exports export default async () => {β¦} or export const loader = async () => {β¦} (never both β default wins). Use it to gate the run on an async dependency (verify a Mongo/Redis connection, stitch monorepo packages, warm caches, run migrations).config/alias.config β global module-import override ({ moduleName: replacement }), or an async factory resolving to that shape (same default-vs-loader rule). Replaces a heavy dep with a stub, mocks an API in CI, or aliases an ESM-only module so require("nanoid") transparently gets the dynamic import. Process-wide, not per-strategy.config/symbol.config (UI symbol list β export const symbol_list = [...]) and config/notification.config (UI notification category toggles), each resolved strategy-dir β project-root β package default.config/telegram.config β export default { getOpenedMarkdown, getClosedMarkdown, getScheduledMarkdown, getCancelledMarkdown, getRiskMarkdown, getPartialProfitMarkdown, getPartialLossMarkdown, getBreakevenMarkdown, getTrailingTakeMarkdown, getTrailingStopMarkdown, getAverageBuyMarkdown, getSignalOpenMarkdown, getSignalCloseMarkdown, getCancelScheduledMarkdown, getClosePendingMarkdown, getSignalInfoMarkdown } β each optional, receives the typed event, returns Promise<string>; unimplemented ones fall back to the Mustache template.Auto-detected: .ts (via tsx/tsImport, cross-format imports), .mjs (native import(), top-level await), .cjs (native require()).
--pine <file.pine> runs a local indicator against exchange data and prints a Markdown table (columns = the names of plot(..., display=display.data_window) calls; other plots ignored). Flags: --timeframe (default 15m), --limit (default 250 β must cover indicator warmup or rows show N/A), --when (ISO 8601 or Unix ms), --exchange, --output, --json/--jsonl/--markdown (written to <pine-dir>/dump/). --editor opens the visual Pine editor at http://localhost:{CC_WWWROOT_PORT}?pine=1. Both use ./modules/pine.module (or editor.module) for exchange registration. Env: CC_WWWROOT_HOST (0.0.0.0), CC_WWWROOT_PORT (60050), CC_TELEGRAM_TOKEN, CC_TELEGRAM_CHANNEL.
@backtest-kit/sidekick β full-control scaffoldernpx -y @backtest-kit/sidekick my-trading-bot
cd my-trading-bot && npm start
The "eject" of --init: every part of the wiring (exchange adapter, frame defs, risk rules, strategy logic, runner) lives as editable source in the generated project. Ships a working multi-timeframe Pine Script strategy β a 4H trend filter (RSI+MACD+ADX β AllowLong/AllowShort/AllowBoth/NoTrades) plus a 15m EMA-crossover entry generator confirmed by volume spike and momentum, with Kelly-optimized partial profit taking (33/33/34%), breakeven trailing stop, SL/TP distance risk filters, predefined backtest frames (Feb 2024 bull / OctβDec 2025 drops & ranges), Binance via CCXT, @backtest-kit/ui, optional Ollama LLM, and a CLAUDE.md for AI-assisted iteration.
Zero-dependency TypeScript ports of vectorbt-style models, each plugging into the Exchange schema:
garch β conditional variance of log-returns (GARCH/EGARCH/GJR-GARCH/HAR-RV/NoVaS, auto-selected by QLIKE) β log-normal TP/SL corridor PΒ·exp(Β±zΒ·Ο). Via Exchange.getCandles.pump-anomaly β coordinated-speculation detection (cross-correlation + union-find author clustering, volume z-scores) β entry/exit plan screened against winner's-curse (DSR/PBO/SPA). Via Exchange.getRawCandles.volume-anomaly β order-flow intensity (Hawkes branching ratio, CUSUM, BOCPD) β composite outlier score as an entry-timing gate. Via Exchange.getAggregatedTrades.Eight production-quality backtests live in example/content/*.strategy/, each a single *.strategy.ts file plus a README.md with price context, full trade log, equity curve, and env vars. All run through @backtest-kit/cli:
npm start -- --backtest --symbol TRXUSDT ./content/jan_2026.strategy/jan_2026.strategy.ts
| Strategy | Ticker | Period | Signal source | Net PNL | Sharpe |
|---|---|---|---|---|---|
| Feb 2021 β Python EMA Crossover | DOTUSDT | Feb 2021 | EMA(9)/EMA(21) crossover via WebAssembly Python | +5.52% | 0.09 |
| Apr 2024 β Polymarket Ξprob | BTCUSDT | Apr 2024 | Polymarket "yes" probability shifts | +0.63% | 0.055 |
| Oct 2021 β TensorFlow NN | BTCUSDT | Oct 2021 | NN predicting next-candle close | +18.26% | 0.31 |
| Dec 2025 β Pine Range Breakout | BTCUSDT | Dec 2025 | Pine BB + range + volume spike | +2.40% | 0.06 |
| Jan 2026 β Liquidity Harvesting | TRXUSDT | Jan 2026 | Telegram channel signals (inverted) | +8.58% | 1.14 |
| Feb 2026 β AI News Sentiment | BTCUSDT | Feb 2026 | LLM forecast on live news (Tavily + Ollama) | +16.99% | 0.25 |
| Mar 2026 β SHORT DCA Ladder | BTCUSDT | Mar 2026 | Fixed SHORT + DCA ladder up (β€10 rungs) | +37.83% | 0.35 |
| Apr 2026 β LONG DCA Ladder | BTCUSDT | Apr 2026 | Fixed LONG + DCA ladder down (β€10 rungs) | +67.85% | 0.12 |
Recurring idioms across all eight: Position.moonbag(...) for entry, minuteEstimatedTime: Infinity (manage the exit manually), Cache.fn/Cache.file to memoize an expensive computation per candle boundary, and listenActivePing for dynamic exit (trailing take, target PNL, sentiment flip, peak staleness).
The two ladder strategies are nearly identical; only position, TARGET_PROFIT, and the band orientation differ. This is the canonical DCA implementation:
import {
addStrategySchema, listenActivePing, listenError, Log, Position,
commitClosePending, getPositionPnlPercent, getPositionEntryOverlap,
getPositionEntries, commitAverageBuy,
} from "backtest-kit";
const HARD_STOP = 25.0; // wide hard stop β DCA needs room
const TARGET_PROFIT = 3; // 0.5 for the SHORT (Mar), 3 for the LONG (Apr)
const LADDER_STEP_COST = 100; // $100 per rung
const LADDER_UPPER_STEP = 5; // band above last entry (Mar: 1)
const LADDER_LOWER_STEP = 1; // band below last entry (Mar: 5)
const LADDER_MAX_STEPS = 10; // cap rungs
addStrategySchema({
strategyName: "apr_2026_strategy",
getSignal: async (symbol, when, currentPrice) => ({
position: "long",
...Position.moonbag({ position: "long", currentPrice, percentStopLoss: HARD_STOP }),
minuteEstimatedTime: Infinity,
cost: LADDER_STEP_COST,
}),
});
// Add a rung on each ping when price has moved outside the spacing band and we are under the cap.
listenActivePing(async ({ symbol, currentPrice }) => {
const { length: steps } = await getPositionEntries(symbol);
if (steps >= LADDER_MAX_STEPS) return;
const hasOverlap = await getPositionEntryOverlap(symbol, currentPrice, {
upperPercent: LADDER_UPPER_STEP, lowerPercent: LADDER_LOWER_STEP,
});
if (hasOverlap) return; // too close to an existing entry β skip
await commitAverageBuy(symbol, LADDER_STEP_COST);
});
// Close the whole blended position once target PNL is hit.
listenActivePing(async ({ symbol }) => {
if ((await getPositionPnlPercent(symbol)) < TARGET_PROFIT) return;
await commitClosePending(symbol, { id: "unknown", note: "# closed at target PNL" });
});
Apr 2026 deployed ~2.4 entries/trade on average for +67.85% on deployed capital with a tighter drawdown than a single entry. Mar 2026 mirrors it on the short side with a 0.5% target and inverted band, +37.83%.
Combines @backtest-kit/graph (sourceNode/outputNode/resolve), Cache.file (persist the daily forecast), and sentiment-flip exit. Achieved +16.99% during a β16.4% month (16 trades, 68.8% win rate, profit factor 2.25 β best trade +14.28% SHORT on Feb 4).
import { addStrategySchema, listenActivePing, commitClosePending, Cache, Position,
getPositionHighestProfitDistancePnlPercentage, getPositionPnlPercent,
getMinutesSinceLatestSignalCreated } from "backtest-kit";
import { sourceNode, outputNode, resolve } from "@backtest-kit/graph";
import { forecast } from "logic"; // monorepo alias β LLM forecast over Tavily news
const TRAILING_TAKE = 2.5, HARD_STOP = 3.0, NEWS_WINDOW = 24 * 60;
const POSITION_LABEL_MAP = { bullish: "long", bearish: "short", neutral: "wait", sideways: "wait" } as const;
const forecastSource = sourceNode(
Cache.file(async (symbol, when, currentPrice) => ({ ...(await forecast(symbol, when)), currentPrice }),
{ interval: "1d", name: "forecast_source" }),
);
const positionOutput = outputNode(async ([f]) => POSITION_LABEL_MAP[f.sentiment], forecastSource);
addStrategySchema({
strategyName: "feb_2026_strategy",
getSignal: async (symbol, when, currentPrice) => {
const since = await getMinutesSinceLatestSignalCreated(symbol);
if (since && since < NEWS_WINDOW) return null; // β€ 1 trade / 24h
const f = await resolve(forecastSource);
const position = await resolve(positionOutput);
if (position === "wait" || f.confidence === "not_reliable") return null;
return { id: `${f.id}_${randomString()}`,
...Position.moonbag({ position, currentPrice, percentStopLoss: HARD_STOP }),
minuteEstimatedTime: Infinity, note: f.reasoning };
},
});
// Sentiment flip β close.
listenActivePing(async ({ symbol, data }) => {
const position = await resolve(positionOutput);
if (position === data.position) return;
await commitClosePending(symbol, { id: "flip", note: "# sentiment changed" });
});
// Trailing take.
listenActivePing(async ({ symbol }) => {
if ((await getPositionPnlPercent(symbol)) < 0) return;
if ((await getPositionHighestProfitDistancePnlPercentage(symbol)) < TRAILING_TAKE) return;
await commitClosePending(symbol, { id: "unknown", note: "# trailing take" });
});
Loads 11 real posts from a Telegram channel (assets/entry.jsonl), matches publishedAt to the current minute, confirms price is inside entry.from..entry.to, then enters counter-trend (the channel's R:R is ~0.375:1 at 25Γ β mathematically a losing setup, so the inverse harvests the crowd's liquidity). SL β0.5%, no fixed TP, trailing-take + peak-staleness exits. +8.58%, Sharpe 1.14 (the highest Sharpe of the set).
import { addStrategySchema, listenActivePing, commitClosePending, alignToInterval,
getClosePrice, getCandles, Position, getPositionHighestProfitDistancePnlPercentage,
getPositionHighestPnlPercentage, getPositionPnlPercent, getPositionHighestProfitMinutes } from "backtest-kit";
const SIGNALS = readFileSync("./assets/entry.jsonl", "utf-8").split("\n").filter(Boolean).map(JSON.parse);
addStrategySchema({
strategyName: "jan_2026_strategy",
getSignal: async (symbol, when, currentPrice) => {
const signal = SIGNALS.find((s) => s.symbol === symbol &&
alignToInterval(new Date(s.publishedAt), "1m").getTime() === when.getTime());
if (!signal) return null;
const close1m = await getClosePrice(symbol, "1m");
if (close1m < signal.entry.from || close1m > signal.entry.to) return null;
const [prev, cur] = await getCandles(symbol, "4h", 2);
const mid = Math.max(prev.high, cur.high) + Math.max(prev.low, cur.low) / 2;
const position = close1m > mid ? "short" : "long"; // counter-trend vs 4h range midpoint
return { position, ...Position.moonbag({ position, currentPrice, percentStopLoss: 1.0 }),
minuteEstimatedTime: 24 * 60, note: signal.note };
},
});
// Exit 1: trailing take (close once peak-distance β₯ 1% and currently in profit).
// Exit 2: peak staleness β close if peak PNL β₯ 1% but it occurred β₯ 240 min ago
// (getPositionHighestPnlPercentage + getPositionHighestProfitMinutes).
Cache.fn(..., { interval: "8h" }) trains an 8β6β4β1 feed-forward net every 8h on 50 normalized candles ((closeβlow)/(highβlow)), predicts the next close, and Interval.fn(..., { interval: "15m" }) opens a LONG (Position.moonbag, 1% hard stop) whenever currentPrice < predictedPrice. Trailing take at 1% drawdown from peak. +18.26%, Sharpe 0.31.
const getPrediction = Cache.fn(async (symbol) => {
const candles = await getCandles(symbol, "8h", 58);
const model = await trainTrendNetwork(candles.slice(0, 50));
return predictNextClose(model, candles.slice(50), { low: last.low, high: last.high });
}, { interval: "8h" });
const getSignal = Interval.fn(async (symbol, currentPrice) => {
const prediction = await getPrediction(symbol);
return currentPrice < prediction.price
? { ...Position.moonbag({ position: "long", currentPrice, percentStopLoss: 1.0 }), minuteEstimatedTime: Infinity }
: null;
}, { interval: "15m" });
addStrategySchema({ strategyName: "oct_2021_strategy", getSignal: (symbol, when, price) => getSignal(symbol, price) });
Cache.fn(..., { interval: "1h" }) runs btc_dec2025_range.pine (RSI 14) via @backtest-kit/pinets and extracts BB bands, range boundaries, signal (Β±1), isRanging, volSpike. Opens a fixed Β±2% Position.bracket on signal === Β±1, but skips if price already moved past the signal-time close or if isRanging. +2.40%, Sharpe 0.06.
import { run, extract, File } from "@backtest-kit/pinets";
const PINE_FILE = File.fromPath("btc_dec2025_range.pine", "./math");
const getPlot = Cache.fn(async (symbol) =>
extract(await run(PINE_FILE, { symbol, inputs: { rsi_len: 14 }, timeframe: "1h", limit: 100 }),
{ signal: "Signal", close: "Close", isRanging: "IsRanging", volSpike: "VolSpike" /* + BB/range */ }),
{ interval: "1h" });
addStrategySchema({ strategyName: "dec_2025_strategy", getSignal: async (symbol, when, currentPrice) => {
const plot = await getPlot(symbol);
if (plot?.signal === 1 && currentPrice <= plot.close && !plot.isRanging)
return { ...Position.bracket({ position: "long", currentPrice, percentTakeProfit: 2, percentStopLoss: 2 }), minuteEstimatedTime: Infinity };
if (plot?.signal === -1 && currentPrice >= plot.close && !plot.isRanging)
return { ...Position.bracket({ position: "short", currentPrice, percentTakeProfit: 2, percentStopLoss: 2 }), minuteEstimatedTime: Infinity };
return null;
}});
Cache.fn(..., { interval: "8h" }) runs a Python indicator (strategy.py) over WASI to compute EMA(9)/EMA(21); Interval.fn(..., { interval: "8h" }) opens a $100 Β±2% Position.bracket LONG on bullish crossover. 33 trades, 63.6% WR, +5.52%. Demonstrates polyglot indicators (Python β WebAssembly) feeding a TypeScript strategy.
singleshot loads assets/polymarket-backtest-result.json, aggregates to one signal/day (max |dprob|), and strips future-data fields (entryPrice/exitPrice) to avoid look-ahead. getSignal picks the most recent signal with timestamp β€ when, rejecting it if older than 1h or |dprob| < 0.10; positive Ξprob β LONG, negative β SHORT, via Position.moonbag (1% hard stop). Trailing-take + 24h timeout exits. 10 trades, 70% WR, +0.63%, Sharpe 0.055. A clean template for ingesting an external dataset without leaking future data.
demo/*/src/index.mjs are minimal single-file programs that use the raw library directly β no @backtest-kit/cli, no scaffold. They are the clearest reference for wiring the engine by hand. Each registers schemas, attaches listeners, and calls Backtest.background / Live.background itself. (demo/broker is CLI-based and excluded here.)
A few APIs these demos exercise that are easy to miss:
Markdown.enable(opts?) β turn on markdown report generation (singleshot). Call once before running. Accepts flags { backtest, breakeven, heat, β¦ }.Exchange.* β a context-free counterpart to the strategy fetch functions, callable outside a strategy by passing { exchangeName } explicitly: Exchange.getCandles(symbol, interval, limit, { exchangeName }), Exchange.getRawCandles(symbol, interval, { exchangeName }, limit?, sDate?, eDate?), Exchange.getAggregatedTrades(symbol, { exchangeName }, limit?), Exchange.getAveragePrice, Exchange.getOrderBook. Use it in setup scripts or to feed quant-math models before a run.roundTicks(value, tickSize) β round a price/quantity to an exchange tick/step size inside formatPrice/formatQuantity.dump takes the context object β every *.dump(symbol, { strategyName, exchangeName, frameName }) call spreads straight from a listen* event (Β§13.1 note).The demos register risk validations with
validate: ({ pendingSignal, currentPrice }) => β¦. Inv13.6.0the canonical payload field iscurrentSignal(Β§16);pendingSignalappears in older demo code. PrefercurrentSignalin new strategies.
demo/backtest β backtest + reports + riskThe end-to-end backtest skeleton: CCXT Binance exchange, a 1:2 R/R risk profile, a 1-day frame, an LLM getSignal (userland json/getMessages/dumpSignalData helpers β not core exports), and listeners that dump backtest / risk / partial reports on the matching events.
import ccxt from "ccxt";
import { addExchangeSchema, addStrategySchema, addFrameSchema, addRiskSchema,
Backtest, Partial, Risk, Markdown, listenSignalBacktest, listenDoneBacktest,
listenBacktestProgress, listenRisk, listenPartialLossAvailable, listenPartialProfitAvailable,
listenError } from "backtest-kit";
Markdown.enable();
addExchangeSchema({
exchangeName: "test_exchange",
getCandles: async (symbol, interval, since, limit) => {
const ohlcv = await new ccxt.binance().fetchOHLCV(symbol, interval, since.getTime(), limit);
return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({ timestamp, open, high, low, close, volume }));
},
formatPrice: async (s, price) => price.toFixed(2),
formatQuantity: async (s, qty) => qty.toFixed(8),
});
addRiskSchema({
riskName: "demo_risk",
validations: [
{ note: "TP β₯ 1%", validate: ({ currentSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, position } = currentSignal;
const tp = position === "long" ? (priceTakeProfit - priceOpen) / priceOpen * 100 : (priceOpen - priceTakeProfit) / priceOpen * 100;
if (tp < 1) throw new Error(`TP ${tp.toFixed(2)}% < 1%`);
} },
{ note: "R/R β₯ 2:1", validate: ({ currentSignal }) => {
const { priceOpen, priceTakeProfit, priceStopLoss, position } = currentSignal;
const reward = position === "long" ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
const risk = position === "long" ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
if (risk <= 0 || reward / risk < 2) throw new Error("Poor R/R");
} },
],
});
addFrameSchema({ frameName: "test_frame", interval: "1m",
startDate: new Date("2025-12-01T00:00:00Z"), endDate: new Date("2025-12-01T23:59:59Z") });
addStrategySchema({ strategyName: "test_strategy", interval: "5m", riskName: "demo_risk",
getSignal: async (symbol) => { /* return an ISignalDto from your model */ } });
Backtest.background("BTCUSDT", { strategyName: "test_strategy", exchangeName: "test_exchange", frameName: "test_frame" });
listenBacktestProgress((e) => console.log(`Progress ${(e.progress * 100).toFixed(2)}% (${e.processedFrames}/${e.totalFrames})`));
listenDoneBacktest(async (e) => await Backtest.dump(e.symbol, { strategyName: e.strategyName, exchangeName: e.exchangeName, frameName: e.frameName }));
listenRisk(async (e) => await Risk.dump(e.symbol, { strategyName: e.strategyName, exchangeName: e.exchangeName, frameName: e.frameName }));
listenPartialLossAvailable(async (e) => await Partial.dump(e.symbol, { strategyName: e.strategyName, exchangeName: e.exchangeName, frameName: e.frameName }));
listenPartialProfitAvailable(async (e) => await Partial.dump(e.symbol, { strategyName: e.strategyName, exchangeName: e.exchangeName, frameName: e.frameName }));
listenError((err) => console.error(err));
Note listenBacktestProgress carries { progress (0β1), processedFrames, totalFrames }.
demo/live β live + per-event report dumpsSame schema setup, but Live.background(...) and a single listenSignalLive that branches on event.action (opened / closed / scheduled / cancelled) to dump Live / Partial / Schedule reports, plus listenBreakevenAvailable β Breakeven.dump, and listenPartialProfit/LossAvailable matching on Constant.TP_LEVEL* / SL_LEVEL*.
import { Live, Partial, Schedule, Risk, Breakeven, Constant, Markdown,
listenSignalLive, listenBreakevenAvailable, listenRisk,
listenPartialProfitAvailable, listenPartialLossAvailable, listenError } from "backtest-kit";
Markdown.enable();
Live.background("BTCUSDT", { strategyName: "test_strategy", exchangeName: "test_exchange", frameName: "test_frame" });
listenSignalLive(async (event) => {
if (event.action === "closed") {
await Live.dump(event.symbol, { strategyName: event.strategyName, exchangeName: event.exchangeName, frameName: event.frameName });
await Partial.dump(event.symbol, { strategyName: event.strategyName, exchangeName: event.exchangeName, frameName: event.frameName });
}
if (event.action === "scheduled" || event.action === "cancelled") {
await Schedule.dump(event.symbol, { strategyName: event.strategyName, exchangeName: event.exchangeName, frameName: event.frameName });
}
});
listenPartialProfitAvailable(({ symbol, price, level }) => {
if (level === Constant.TP_LEVEL1) {/* +30% of TP distance β +3% */}
if (level === Constant.TP_LEVEL2) {/* +6% */}
if (level === Constant.TP_LEVEL3) {/* +9% */}
});
demo/ccxt β full exchange schema + quant-math modelsThe reference production exchange adapter: a singleshot ccxt Binance instance, roundTicks-based precision via market metadata, real getOrderBook (throws in backtest β supply your own snapshot store) and getAggregatedTrades (publicGetAggTrades). It then feeds the context-free Exchange.* API into the three quant-math companions (Β§33.9):
import { addExchangeSchema, Exchange, roundTicks } from "backtest-kit";
import * as volume from "volume-anomaly";
import * as pump from "pump-anomaly";
import * as volatility from "garch";
addExchangeSchema({
exchangeName: "ccxt-exchange",
getCandles: async (symbol, interval, since, limit) => { /* ccxt fetchOHLCV */ },
formatPrice: async (symbol, price) => {
const m = (await getExchange()).market(symbol);
const tick = m.limits?.price?.min || m.precision?.price;
return tick !== undefined ? roundTicks(price, tick) : (await getExchange()).priceToPrecision(symbol, price);
},
formatQuantity: async (symbol, qty) => { /* roundTicks by step size */ },
getOrderBook: async (symbol, depth, from, to, backtest) => {
if (backtest) throw new Error("supply your own snapshot store for backtest order book");
return /* ccxt fetchOrderBook β IOrderBookData */;
},
getAggregatedTrades: async (symbol, from, to) => /* ccxt publicGetAggTrades β IAggregatedTradeData[] */,
});
// volume-anomaly β order-flow skew from aggregated trades
const all = await Exchange.getAggregatedTrades("BTCUSDT", { exchangeName: "ccxt-exchange" }, 1400);
const skew = volume.predict(all.slice(0, 1200), all.slice(1200), 0.75);
// garch β per-timeframe volatility forecast feeding TP/SL corridor
const c1h = await Exchange.getCandles("BTCUSDT", "1h", 500, { exchangeName: "ccxt-exchange" });
const { sigma, reliable } = await volatility.predict(c1h, "1h");
// pump-anomaly β fit / plan / backtest via getRawCandles
const getCandles = (symbol, interval, limit, sDate, eDate) =>
Exchange.getRawCandles(symbol, interval, { exchangeName: "ccxt-exchange" }, limit, sDate, eDate);
const model = pump.PumpMatrix.load(weights);
const plan = await model.plan(signals, getCandles);
This is the canonical template for the "See also" quant models β note every model receives data through the look-ahead-safe Exchange.* accessors.
demo/optimization β AI optimizer end-to-endWires @backtest-kit/ollama's addOptimizerSchema with 7 daily training ranges + 1 test day, four paginated multi-timeframe data sources (long/swing/short/micro-term-range, each fetch + user/assistant message templates with exhaustive indicator documentation), a getPrompt backed by an Ollama deepseek-v3.1:671b call, listenOptimizerProgress, and Optimizer.dump("BTCUSDT", { optimizerName }, "./generated"). See Β§29 / Β§33.3 for the API. Each source entry has the shape:
{
name: "short-term-range",
fetch: async ({ symbol, startDate, endDate, limit, offset }) => /* paginated rows */,
user: (symbol, data) => /* markdown table + indicator legend for the LLM */,
assistant: () => /* acknowledgement message */,
}
demo/pinets β raw Pine Script runThe smallest demo β register a candle-only exchange, then run a .pine file with an explicit exchangeName and when, and render the plots:
import { addExchangeSchema } from "backtest-kit";
import { run, File, toMarkdown } from "@backtest-kit/pinets";
addExchangeSchema({ exchangeName: "ccxt-exchange", getCandles: async (...) => /* ccxt */ });
const plots = await run(
File.fromPath("test_request_security.pine", "./math"),
{ symbol: "ETHUSDT", timeframe: "15m", limit: 180 },
"ccxt-exchange", // explicit exchange (no execution context here)
new Date("2025-09-24T12:00:00.000Z"), // explicit `when` end-date
);
console.log(await toMarkdown(randomString(), plots, { position: "Position", close: "Close", btcClose: "BTC Close" }));
Note run(...) accepts an optional exchangeName and when as 3rd/4th args for use outside a strategy context β the only way to drive Pine from a plain script. toMarkdown(id, plots, schema) renders the extracted columns as a markdown table.
backtest-kit is opinionated. Its API choices fall out of a small set of convictions about why trading systems fail and how AI changes the way they are built. The long-form arguments live in docs/; this section distills each into a thesis you can reason from.
No edge survives the crowd β escape to a growing-sum game. On an infinite horizon every strategy decays to zero expectancy minus commission: market volume is finite, so it is a fixed-sum game where your gain is someone's loss, and any exploited inefficiency disappears once others arbitrage it (the LuxAlgo "plateau"). The only durable edge taps capital flowing into the system from outside β e.g. a public recommendation to an audience that tends to infinity. Crowd behavior (Telegram pumps) reproduces every time the author has subscribers, so it is not a bug that gets arbitraged away β it is a growing-sum factor. β docs/concept/02_zero_expectation_escape.md
Declarative monorepo over the Python God Object. Imperative, thousands-of-line IStrategy subclasses (Freqtrade-style) don't survive the AI era: three engineers block each other on merge conflicts, split repos accumulate no shared knowledge, and a coding agent can't read prior iterations β every new strategy is a random shot, not a continuation. backtest-kit's declarative schema registration + monorepo with shared packages (broker, signals, DB) lets parallel strategies and parallel authors (human or agent) compound knowledge instead of colliding. This is the architectural root of the string-name dependency inversion (Β§4.1), per-strategy isolation (Β§33.7), and single-process parallel background() runs. β docs/concept/01_monorepo_parallel_execution.md
Look-ahead bias, made architecturally impossible. The failure that breaks most bots β a backtest that accidentally reads its own future β is prevented structurally via AsyncLocalStorage: the clock (when) propagates through every fetch, candles are aligned and clamped to the current tick, and the in-progress candle is excluded. It is not a lint rule you can forget; the data simply isn't reachable (Β§5.4, Β§11). β docs/article/01_look_ahead_bias.md
Second-order chaos: bots trade against themselves. In a first-order-chaos market you can find edge by analysis; in second-order chaos the market is unpredictable because everyone knows the same patterns and reacts to each other β strategies create the very noise they try to trade. Thousands of copies of the same algorithm blowing up together is why ~95% of bots fail. The design implication: prefer flow you understand (crowd liquidity, capital inflow) over yet another indicator everyone already runs. β docs/article/02_second_order_chaos.md
Markets are fragile β price moves in jumps, not diffusion. "Buy and hold" in 2026 is a bet you can stomach a β40β¦β70% drawdown; liquidation cascades happen several times a year. Textbook continuous-diffusion models break on gaps where no intermediate trades exist, so a hedge can't keep up. This motivates path-aware OHLC-replay exits (Β§1), wide hard stops for DCA, and option-style convexity thinking. β docs/article/04_option_hedging.md
Give the AI hands. Claude Code has superhuman pattern recognition (it can dissect 50 MB of backtest logs, find why 80% of trades hit timeout/SL, and edit Pine Script surgically) but no hands β the human running TradingView is the bottleneck. backtest-kit + @backtest-kit/cli + @backtest-kit/pinets give the agent an executable loop it can drive end-to-end. β docs/article/03_claude_trader.md
A self-updating workflow via /loop + Cron. Liquidation-cascade criteria drift monthly, so the parameters must be re-derived from the news feed continuously. With Claude Code's /loop (a local crontab) plus the framework's virtual-time Cron (Β§18) and self-enforcement runtime, an agent re-reads the feed, rewrites the filters as code, and re-backtests β closing the loop without a human in the inner cycle. β docs/article/05_ai_strategy_workflow.md
News-sentiment via ReAct, not debate theater. Most LLM trading agents (e.g. multi-agent "debate" pipelines) have fatal flaws; the reasoning-plus-acting (ReAct) pattern with a hierarchical search prompt produces correct BUY/SELL/WAIT calls under real shocks (the April 2026 Iran escalation). This is the blueprint behind the Feb 2026 news strategy (Β§34.3) and @backtest-kit/ollama outlines (Β§33.3). β docs/article/06_ai_strategy_blueprint.md
docs/article/07_ai_news_trading_signals.mddocs/article/08_ai_liquidity_harvesting.md@backtest-kit/pinets + a custom exchange adapter (Β§33.1). β docs/article/09_pinescript_local_markets.mdcommitAverageBuy + the ladder recipe (Β§12, Β§22.5, Β§34.2). β docs/article/10_dca_averaging_strategy.mdNew readers: start with the two concepts (the why of the architecture), then 01_look_ahead_bias and 02_second_order_chaos (the why of the guarantees), then the AI-workflow trio (03β05β06), and finally the worked edges (07β10) alongside their strategy examples in Β§34.
The framework auto-generates 13 distinct markdown reports, one per analytics domain. Each is produced by a dedicated markdown service (src/lib/services/markdown/*MarkdownService.ts) that subscribes to an event subject/emitter, accumulates rows in a per-context memoized store (capped FIFO), and renders a markdown table plus a statistics footer. You consume them through the public wrapper classes (Β§13βΒ§14, Β§19) via getData / getReport / dump, or let the CLI/UI subscribe automatically (Markdown.enable(), Β§35).
| # | Report (title) | Wrapper | Default dump path | Fed by | Row cap |
|---|---|---|---|---|---|
| 1 | # Backtest Report: |
Backtest |
./dump/backtest/ |
signalBacktestEmitter (closed signals) |
CC_MAX_BACKTEST_MARKDOWN_ROWS (250) |
| 2 | # Live Trading Report: |
Live |
./dump/live/ |
signalLiveEmitter (all tick events) |
CC_MAX_LIVE_MARKDOWN_ROWS (250) |
| 3 | # Walker Comparison Report: |
Walker |
./dump/walker/ |
walkerEmitter (per-strategy results) |
top CC_WALKER_MARKDOWN_TOP_N (10) |
| 4 | # Portfolio Heatmap: |
Heat |
./dump/heatmap/ |
signalEmitter (closed, all symbols) |
CC_MAX_HEATMAP_MARKDOWN_ROWS (250) |
| 5 | # Scheduled Signals Report: |
Schedule |
./dump/schedule/ |
signalEmitter (scheduled/opened/cancelled) |
CC_MAX_SCHEDULE_MARKDOWN_ROWS (250) |
| 6 | # Partial Profit/Loss Report: |
Partial |
./dump/partial/ |
partialProfitSubject + partialLossSubject |
CC_MAX_PARTIAL_MARKDOWN_ROWS (250) |
| 7 | # Risk Rejection Report: |
Risk |
./dump/risk/ |
riskSubject (rejections only) |
CC_MAX_RISK_MARKDOWN_ROWS (250) |
| 8 | # Breakeven Report: |
Breakeven |
./dump/breakeven/ |
breakevenSubject |
CC_MAX_BREAKEVEN_MARKDOWN_ROWS (250) |
| 9 | # Highest Profit Report: |
HighestProfit |
./dump/highest_profit/ |
highestProfitSubject |
CC_MAX_HIGHEST_PROFIT_MARKDOWN_ROWS (250) |
| 10 | # Max Drawdown Report: |
MaxDrawdown |
./dump/max_drawdown/ |
maxDrawdownSubject |
CC_MAX_MAX_DRAWDOWN_MARKDOWN_ROWS (250) |
| 11 | # Strategy Report: |
Strategy |
./dump/strategy/ |
strategyCommitSubject (commit events) |
CC_MAX_STRATEGY_MARKDOWN_ROWS (250) |
| 12 | # Signal Sync Report: |
Sync |
./dump/sync/ |
syncSubject (open/close sync) |
CC_MAX_SYNC_MARKDOWN_ROWS (250) |
| 13 | # Performance Report: |
Performance |
./dump/performance/ |
performanceEmitter (timing metrics) |
CC_MAX_PERFORMANCE_MARKDOWN_ROWS (10000) |
Dump filenames embed the context and a timestamp: {symbol}_{strategyName}_{exchangeName}_{frameName}_backtest-{ts}.md (backtest) or {symbol}_{strategyName}_{exchangeName}_live-{ts}.md (live). Backtest/Live/Heat reports are keyed per (symbol, strategy, exchange, frame, mode); clearing one context (clear(...)) does not touch others. The MarkdownWriter adapter (Β§14.9/persistence) decides the physical sink β separate .md files (useMd, default), one JSONL stream (useJsonl), or silent (useDummy).
Backtest (1) & Live (2) β the two richest reports. A per-signal/per-event table plus an extensive statistics footer (every value null/N/A when the sample is too small or the math is unsafe):
totalSignals/totalEvents (Live adds totalClosed), winCount/lossCount, win rate, avg PNL, total PNL, median PNL.bullish/bearish/sideways/neutral), trend strength (%/day), trend confidence (RΒ²), buyer/seller pressure, buyer/seller strength, pressure imbalance, median step size.Statistical gates (verified constants): per-trade ratios are null below 10 closed signals (MIN_SIGNALS_FOR_RATIOS); annualized metrics additionally require calendar span β₯ 14 days and raw frequency β€ 365/yr; |Expected Yearly Returns| capped at 100% (else null); Calmar/Recovery clamped to Β±1000; stdDev below 1e-9 treated as zero (identical-returns guard). All equity-curve metrics assume 100% capital allocation per position β they ignore the position-sizing subsystem and are theoretical upper bounds. Backtest reports lazily merge persisted closed history from disk before computing, so reports work even with event capture disabled.
Walker (3) β ranked comparison table of the strategies in the walker (top CC_WALKER_MARKDOWN_TOP_N), each row carrying its BacktestStatisticsModel and the chosen metric; the best strategy + best metric are surfaced (Β§13.3).
Heat (4) β per-symbol rows (the full IHeatmapRow, Β§14.1) sorted by Sharpe, plus a portfolio aggregate: portfolioTotalPnl (sum of non-null per-symbol PNL), portfolioTotalTrades, pooled portfolioSharpeRatio/portfolioSortinoRatio/portfolioCalmarRatio/portfolioAnnualizedSharpeRatio (computed over all trades across symbols β not a Markowitz cross-correlation Sharpe), portfolioTradesPerYear, and trade-weighted portfolioAvgPeakPnl/portfolioAvgFallPnl. Built defensively against NaN/β.
Schedule (5) β SCHEDULED / OPENED / CANCELLED events with entry/TP/SL, wait time, and the ScheduleStatisticsModel footer (totalScheduled, totalOpened, totalCancelled, cancellationRate, activationRate, avgWaitTime, avgActivationTime β Β§14.2).
Partial (6) β PROFIT/LOSS milestone rows (PartialEvent: level %, price, position, signalId, mode) with totalEvents/totalProfit/totalLoss. Subscribes to both the profit and loss subjects.
Risk (7) β one row per rejection (RiskEvent: symbol, strategy, rejection note, price, position) + counts by symbol/strategy. Emitted only on rejection, never on allow.
Breakeven (8) β one row per breakeven trigger (BreakevenEvent) + total count.
Highest Profit (9) & Max Drawdown (10) β per-signal best favorable / worst adverse excursion events (HighestProfitEvent / MaxDrawdownEvent: PNL, peak/trough price + timestamp, position) newest-first, with total counts.
Strategy (11) β an audit log of every commit (StrategyEvent): cancel-scheduled, close-pending, partial profit/loss, trailing stop/take, breakeven, activate-scheduled, average-buy β with price, percent, effective entry, DCA entry count/cost, and a note. StrategyStatisticsModel totals each action type.
Sync (12) β signal open/close synchronization lifecycle (SyncEvent: entry/exit prices, TP/SL, PNL, close reason) with totalEvents/openCount/closeCount β the audit trail behind broker order routing (Β§19).
Performance (13) β revenue profiling: per-metric timing aggregates (MetricStats: count, total/avg duration, stdDev, median, P95, P99, inter-event wait times) for bottleneck analysis. Larger row cap (10000) since samples are lightweight.
The table columns of every report are driven by COLUMN_CONFIG (per-report arrays like backtest_columns, live_columns, β¦). Override them globally with setColumns({ backtest_columns: [...] }) (Β§26) or pass a columns argument to getReport/dump. Each ColumnModel is { key, label, format(row, index) => string, isVisible() => boolean }. CC_REPORT_SHOW_SIGNAL_NOTE toggles the Note column across all reports.
Parallel to the human-facing markdown reports (Β§37), the framework keeps a machine-facing JSONL layer: 13 report services (src/lib/services/report/*ReportService.ts) that stream one structured row per event into append-only JSONL files for analytics, audit, and post-processing with standard tools. This is the data backbone behind @backtest-kit/ui, the MongoDB stack, and any downstream pipeline.
Same event sources, different purpose:
| Markdown (Β§37) | JSONL (Β§38) | |
|---|---|---|
| Service suffix | *MarkdownService |
*ReportService |
| Output | .md table + stats footer |
append-only .jsonl, one object per line |
| Path | ./dump/<domain>/{context}-{ts}.md (per-context) |
./dump/report/{reportType}.jsonl (one file per type) |
| Retention | capped FIFO (CC_MAX_*_MARKDOWN_ROWS) |
unbounded β append-only, never overwrites |
| Activated by | Markdown.enable(opts?) |
Report.enable(opts?) |
| Audience | human review | analytics / DB ingest / UI |
Both subscribe to the same emitters/subjects, so enabling one, the other, or both is independent.
Report.enable(config?)Report (the ReportUtils singleton) toggles JSONL capture per type. enable is singleshot and returns an unsubscribe closure; disable(config?) stops selected types without unsubscribing the rest. With no argument every type is enabled (all flags default true):
import { Report } from "backtest-kit";
const stop = Report.enable(); // all 13 streams
// or selectively:
Report.enable({ backtest: true, walker: true, performance: false });
// ... run backtests ...
stop(); // unsubscribe all
Config keys (all boolean, default true): backtest, live, walker, heat, schedule, partial, risk, breakeven, highest_profit, max_drawdown, strategy, sync, performance. The physical sink is the ReportWriter adapter β useJsonl (default, appends to ./dump/report/{type}.jsonl) or useDummy (discard, for tests); you can register a custom writer (e.g. the MongoDB adapter, Β§25). Every row is written with search keys { symbol, strategyName, exchangeName, frameName, signalId, walkerName }, and ReportBase/MarkdownFileBase expose a search(...) over those keys so the JSONL can be queried by context.
reportType (file) |
Fed by | Row = one β¦ |
|---|---|---|
backtest β ./dump/report/backtest.jsonl |
signalBacktestEmitter |
backtest tick: idle / opened / active / closed |
live β live.jsonl |
signalLiveEmitter |
live tick (all lifecycle actions) |
walker β walker.jsonl |
walkerEmitter |
per-strategy walker result |
heat β heat.jsonl |
signalEmitter |
closed signal (portfolio-wide, all symbols) |
schedule β schedule.jsonl |
signalEmitter |
scheduled / opened / cancelled event |
partial β partial.jsonl |
partialProfitSubject + partialLossSubject |
partial profit/loss milestone |
risk β risk.jsonl |
riskSubject |
risk rejection |
breakeven β breakeven.jsonl |
breakevenSubject |
breakeven trigger |
highest_profit β highest_profit.jsonl |
highestProfitSubject |
new peak-profit record |
max_drawdown β max_drawdown.jsonl |
maxDrawdownSubject |
new max-drawdown record |
strategy β strategy.jsonl |
strategyCommitSubject |
commit (cancel/close/partial/trailing/breakeven/activate/average-buy) |
sync β sync.jsonl |
syncSubject |
signal open/close sync event |
performance β performance.jsonl |
performanceEmitter |
timing metric sample |
Each line is a flat JSON object combining a base envelope with action-specific fields. For the backtest/live streams the envelope is { timestamp, action, symbol, strategyName, exchangeName, frameName, backtest, currentPrice }, and richer actions append the full signal/PNL breakdown. Example fields by action (backtest stream):
idle β envelope only.opened β + signalId, position, note, priceOpen, priceTakeProfit, priceStopLoss, originalPrice{Open,TakeProfit,StopLoss}, totalEntries, _partial, partialExecuted, totalPartials, cost, openTime (pendingAt), scheduledAt, minuteEstimatedTime.active β opened fields + percentTp, percentSl, pnl, pnlCost, pnlEntries, pnlPriceOpen, pnlPriceClose, and flattened peakProfit* / maxDrawdown* (priceOpen/priceClose/percentage/cost/entries each).closed β opened+pnl fields + closeReason, closeTime, duration (minutes), peakProfit*/maxDrawdown*.PNL DTOs are flattened into scalar columns (e.g. peakProfitPercentage, maxDrawdownCost) rather than nested objects, so the JSONL maps cleanly onto SQL columns / dataframes. Other streams follow the analogous event shapes from Β§14 / Β§24 (e.g. risk rows carry rejectionNote + activePositionCount; performance rows carry per-metric durations; strategy rows carry the commit action + price/percent/DCA fields). Because the files are append-only, a single run produces a complete, replayable event log per type β load with any JSONL reader (jq, pandas read_json(lines=True), DuckDB read_json_auto, etc.).
The framework validates configuration in two distinct phases, by two distinct service families:
addXxxSchema), each *SchemaService.validateShallow checks the schema object's own structure/types before it enters the registry. Catches malformed config immediately.Backtest.run/background, Live.*, Walker.*, getPendingSignal, β¦), each *ValidationService.validate(name, source) checks the named entity exists and recursively validates everything it references. Catches dangling string-name links across the dependency graph.Plus two standalone validators for global config and report columns. All of this is independent from the per-signal/per-candle data validation (Β§7.5β7.6).
Every addXxxSchema(schema) calls register(name, schema), which runs validateShallow(schema) and then inserts into a ToolRegistry β registering a name that already exists throws. validateShallow only inspects the object's own fields (no cross-references). Verified checks per domain:
| Schema | validateShallow throws when β¦ |
|---|---|
| Strategy | strategyName not a string; riskName present but not a string; riskList not an array / has duplicates / non-string entries; actions not an array / has duplicates / non-string entries; interval present but not a string; getSignal present but not a function. |
| Exchange | exchangeName missing; getCandles missing (the one mandatory adapter fn). |
| Frame | frameName missing; interval invalid; startDate missing; endDate missing. |
| Risk | riskName missing; validations not an array / invalid entries. |
| Sizing | sizingName missing; method missing; riskPercentage missing for fixed-percentage / atr-based methods. |
| Walker | walkerName missing; exchangeName missing; frameName missing; strategies not an array / empty / has duplicates / invalid entries. |
| Action | actionName missing; handler not a function or plain object; callbacks present but not an object. |
overrideXxxSchema does not re-run validateShallow β it applies a partial update to an already-registered (already-validated) schema via the registry.
Each domain also has a *ValidationService holding its own Map<name, schema> (populated by add* alongside registration; add* throws on a duplicate name). validate(name, source) is memoized (each name validated at most once per process β repeat calls are free) and throws "<domain> <name> not found source=<source>" if the entity was never registered. The source string is the call site (e.g. "BacktestUtils.run") and appears in the error for traceability.
Leaf validators (existence only, no cascade): Exchange, Frame, Risk, Sizing, Action. Each just confirms the name is in its map. (ActionValidationService memoizes on actionName:source; it does not introspect handler method names β that's a claim in some prose docs that the code does not implement.)
Cascade roots (existence + recursive dependency validation):
StrategyValidationService.validate(strategyName, source) β confirms the strategy exists, then validates each referenced dependency:
riskName β RiskValidationService.validateriskList[i] β RiskValidationService.validateactions[i] β ActionValidationService.validateWalkerValidationService.validate(walkerName, source) β confirms the walker exists, then for every strategy in walker.strategies: validates the strategy and its riskName / riskList / actions. So validating one walker transitively validates the entire sub-graph (walker β strategies β risks + actions).This is why a typo in riskName or an actions: ["telegram"] referencing an unregistered action surfaces as a clear "risk β¦ not found" / "action β¦ not found" at the first run/background call, rather than as a silent no-op deep in execution. The runners invoke these validators up front:
Backtest.run("BTCUSDT", { strategyName, exchangeName, frameName })
ββ strategyValidationService.validate(strategyName, "BacktestUtils.run")
β ββ riskValidationService.validate(riskName, β¦) // if riskName
β ββ riskValidationService.validate(each riskList, β¦) // if riskList
β ββ actionValidationService.validate(each action, β¦) // if actions
ββ exchangeValidationService.validate(exchangeName, β¦)
ββ frameValidationService.validate(frameName, β¦)
Reflect / the listXxxSchema functions (Β§28) expose the same registries for read-only introspection.
Two aggregating validators run when you change global settings (each collects all failures and throws one combined message; both are skipped when the internal _unsafe flag is set, used only by the test harness):
ConfigValidationService.validate() β invoked by setConfig(...). Checks GLOBAL_CONFIG for mathematical/economic soundness: slippage/fee/breakeven percentages non-negative; CC_MIN_TAKEPROFIT_DISTANCE_PERCENT must cover 2Γslippage + 2Γfee (else "all TakeProfit signals will be unprofitable"); SL min/max positive and min < max; time params (CC_SCHEDULE_AWAIT_MINUTES, CC_MAX_SIGNAL_GENERATION_SECONDS) positive integers; CC_MAX_SIGNAL_LIFETIME_MINUTES a positive integer or Infinity; candle params (CC_AVG_PRICE_CANDLES_COUNT, retry count/delay, anomaly factor, min-candles-for-median, max-per-request, order-book offset) integers in range; storage limits (CC_MAX_NOTIFICATIONS, CC_MAX_SIGNALS) positive integers. On failure setConfig rolls back to the previous config and rethrows (Β§26).ColumnValidationService.validate() β invoked by setColumns(...). For every collection in COLUMN_CONFIG: each column must be an object with key, label, format, isVisible; key/label non-empty strings; format/isVisible functions; and keys unique within each collection (reports duplicate key + indexes). On failure setColumns rolls back and rethrows (Β§37.3).add*).source in the message.Β§26 lists every GLOBAL_CONFIG key with its default. This section maps the keys to where in the source they are actually read, so you can predict the effect of changing one. Two config objects exist: GLOBAL_CONFIG (src/config/params.ts, numeric/boolean knobs) and COLUMN_CONFIG (src/config/columns.ts, report table columns). Defaults are frozen as DEFAULT_CONFIG / DEFAULT_COLUMNS.
| Key | Consumed by | Effect |
|---|---|---|
CC_PERCENT_SLIPPAGE, CC_PERCENT_FEE |
toProfitLossDto (helpers), ClientBreakeven, ClientStrategy breakeven formula |
Applied twice (entry+exit) to every PNL; also feed the breakeven & min-TP-distance math. |
CC_AVG_PRICE_CANDLES_COUNT |
VWAP (getAveragePrice / exchange services), candle fetch sizing |
Number of 1m candles averaged for the engine's "current price". |
CC_POSITION_ENTRY_COST |
commitAverageBuy(symbol, cost?) default, ClientRisk & ClientStrategy cost fallback (signal.cost || β¦), Backtest/Live DCA cost default |
The $100 unit per entry when a signal/DCA omits cost. Drives DCA weighting and pnlEntries. |
CC_BREAKEVEN_THRESHOLD |
ClientBreakeven, ClientStrategy (getBreakeven) |
Breakeven trigger = (CC_PERCENT_SLIPPAGE + CC_PERCENT_FEE) Γ 2 + CC_BREAKEVEN_THRESHOLD β the extra margin above cost recovery. |
| Key | Consumed by | Effect |
|---|---|---|
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
validateCommonSignal, ConfigValidationService |
Rejects signals whose TP is closer than this; config-validated to cover 2Γslippage + 2Γfee (Β§39.3). |
CC_MIN_STOPLOSS_DISTANCE_PERCENT, CC_MAX_STOPLOSS_DISTANCE_PERCENT |
validateCommonSignal |
Floor/ceiling on SL distance; config-validated min < max. |
CC_MAX_SIGNAL_LIFETIME_MINUTES |
ClientStrategy β minuteEstimatedTime ?? CC_MAX_SIGNAL_LIFETIME_MINUTES at every signal-create path |
Default position lifetime when the DTO omits minuteEstimatedTime; Infinity β no time-expiry. |
CC_MAX_SIGNAL_GENERATION_SECONDS |
signal-generation guard | Aborts a getSignal that runs longer than this. |
CC_ENABLE_LONG_SIGNAL, CC_ENABLE_SHORT_SIGNAL |
validateCommonSignal (position === "long"/"short" && !flag β throw) |
Hard gate that rejects a whole direction at validation. |
*_EVERYWHERE)These are all read in ClientStrategy (and one in validateCommonSignal) and change when a commit is permitted:
| Key | Consumed by | Effect when true |
|---|---|---|
CC_ENABLE_DCA_EVERYWHERE |
ClientStrategy averageBuy gate + its validate path (!flag && currentPrice >= minEntryPrice β reject for LONG; <= maxEntryPrice for SHORT) |
Lets commitAverageBuy fire when price is merely beyond priceOpen, not only at a new all-time extreme since entry. |
CC_ENABLE_PPPL_EVERYWHERE |
ClientStrategy partial-profit/partial-loss direction gates |
Allows partial profit/loss even when it mixes exit directions. |
CC_ENABLE_TRAILING_EVERYWHERE |
ClientStrategy trailing stop/take absorption gates |
Activates trailing without requiring the absorption condition. |
(validation) CC_ENABLE_LONG/SHORT_SIGNAL |
see Β§40.2 | β |
Default false for the three *_EVERYWHERE (conservative); the DCA/PPPL/trailing recipes in Β§22.5 work under the default rules.
| Key | Consumed by | Effect |
|---|---|---|
CC_GET_CANDLES_RETRY_COUNT, CC_GET_CANDLES_RETRY_DELAY_MS |
candle fetch path | Retry policy for getCandles failures. |
CC_MAX_CANDLES_PER_REQUEST |
candle fetch / cache (5 sites) | Pagination chunk size when a request exceeds it. |
CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR, CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN |
validateCandles |
Anomaly rejection (price β« factor below median) + median-vs-average switch (Β§7.6). |
CC_ENABLE_CANDLE_FETCH_MUTEX |
Candle.ts (spinLock, fetch lock) |
Serializes concurrent identical-candle fetches. |
CC_ENABLE_BACKTEST_PARALLEL_SPIN |
Candle.ts (spinLock) |
Cooperative round-robin yield between parallel backtests after each fetch (skipped if single workload or mutex off). |
CC_ORDER_BOOK_TIME_OFFSET_MINUTES |
order-book window math | Time window/offset for getOrderBook. |
CC_ORDER_BOOK_MAX_DEPTH_LEVELS |
Exchange.getOrderBook / ClientExchange default depth arg |
Default depth when getOrderBook(symbol) omits depth. |
CC_AGGREGATED_TRADES_MAX_MINUTES |
Exchange / ClientExchange (windowMs = CC_β¦ Γ 60000 β 60000) |
Aggregated-trades window size & pagination chunk. |
| Key | Consumed by | Effect |
|---|---|---|
CC_MAX_*_MARKDOWN_ROWS (12 keys) |
the matching *MarkdownService (Β§37) |
FIFO cap on rows retained per markdown report (250 default; Performance 10000). |
CC_WALKER_MARKDOWN_TOP_N |
WalkerMarkdownService |
How many top strategies the walker comparison table shows (10). |
CC_MAX_NOTIFICATIONS, CC_MAX_SIGNALS |
notification store, signal store | FIFO retention caps (also config-validated as positive ints). |
CC_MAX_LOG_LINES |
Log.ts (_entries.slice(-CC_MAX_LOG_LINES) + trim) |
Rolling log buffer size. |
CC_REPORT_SHOW_SIGNAL_NOTE |
the isVisible of the Note column in src/assets/*.columns (backtest/live/breakeven/β¦) |
Toggles the Note column across all report tables without editing columns. |
JSONL report streams (Β§38) are append-only and not subject to the
CC_MAX_*_MARKDOWN_ROWScaps β those bound only the in-memory markdown stores.
COLUMN_CONFIG β report table columnssrc/config/columns.ts exports COLUMN_CONFIG, mapping each report to a column array imported from src/assets/*.columns. There are 14 collections (Walker has two): backtest_columns, heat_columns, live_columns, partial_columns, breakeven_columns, performance_columns, risk_columns, schedule_columns, strategy_columns, sync_columns, highest_profit_columns, max_drawdown_columns, walker_pnl_columns, walker_strategy_columns.
Each entry is a ColumnModel ({ key, label, format(row, index) => string, isVisible() => boolean }) β format builds the cell, isVisible gates the whole column (e.g. the Note column returns GLOBAL_CONFIG.CC_REPORT_SHOW_SIGNAL_NOTE). Override globally with setColumns({ <collection>: [...] }) or per-call via the columns argument to getReport/dump; both paths run ColumnValidationService (Β§39.3) and roll back on failure. Inspect with getColumns() / getDefaultColumns() (ColumnConfig is the exported type). The markdown services iterate visible columns to build the table header + rows (Β§37.3).
π€ For the human-readable narrative, see README.md. MIT Β© tripolskypetr.