Skip to content

๐Ÿงฎ Multi-Symbol Budget

Latest

Choose a tag to compare

@tripolskypetr tripolskypetr released this 25 Oct 18:48
· 35 commits to master since this release
2ed2585

๐Ÿš€ New to backtest-kit? The fastest way to get a real, production-ready setup is to clone the reference implementation โ€” a fully working news-sentiment AI trading system with LLM forecasting, multi-timeframe data, and a documented February 2026 backtest. Start there instead of from scratch.

Budget API

Budget is a shared account for every symbol of a strategy run, written in the State idiom: an instance in the strategy module, no schema, every method resolves the mode, the logical time and the symbol from the execution context.

Budget.enable();
const fund = new Budget({ name: "fund", initialCapital: 10_000 });

// inside getSignal or any lifecycle callback
const free = await fund.getFree();
if (!(await fund.reserve({ id: signalId, amount: cost, note: "entry" }))) {
  return null; // not enough free capital at this logical time: a `reject` row is on record
}
await fund.release({ id: signalId, pnl: pnlCost }); // on close: capital += pnl
await fund.deposit({ asset: "ETH", amount: 2 });   // units of an asset (dividends, staking)
await fund.withdraw({ asset: "ETH", amount: 1 });

The record is keyed by (strategyName, exchangeName, frameName, backtest) plus the budget name, so every symbol of the run reads and writes one journal. The journal holds reserve / release / reject / deposit / withdraw rows with the balance after each row, ordered by (timestamp, symbol, seq); a read at when folds only the rows up to when, so a symbol that is ahead in logical time never leaks its future into a lagging one, and the same run yields the same journal whatever order Node interleaved the symbols in. A reserve exceeding the free balance (or a withdrawal above the held units) is refused: reserve / withdraw return false and leave a reject row; with CC_BUDGET_THROW_ON_REJECT: true the refusal is a BudgetRejectedError instead, for code that wants it to surface as an exception. An overdraft produced by an out-of-order insert is marked on the row and logged, never hidden.

Context-free statics for listeners and dashboards take the context explicitly: Budget._reserve(dto, context), _release, _deposit, _withdraw, _getBalance, _getFree, _getHistory with IBudgetContext { strategyName, exchangeName, frameName, backtest, name, when, symbol?, initialCapital? }; Budget._getNames() lists the budgets constructed in the process. A static call that would create a record without initialCapital throws BudgetMissingError instead of inventing a capital.

Storage follows the other persisted entities: BudgetBacktest (in-memory by default) and BudgetLive (persisted by default) with useLocal / usePersist / useDummy / useBudgetAdapter, PersistBudgetAdapter in the core, adapters in @backtest-kit/mongo, @backtest-kit/pg, @backtest-kit/minio and the docker satellites, Setup.ts in the CLI.

Reporting: every journal row is emitted on budgetSubject (listenBudget / listenBudgetOnce, BudgetContract), written to ./dump/report/budget.jsonl by Report.enable() and accumulated by Markdown.enable() for the # Budget Report: markdown (Budget.getData / getReport / dump, BudgetStatisticsModel, budget_columns). The UI gets /api/v1/view/budget_balance and budget_history (the Balance page), /api/v1/markdown_view/budget_data and budget_report (the Budget tab of the markdown report), with mocks.

Lockstep: parallel backtests on one logical clock

A shared budget needs the symbols to write in time order, so parallel backtests now advance together under CC_BACKTEST_LOCKSTEP (default true; the test config turns it off). Every timeframe iteration runs under a process-wide FIFO mutex behind a time barrier, and a candle replay hands the lock off on the finest timeframe grid among the running backtests โ€” from its pings, and before any exchange-forced event or light look inside a watch span โ€” so a strategy blocked in getSignal or a ping handler (an LLM call) parks its peers at the same when, and the symbols stay at most one timeframe apart even while one of them replays a long position. Peers launched together through Backtest.background (the Launcher loop) are waited for before the first iteration; Backtest.run joins once started. Ticks of the same timeframe run in launch order, so with a shared budget the Launcher symbol list is the priority at the money when several symbols signal in the same hour. The price is parallelism: the group moves at the pace of its slowest strategy, consumer or progress listener โ€” a slowdown, never a hang โ€” so turn the flag off for independent symbols and keep for await bodies and listeners cheap. The peer wait logs a warning (logger + console) if it ever gives up on peers that make no progress; in a healthy run it does not fire.

Frame cadences: entryInterval and watchInterval

IFrameSchema has two cadences. entryInterval is the tick: how often getSignal runs and idle pings fire (the lockstep hand-off follows it). watchInterval is the stride of the candle loops that watch an open or scheduled signal. interval stays as an alias of entryInterval; a schema that sets both throws. watchInterval defaults to "1m", so existing frames keep their per-minute replay. The process-wide CC_BACKTEST_STEP_MINUTES is removed: the stride is a property of the frame. A daily-bar source polyfilled to minutes (the Yahoo template) sets watchInterval: "1d" and gets one look per real bar.

Candles stay 1m. The strategy looks at a position once per watch span, at its last candle: the pings and whatever it decides in them. Everything else happens on the exact candle inside the span, identically to a 1m watch:

  • exchange-forced events โ€” stop-loss / liquidation touch, take-profit by VWAP (the 5-candle window computed on every minute), minuteEstimatedTime expiry, scheduled activation (with the touch minute as pendingAt), stop reject and wait timeout, frame end; the first event in time wins, not the span-end priority;
  • the strategy's protective events โ€” a partial profit / loss level crossed or the breakeven threshold reached gets its callbacks on that candle through a light look without a ping;
  • a commit issued between looks (the action itself was always immediate) has its commit event stamped on the next candle, like a live tick a minute later;
  • the peak and the drawdown of the position are recorded candle by candle and reported once at the next look;
  • a close refused by the order gate โ€” the user close as well as a stop / liquidation / take-profit / expiry the exchange forced โ€” is retried on the next candle, across span ends and replay-window edges, with the refused candle still observed (ping, loss levels, commit drain) like a live tick.

The spans are on the time axis, anchored at the signal (pendingAt / scheduledAt): a gap in the 1m feed shortens a span's candle count and leaves its boundary in place, and the grid survives replay windows and the hand-back after a scheduled activation. Replay windows of Infinity positions hold a whole number of spans (the largest multiple of the step within CC_MAX_CANDLES_PER_REQUEST, paged by the exchange layer).

Ping listeners on a grid

listenActivePingInterval, listenSchedulePingInterval, listenIdlePingInterval (fn, { interval }) thin the ping channels to a time grid: active / schedule anchored at the signal's open with the first ping skipped, idle anchored at the first idle ping.

Behaviour changes to note

  • onHighestProfit / onMaxDrawdown are emitted once per look, after the ping and after the breakeven / partial callbacks of that look, carrying the price and the minute of the recorded extreme. On a 1m watch they used to come between the ping and the breakeven check.
  • A rejected close is retried on every following candle (as in live); with CC_ORDER_CLOSE_RETRY_ATTEMPTS: 0 and a gate that keeps refusing, that is one gate call per minute. This now holds for the time-expiry close of a finite hold too: that candle is the last one of the replay window the orchestrator fetched for the hold, and a refusal there used to get a second attempt on the same candle and then kill the backtest. The replay hands the position back "active" instead and the orchestrator continues in whole watch spans until the close is accepted, as it does for Infinity holds; on the frame end a refusal stays fatal, there is no next candle. test/e2e/refuse_close.test.mjs attacks every forced close with the gate on a 1m, 15m, 1h and 4h watch.
  • A transient refusal of the broker gate on the ACTIVATION of a resting entry (onOrderOpenCommit type "active", fired by the price touch or by activateScheduled) no longer cancels the scheduled signal. The gate fires exactly when a real resting order is likely to have filled, so the cancel made the adapter cancel a filled order and left the position on the exchange without the engine. The activation is now retried on the next tick / candle with the same id and attempt + 1 up to CC_ORDER_OPEN_RETRY_ATTEMPTS (the new retryActivateCount in the strategy snapshot carries the reconcile bit across a restart); a terminal OrderRejectedError still cancels at once, exhaustion cancels and signals the fatal exit, and CC_ORDER_OPEN_RETRY_ATTEMPTS: 0 keeps the old cancel-on-refusal rule. test/e2e/refuse_activate.test.mjs covers it on every watch and through the live tick.
  • The open gate carries a scheduled flag (OrderOpenContract, BrokerOrderOpenPayload, mirrored on the fill and reject channels): true when the row is a resting entry โ€” type "schedule" is its placement, type "active" its activation โ€” and both arrive under the same signalId. On an activation the adapter must check whether the resting order it placed under that clientOrderId has already resolved before sending anything, whatever attempt says: the activation counts its own attempts, and the placement is a prior order under this id by definition. The flag also marks a retried market entry that the price overtook: it is re-submitted as a resting order at its original price (same id) and later activates with scheduled: true.
  • listenError, listenExit and listenValidation catch a throwing listener and log it: those channels are emitted fire-and-forget and a throw used to become an unhandled rejection.