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>
| Parameter | Type | Description |
|---|
symbol | string | The trading pair being evaluated (e.g. "TRXUSDT") |
when | Date | The current bar’s aligned timestamp |
currentPrice | number | The 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
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
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;
},
});
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
}
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:
| Method | Description |
|---|
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 format — assets/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.