Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/backtest-kit/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/. Every strategy is a self-contained TypeScript module that calls addStrategySchema once at module load time and optionally attaches event listeners for active-position management. The project ships with a fully worked example — jan_2026_strategy — that demonstrates the complete pattern from signal lookup through position sizing and automated exit rules.

Strategy registration

A strategy is registered by calling addStrategySchema with a strategyName string and an async getSignal function:
import { addStrategySchema } from "backtest-kit";

addStrategySchema({
  strategyName: "jan_2026_strategy",
  getSignal: async (symbol, when, currentPrice) => {
    // Return a SignalResult, or null to skip this bar
  },
});
getSignal signature
(
  symbol: string,
  when: Date,
  currentPrice: number
) => Promise<SignalResult | null>
ParameterTypeDescription
symbolstringThe trading pair being evaluated (e.g. "TRXUSDT")
whenDateThe current bar’s aligned timestamp
currentPricenumberThe exchange mid-price at this bar
Return null to pass on the bar with no action. Return a SignalResult object to open a position.

The example strategy walkthrough

The full source of src/logic/strategy/jan_2026.strategy.ts walks through every stage of a realistic signal-driven strategy:

1. Loading signals from assets/entry.jsonl

Signals are pre-baked into a JSONL file and loaded once at module initialisation using readFileSync. Each line is parsed into a SignalEntryModel:
import { readFileSync } from "fs";
import { SignalEntryModel } from "../../model/SignalEntry.model";

const SIGNALS: SignalEntryModel[] = readFileSync(
  "./assets/entry.jsonl",
  "utf-8",
)
  .split("\n")
  .filter(Boolean)
  .map((line) => JSON.parse(line));

2. The getActiveSignal() helper

getActiveSignal finds the signal whose publishedAt timestamp, aligned to the nearest minute, matches the when parameter for the given symbol:
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 floors a Date to the nearest interval boundary — here "1m" — so that slight sub-second offsets in the raw timestamp never prevent a match.

3. Entry range check using getClosePrice

Once an active signal is found the strategy reads the current 1-minute close price and validates it falls inside the signal’s entry window:
const close_1m = await getClosePrice(symbol, "1m");

if (close_1m < signal.entry.from || close_1m > signal.entry.to) {
  return null;
}
If the price has drifted outside signal.entry.from … signal.entry.to the bar is skipped.

4. 4h candle range position logic

The strategy fetches the last two completed 4-hour candles and computes a range midpoint to determine whether to go long or short:
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";
Price above the range midpoint favours a short; below favours a long.

5. Returning Position.moonbag()

The strategy builds its return value using the Position.moonbag() helper, which calculates a default moonbag sizing and attaches the hard stop-loss distance:
const HARD_STOP = 1.0; // 1 % hard stop-loss

return {
  position,
  ...Position.moonbag({
    position,
    currentPrice,
    percentStopLoss: HARD_STOP,
  }),
  minuteEstimatedTime: 24 * 60, // estimate: hold up to 24 hours
  note: signal.note,
};

Event listeners

After addStrategySchema, the module registers active-position event listeners using listenActivePing. Both handlers run on every “ping” tick while a position is open.

Trailing-take listener

Closes the position when the price has retreated TRAILING_TAKE percent from its peak profit:
const TRAILING_TAKE = 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",
    ),
  });
});

Peak-staleness listener

Closes the position when it has been profitable for longer than PEAK_STALENESS_SINCE_MINUTES minutes without moving higher:
const PEAK_STALENESS_SINCE_PROFIT  = 1.0;  // minimum peak PnL %
const PEAK_STALENESS_SINCE_MINUTES = 240;  // 4 hours

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",
    ),
  });
});

Error listener

A listenError handler logs any unhandled errors that occur during strategy execution:
import { errorData, getErrorMessage, str } from "functools-kit";

listenError((error) => {
  console.log(error);
  Log.debug("error", {
    error:   errorData(error),
    message: getErrorMessage(error),
  });
});

Adding your own strategy

1

Create a new strategy file

Add a new file under src/logic/strategy/, following the same pattern as jan_2026.strategy.ts:
touch src/logic/strategy/my_strategy.strategy.ts
2

Register with addStrategySchema

In your new file, import addStrategySchema from backtest-kit and register your strategy:
import { addStrategySchema } from "backtest-kit";

addStrategySchema({
  strategyName: "my_strategy",
  getSignal: async (symbol, when, currentPrice) => {
    // your logic here
    return null;
  },
});
3

Add the strategy name to StrategyName.ts

Open src/enum/StrategyName.ts and add a new enum member:
export enum StrategyName {
  Jan2026Strategy = "jan_2026_strategy",
  MyStrategy      = "my_strategy",        // ← add this
}
4

Import in src/logic/index.ts

Register the module by adding an import to src/logic/index.ts:
import "./frame/jan_2026.frame";
import "./strategy/jan_2026.strategy";
import "./strategy/my_strategy.strategy";   // ← add this
The import has no named exports — the side-effect of calling addStrategySchema at module load is all that is needed.

The exchange module

modules/backtest.module.ts registers a CCXT/Binance adapter under the name "ccxt-exchange" using addExchangeSchema. The adapter implements four methods that the backtest-kit runtime calls during both historical replay and live trading:
MethodDescription
getCandles(symbol, interval, since, limit)Fetches OHLCV data from Binance via exchange.fetchOHLCV
getOrderBook(symbol, depth, from, to, backtest)Fetches the live order book; throws in backtest mode
formatPrice(symbol, price)Rounds a price to the market’s tick size using roundTicks
formatQuantity(symbol, quantity)Rounds a quantity to the market’s step size using roundTicks
import { addExchangeSchema, roundTicks, setConfig } from "backtest-kit";
import { singleshot } from "functools-kit";
import ccxt from "ccxt";

setConfig({ CC_MAX_STOPLOSS_DISTANCE_PERCENT: 100 });

const getExchange = singleshot(async () => {
  const exchange = new ccxt.binance({
    options: {
      defaultType: "spot",
      adjustForTimeDifference: true,
      recvWindow: 60000,
    },
    enableRateLimit: true,
  });
  await exchange.loadMarkets();
  return exchange;
});

addExchangeSchema({
  exchangeName: "ccxt-exchange",
  getCandles: async (symbol, interval, since, limit) => {
    const exchange = await getExchange();
    const candles = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
    return candles.map(([timestamp, open, high, low, close, volume]) => ({
      timestamp, open, high, low, close, volume,
    }));
  },
  formatPrice: async (symbol, price) => {
    const exchange = await getExchange();
    const market = exchange.market(symbol);
    const tickSize = market.limits?.price?.min || market.precision?.price;
    if (tickSize !== undefined) {
      return roundTicks(price, tickSize);
    }
    return exchange.priceToPrecision(symbol, price);
  },
  // ... getOrderBook, formatQuantity
});
Signal file formatassets/entry.jsonl contains one JSON object per line. Each object must match the SignalEntryModel interface:
{
  "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 …"
}
publishedAt is an ISO-8601 UTC string. direction is informational only — the actual long/short decision is made by the 4h range logic inside getSignal.
Use alignToInterval(date, interval) from backtest-kit whenever you need to match a raw external timestamp against the when parameter passed to getSignal. The when value is always pre-aligned by the runner, so aligning your own timestamps to the same boundary is the simplest way to guarantee an exact match without floating-point or millisecond-offset surprises.

Build docs developers (and LLMs) love