Skip to main content

Documentation Index

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

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

Strategy logic lives in src/logic/strategy/ and is registered with addStrategySchema() from backtest-kit. The persistence layer is entirely transparent to strategy code — strategies read and write state through the same backtest-kit API regardless of whether MinIO, Redis, or the default file system is used as the backing store. The only contract the strategy must satisfy is the addStrategySchema interface.
1

Define the strategy function

Implement a getSignal function that receives (symbol, when, currentPrice) and returns a position object or null.
2

Register with addStrategySchema

Call addStrategySchema({ strategyName, getSignal }) at module load time. The strategyName string must match the value in your StrategyName enum.
3

Add lifecycle listeners

Attach listenActivePing handlers to manage open positions — trailing takes, peak staleness checks, or any time-driven exit logic. Attach a listenError handler for error observability.
4

Export via src/logic/index.ts

Import the strategy file (and its frame file) from src/logic/index.ts. This index is the single entry point that the runners load at startup.

Strategy registration

addStrategySchema accepts an object with two required fields: strategyName (a plain string) and getSignal (an async function). The function is called on every candle tick for every symbol the runner is watching.
// src/logic/strategy/jan_2026.strategy.ts
import {
  addStrategySchema,
  listenError,
  listenActivePing,
  Log,
  alignToInterval,
  getClosePrice,
  Position,
  commitClosePending,
  getPositionHighestProfitDistancePnlPercentage,
  getPositionHighestPnlPercentage,
  getPositionPnlPercent,
  getCandles,
  getPositionHighestProfitMinutes,
} from "backtest-kit";
import { errorData, getErrorMessage, str } from "functools-kit";
import { readFileSync } from "fs";
import { SignalEntryModel } from "../../model/SignalEntry.model";

addStrategySchema({
  strategyName: "jan_2026_strategy",
  getSignal: async (symbol, when, currentPrice) => {
    // ...
  },
});

Strategy naming

The strategyName string in addStrategySchema must exactly match the enum value used when starting a runner. The StrategyName enum is the single source of truth:
// src/enum/StrategyName.ts
export enum StrategyName {
    Jan2026Strategy = "jan_2026_strategy",
}
The string "jan_2026_strategy" appears in both addStrategySchema and in Backtest.background() / Live.background() calls. A mismatch causes backtest-kit to silently skip the strategy.

getSignal

getSignal is the core of every strategy. It is called with three arguments on each 1-minute candle close:
ParameterTypeDescription
symbolstringThe trading pair being evaluated (e.g., "TRXUSDT")
whenDateThe candle close timestamp, aligned to the frame interval
currentPricenumberThe close price of the current candle
Return a position descriptor to open a trade, or null to do nothing. The position descriptor must include at least position ("long" or "short"), percentStopLoss, minuteEstimatedTime, and optionally note.
// src/logic/strategy/jan_2026.strategy.ts (getSignal body)
getSignal: async (symbol, when, currentPrice) => {

  console.log(when)

  const signal = getActiveSignal(symbol, when);

  if (!signal) {
    return null;
  }

  const close_1m = await getClosePrice(symbol, "1m");

  if (close_1m < signal.entry.from || close_1m > signal.entry.to) {
    return null;
  }

  const [close_4h_prev, close_4h_cur] = await getCandles(symbol, "4h", 2);

  const range_high = Math.max(close_4h_prev.high, close_4h_cur.high);
  const range_low = Math.max(close_4h_prev.low, close_4h_cur.low);
  const range_middle = range_high + range_low / 2;

  const position = close_1m > range_middle ? "short" : "long";

  console.log({ position, signal })

  return {
    position,
    ...Position.moonbag({
      position,
      currentPrice,
      percentStopLoss: HARD_STOP,
    }),
    minuteEstimatedTime: 24 * 60,
    note: signal.note,
  };
},
Position.moonbag() is a helper from backtest-kit that calculates percentStopLoss and percentTakeProfit levels from a simple percentage-based stop-loss distance. It returns the correct levels for both long and short positions so you don’t need to invert the math manually.

Signal entry files

Trade signals are loaded from assets/entry.jsonl — a newline-delimited JSON file where each line is one SignalEntryModel object. This pattern decouples signal generation (e.g., Telegram alerts, LLM analysis) from strategy execution.
// SignalEntryModel interface
interface SignalEntryModel {
  publishedAt: string;        // ISO 8601 timestamp of signal publication
  symbol: string;             // e.g., "TRXUSDT"
  direction: "long" | "short";
  entry: { from: number; to: number };  // price entry zone
  targets: number[];          // profit target levels
  stoploss: number;           // hard stop-loss price
  note: string;               // human-readable signal description
}
Example entry from assets/entry.jsonl:
{
  "publishedAt": "2026-01-06T10:16:16Z",
  "symbol": "TRXUSDT",
  "direction": "short",
  "entry": { "from": 0.2898, "to": 0.293 },
  "targets": [0.2875, 0.2864, 0.2838, 0.2809, 0.2765],
  "stoploss": 0.3027,
  "note": "СИГНАЛ #TRX/USDT\n\n🔑 Откройте ШОРТ в зоне $0.2898 - $0.293 с кредитным плечом X25."
}
SIGNALS is loaded synchronously at module import time with readFileSync, then each line is parsed as JSON:
const SIGNALS: SignalEntryModel[] = readFileSync(
  "./assets/entry.jsonl",
  "utf-8",
)
  .split("\n")
  .filter(Boolean)
  .map((line) => JSON.parse(line));
getActiveSignal matches signals by symbol and by minute-aligned publishedAt timestamp:
function getActiveSignal(symbol: string, when: Date): SignalEntryModel | null {
  const now = when.getTime();
  const match = SIGNALS.find(
    (s) => {
      if (s.symbol !== symbol) {
        return false;
      }
      const publishedAt = alignToInterval(new Date(s.publishedAt), "1m");
      return publishedAt.getTime() === now;
    }
  );
  return match ?? null;
}
alignToInterval snaps the publishedAt timestamp to the nearest 1-minute boundary, matching the when argument that backtest-kit supplies to getSignal. This ensures a signal fires on exactly the candle corresponding to when it was published.

listenActivePing

listenActivePing registers a callback that fires on every candle tick for each currently open position. Multiple listeners can be registered — they run sequentially in registration order. Each listener receives { symbol, data } where data is the current position context. The strategy registers two independent ping listeners: Listener 1 — Trailing take-profit Closes the position when the price has fallen back from the peak profit by more than TRAILING_TAKE percent:
const TRAILING_TAKE = 1.0;
const HARD_STOP = 1.0;

listenActivePing(async ({ symbol, data }) => {
  const peakProfitDistance = await getPositionHighestProfitDistancePnlPercentage(symbol);
  const currentProfit = await getPositionPnlPercent(symbol);
  if (currentProfit < 0) {
    return;
  }
  if (peakProfitDistance < TRAILING_TAKE) {
    return;
  }
  Log.info("position closed due to the trailing take", {
    symbol,
    data,
  });
  await commitClosePending(symbol, {
    id: "unknown",
    note: str.newline(
      "# Позиция закрыта по trailing take",
    ),
  });
});
Listener 2 — Peak staleness Closes the position when the peak profit was reached long ago but the position has since stalled:
const PEAK_STALENESS_SINCE_PROFIT = 1.0;
const PEAK_STALENESS_SINCE_MINUTES = 240;

listenActivePing(async ({ symbol, data }) => {
  const peakProfitCost = await getPositionHighestPnlPercentage(symbol);
  const peakProfitMinutes = await getPositionHighestProfitMinutes(symbol);
  if (peakProfitCost < PEAK_STALENESS_SINCE_PROFIT) {
    return;
  }
  if (peakProfitMinutes < PEAK_STALENESS_SINCE_MINUTES) {
    return;
  }
  Log.info("position closed due to the peak staleness", {
    symbol,
    data,
  });
  await commitClosePending(symbol, {
    id: "unknown",
    note: str.newline(
      "# Позиция закрыта по peak staleness",
    ),
  });
});
Key helper functions used in ping listeners:
FunctionDescription
getPositionPnlPercent(symbol)Current unrealised PnL as a percentage
getPositionHighestPnlPercentage(symbol)Highest PnL percentage ever reached by this position
getPositionHighestProfitDistancePnlPercentage(symbol)Distance from the peak profit back to the current price, as a percentage
getPositionHighestProfitMinutes(symbol)Minutes elapsed since the position last reached its highest PnL
commitClosePending(symbol, note)Schedules the position for close on the next tick

listenError

Attach a global error listener to capture any unhandled errors thrown inside strategy callbacks. This is required for production observability:
listenError((error) => {
  console.log(error);
  Log.debug("error", {
    error: errorData(error),
    message: getErrorMessage(error),
  });
});
errorData and getErrorMessage are utilities from functools-kit that serialise error objects to plain data for structured logging.

Exporting via src/logic/index.ts

All frame and strategy files must be imported from src/logic/index.ts. This single file is the entry point that the runner loads via the --ui flag:
// src/logic/index.ts
import "./frame/jan_2026.frame";

import "./strategy/jan_2026.strategy";
Importing a frame file registers the frame schema with backtest-kit. Importing a strategy file registers the strategy schema and all its ping and error listeners. The order of imports matters — frames must be registered before strategies that reference them.

Build docs developers (and LLMs) love