Skip to main content

Documentation Index

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

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

A strategy file lives under ./content/<name>.strategy/ and is loaded at runtime by @backtest-kit/cli. The CLI receives the file path as a CLI argument, evaluates it in the running Node process, and registers all schemas declared inside it — no bundler step, no imports from @pro/*, and no changes to the monorepo itself.

Directory Convention

Every strategy lives in its own directory following the <name>.strategy/ pattern:
content/
└── apr_2026.strategy/
    ├── apr_2026.strategy.ts      ← production strategy
    ├── apr_2026.test.ts          ← unit-test variant
    └── modules/
        └── backtest.module.ts    ← exchange + frame schemas
The companion modules/backtest.module.ts file registers addExchangeSchema and addFrameSchema alongside the strategy. All three schemas must be registered before the runner starts.

Constants

The strategy declares its tuning knobs as module-level constants at the top of the file, making them easy to locate and adjust without hunting through callback bodies:
const HARD_STOP = 25.0;         // % stop-loss distance for Position.moonbag()
const TARGET_PROFIT = 3;        // % unrealised PnL required to close

const LADDER_STEP_COST = 100;   // USDT cost per DCA buy
const LADDER_UPPER_STEP = 5;    // % above existing entry — avoids re-buying too high
const LADDER_LOWER_STEP = 1;    // % below existing entry — minimum distance before adding
const LADDER_MAX_STEPS = 10;    // hard cap on DCA ladder depth
Keeping all tuning constants at the module top level lets you diff parameter changes in version control without reading through callback logic.

addStrategySchema

addStrategySchema is the entry point for every strategy. It registers the strategy under a unique strategyName string and provides a getSignal callback that the engine calls once per symbol to open the initial position:
addStrategySchema({
  strategyName: "apr_2026_strategy",
  getSignal: async (symbol, when, currentPrice) => {
    console.log(symbol, when.getTime());
    return {
      position: "long",
      ...Position.moonbag({
        position: "long",
        currentPrice,
        percentStopLoss: HARD_STOP,
      }),
      minuteEstimatedTime: 50,
      cost: LADDER_STEP_COST,
    };
  },
});
ParameterTypeDescription
strategyNamestringUnique identifier referenced by Backtest.background() and listStrategySchema()
symbolstringTrading pair e.g. "BTCUSDT"
whenDateCurrent backtest timestamp (or wall-clock time in live mode)
currentPricenumberLast close price of the current candle

getSignal Return Value

FieldDescription
position"long" or "short" — direction of the trade
...Position.moonbag(...)Spreads stopLoss and takeProfit levels calculated from percentStopLoss
minuteEstimatedTimeExpected hold time in minutes; the engine uses this for timeout-based exits
costInitial USDT amount to spend on the first entry
Position.moonbag() is a convenience factory that computes absolute stop-loss and take-profit prices from a percentage distance. Pass percentStopLoss: HARD_STOP to cap your maximum loss per position.

listenActivePing — Laddering Handler

The first listenActivePing implements a DCA (dollar-cost averaging) ladder. It is called on every tick where the symbol has an active position:
listenActivePing(async ({ symbol, currentPrice }) => {
  // 1. Check ladder depth cap
  const { length: steps } = await getPositionEntries(symbol);
  if (steps >= LADDER_MAX_STEPS) {
    return;
  }

  // 2. Avoid buying into an existing entry zone
  const hasOverlap = await getPositionEntryOverlap(symbol, currentPrice, {
    upperPercent: LADDER_UPPER_STEP,
    lowerPercent: LADDER_LOWER_STEP,
  });
  if (hasOverlap) {
    return;
  }

  // 3. Place the next DCA buy
  await commitAverageBuy(symbol, LADDER_STEP_COST);
});
Flow:
  1. getPositionEntries(symbol) returns all open DCA entries. If the ladder is already at LADDER_MAX_STEPS, skip.
  2. getPositionEntryOverlap(symbol, currentPrice, { upperPercent, lowerPercent }) checks whether currentPrice falls within ±LADDER_UPPER_STEP / ±LADDER_LOWER_STEP percent of any existing entry. If so, skip — this prevents buying the same price zone twice.
  3. commitAverageBuy(symbol, LADDER_STEP_COST) places a new buy at currentPrice for LADDER_STEP_COST USDT, widening the average entry.

listenActivePing — Profit-Taking Handler

The second listenActivePing handles the exit condition. Multiple handlers for the same event run in registration order:
listenActivePing(async ({ symbol, data, timestamp }) => {
  console.log("active", symbol, timestamp);

  const currentProfit = await getPositionPnlPercent(symbol);
  if (currentProfit < TARGET_PROFIT) {
    return;
  }

  Log.info("position closed due to the target pnl reached", {
    symbol,
    data,
  });

  await commitClosePending(symbol, {
    id: "unknown",
    note: str.newline(
      "# Позиция закрыта по target pnl",
    ),
  });
});
getPositionPnlPercent(symbol) returns the current unrealised PnL as a percentage. When it crosses TARGET_PROFIT (3%), commitClosePending closes the position and attaches a Markdown note to the log record.

listenError — Global Error Handler

listenError registers a catch-all sink for any unhandled error thrown during strategy execution:
listenError((error) => {
  console.log(error);
  Log.debug("error", {
    error: errorData(error),
    message: getErrorMessage(error),
  });
});
errorData and getErrorMessage come from functools-kit and serialize the error object into a plain record safe for JSONL logging.

Zero-Import Pattern

Strategy files import only from backtest-kit and functools-kit. They never import from @pro/* packages. Any workspace service (e.g. core.candleDbService) is reached through globalThis.core, which is typed via the root tsconfig.json path aliases pointing at rolled-up types.d.ts bundles. This means strategy files are evaluated by @backtest-kit/cli without any knowledge of the internal DI container — yet they still have full type-safe access to every service at runtime.

Complete Strategy File vs. Test Variant

The production strategy and the test variant share the same schema name and DCA logic. The test file strips the laddering listener and sets minuteEstimatedTime: Infinity to keep the position open indefinitely, making it easier to validate profit-taking logic in isolation.
import {
  addStrategySchema,
  listenError,
  listenActivePing,
  Log,
  Position,
  commitClosePending,
  getPositionPnlPercent,
  getPositionEntryOverlap,
  getPositionEntries,
  commitAverageBuy,
} from "backtest-kit";
import { errorData, getErrorMessage, str } from "functools-kit";

const HARD_STOP = 25.0;
const TARGET_PROFIT = 3;

const LADDER_STEP_COST = 100;
const LADDER_UPPER_STEP = 5;
const LADDER_LOWER_STEP = 1;
const LADDER_MAX_STEPS = 10;

addStrategySchema({
  strategyName: "apr_2026_strategy",
  getSignal: async (symbol, when, currentPrice) => {
    console.log(symbol, when.getTime());
    return {
      position: "long",
      ...Position.moonbag({
        position: "long",
        currentPrice,
        percentStopLoss: HARD_STOP,
      }),
      minuteEstimatedTime: 50,
      cost: LADDER_STEP_COST,
    };
  },
});

listenActivePing(async ({ symbol, currentPrice }) => {
  const { length: steps } = await getPositionEntries(symbol);
  if (steps >= LADDER_MAX_STEPS) {
    return;
  }
  const hasOverlap = await getPositionEntryOverlap(symbol, currentPrice, {
    upperPercent: LADDER_UPPER_STEP,
    lowerPercent: LADDER_LOWER_STEP,
  });
  if (hasOverlap) {
    return;
  }
  await commitAverageBuy(symbol, LADDER_STEP_COST);
});

listenActivePing(async ({ symbol, data, timestamp }) => {
  console.log("active", symbol, timestamp);
  const currentProfit = await getPositionPnlPercent(symbol);
  if (currentProfit < TARGET_PROFIT) {
    return;
  }
  Log.info("position closed due to the target pnl reached", { symbol, data });
  await commitClosePending(symbol, {
    id: "unknown",
    note: str.newline("# Позиция закрыта по target pnl"),
  });
});

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

Build docs developers (and LLMs) love