Skip to main content

Documentation Index

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

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

Every tick the engine evaluates your strategy and returns one of several states as a discriminated union. The action field on the result object is the discriminator — use it to safely narrow the TypeScript type and access state-specific fields. Understanding the lifecycle is key to writing correct event handlers, callbacks, and monitoring logic.

The Four Core States

idle ──▶ opened ──▶ active ──▶ closed


        scheduled ──▶ (opened) ──▶ active ──▶ closed
                        └──▶ cancelled
1
Idle
2
No signal is active. The engine calls getSignal if the strategy’s interval has elapsed since the last call. If getSignal returns null, the state remains idle.
3
interface IStrategyTickResultIdle {
  action: "idle";
  signal: null;
  strategyName: StrategyName;
  exchangeName: ExchangeName;
  frameName: FrameName;
  symbol: string;
  currentPrice: number;
  backtest: boolean;
  createdAt: number;
}
4
Opened
5
getSignal returned a valid ISignalDto without a priceOpen field (market entry). The signal passed validation and risk checks, and a position is immediately opened at the current VWAP price.
6
interface IStrategyTickResultOpened {
  action: "opened";
  signal: IPublicSignalRow;   // full signal with id, position, prices
  strategyName: StrategyName;
  exchangeName: ExchangeName;
  frameName: FrameName;
  symbol: string;
  currentPrice: number;
  backtest: boolean;
  createdAt: number;
}
7
The IPublicSignalRow on signal includes priceOpen, priceTakeProfit, priceStopLoss, position, minuteEstimatedTime, pendingAt, scheduledAt, id, and derived fields like pnl, peakProfit, maxDrawdown, partialExecuted, totalEntries, and totalPartials.
8
Scheduled (Limit Entry)
9
When getSignal returns an ISignalDto with a priceOpen field, the signal becomes a scheduled limit order that waits for price to reach priceOpen before activating. While waiting, each tick emits IStrategyTickResultScheduled (first creation) or IStrategyTickResultWaiting (subsequent monitoring ticks). When price reaches priceOpen, the signal transitions to openedactive.
10
Active
11
The signal is open and the engine is monitoring VWAP against TP and SL on every tick. Active ticks emit unrealized PNL, percentage progress toward TP and SL, and the current signal snapshot.
12
interface IStrategyTickResultActive {
  action: "active";
  signal: IPublicSignalRow;
  currentPrice: number;
  percentTp: number;    // 0–100, progress toward take-profit
  percentSl: number;    // 0–100, progress toward stop-loss
  pnl: IStrategyPnL;   // unrealized PNL with fees and slippage
  strategyName: StrategyName;
  exchangeName: ExchangeName;
  frameName: FrameName;
  symbol: string;
  backtest: boolean;
  createdAt: number;
  _backtestLastTimestamp: number;
}
13
Closed
14
The signal exited. The closeReason discriminates between the three automatic exits and one manual exit.
15
type StrategyCloseReason = "time_expired" | "take_profit" | "stop_loss" | "closed";

interface IStrategyTickResultClosed {
  action: "closed";
  signal: IPublicSignalRow;
  currentPrice: number;
  closeReason: StrategyCloseReason;
  closeTimestamp: number;
  pnl: IStrategyPnL;
  strategyName: StrategyName;
  exchangeName: ExchangeName;
  frameName: FrameName;
  symbol: string;
  backtest: boolean;
  closeId?: string;         // present for manual "closed" reason
  createdAt: number;
}

Cancelled State

When a scheduled signal’s minuteEstimatedTime elapses before priceOpen is reached, or when commitCancelScheduled() is called manually, the signal transitions to cancelled with a reason field.
type StrategyCancelReason = "timeout" | "price_reject" | "user";

interface IStrategyTickResultCancelled {
  action: "cancelled";
  signal: IPublicSignalRow;
  currentPrice: number;
  closeTimestamp: number;
  reason: StrategyCancelReason;
  cancelId?: string;
  // ...
}

IStrategyPnL Shape

The pnl field on active and closed results is always an IStrategyPnL object:
interface IStrategyPnL {
  pnlPercentage: number;  // e.g. 1.5 for +1.5%, -2.3 for -2.3%
  priceOpen: number;      // entry price adjusted with slippage + fees
  priceClose: number;     // exit price adjusted with slippage + fees
  pnlCost: number;        // absolute PNL in USD: pnlPercentage / 100 × pnlEntries
  pnlEntries: number;     // total invested capital in USD
}
PNL accounting: slippage is 0.1% per transaction (configurable via CC_PERCENT_SLIPPAGE) and fees are 0.1% per transaction (configurable via CC_PERCENT_FEE). Both apply on entry and exit.

Signal Validation

Before a signal can open, VALIDATE_SIGNAL_FN checks:
  1. All prices (priceOpen, priceTakeProfit, priceStopLoss) must be finite positive numbers
  2. For a long position: priceTakeProfit > priceOpen and priceStopLoss < priceOpen
  3. For a short position: priceTakeProfit < priceOpen and priceStopLoss > priceOpen
  4. TP distance must be at least CC_MIN_TAKEPROFIT_DISTANCE_PERCENT (default 0.5%)
  5. SL distance must be between CC_MIN_STOPLOSS_DISTANCE_PERCENT (0.5%) and CC_MAX_STOPLOSS_DISTANCE_PERCENT (20%)
  6. minuteEstimatedTime must be a positive number (or Infinity)
Invalid signals are rejected with trycatch returning null — the engine logs the error and skips to the next tick without crashing.

Interval Throttling

The interval field on IStrategySchema enforces a minimum gap between getSignal calls. The engine tracks _lastSignalTimestamp and skips getSignal if the elapsed time is less than the configured interval:
const INTERVAL_MINUTES: Record<SignalInterval, number> = {
  "1m": 1, "3m": 3, "5m": 5, "15m": 15, "30m": 30, "1h": 60
};

// In ClientStrategy.GET_SIGNAL_FN:
if (currentTime - self._lastSignalTimestamp < intervalMs) {
  return null;  // throttled — skip this tick
}
self._lastSignalTimestamp = currentTime;
This prevents signal spam on high-frequency frames (interval: '1m') when the strategy is designed to fire at most hourly.

Scheduled Signals (Brief)

Return a priceOpen in your signal DTO to create a scheduled (limit order) signal:
return {
  id: uuid(),
  position: 'long',
  priceOpen: currentPrice * 0.99,      // activate 1% below current price
  priceTakeProfit: currentPrice * 1.03,
  priceStopLoss:   currentPrice * 0.97,
  minuteEstimatedTime: 60,
};
The signal waits at the scheduled state and activates when VWAP reaches priceOpen. If the timeout elapses first, the signal is cancelled with reason: "timeout".

Handling Signal Events

import { listenSignalBacktest, listenSignalLive } from 'backtest-kit';

// Backtest events (both modes)
listenSignalBacktest((event) => {
  switch (event.action) {
    case 'opened':
      console.log(`Position opened: ${event.signal.position} @ ${event.currentPrice}`);
      break;
    case 'active':
      console.log(`Active — TP: ${event.percentTp.toFixed(1)}%, SL: ${event.percentSl.toFixed(1)}%`);
      break;
    case 'closed':
      console.log(`Closed (${event.closeReason}): PNL ${event.pnl.pnlPercentage.toFixed(2)}%`);
      break;
    case 'cancelled':
      console.log(`Cancelled: ${event.reason}`);
      break;
  }
});

// Live-only listener (same shape)
listenSignalLive((event) => {
  if (event.action === 'closed') {
    // Send real notification
  }
});
listenSignalOnce is available for one-shot handlers with an optional filter predicate:
import { listenSignalOnce } from 'backtest-kit';

listenSignalOnce(
  (event) => event.action === 'closed' && event.closeReason === 'stop_loss',
  (event) => console.log('First stop-loss hit:', event.pnl.pnlPercentage)
);

Build docs developers (and LLMs) love