Skip to main content

Documentation Index

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

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

Every trading strategy in backtest-kit-redis-postgres-pgpool-docker is a self-contained TypeScript module that registers its logic through two schema functions — addStrategySchema and addFrameSchema — and is compiled into a single CommonJS bundle that the CLI can load at runtime. The source lives in src/logic/, strategies and frames are kept in dedicated sub-folders, and src/logic/index.ts acts as the single entrypoint that imports them both.

Project Layout

src/
  logic/
    index.ts                          ← entrypoint: imports frames then strategies
    frame/
      jan_2026.frame.ts               ← addFrameSchema call
    strategy/
      jan_2026.strategy.ts            ← addStrategySchema call
  enum/
    ExchangeName.ts
    FrameName.ts
    StrategyName.ts
  model/
    SignalEntry.model.ts
assets/
  entry.jsonl                         ← signal entry data (one JSON object per line)
src/logic/index.ts simply side-effects both modules in the correct order — frames before strategies:
import "./frame/jan_2026.frame";
import "./strategy/jan_2026.strategy";

Enum Registration

All three enums act as type-safe string constants that tie the strategy, frame, and exchange together. Add a new entry to each enum whenever you create a new strategy or frame.
// src/enum/ExchangeName.ts
export enum ExchangeName {
  CCXT = "ccxt-exchange",
}

// src/enum/FrameName.ts
export enum FrameName {
  Jan2026Frame = "jan_2026_frame",
}

// src/enum/StrategyName.ts
export enum StrategyName {
  Jan2026Strategy = "jan_2026_strategy",
}
The string values — "ccxt-exchange", "jan_2026_frame", "jan_2026_strategy" — must exactly match the exchangeName, frameName, and strategyName strings passed to the respective add*Schema calls and to Backtest.background / Live.background in src/main/.

Registering a Frame with addFrameSchema

A frame defines the time window and candle resolution for a backtest run. It has no signal logic — it is pure configuration that tells the backtest engine which candles to replay and at what interval.
// src/logic/frame/jan_2026.frame.ts
import { addFrameSchema } from "backtest-kit";
import { FrameName } from "../../enum/FrameName";

addFrameSchema({
  frameName: FrameName.Jan2026Frame,   // "jan_2026_frame"
  interval: "1m",                      // candle resolution used during replay
  startDate: new Date("2026-01-01T00:00:00Z"),
  endDate:   new Date("2026-01-31T23:59:59Z"),
  note: "January 2026",
});

frameName

Must match a value in the FrameName enum and the frameName argument passed to Backtest.background.

interval

The primary candle resolution the backtest engine ticks at. Common values: "1m", "5m", "1h", "4h", "1d".

startDate / endDate

Inclusive date range that the backtest replays. Use ISO-8601 UTC strings to avoid timezone surprises.

note

Human-readable label shown in logs and the web UI.

Registering a Strategy with addStrategySchema

addStrategySchema takes a strategyName string and an async getSignal function. The engine calls getSignal once per candle tick for every symbol that is running under this strategy name.

getSignal signature

getSignal(
  symbol:       string,   // e.g. "TRXUSDT"
  when:         Date,     // current simulation (or wall-clock) timestamp
  currentPrice: number,   // latest close price at this tick
): Promise<SignalResult | null>
Return null to skip the tick. Return a signal object to open (or update) a position.

Signal object shape

{
  position: "long" | "short",        // direction
  ...Position.moonbag({              // spreads stop-loss and take-profit levels
    position,
    currentPrice,
    percentStopLoss: HARD_STOP,
  }),
  minuteEstimatedTime: 24 * 60,      // expected hold time in minutes
  note: signal.note,                 // free-text note persisted with the signal
}
Position.moonbag is a helper from backtest-kit that computes ladder take-profit levels and a stop-loss from a percentage distance, spreading the risk across multiple partial exits.

Full Strategy Example (jan_2026.strategy.ts)

The strategy below reads pre-loaded signal entries from assets/entry.jsonl, checks whether the current 1-minute close is inside the entry range, uses a 4-hour candle mid-range to decide direction, and returns the position payload.
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";

const PEAK_STALENESS_SINCE_PROFIT  = 1.0;   // % profit required before staleness check
const PEAK_STALENESS_SINCE_MINUTES = 240;   // minutes since peak before force-close
const TRAILING_TAKE = 1.0;                  // % drawdown from peak that triggers trailing take
const HARD_STOP     = 1.0;                  // % stop-loss distance from entry

// ── Load signal entries from JSONL at startup ──────────────────────────────
const SIGNALS: SignalEntryModel[] = readFileSync("./assets/entry.jsonl", "utf-8")
  .split("\n")
  .filter(Boolean)
  .map((line) => JSON.parse(line));

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;
}

// ── Strategy registration ──────────────────────────────────────────────────
addStrategySchema({
  strategyName: "jan_2026_strategy",
  getSignal: async (symbol, when, currentPrice) => {
    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";

    return {
      position,
      ...Position.moonbag({ position, currentPrice, percentStopLoss: HARD_STOP }),
      minuteEstimatedTime: 24 * 60,
      note: signal.note,
    };
  },
});

Key backtest-kit Helpers Used

Returns the close price of the most recent complete candle for the given symbol and interval. In backtest mode this is the close of the current replay candle; in live mode it is the most recently cached close.
const close_1m = await getClosePrice(symbol, "1m");
Returns an array of the last count complete candles (oldest first). Each element has { open, high, low, close, volume, timestamp }.
const [prev, cur] = await getCandles(symbol, "4h", 2);
Floors a Date to the start of the given candle interval. Used here to align the signal’s publishedAt timestamp to a 1-minute boundary so it can be compared to the simulation clock.
const publishedAt = alignToInterval(new Date(s.publishedAt), "1m");
Returns the current unrealised P&L percentage of the open position for the symbol, measured from entry price to the current close.
Returns the percentage drawdown from the position’s all-time profit peak to the current P&L. A positive value means the position has pulled back from its peak — used here to trigger the trailing take.
Marks the open position for symbol as pending-close. The engine executes the close at the next available price. Pass an id (or "unknown") and an optional note that is persisted in the log.
Structured logger that writes to the log-items table via the Log persist adapter. Log.info is for operational messages visible in the web UI; Log.debug is for verbose diagnostic output.

Trailing Take and Peak Staleness with listenActivePing

listenActivePing registers a callback that fires every candle tick while a position is open. Register multiple listeners; they run independently. The two listeners in jan_2026.strategy.ts handle two distinct exit conditions:

Trailing Take

Closes the position once it has pulled back TRAILING_TAKE percent from its all-time profit peak:
listenActivePing(async ({ symbol, data }) => {
  const peakProfitDistance = await getPositionHighestProfitDistancePnlPercentage(symbol);
  const currentProfit      = await getPositionPnlPercent(symbol);

  if (currentProfit < 0) return;               // don't close a losing position here
  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

Closes the position if it reached a meaningful profit peak but has not advanced for PEAK_STALENESS_SINCE_MINUTES minutes since that peak:
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 Handling with listenError

listenError registers a global error handler for any exception thrown inside getSignal or listenActivePing callbacks. Always register one to avoid silent failures:
listenError((error) => {
  console.log(error);
  Log.debug("error", {
    error:   errorData(error),
    message: getErrorMessage(error),
  });
});

The SignalEntryModel Shape

Signal entry data loaded from assets/entry.jsonl follows this interface:
interface SignalEntryModel {
  publishedAt: string;              // ISO-8601 timestamp when the signal fires
  symbol:      string;              // e.g. "TRXUSDT"
  direction:   "long" | "short";
  entry: {
    from: number;                   // lower bound of the entry price range
    to:   number;                   // upper bound of the entry price range
  };
  targets:  number[];               // take-profit price levels
  stoploss: number;                 // absolute stop-loss price
  note:     string;                 // human-readable description
}
Each line of assets/entry.jsonl is one JSON object conforming to this interface. The strategy loads all of them at startup and matches each to the correct symbol and tick timestamp.

Building the Strategy Bundle

The strategy is compiled by Rollup into a single CommonJS file at ./build/index.cjs. The CLI then loads that file at runtime via --ui ./build/index.cjs.
npm run build
Under the hood, rollup -c runs rollup.config.mjs which compiles src/index.ts (which imports src/logic/index.ts) and outputs:
OutputFormatPurpose
build/index.cjsCommonJSLoaded by the backtest-kit CLI at runtime
types.d.tsESM type declarationsTypeScript consumers
The npm run start script runs npm run build before invoking the CLI, so a rebuild is always performed before each run. For faster iteration, run npm run build once and then invoke the CLI directly with node ./node_modules/@backtest-kit/cli/build/index.mjs with your flags.
The assets/entry.jsonl file is loaded with readFileSync at module initialisation time. Make sure the file exists and the path is relative to the working directory from which you run the CLI (the project root), not relative to the source file.

Build docs developers (and LLMs) love