Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/backtest-kit/backtest-kit-docs/llms.txt

Use this file to discover all available pages before exploring further.

Every signal in Backtest Kit moves through a strict, deterministic state machine. Rather than relying on runtime null checks or optional fields, the framework encodes each lifecycle phase as a TypeScript discriminated union — invalid states are structurally unrepresentable at compile time, so an entire class of state corruption bugs simply cannot exist.

State Diagram

idle → scheduled → opened → active → closed
                         ↘ cancelled
Transitions flow in one direction only. A signal cannot move backward through the lifecycle, and no state can be skipped. Once a signal reaches closed or cancelled, it is terminal.

States Explained

idle

No active signal exists for this symbol. The strategy is running getSignal on each tick, monitoring market conditions and waiting for a setup to form.

scheduled

A signal has been created and validated. The framework is waiting for price to reach the specified entry level before establishing a position. The signal holds an expiry timeout governed by CC_SCHEDULE_AWAIT_MINUTES.

opened

The entry price has been reached and the position has been established. The signal transitions to opened for exactly one tick before becoming active. Strategy callbacks like onOpen fire at this point.

active

The position is live and being monitored on every tick. The framework evaluates VWAP against the take-profit and stop-loss levels. Trailing stops are updated here as price moves favorably.

closed

The position has been exited. The closeReason field identifies how it ended: take_profit, stop_loss, manual, or time_expired. All PnL calculations are finalized and the closed signal is emitted to reporting.

cancelled

The signal was rejected before it could be executed — either by risk validation, a conflicting signal, user intervention, or a timeout. No position was ever opened.

TypeScript Discriminated Union

The type safety is enforced through a discriminated union on the action field. Each result variant carries only the fields that are meaningful in that state — there are no optional properties anywhere in the type hierarchy.
type IStrategyTickResult =
  | IStrategyTickResultIdle
  | IStrategyTickResultOpened
  | IStrategyTickResultActive
  | IStrategyTickResultClosed;
Each member of the union is fully required. For example, IStrategyTickResultClosed includes closeReason, pnl, and closeTimestamp — fields that make no sense on IStrategyTickResultIdle and therefore don’t exist on it. The TypeScript compiler enforces this: you cannot access result.closeReason without first narrowing to the closed variant. Narrowing in practice looks like this:
listenSignalLive((result) => {
  if (result.action === 'closed') {
    // TypeScript knows result.closeReason, result.pnl, result.closeTimestamp exist here
    console.log(`Closed: ${result.closeReason} | PnL: ${result.pnl.toFixed(2)}%`);
  }

  if (result.action === 'active') {
    // result.closeReason does not exist — compile-time error if you try to access it
    console.log(`Monitoring position for ${result.symbol}`);
  }
});
Accessing closed-position data like pnl or closeReason on an active result is a compile-time error, not a runtime one. The TypeScript structural type system makes it impossible to express — the property simply doesn’t exist on the type.

Signal Validation

Before a signal is accepted into the lifecycle at all, it passes through VALIDATE_SIGNAL_FN. This runs synchronously and throws with a descriptive error if any constraint is violated. Invalid signals are caught by a trycatch wrapper and silently rejected — the strategy continues running without crashing. Validation enforces the following invariants:
CheckRule
Positive pricespriceOpen, priceTakeProfit, and priceStopLoss must all be greater than zero
Long TP directionpriceTakeProfit > priceOpen
Long SL directionpriceStopLoss < priceOpen
Short TP directionpriceTakeProfit < priceOpen
Short SL directionpriceStopLoss > priceOpen
Time parametersminuteEstimatedTime and timestamp must be positive
Interval throttlegetSignal cannot be called more frequently than the strategy’s configured interval
The interval throttle is enforced at the ClientStrategy level by comparing the current tick timestamp against _lastSignalTimestamp. If less than intervalMs has elapsed, getSignal is skipped entirely and null is returned.
const INTERVAL_MINUTES: Record<SignalInterval, number> = {
  "1m": 1, "3m": 3, "5m": 5, "15m": 15, "30m": 30, "1h": 60
};

// Enforces minimum interval between getSignal calls
if (currentTime - self._lastSignalTimestamp < intervalMs) {
  return null;
}
self._lastSignalTimestamp = currentTime;
Custom risk validations registered via addRiskSchema run after structural validation passes. Risk validations can additionally check reward/risk ratio, portfolio exposure, time-of-day windows, and any other portfolio-level constraint.

Signal Cancellation and Timeout

A signal in the scheduled state can be cancelled before it ever becomes opened. The cancellation causes are:
  • Timeout — the CC_SCHEDULE_AWAIT_MINUTES window expires before price reaches the entry level. The default timeout is 120 minutes.
  • Risk rejection — a risk validation function throws during the pending-signal evaluation cycle.
  • Conflicting signal — a new getSignal call returns a different signal ID while another is still scheduled. The previous signal is cancelled and the new one takes its place.
  • User intervention — the live bot is stopped and restarted without the signal being recoverable from persisted state.
Cancellation is final. A cancelled signal is never retried automatically; the strategy must generate a new signal on the next valid tick.

Build docs developers (and LLMs) love