From 0c35bc62721bc568db59e7548842d6f991da98f7 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 23 Oct 2026 03:40:35 +0200 Subject: [PATCH 1/2] examples: add honest backtest protocol on getCandles data Add examples/honest-backtest, a self-contained RSI divergence + EMA 200 trend filter backtest (BINANCE:BTCUSDT 4H by default, all parameters configurable) built only on getCandles: - candles fetched once, validated and frozen (forming bar dropped, gaps and SHA-256 recorded, saved for offline replay with --snapshot) - warm-up bars never traded, chronological 70/30 IS/OOS split, no position across the split - pivots confirmed N bars later, signal on the confirmation close, entry at the next bar open - fee and adverse slippage on both sides; same-bar stop and target counts as a loss; gaps through a stop fill at the open - interchangeable exits: fixed 1.5:1 and 2:1, ATR 1.5x stop with 2R target, ATR trailing stop updated on closed bars only - seeded random-entry baseline per exit and split with percentile and p5/p50/p95, compact metrics table and trades CSV Deterministic unit tests on synthetic candles cover pivot timing, next-open entry, same-bar fills, costs, the split, the trailing stop and baseline reproducibility. Link the example from examples/README.md, docs/data-api.md and llms.txt. --- .gitignore | 1 + docs/data-api.md | 8 + examples/README.md | 5 + examples/honest-backtest/README.md | 54 +++ examples/honest-backtest/engine.d.ts | 180 ++++++++ examples/honest-backtest/engine.js | 623 +++++++++++++++++++++++++++ examples/honest-backtest/index.js | 117 +++++ llms.txt | 1 + tests/unit/honest-backtest.test.ts | 243 +++++++++++ 9 files changed, 1232 insertions(+) create mode 100644 examples/honest-backtest/README.md create mode 100644 examples/honest-backtest/engine.d.ts create mode 100644 examples/honest-backtest/engine.js create mode 100644 examples/honest-backtest/index.js create mode 100644 tests/unit/honest-backtest.test.ts diff --git a/.gitignore b/.gitignore index 0be9a475..1a31e362 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ dist/ coverage/ *.tgz +honest-backtest-output/ diff --git a/docs/data-api.md b/docs/data-api.md index 4132eea2..d0632ae8 100644 --- a/docs/data-api.md +++ b/docs/data-api.md @@ -208,6 +208,14 @@ for the tested symbol/timeframe; UI exports and Deep Backtesting have separate subscription requirements. +## Backtesting a strategy on candles + +The library ships no backtesting engine. [examples/honest-backtest](../examples/honest-backtest) shows a protocol to copy: candles fetched once with `getCandles` and frozen, a chronological 70/30 in-sample/out-of-sample split, signals confirmed without look-ahead and entered at the next bar's open, fees and slippage on both sides, a pessimistic rule when stop and target fall in the same candle, and a seeded random-entry baseline for each exit. A negative or inconclusive result is a valid outcome. + +```bash +npm run build && node examples/honest-backtest/index.js --symbol BINANCE:BTCUSDT --timeframe 240 +``` + ## Search and technical analysis ```ts diff --git a/examples/README.md b/examples/README.md index 86b87f09..11bd7162 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,6 +14,7 @@ Examples that need an account read the `SESSION` and `SIGNATURE` cookies from th | Example | API | Account | | --- | --- | --- | | [candles.js](candles.js) | `getCandles` (count, from/to) | No | +| [honest-backtest/](honest-backtest) | `getCandles` → backtest protocol: no look-ahead, costs, IS/OOS split, random-entry baseline | No | | [watch-candles.js](watch-candles.js) | `watchCandles` | No | | [quotes.js](quotes.js) | `getQuote`, `getQuotes`, `watchQuotes` | No | | [indicator-data.js](indicator-data.js) | `getIndicatorData` | Pine scripts only | @@ -35,3 +36,7 @@ Examples that need an account read the `SESSION` and `SIGNATURE` cookies from th | [drawings.js](drawings.js) | `getDrawings` | Private layouts | | [pine-permissions.js](pine-permissions.js) | `PinePermissionManager` | Yes (script owner) | | [user-login.js](user-login.js) | `loginUser`, `getUser` | Username/password | + +## Backtesting honestly + +[honest-backtest/](honest-backtest) is a reference protocol for testing a strategy on `getCandles` data: frozen snapshot, chronological in-sample/out-of-sample split, no look-ahead, fees and slippage, pessimistic fills, interchangeable exits and a seeded random-entry baseline. It makes no claim of profitability: a negative or inconclusive result is a valid outcome. diff --git a/examples/honest-backtest/README.md b/examples/honest-backtest/README.md new file mode 100644 index 00000000..c243f38e --- /dev/null +++ b/examples/honest-backtest/README.md @@ -0,0 +1,54 @@ +# Honest backtest (reference protocol) + +A self-contained example that backtests a simple strategy **and checks whether it does better than chance**, using only `getCandles` from the v4 data API. Copy it as a starting point for your own strategy, by hand or with a coding agent. + +**This is not trading advice and makes no claim of profitability.** A negative or inconclusive result is a valid outcome of this protocol, and the most common one. The point is to find out honestly, not to produce a nice equity curve. + +```bash +npm ci && npm run build +node examples/honest-backtest/index.js # BINANCE:BTCUSDT, 4H, about 2 years +node examples/honest-backtest/index.js --symbol BINANCE:ETHUSDT --timeframe D +node examples/honest-backtest/index.js --snapshot honest-backtest-output/snapshot.json # Offline replay +node examples/honest-backtest/index.js --help # Every parameter +``` + +No account is needed. The run writes `snapshot.json` and `trades.csv` to `honest-backtest-output/` (change it with `--out`). + +## Files + +| File | Role | +| --- | --- | +| [engine.js](engine.js) | Pure functions: snapshot, indicators, signals, split, exits, simulation, metrics, random baseline. No network, no I/O. | +| [index.js](index.js) | Downloads candles once with `getCandles`, runs the protocol, prints the report, writes the CSV. | +| [engine.d.ts](engine.d.ts) | Types for the engine. | +| [tests/unit/honest-backtest.test.ts](../../tests/unit/honest-backtest.test.ts) | Deterministic tests on synthetic candles for every rule below. | + +## Strategy + +- **Long**: regular bullish RSI(14) divergence (a lower price pivot low with a higher RSI value than the previous pivot low, 5 to 60 bars apart) while the close is **above** the EMA(200). +- **Short**: regular bearish divergence (higher price pivot high, lower RSI) while the close is **below** the EMA(200). + +Every parameter is a command-line option. Replace `findSignals` in `engine.js` to test your own entries; the rest of the protocol stays the same. + +## Protocol + +1. **Frozen snapshot.** Candles are downloaded once, validated (ordered, consistent OHLC), and frozen. The bar still forming is dropped. The report prints the symbol, timeframe, first and last bar time, bar count, gaps and a SHA-256 of the data. By default it requests enough bars for 2 years of tradable history after warm-up and warns when the server returns less. A live rerun gets new bars; replay `snapshot.json` to reproduce a result exactly. +2. **Chronological split.** The first bars only warm the indicators up and are never traded. The remaining bars are split 70% in-sample (IS) / 30% out-of-sample (OOS), with exact dates printed. A signal and its entry must be in the same segment, and a position still open on the last bar of a segment is closed at that bar's close (`segment_end`), so no position crosses the split. +3. **No look-ahead.** A pivot with lookback N (default 5) needs N later bars, so it is only confirmed N bars after it formed. The signal fires at the close of the confirmation bar and the entry is the **next bar's open**. Nothing is backdated. Indicators at bar i only use bars up to i. +4. **Costs.** A fee (default 0.1%) and an adverse slippage (default 0.05%) on every entry and every exit, in the strategy and in the random baseline. +5. **Pessimistic fills.** When the stop and the target are both inside the same candle, the trade is a loss. A bar that opens beyond the stop fills at its open (worse than the stop). +6. **Interchangeable exits.** Entries never depend on the exit. Four exits run on the same entries: + - `fixed_1.5R` and `fixed_2R`: stop 2% from the entry bar open (`--stopPct`), target 1.5x or 2x that distance; + - `atr1.5_2R`: stop 1.5x ATR(14) of the signal bar, target 2R; + - `trail_atr3`: initial stop 3x ATR(14), then trailed at close minus 3x ATR on every **closed** bar (`--trailAtrMult`); the new stop applies from the next bar. +7. **Random-entry baseline.** For each exit and each split, 1,000 seeded runs (`--runs`, `--seed`) enter at random bars of the same segment, with the same exit, costs, sizing (100% of equity, no leverage, compounded) and one-position-at-a-time rule, the strategy's number of trades and its long/short mix. Entries skipped because a position was open are redrawn among the free bars; the report says when a run could not reach the strategy's trade count. **Percentile** = 100 x share of random runs with a strictly lower net return. p5, p50 and p95 of the random returns are printed (linear interpolation). +8. **Metrics** per exit and split: net return (compounded), win rate (trades with a positive net return), profit factor (gross profit / gross loss, `N/A` without a losing trade), max drawdown (largest peak-to-trough fall of equity, marked to market at every bar close while in a position) and the number of closed trades. +9. **Trades CSV** with, per trade: exit variant, split, direction, previous pivot, pivot, confirmation and entry bar times, entry open and fill, initial stop, target, exit time, level and fill, exit reason and net return. Times are bar open times in UTC. + +## Reading the result + +- Look at the **out-of-sample** rows first. In-sample results were seen while choosing the parameters. +- A percentile around 50 means "no better than random entries with the same exits". Above 95 on OOS is weak evidence, not proof: with four exits and two splits, one of them will look good by chance from time to time. +- With a few dozen trades or fewer, returns and percentiles are mostly noise. The run prints a warning. +- Changing parameters until the OOS rows look good turns OOS into in-sample. Decide the parameters first, or keep a final untouched period. +- Not modelled: funding rates, spreads wider than the slippage, partial fills, latency, exchange outages, taxes. diff --git a/examples/honest-backtest/engine.d.ts b/examples/honest-backtest/engine.d.ts new file mode 100644 index 00000000..eeffb4b8 --- /dev/null +++ b/examples/honest-backtest/engine.d.ts @@ -0,0 +1,180 @@ +// Type declarations for engine.js (used by the TypeScript unit tests and editors). + +export interface Bar { + time: number; + open: number; + high: number; + low: number; + close: number; + volume?: number; +} + +export type Direction = 'long' | 'short'; + +export interface Options { + symbol: string; + timeframe: string; + minYears: number; + splitRatio: number; + pivotLookback: number; + minPivotDistance: number; + maxPivotDistance: number; + rsiLength: number; + emaLength: number; + atrLength: number; + fee: number; + slippage: number; + stopPct: number; + atrStopMult: number; + atrTargetR: number; + trailAtrMult: number; + runs: number; + seed: number; +} + +export declare const DEFAULTS: Readonly; + +export interface Gap { afterTime: number; beforeTime: number; missingBars: number } + +export interface Snapshot { + readonly symbol: string; + readonly timeframe: string; + readonly barSeconds: number; + readonly count: number; + readonly firstTime: number; + readonly lastTime: number; + readonly spanYears: number; + readonly gaps: readonly Gap[]; + readonly droppedFormingBar: boolean; + readonly sha256: string; + readonly candles: readonly Readonly>[]; +} + +export declare function freezeSnapshot( + candles: readonly Bar[], + meta: { symbol: string; timeframe: string; barSeconds: number; now?: number }, +): Snapshot; + +export interface Indicators { rsi: number[]; ema: number[]; atr: number[]; warmupBars?: number } + +export declare function ema(values: readonly number[], length: number): number[]; +export declare function rsi(closes: readonly number[], length: number): number[]; +export declare function atr(candles: readonly Bar[], length: number): number[]; +export declare function computeIndicators(candles: readonly Bar[], options?: Partial): Required; + +export interface Pivot { bar: number; confirmBar: number; price: number } +export declare function findPivots(values: readonly number[], lookback: number, kind: 'low' | 'high'): Pivot[]; + +export interface Entry { + direction: Direction; + signalBar: number; + entryBar: number; + pivotBar: number | null; + prevPivotBar: number | null; +} +export declare function findSignals(candles: readonly Bar[], ind: Pick, options?: Partial): Entry[]; + +export interface Segment { name: string; start: number; end: number } +export declare function splitSegments(barCount: number, warmupBars: number, ratio: number): { inSample: Segment; outOfSample: Segment }; + +export interface ExitContext { direction: Direction; candles: readonly Bar[]; ind: Indicators } +export interface ExitModel { + id: string; + label: string; + levels(ctx: ExitContext & { refPrice: number; signalBar: number }): { stop: number; target: number | null } | null; + trail?(stop: number, bar: number, ctx: ExitContext): number; +} +export declare function fixedExit(rr: number, stopPct: number): ExitModel; +export declare function atrExit(mult: number, rr: number): ExitModel; +export declare function atrTrailingExit(mult: number): ExitModel; +export declare function defaultExits(options?: Partial): ExitModel[]; + +export interface Costs { fee: number; slippage: number } +export declare function fillPrice(side: 'entry' | 'exit', direction: Direction, price: number, slippage: number): number; +export declare function tradeReturn(direction: Direction, entryFill: number, exitFill: number, fee: number): number; + +export interface Trade extends Entry { + exitBar: number; + entryOpen: number; + entryPrice: number; + initialStop: number; + target: number | null; + exitLevel: number; + exitPrice: number; + reason: 'stop' | 'trailing_stop' | 'stop_gap' | 'target' | 'target_gap' | 'stop_and_target_same_bar' | 'segment_end'; + return: number; + marks: number[]; + /** Stop in force on each bar from entry to exit (only with `trace: true`). */ + stops?: number[]; +} + +export declare function simulate(input: { + candles: readonly Bar[]; + ind: Indicators; + entries: readonly Entry[]; + exit: ExitModel; + costs: Costs; + segment: Segment; + maxTrades?: number; + trace?: boolean; +}): { trades: Trade[]; skippedInPosition: number; outsideSegment: number; invalidLevels: number }; + +export interface Metrics { + trades: number; + netReturn: number; + winRate: number | null; + profitFactor: number | null; + maxDrawdown: number; +} +export declare function computeMetrics(trades: readonly Pick[]): Metrics; + +export declare function createRng(seed: number): () => number; +export declare function deriveSeed(...parts: (string | number)[]): number; +export declare function quantile(sorted: readonly number[], q: number): number | null; + +export interface Baseline { + runs: number; + seed: number; + netReturns: number[]; + matchedRuns: number; + meanTrades: number; + p5: number | null; + p50: number | null; + p95: number | null; +} +export declare function randomBaseline(input: { + candles: readonly Bar[]; + ind: Indicators; + exit: ExitModel; + costs: Costs; + segment: Segment; + tradeCount: number; + longShare: number; + runs: number; + seed: number; +}): Baseline; +export declare function percentileRank(value: number, randomReturns: readonly number[]): number | null; + +export interface ReportRow { + exit: string; + exitLabel: string; + split: string; + metrics: Metrics; + longs: number; + shorts: number; + skippedInPosition: number; + baseline: Omit | null; + percentile: number | null; +} +export declare function runBacktest(snapshot: { candles: readonly Bar[] }, options?: Partial & { exits?: ExitModel[] }): { + options: Options; + warmupBars: number; + segments: { inSample: Segment; outOfSample: Segment }; + signals: Entry[]; + rows: ReportRow[]; + trades: (Trade & { exit: string; split: string })[]; +}; + +export declare function isoTime(seconds: number): string; +export declare function tradesToCsv(trades: readonly (Trade & { exit: string; split: string })[], candles: readonly Bar[]): string; +export declare function formatTable(rows: readonly ReportRow[]): string; diff --git a/examples/honest-backtest/engine.js b/examples/honest-backtest/engine.js new file mode 100644 index 00000000..e275982f --- /dev/null +++ b/examples/honest-backtest/engine.js @@ -0,0 +1,623 @@ +// Backtest engine for examples/honest-backtest: pure functions, no network, no file I/O. +// Every rule of the protocol (see README.md next to this file) lives here so that it can +// be unit-tested on synthetic candles. Bars are indexed oldest first; `time` is Unix seconds. +import { createHash } from 'node:crypto'; + +export const DEFAULTS = Object.freeze({ + symbol: 'BINANCE:BTCUSDT', + timeframe: '240', + minYears: 2, // Tradable history wanted after warm-up + splitRatio: 0.7, // In-sample share of the tradable bars + pivotLookback: 5, // Bars on each side of a pivot; it is confirmed this many bars later + minPivotDistance: 5, // Bars between the two pivots of a divergence + maxPivotDistance: 60, + rsiLength: 14, + emaLength: 200, + atrLength: 14, + fee: 0.001, // 0.1% per side + slippage: 0.0005, // 0.05% per side, always adverse + stopPct: 0.02, // Fixed stop distance for the fixed R:R exits + atrStopMult: 1.5, // ATR exit: stop = 1.5 x ATR(14), target = 2R + atrTargetR: 2, + trailAtrMult: 3, // ATR trailing stop multiplier + runs: 1000, // Random-entry runs per exit variant and split + seed: 42, +}); + +const isNumber = (value) => typeof value === 'number' && Number.isFinite(value); + +// --------------------------------------------------------------------------- +// 1. Frozen snapshot +// --------------------------------------------------------------------------- + +function freezeDeep(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const key of Object.keys(value)) freezeDeep(value[key]); + Object.freeze(value); + } + return value; +} + +/** + * Validates candles once and freezes them. The bar that is still forming (its close time is + * after `now`) is dropped: its high, low and close are not final yet. + * Gaps are intervals longer than 1.5 bars (market closures also show up as gaps). + */ +export function freezeSnapshot(rawCandles, { symbol, timeframe, barSeconds, now = Date.now() / 1000 }) { + if (!isNumber(barSeconds) || barSeconds <= 0) throw new Error('barSeconds must be a positive number'); + const candles = rawCandles.map(({ time, open, high, low, close, volume }) => ({ + time, open, high, low, close, volume: volume ?? 0, + })); + candles.forEach((c, i) => { + if (![c.time, c.open, c.high, c.low, c.close].every(isNumber)) throw new Error(`Invalid candle at index ${i}`); + if (c.high < Math.max(c.open, c.close) || c.low > Math.min(c.open, c.close)) throw new Error(`Inconsistent OHLC at index ${i}`); + if (i > 0 && c.time <= candles[i - 1].time) throw new Error(`Candles are not strictly ordered at index ${i}`); + }); + + let droppedFormingBar = false; + if (candles.length > 0 && candles[candles.length - 1].time + barSeconds > now) { + candles.pop(); + droppedFormingBar = true; + } + if (candles.length < 2) throw new Error('Not enough closed candles'); + + const gaps = []; + for (let i = 1; i < candles.length; i++) { + const diff = candles[i].time - candles[i - 1].time; + if (diff > barSeconds * 1.5) { + gaps.push({ afterTime: candles[i - 1].time, beforeTime: candles[i].time, missingBars: Math.round(diff / barSeconds) - 1 }); + } + } + + const firstTime = candles[0].time; + const lastTime = candles[candles.length - 1].time; + return freezeDeep({ + symbol, + timeframe: String(timeframe), + barSeconds, + count: candles.length, + firstTime, + lastTime, + spanYears: (lastTime + barSeconds - firstTime) / (365.25 * 86_400), + gaps, + droppedFormingBar, + sha256: createHash('sha256').update(JSON.stringify(candles)).digest('hex'), + candles, + }); +} + +// --------------------------------------------------------------------------- +// 2. Causal indicators (value at bar i only uses bars 0..i) +// --------------------------------------------------------------------------- + +/** EMA seeded with the SMA of the first `length` values. NaN before. */ +export function ema(values, length) { + const out = new Array(values.length).fill(NaN); + if (values.length < length) return out; + let sum = 0; + for (let i = 0; i < length; i++) sum += values[i]; + out[length - 1] = sum / length; + const k = 2 / (length + 1); + for (let i = length; i < values.length; i++) out[i] = values[i] * k + out[i - 1] * (1 - k); + return out; +} + +/** Wilder RSI. First value at index `length`. */ +export function rsi(closes, length) { + const out = new Array(closes.length).fill(NaN); + if (closes.length <= length) return out; + let gain = 0; + let loss = 0; + for (let i = 1; i <= length; i++) { + const change = closes[i] - closes[i - 1]; + gain += Math.max(change, 0); + loss += Math.max(-change, 0); + } + gain /= length; + loss /= length; + const value = () => (loss === 0 ? (gain === 0 ? 50 : 100) : 100 - 100 / (1 + gain / loss)); + out[length] = value(); + for (let i = length + 1; i < closes.length; i++) { + const change = closes[i] - closes[i - 1]; + gain = (gain * (length - 1) + Math.max(change, 0)) / length; + loss = (loss * (length - 1) + Math.max(-change, 0)) / length; + out[i] = value(); + } + return out; +} + +/** Wilder ATR. First value at index `length - 1`. */ +export function atr(candles, length) { + const out = new Array(candles.length).fill(NaN); + if (candles.length < length) return out; + const tr = candles.map((c, i) => (i === 0 + ? c.high - c.low + : Math.max(c.high - c.low, Math.abs(c.high - candles[i - 1].close), Math.abs(c.low - candles[i - 1].close)))); + let sum = 0; + for (let i = 0; i < length; i++) sum += tr[i]; + out[length - 1] = sum / length; + for (let i = length; i < candles.length; i++) out[i] = (out[i - 1] * (length - 1) + tr[i]) / length; + return out; +} + +/** Indicators plus `warmupBars`: the first bar index where all of them are defined. */ +export function computeIndicators(candles, options = {}) { + const { rsiLength, emaLength, atrLength } = { ...DEFAULTS, ...options }; + const closes = candles.map((c) => c.close); + return { + rsi: rsi(closes, rsiLength), + ema: ema(closes, emaLength), + atr: atr(candles, atrLength), + warmupBars: Math.max(rsiLength, emaLength - 1, atrLength - 1), + }; +} + +// --------------------------------------------------------------------------- +// 3. Pivots and divergence signals (no look-ahead) +// --------------------------------------------------------------------------- + +/** + * A pivot low at bar p: lower than the `lookback` bars before it and not higher than the + * `lookback` bars after it (mirror for highs). It needs those later bars, so it is only + * known at the close of `confirmBar = p + lookback`. Never use it earlier. + */ +export function findPivots(values, lookback, kind) { + const pivots = []; + for (let p = lookback; p + lookback < values.length; p++) { + const v = values[p]; + let ok = true; + for (let j = p - lookback; ok && j < p; j++) ok = kind === 'low' ? values[j] > v : values[j] < v; + for (let j = p + 1; ok && j <= p + lookback; j++) ok = kind === 'low' ? values[j] >= v : values[j] <= v; + if (ok) pivots.push({ bar: p, confirmBar: p + lookback, price: v }); + } + return pivots; +} + +/** + * Regular RSI divergence with a trend filter. For each newly confirmed pivot, compare it with + * the previous pivot of the same kind: + * - long: lower price low, higher RSI low, and close above the EMA on the confirmation bar; + * - short: higher price high, lower RSI high, and close below the EMA on the confirmation bar. + * The signal fires at the close of the confirmation bar; the entry is the NEXT bar's open. + * `ind` only needs `rsi` and `ema` arrays (tests can pass synthetic ones). + */ +export function findSignals(candles, ind, options = {}) { + const { pivotLookback, minPivotDistance, maxPivotDistance } = { ...DEFAULTS, ...options }; + const signals = []; + const scan = (kind, direction) => { + const pivots = findPivots(candles.map((c) => (kind === 'low' ? c.low : c.high)), pivotLookback, kind); + for (let k = 1; k < pivots.length; k++) { + const prev = pivots[k - 1]; + const cur = pivots[k]; + const distance = cur.bar - prev.bar; + if (distance < minPivotDistance || distance > maxPivotDistance) continue; + const c = cur.confirmBar; + const [r1, r2, trend, close] = [ind.rsi[prev.bar], ind.rsi[cur.bar], ind.ema[c], candles[c].close]; + if (![r1, r2, trend].every(isNumber)) continue; + const divergence = kind === 'low' ? cur.price < prev.price && r2 > r1 : cur.price > prev.price && r2 < r1; + const trendOk = direction === 'long' ? close > trend : close < trend; + if (divergence && trendOk) { + signals.push({ direction, signalBar: c, entryBar: c + 1, pivotBar: cur.bar, prevPivotBar: prev.bar }); + } + } + }; + scan('low', 'long'); + scan('high', 'short'); + return signals.sort((a, b) => a.signalBar - b.signalBar); +} + +// --------------------------------------------------------------------------- +// 4. Chronological split +// --------------------------------------------------------------------------- + +/** + * Bars before `warmupBars` only warm the indicators up. The remaining bars are split + * chronologically: in-sample `[start, splitBar)`, out-of-sample `[splitBar, end)`. + */ +export function splitSegments(barCount, warmupBars, ratio) { + if (!(ratio > 0 && ratio < 1)) throw new Error('splitRatio must be between 0 and 1'); + const start = Math.min(warmupBars, barCount); + const splitBar = start + Math.floor((barCount - start) * ratio); + return { + inSample: { name: 'IS', start, end: splitBar }, + outOfSample: { name: 'OOS', start: splitBar, end: barCount }, + }; +} + +// --------------------------------------------------------------------------- +// 5. Exits (interchangeable: entries never depend on them) +// --------------------------------------------------------------------------- +// An exit model has `id`, `label`, `levels(ctx)` returning the initial `{ stop, target }` +// (target may be null) from information known at entry, and optionally `trail(stop, i, ctx)` +// which runs after bar i has CLOSED and returns the stop used from bar i + 1. + +const offset = (direction, price, distance) => (direction === 'long' ? price + distance : price - distance); + +/** Fixed stop at `stopPct` from the entry bar open, target at `rr` times that distance. */ +export function fixedExit(rr, stopPct) { + return { + id: `fixed_${rr}R`, + label: `Fixed ${(stopPct * 100).toFixed(1)}% stop, ${rr}:1 target`, + levels({ direction, refPrice }) { + const d = refPrice * stopPct; + return { stop: offset(direction, refPrice, -d), target: offset(direction, refPrice, rr * d) }; + }, + }; +} + +/** Stop at `mult` x ATR of the signal bar (last closed bar before entry), target at `rr` R. */ +export function atrExit(mult, rr) { + return { + id: `atr${mult}_${rr}R`, + label: `ATR x${mult} stop, ${rr}R target`, + levels({ direction, refPrice, signalBar, ind }) { + const d = mult * ind.atr[signalBar]; + if (!isNumber(d) || d <= 0) return null; + return { stop: offset(direction, refPrice, -d), target: offset(direction, refPrice, rr * d) }; + }, + }; +} + +/** ATR trailing stop, no target. Ratchets on each closed bar: close -/+ mult x ATR(that bar). */ +export function atrTrailingExit(mult) { + return { + id: `trail_atr${mult}`, + label: `ATR x${mult} trailing stop`, + levels({ direction, refPrice, signalBar, ind }) { + const d = mult * ind.atr[signalBar]; + if (!isNumber(d) || d <= 0) return null; + return { stop: offset(direction, refPrice, -d), target: null }; + }, + trail(stop, i, { direction, candles, ind }) { + const candidate = offset(direction, candles[i].close, -mult * ind.atr[i]); + if (!isNumber(candidate)) return stop; + return direction === 'long' ? Math.max(stop, candidate) : Math.min(stop, candidate); + }, + }; +} + +export function defaultExits(options = {}) { + const o = { ...DEFAULTS, ...options }; + return [fixedExit(1.5, o.stopPct), fixedExit(2, o.stopPct), atrExit(o.atrStopMult, o.atrTargetR), atrTrailingExit(o.trailAtrMult)]; +} + +// --------------------------------------------------------------------------- +// 6. Simulation (costs, one position at a time, no position across a segment boundary) +// --------------------------------------------------------------------------- + +/** Fill price after adverse slippage. */ +export function fillPrice(side, direction, price, slippage) { + const buying = (side === 'entry') === (direction === 'long'); + return buying ? price * (1 + slippage) : price * (1 - slippage); +} + +/** + * Return on equity of one trade (100% of equity, no leverage), after slippage on both fills + * and a fee on the notional of each side. + */ +export function tradeReturn(direction, entryFill, exitFill, fee) { + const ratio = exitFill / entryFill; + const gross = direction === 'long' ? ratio - 1 : 1 - ratio; + return gross - fee - fee * ratio; +} + +function runTrade(entry, exit, context) { + const { candles, ind, costs, end, trace } = context; + const { direction, entryBar: e } = entry; + const long = direction === 'long'; + const refPrice = candles[e].open; + const levels = exit.levels({ direction, refPrice, signalBar: entry.signalBar, ind, candles }); + if (!levels) return null; + const entryPrice = fillPrice('entry', direction, refPrice, costs.slippage); + let { stop } = levels; + const { target } = levels; + const stops = trace ? [] : undefined; + const marks = []; + + for (let i = e; i < end; i++) { + const bar = candles[i]; + stops?.push(stop); + let level; + let reason; + if (i > e) { + // The bar opened beyond a level: the order fills at the open, the worse price for a stop. + if (long ? bar.open <= stop : bar.open >= stop) [level, reason] = [bar.open, 'stop_gap']; + else if (target !== null && (long ? bar.open >= target : bar.open <= target)) [level, reason] = [bar.open, 'target_gap']; + } + if (level === undefined) { + const hitStop = long ? bar.low <= stop : bar.high >= stop; + const hitTarget = target !== null && (long ? bar.high >= target : bar.low <= target); + // Intrabar order is unknown: when both are touched, assume the stop came first. + if (hitStop && hitTarget) [level, reason] = [stop, 'stop_and_target_same_bar']; + else if (hitStop) [level, reason] = [stop, exit.trail ? 'trailing_stop' : 'stop']; + else if (hitTarget) [level, reason] = [target, 'target']; + } + if (level === undefined && i === end - 1) [level, reason] = [bar.close, 'segment_end']; + if (level !== undefined) { + const exitPrice = fillPrice('exit', direction, level, costs.slippage); + return { + ...entry, + exitBar: i, + entryOpen: refPrice, + entryPrice, + initialStop: levels.stop, + target, + exitLevel: level, + exitPrice, + reason, + return: tradeReturn(direction, entryPrice, exitPrice, costs.fee), + marks, + ...(trace ? { stops } : {}), + }; + } + // Still open at the close: mark to market as if closed there (with exit costs). + marks.push(tradeReturn(direction, entryPrice, fillPrice('exit', direction, bar.close, costs.slippage), costs.fee)); + if (exit.trail) stop = exit.trail(stop, i, { direction, candles, ind }); + } + return null; // Only reached when the entry bar is outside the segment +} + +/** + * Simulates entries (sorted by `entryBar`) inside one segment. An entry is taken only if its + * signal bar and entry bar are inside the segment and the previous trade exited on an earlier + * bar. A trade still open on the last bar of the segment is closed at that bar's close. + */ +export function simulate({ candles, ind, entries, exit, costs, segment, maxTrades = Infinity, trace = false }) { + const context = { candles, ind, costs, end: segment.end, trace }; + const trades = []; + let freeFrom = segment.start; + let skippedInPosition = 0; + let outsideSegment = 0; + let invalidLevels = 0; + for (const entry of entries) { + if (trades.length >= maxTrades) break; + if (entry.signalBar < segment.start || entry.entryBar >= segment.end) { + outsideSegment++; + continue; + } + if (entry.entryBar < freeFrom) { + skippedInPosition++; + continue; + } + const trade = runTrade(entry, exit, context); + if (!trade) { + invalidLevels++; + continue; + } + trades.push(trade); + freeFrom = trade.exitBar + 1; + } + return { trades, skippedInPosition, outsideSegment, invalidLevels }; +} + +// --------------------------------------------------------------------------- +// 7. Metrics +// --------------------------------------------------------------------------- + +/** + * - netReturn: compounded return of all closed trades. + * - winRate: share of trades with a positive net return (null without trades). + * - profitFactor: gross profit / gross loss in equity terms; null when undefined + * (no trade, or no losing trade). + * - maxDrawdown: largest peak-to-trough fall of equity, marked to market at every bar close + * while in a position and at every exit (fraction, positive number). + */ +export function computeMetrics(trades) { + let equity = 1; + let peak = 1; + let maxDrawdown = 0; + let grossProfit = 0; + let grossLoss = 0; + let wins = 0; + const visit = (value) => { + peak = Math.max(peak, value); + maxDrawdown = Math.max(maxDrawdown, (peak - value) / peak); + }; + for (const trade of trades) { + for (const mark of trade.marks ?? []) visit(equity * (1 + mark)); + const pnl = equity * trade.return; + if (pnl > 0) { + grossProfit += pnl; + wins++; + } else grossLoss -= pnl; + equity += pnl; + visit(equity); + } + return { + trades: trades.length, + netReturn: equity - 1, + winRate: trades.length ? wins / trades.length : null, + profitFactor: trades.length && grossLoss > 0 ? grossProfit / grossLoss : null, + maxDrawdown, + }; +} + +// --------------------------------------------------------------------------- +// 8. Seeded random-entry baseline +// --------------------------------------------------------------------------- + +/** mulberry32: small, fast, seedable PRNG returning floats in [0, 1). */ +export function createRng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +/** Deterministic 32-bit seed for one (base seed, variant, split) combination (FNV-1a). */ +export function deriveSeed(...parts) { + let h = 0x811c9dc5; + for (const ch of parts.join('|')) { + h ^= ch.charCodeAt(0); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +} + +function sample(pool, k, rng) { + const copy = [...pool]; + const n = Math.min(k, copy.length); + for (let i = 0; i < n; i++) { + const j = i + Math.floor(rng() * (copy.length - i)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy.slice(0, n); +} + +/** Linear-interpolated quantile of an ascending array (q in [0, 1]). */ +export function quantile(sorted, q) { + if (sorted.length === 0) return null; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo); +} + +/** + * Random entries on the same eligible bars as the strategy (signal bar and entry bar inside the + * segment), with the same exit, costs, sizing and one-position rule. Each run draws `tradeCount` + * distinct entry bars uniformly; entries skipped because a position was open are replaced by + * bars drawn among the remaining free bars, so the run matches `tradeCount` when the segment + * has room. Each entry is long with probability `longShare`. + */ +export function randomBaseline({ candles, ind, exit, costs, segment, tradeCount, longShare, runs, seed }) { + const eligible = []; + for (let e = segment.start + 1; e < segment.end; e++) eligible.push(e); + const rng = createRng(seed); + const netReturns = []; + let matchedRuns = 0; + let totalTrades = 0; + for (let run = 0; run < runs; run++) { + const direction = new Map(); + const draw = (bars) => bars.forEach((bar) => direction.set(bar, rng() < longShare ? 'long' : 'short')); + draw(sample(eligible, tradeCount, rng)); + let result; + for (let round = 0; round < 20; round++) { + const entries = [...direction.keys()].sort((a, b) => a - b) + .map((bar) => ({ direction: direction.get(bar), signalBar: bar - 1, entryBar: bar, pivotBar: null, prevPivotBar: null })); + result = simulate({ candles, ind, entries, exit, costs, segment, maxTrades: tradeCount }); + const missing = tradeCount - result.trades.length; + if (missing <= 0) break; + const busy = new Set(direction.keys()); + for (const t of result.trades) for (let b = t.entryBar; b <= t.exitBar; b++) busy.add(b); + const free = eligible.filter((b) => !busy.has(b)); + if (free.length === 0) break; + draw(sample(free, missing, rng)); + } + if (result.trades.length === tradeCount) matchedRuns++; + totalTrades += result.trades.length; + netReturns.push(computeMetrics(result.trades).netReturn); + } + const sorted = [...netReturns].sort((a, b) => a - b); + return { + runs, + seed, + netReturns, + matchedRuns, + meanTrades: runs ? totalTrades / runs : 0, + p5: quantile(sorted, 0.05), + p50: quantile(sorted, 0.5), + p95: quantile(sorted, 0.95), + }; +} + +/** 100 x share of random runs with a strictly lower net return than the strategy. */ +export function percentileRank(value, randomReturns) { + if (randomReturns.length === 0) return null; + return (100 * randomReturns.filter((r) => r < value).length) / randomReturns.length; +} + +// --------------------------------------------------------------------------- +// 9. Full protocol +// --------------------------------------------------------------------------- + +export function runBacktest(snapshot, options = {}) { + const o = { ...DEFAULTS, ...options }; + const { candles } = snapshot; + const ind = computeIndicators(candles, o); + const segments = splitSegments(candles.length, ind.warmupBars, o.splitRatio); + const signals = findSignals(candles, ind, o); + const exits = options.exits ?? defaultExits(o); + const costs = { fee: o.fee, slippage: o.slippage }; + const rows = []; + const trades = []; + for (const exit of exits) { + for (const segment of [segments.inSample, segments.outOfSample]) { + const result = simulate({ candles, ind, entries: signals, exit, costs, segment }); + const metrics = computeMetrics(result.trades); + const longs = result.trades.filter((t) => t.direction === 'long').length; + let baseline = null; + let percentile = null; + if (metrics.trades > 0 && o.runs > 0) { + baseline = randomBaseline({ + candles, ind, exit, costs, segment, + tradeCount: metrics.trades, + longShare: longs / metrics.trades, + runs: o.runs, + seed: deriveSeed(o.seed, exit.id, segment.name), + }); + percentile = percentileRank(metrics.netReturn, baseline.netReturns); + } + rows.push({ + exit: exit.id, + exitLabel: exit.label, + split: segment.name, + metrics, + longs, + shorts: metrics.trades - longs, + skippedInPosition: result.skippedInPosition, + baseline: baseline && { ...baseline, netReturns: undefined }, + percentile, + }); + for (const t of result.trades) trades.push({ ...t, exit: exit.id, split: segment.name }); + } + } + return { options: o, warmupBars: ind.warmupBars, segments, signals, rows, trades }; +} + +// --------------------------------------------------------------------------- +// 10. Output helpers +// --------------------------------------------------------------------------- + +export const isoTime = (seconds) => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z'); + +const CSV_COLUMNS = [ + 'exit', 'split', 'direction', 'prev_pivot_time', 'pivot_time', 'confirmation_time', 'entry_time', + 'entry_open', 'entry_price', 'initial_stop', 'target', 'exit_time', 'exit_level', 'exit_price', + 'exit_reason', 'return_pct', +]; + +/** Trades as CSV. Times are bar open times (ISO, UTC); prices include slippage. */ +export function tradesToCsv(trades, candles) { + const time = (bar) => (bar === null || bar === undefined ? '' : isoTime(candles[bar].time)); + const num = (value) => (value === null || value === undefined ? '' : String(Number(value.toPrecision(10)))); + const lines = trades.map((t) => [ + t.exit, t.split, t.direction, time(t.prevPivotBar), time(t.pivotBar), time(t.signalBar), time(t.entryBar), + num(t.entryOpen), num(t.entryPrice), num(t.initialStop), num(t.target), time(t.exitBar), num(t.exitLevel), + num(t.exitPrice), t.reason, (t.return * 100).toFixed(4), + ].join(',')); + return [CSV_COLUMNS.join(','), ...lines].join('\n') + '\n'; +} + +const pct = (value, digits = 1) => (value === null || value === undefined ? 'N/A' : `${(value * 100).toFixed(digits)}%`); + +/** Compact side-by-side table: one row per exit variant and split. */ +export function formatTable(rows) { + const head = ['Exit', 'Split', 'Trades', 'Net', 'Win', 'PF', 'MaxDD', 'Rand p5', 'Rand p50', 'Rand p95', 'Pctl']; + const body = rows.map((row) => { + const m = row.metrics; + const b = row.baseline; + return [ + row.exit, row.split, String(m.trades), pct(m.netReturn), pct(m.winRate, 0), + m.profitFactor === null ? 'N/A' : m.profitFactor.toFixed(2), pct(m.maxDrawdown), + pct(b?.p5), pct(b?.p50), pct(b?.p95), row.percentile === null ? 'N/A' : row.percentile.toFixed(1), + ]; + }); + const all = [head, ...body]; + const widths = head.map((_, c) => Math.max(...all.map((r) => r[c].length))); + return all.map((r) => r.map((v, c) => (c < 2 ? v.padEnd(widths[c]) : v.padStart(widths[c]))).join(' ')).join('\n'); +} diff --git a/examples/honest-backtest/index.js b/examples/honest-backtest/index.js new file mode 100644 index 00000000..29a9736d --- /dev/null +++ b/examples/honest-backtest/index.js @@ -0,0 +1,117 @@ +// Honest backtest of an RSI divergence + EMA trend filter strategy, with a random-entry baseline. +// Run: npm run build && node examples/honest-backtest/index.js [--symbol BINANCE:BTCUSDT] [--timeframe 240] +// Offline replay of a saved snapshot: node examples/honest-backtest/index.js --snapshot path/to/snapshot.json +// Every option: node examples/honest-backtest/index.js --help +// Research example only: it places no orders and says nothing about future returns. +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { getCandles, timeframeSeconds } from '@mathieuc/tradingview'; +import { + DEFAULTS, freezeSnapshot, formatTable, isoTime, runBacktest, tradesToCsv, +} from './engine.js'; + +const numeric = ['minYears', 'splitRatio', 'pivotLookback', 'minPivotDistance', 'maxPivotDistance', 'rsiLength', + 'emaLength', 'atrLength', 'fee', 'slippage', 'stopPct', 'atrStopMult', 'atrTargetR', 'trailAtrMult', 'runs', 'seed']; +const { values: args } = parseArgs({ + options: { + symbol: { type: 'string', default: DEFAULTS.symbol }, + timeframe: { type: 'string', default: DEFAULTS.timeframe }, + count: { type: 'string' }, + snapshot: { type: 'string' }, + out: { type: 'string', default: 'honest-backtest-output' }, + help: { type: 'boolean', default: false }, + ...Object.fromEntries(numeric.map((name) => [name, { type: 'string' }])), + }, +}); + +if (args.help) { + console.log(`Options (defaults in brackets): + --symbol [${DEFAULTS.symbol}] --timeframe [${DEFAULTS.timeframe}] --count [enough for --minYears after warm-up] + --snapshot replay a saved snapshot instead of downloading candles + --out [honest-backtest-output] folder for snapshot.json and trades.csv + ${numeric.map((name) => `--${name} [${DEFAULTS[name]}]`).join(' ')}`); + process.exit(0); +} + +const options = { ...DEFAULTS, symbol: args.symbol, timeframe: args.timeframe }; +for (const name of numeric) { + if (args[name] === undefined) continue; + const value = Number(args[name]); + if (!Number.isFinite(value)) throw new Error(`--${name} must be a number`); + options[name] = value; +} + +const pct = (value) => `${(value * 100).toFixed(2)}%`; +const outDir = resolve(args.out); +mkdirSync(outDir, { recursive: true }); + +// 1. Freeze the snapshot once. Everything below reads this frozen copy only. +let snapshot; +let droppedFormingBar; +if (args.snapshot) { + const saved = JSON.parse(readFileSync(args.snapshot, 'utf8')); + snapshot = freezeSnapshot(saved.candles, { ...saved, now: saved.lastTime + saved.barSeconds }); + if (snapshot.sha256 !== saved.sha256) throw new Error('Snapshot checksum mismatch'); + Object.assign(options, { symbol: snapshot.symbol, timeframe: snapshot.timeframe }); + droppedFormingBar = saved.droppedFormingBar; // As recorded when the snapshot was captured +} else { + const barSeconds = timeframeSeconds(options.timeframe); + if (!barSeconds) throw new Error(`Unsupported timeframe for this example: ${options.timeframe}`); + const warmup = Math.max(options.rsiLength, options.emaLength, options.atrLength); + const wanted = Math.ceil((options.minYears * 365.25 * 86_400) / barSeconds) + warmup + 1; + const count = args.count ? Number(args.count) : wanted; + const candles = await getCandles({ symbol: options.symbol, timeframe: options.timeframe, count, timeoutMs: 120_000 }); + snapshot = freezeSnapshot(candles, { symbol: options.symbol, timeframe: options.timeframe, barSeconds }); + droppedFormingBar = snapshot.droppedFormingBar; + writeFileSync(join(outDir, 'snapshot.json'), JSON.stringify(snapshot)); +} + +console.log('== Snapshot (frozen once, reused for every run) =='); +console.log(`${snapshot.symbol} ${snapshot.timeframe}: ${snapshot.count} closed bars, ${isoTime(snapshot.firstTime)} -> ${isoTime(snapshot.lastTime)} (bar open times, UTC)`); +console.log(`Forming bar dropped: ${droppedFormingBar ? 'yes' : 'no'}. sha256 ${snapshot.sha256.slice(0, 16)}`); +const missing = snapshot.gaps.reduce((sum, gap) => sum + gap.missingBars, 0); +console.log(`Gaps: ${snapshot.gaps.length} (${missing} missing bars)${snapshot.gaps.slice(0, 5).map((g) => `\n ${isoTime(g.afterTime)} -> ${isoTime(g.beforeTime)} (${g.missingBars} bars)`).join('')}`); + +const report = runBacktest(snapshot, options); +const { inSample, outOfSample } = report.segments; +const tradableYears = ((snapshot.count - report.warmupBars) * snapshot.barSeconds) / (365.25 * 86_400); +console.log(tradableYears >= options.minYears + ? `History: ${tradableYears.toFixed(2)} years tradable after warm-up (>= ${options.minYears} wanted).` + : `WARNING: only ${tradableYears.toFixed(2)} years tradable after warm-up; ${options.minYears} wanted. The server returned less history. Results are even less conclusive.`); + +// 2. Chronological split. +const range = (s) => `${isoTime(snapshot.candles[s.start].time)} -> ${isoTime(snapshot.candles[s.end - 1].time)} (${s.end - s.start} bars)`; +console.log('\n== Split (chronological, no position crosses it) =='); +console.log(`Warm-up: ${isoTime(snapshot.firstTime)} -> ${isoTime(snapshot.candles[report.warmupBars - 1].time)} (${report.warmupBars} bars, indicators only, never traded)`); +console.log(`In-sample: ${range(inSample)}`); +console.log(`Out-of-sample: ${range(outOfSample)}`); + +// 3. Signals. +const inside = (s, seg) => s.signalBar >= seg.start && s.entryBar < seg.end; +const count = (seg) => { + const list = report.signals.filter((s) => inside(s, seg)); + return `${list.length} (${list.filter((s) => s.direction === 'long').length} long, ${list.filter((s) => s.direction === 'short').length} short)`; +}; +console.log('\n== Signals: RSI divergence + EMA trend filter =='); +console.log(`Pivot lookback ${options.pivotLookback}: a pivot is confirmed ${options.pivotLookback} bars later; signal at that bar's close, entry at the NEXT bar's open.`); +console.log(`RSI ${options.rsiLength}, EMA ${options.emaLength}, pivots ${options.minPivotDistance}-${options.maxPivotDistance} bars apart. IS signals: ${count(inSample)}. OOS signals: ${count(outOfSample)}.`); +console.log(`Costs per side: fee ${pct(options.fee)}, adverse slippage ${pct(options.slippage)}. Sizing: 100% of equity, no leverage, one position at a time.`); +console.log('Same-bar stop and target: counted as a stop. Gap through a level: filled at the open.'); + +console.log(`\n== Results (random baseline: ${options.runs} seeded runs per row, seed ${options.seed}) ==`); +console.log(formatTable(report.rows)); +for (const row of report.rows) { + if (row.baseline && row.baseline.matchedRuns < row.baseline.runs) { + console.log(`Note: ${row.exit} ${row.split}: ${row.baseline.matchedRuns}/${row.baseline.runs} random runs reached ${row.metrics.trades} trades (mean ${row.baseline.meanTrades.toFixed(1)}).`); + } +} +console.log(`Exits: ${[...new Set(report.rows.map((r) => `${r.exit} = ${r.exitLabel}`))].join('; ')}.`); +console.log('Net: compounded net return. Win: share of trades with a positive net return. PF: gross profit / gross loss (N/A without a losing trade).'); +console.log('MaxDD: largest peak-to-trough equity fall, marked to market at bar closes. Pctl: 100 x share of random runs with a strictly lower net return.'); +const fewest = Math.min(...report.rows.map((r) => r.metrics.trades)); +if (fewest < 30) console.log(`WARNING: as few as ${fewest} trades in a row. With so few trades, net return and percentile are mostly noise.`); +console.log('A percentile near 50, a negative OOS return or few trades is a valid, inconclusive outcome, not a bug.'); + +writeFileSync(join(outDir, 'trades.csv'), tradesToCsv(report.trades, snapshot.candles)); +console.log(`\nWrote ${report.trades.length} trades to ${join(outDir, 'trades.csv')}${args.snapshot ? '' : ` and the snapshot to ${join(outDir, 'snapshot.json')}`}.`); diff --git a/llms.txt b/llms.txt index 2b22d7b7..f78c3bbb 100644 --- a/llms.txt +++ b/llms.txt @@ -22,5 +22,6 @@ Low-level API: TradingViewClient, client.createChart() (ChartSession: setMarket, Pine studies need a TradingView account (session cookies). Built-in studies and candles/quotes work anonymously, with history limits. Docs: docs/data-api.md, docs/low-level-api.md, docs/migration-v4.md, docs/v4-coverage.md, docs/v4-reliability.md, docs/protocol-contributing.md. Examples: examples/. +Backtesting: examples/honest-backtest/ is an example protocol on getCandles data (no look-ahead, costs, IS/OOS split, random-entry baseline), not a library API. The interactive setup CLI is available with npx @mathieuc/tradingview. It opens Molted Studio, launches a local coding agent with a prompt, prints that prompt, or installs the library. It is not a market-data query CLI. No MCP server, backtesting engine or hosted trading API is shipped. Questions and bugs: https://github.com/Mathieu2301/TradingView-API/issues/new/choose diff --git a/tests/unit/honest-backtest.test.ts b/tests/unit/honest-backtest.test.ts new file mode 100644 index 00000000..e2236097 --- /dev/null +++ b/tests/unit/honest-backtest.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; +import { + atrTrailingExit, computeMetrics, createRng, findPivots, findSignals, fillPrice, fixedExit, freezeSnapshot, + percentileRank, quantile, randomBaseline, runBacktest, simulate, splitSegments, tradeReturn, tradesToCsv, + type Bar, type Entry, type Indicators, +} from '../../examples/honest-backtest/engine.js'; + +const HOUR = 3_600; +const NO_COSTS = { fee: 0, slippage: 0 }; + +/** Flat bars: open = close = 100, high 101, low 99, one every 4 hours. */ +function flatBars(n: number): Bar[] { + return Array.from({ length: n }, (_, i) => ({ time: i * 4 * HOUR, open: 100, high: 101, low: 99, close: 100, volume: 1 })); +} + +/** Seeded random walk, valid OHLC. */ +function randomWalk(n: number, seed: number): Bar[] { + const rng = createRng(seed); + let price = 100; + return Array.from({ length: n }, (_, i) => { + const open = price; + const close = open * (1 + (rng() - 0.5) * 0.04); + price = close; + return { + time: i * 4 * HOUR, open, close, + high: Math.max(open, close) * (1 + rng() * 0.01), + low: Math.min(open, close) * (1 - rng() * 0.01), + volume: 1, + }; + }); +} + +const constant = (n: number, value: number) => new Array(n).fill(value); +const longAt = (entryBar: number): Entry => ({ direction: 'long', signalBar: entryBar - 1, entryBar, pivotBar: null, prevPivotBar: null }); +const whole = (bars: Bar[]) => ({ name: 'ALL', start: 0, end: bars.length }); + +describe('honest backtest example: snapshot', () => { + it('drops the forming bar, records gaps and freezes the data', () => { + const bars = flatBars(10).filter((_, i) => i !== 4 && i !== 5); + const last = bars[bars.length - 1]!; + const snapshot = freezeSnapshot(bars, { symbol: 'X:Y', timeframe: '240', barSeconds: 4 * HOUR, now: last.time + HOUR }); + expect(snapshot.droppedFormingBar).toBe(true); + expect(snapshot.count).toBe(7); + expect(snapshot.lastTime).toBe(8 * 4 * HOUR); + expect(snapshot.gaps).toEqual([{ afterTime: 3 * 4 * HOUR, beforeTime: 6 * 4 * HOUR, missingBars: 2 }]); + expect(Object.isFrozen(snapshot.candles) && Object.isFrozen(snapshot.candles[0])).toBe(true); + expect(snapshot.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it('rejects unordered candles', () => { + const bars = flatBars(5); + [bars[1], bars[2]] = [bars[2]!, bars[1]!]; + expect(() => freezeSnapshot(bars, { symbol: 'X:Y', timeframe: '240', barSeconds: 4 * HOUR, now: 1e12 })).toThrow(/ordered/); + }); +}); + +describe('honest backtest example: no look-ahead', () => { + it('confirms a pivot only lookback bars later', () => { + const lows = [10, 9, 8, 7, 6, 5, 6, 7, 8, 9, 10, 11]; + expect(findPivots(lows, 3, 'low')).toEqual([{ bar: 5, confirmBar: 8, price: 5 }]); + // Without bar 8, the same pivot cannot be known yet. + expect(findPivots(lows.slice(0, 8), 3, 'low')).toEqual([]); + }); + + it('fires on the confirmation bar close and enters at the next bar open', () => { + const bars = flatBars(40).map((bar, i) => ({ ...bar, open: 100 + i / 100 })); + bars[10] = { ...bars[10]!, low: 95 }; + bars[20] = { ...bars[20]!, low: 90 }; // Lower price low... + const rsi = constant(40, 50); + rsi[10] = 20; + rsi[20] = 30; // ...with a higher RSI low: bullish divergence + const ind: Indicators = { rsi, ema: constant(40, 50), atr: constant(40, 1) }; // Close 100 > EMA 50 + const options = { pivotLookback: 3, minPivotDistance: 5, maxPivotDistance: 60 }; + + const signals = findSignals(bars, ind, options); + expect(signals).toEqual([{ direction: 'long', signalBar: 23, entryBar: 24, pivotBar: 20, prevPivotBar: 10 }]); + expect(findSignals(bars.slice(0, 23), ind, options)).toEqual([]); + + const costs = { fee: 0, slippage: 0.0005 }; + const { trades } = simulate({ candles: bars, ind, entries: signals, exit: fixedExit(2, 0.02), costs, segment: whole(bars) }); + expect(trades[0]!.entryBar).toBe(24); + expect(trades[0]!.entryOpen).toBe(bars[24]!.open); + expect(trades[0]!.entryPrice).toBeCloseTo(bars[24]!.open * 1.0005, 10); + }); + + it('ignores the divergence when the trend filter disagrees', () => { + const bars = flatBars(40); + bars[10] = { ...bars[10]!, low: 95 }; + bars[20] = { ...bars[20]!, low: 90 }; + const rsi = constant(40, 50); + rsi[10] = 20; + rsi[20] = 30; + expect(findSignals(bars, { rsi, ema: constant(40, 150) }, { pivotLookback: 3 })).toEqual([]); + }); +}); + +describe('honest backtest example: fills and costs', () => { + it('counts a bar touching both target and stop as a loss', () => { + const bars = flatBars(10); + bars[3] = { ...bars[3]!, high: 110, low: 90 }; + const { trades } = simulate({ candles: bars, ind: { rsi: [], ema: [], atr: [] }, entries: [longAt(2)], exit: fixedExit(2, 0.02), costs: NO_COSTS, segment: whole(bars) }); + expect(trades[0]).toMatchObject({ exitBar: 3, exitLevel: 98, reason: 'stop_and_target_same_bar' }); + expect(trades[0]!.return).toBeCloseTo(-0.02, 12); + }); + + it('fills a gap through the stop at the worse open', () => { + const bars = flatBars(10); + bars[3] = { ...bars[3]!, open: 95, high: 96, low: 94, close: 95 }; + const { trades } = simulate({ candles: bars, ind: { rsi: [], ema: [], atr: [] }, entries: [longAt(2)], exit: fixedExit(2, 0.02), costs: NO_COSTS, segment: whole(bars) }); + expect(trades[0]).toMatchObject({ exitBar: 3, exitLevel: 95, reason: 'stop_gap' }); + }); + + it('applies fee and adverse slippage on entry and exit, long and short', () => { + const fee = 0.001; + const slippage = 0.0005; + const bars = flatBars(10); + bars[3] = { ...bars[3]!, high: 105 }; // Long target 104 hit + const ind = { rsi: [], ema: [], atr: [] }; + const long = simulate({ candles: bars, ind, entries: [longAt(2)], exit: fixedExit(2, 0.02), costs: { fee, slippage }, segment: whole(bars) }).trades[0]!; + expect(long.entryPrice).toBeCloseTo(100.05, 10); + expect(long.exitPrice).toBeCloseTo(104 * 0.9995, 10); + expect(long.return).toBeCloseTo(103.948 / 100.05 - 1 - fee - fee * (103.948 / 100.05), 12); + + const shortBars = flatBars(10); + shortBars[3] = { ...shortBars[3]!, low: 95 }; // Short target 96 hit + const short = simulate({ candles: shortBars, ind, entries: [{ ...longAt(2), direction: 'short' }], exit: fixedExit(2, 0.02), costs: { fee, slippage }, segment: whole(shortBars) }).trades[0]!; + expect(short.entryPrice).toBeCloseTo(99.95, 10); + expect(short.exitPrice).toBeCloseTo(96 * 1.0005, 10); + expect(short.return).toBeCloseTo(1 - 96.048 / 99.95 - fee - fee * (96.048 / 99.95), 12); + expect(fillPrice('exit', 'short', 100, slippage)).toBeCloseTo(100.05, 10); + }); + + it('charges the same costs in the random baseline', () => { + const bars = flatBars(30); // Never reaches a 2% stop or target: every trade ends at the segment close + const costs = { fee: 0.001, slippage: 0.0005 }; + const baseline = randomBaseline({ + candles: bars, ind: { rsi: [], ema: [], atr: [] }, exit: fixedExit(2, 0.02), costs, + segment: whole(bars), tradeCount: 1, longShare: 1, runs: 20, seed: 1, + }); + const expected = tradeReturn('long', 100.05, 100 * 0.9995, costs.fee); + expect(expected).toBeLessThan(-0.002); + expect(baseline.netReturns).toHaveLength(20); + for (const value of baseline.netReturns) expect(value).toBeCloseTo(expected, 12); + }); +}); + +describe('honest backtest example: split', () => { + it('never lets a position cross a segment boundary', () => { + const bars = flatBars(20); + const ind = { rsi: [], ema: [], atr: [] }; + const segments = splitSegments(20, 0, 0.5); + const exit = fixedExit(2, 0.02); + // Signal on the last in-sample bar, entry on the first out-of-sample bar: taken by neither. + const straddling = longAt(10); + expect(simulate({ candles: bars, ind, entries: [straddling], exit, costs: NO_COSTS, segment: segments.inSample }).trades).toEqual([]); + expect(simulate({ candles: bars, ind, entries: [straddling], exit, costs: NO_COSTS, segment: segments.outOfSample }).trades).toEqual([]); + // A trade still open at the end of the segment is closed on its last bar. + const { trades } = simulate({ candles: bars, ind, entries: [longAt(5)], exit, costs: NO_COSTS, segment: segments.inSample }); + expect(trades[0]).toMatchObject({ exitBar: 9, reason: 'segment_end' }); + }); + + it('keeps every trade of a full run inside its segment, after the warm-up', () => { + const bars = randomWalk(1200, 7); + const report = runBacktest({ candles: bars }, { emaLength: 50, pivotLookback: 3, runs: 10 }); + const { inSample, outOfSample } = report.segments; + expect(inSample.start).toBe(report.warmupBars); + expect(inSample.end).toBe(outOfSample.start); + expect(outOfSample.end).toBe(bars.length); + expect(report.trades.length).toBeGreaterThan(0); + for (const trade of report.trades) { + const segment = trade.split === 'IS' ? inSample : outOfSample; + expect(trade.signalBar).toBeGreaterThanOrEqual(segment.start); + expect(trade.entryBar).toBe(trade.signalBar + 1); + expect(trade.exitBar).toBeLessThan(segment.end); + } + expect(report.rows).toHaveLength(8); + const csv = tradesToCsv(report.trades, bars).trim().split('\n'); + expect(csv[0]).toContain('pivot_time,confirmation_time,entry_time'); + expect(csv).toHaveLength(report.trades.length + 1); + }); +}); + +describe('honest backtest example: trailing stop', () => { + // Rising bars: close = 100.5 + i, ATR fixed at 1, multiplier 2 => the stop never triggers. + const rising = (n: number): Bar[] => Array.from({ length: n }, (_, i) => ({ + time: i * 4 * HOUR, open: 100 + i, high: 101 + i, low: 99.5 + i, close: 100.5 + i, volume: 1, + })); + const ind = { rsi: [], ema: [], atr: constant(30, 1) }; + const exit = atrTrailingExit(2); + + it('ratchets from closed bars only', () => { + const bars = rising(30); + const { trades } = simulate({ candles: bars, ind, entries: [longAt(5)], exit, costs: NO_COSTS, segment: whole(bars), trace: true }); + const stops = trades[0]!.stops!; + expect(stops[0]).toBe(bars[5]!.open - 2); // From the signal bar ATR, before any trailing + for (let j = 1; j < stops.length; j++) { + // The stop used on bar 5 + j comes from the close of bar 5 + j - 1, never from bar 5 + j itself. + expect(stops[j]).toBe(Math.max(stops[j - 1]!, bars[5 + j - 1]!.close - 2)); + } + }); + + it('is unchanged on a bar when that bar or later bars change', () => { + const a = rising(30); + const b = rising(30).map((bar, i) => (i >= 12 ? { ...bar, high: bar.high + 500, close: bar.close + 400, open: bar.open + 300 } : bar)); + const run = (bars: Bar[]) => simulate({ candles: bars, ind, entries: [longAt(5)], exit, costs: NO_COSTS, segment: whole(bars), trace: true }).trades[0]!.stops!; + const stopsA = run(a); + const stopsB = run(b); + expect(stopsB.slice(0, 12 - 5 + 1)).toEqual(stopsA.slice(0, 12 - 5 + 1)); // Up to and including bar 12 + expect(stopsB[13 - 5]).not.toBe(stopsA[13 - 5]); // Bar 13 sees bar 12's close + }); +}); + +describe('honest backtest example: random baseline and metrics', () => { + const bars = randomWalk(600, 3); + const ind = { rsi: [], ema: [], atr: constant(600, 2) }; + const input = { + candles: bars, ind, exit: fixedExit(1.5, 0.02), costs: { fee: 0.001, slippage: 0.0005 }, + segment: { name: 'IS', start: 50, end: 600 }, tradeCount: 12, longShare: 0.5, runs: 50, + }; + + it('is reproducible with a seed', () => { + const first = randomBaseline({ ...input, seed: 123 }); + const second = randomBaseline({ ...input, seed: 123 }); + const other = randomBaseline({ ...input, seed: 124 }); + expect(second.netReturns).toEqual(first.netReturns); + expect(other.netReturns).not.toEqual(first.netReturns); + expect(first.matchedRuns).toBe(50); + expect(first.meanTrades).toBe(12); + }); + + it('computes percentile, quantiles, profit factor and drawdown as documented', () => { + expect(percentileRank(0.1, [0, 0.1, 0.2, -0.1])).toBe(50); // Strictly lower only + expect(quantile([1, 2, 3, 4, 5], 0.5)).toBe(3); + expect(quantile([0, 10], 0.05)).toBeCloseTo(0.5, 12); + expect(computeMetrics([]).profitFactor).toBeNull(); + expect(computeMetrics([{ return: 0.1, marks: [] }]).profitFactor).toBeNull(); // No losing trade + const m = computeMetrics([{ return: 0.1, marks: [] }, { return: -0.05, marks: [-0.2] }]); + expect(m.netReturn).toBeCloseTo(1.1 * 0.95 - 1, 12); + expect(m.profitFactor).toBeCloseTo(0.1 / (1.1 * 0.05), 12); + expect(m.maxDrawdown).toBeCloseTo(0.2, 12); // Marked-to-market low while in the trade + expect(m.winRate).toBe(0.5); + }); +}); From 76f4766b8ffd999a833221df4d90b331019ff17a Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 23 Oct 2026 03:42:50 +0200 Subject: [PATCH 2/2] Refactor honest backtest trade loop for CodeFactor Split runTrade into small helpers (openTrade, barExit, gapExit, intrabarExit, closedTrade, reachedStop, reachedTarget) to reduce its complexity. Behaviour is unchanged: fills, exit reasons, costs and the trailing stop update follow the same rules and order as before. --- examples/honest-backtest/engine.js | 123 ++++++++++++++++++----------- 1 file changed, 75 insertions(+), 48 deletions(-) diff --git a/examples/honest-backtest/engine.js b/examples/honest-backtest/engine.js index e275982f..34015e66 100644 --- a/examples/honest-backtest/engine.js +++ b/examples/honest-backtest/engine.js @@ -301,58 +301,85 @@ export function tradeReturn(direction, entryFill, exitFill, fee) { return gross - fee - fee * ratio; } -function runTrade(entry, exit, context) { - const { candles, ind, costs, end, trace } = context; - const { direction, entryBar: e } = entry; +const reachedStop = (direction, price, stop) => (direction === 'long' ? price <= stop : price >= stop); +const reachedTarget = (direction, price, target) => target !== null && (direction === 'long' ? price >= target : price <= target); + +/** The bar opened beyond a level: the order fills at the open, the worse price for a stop. */ +function gapExit(bar, direction, stop, target) { + if (reachedStop(direction, bar.open, stop)) return { level: bar.open, reason: 'stop_gap' }; + if (reachedTarget(direction, bar.open, target)) return { level: bar.open, reason: 'target_gap' }; + return null; +} + +/** Levels touched inside the bar. The intrabar order is unknown: when both are, the stop comes first. */ +function intrabarExit(bar, direction, stop, target, trailing) { const long = direction === 'long'; - const refPrice = candles[e].open; + const hitStop = reachedStop(direction, long ? bar.low : bar.high, stop); + const hitTarget = reachedTarget(direction, long ? bar.high : bar.low, target); + if (hitStop && hitTarget) return { level: stop, reason: 'stop_and_target_same_bar' }; + if (hitStop) return { level: stop, reason: trailing ? 'trailing_stop' : 'stop' }; + if (hitTarget) return { level: target, reason: 'target' }; + return null; +} + +/** Exit on bar i, if any: gap at the open (not on the entry bar), then intrabar, then segment end. */ +function barExit(i, trade) { + const { candles, direction, stop, target, entryBar, end, trailing } = trade; + const bar = candles[i]; + return (i > entryBar ? gapExit(bar, direction, stop, target) : null) + ?? intrabarExit(bar, direction, stop, target, trailing) + ?? (i === end - 1 ? { level: bar.close, reason: 'segment_end' } : null); +} + +function closedTrade(entry, trade, i, { level, reason }, costs) { + const exitPrice = fillPrice('exit', entry.direction, level, costs.slippage); + return { + ...entry, + exitBar: i, + entryOpen: trade.refPrice, + entryPrice: trade.entryPrice, + initialStop: trade.initialStop, + target: trade.target, + exitLevel: level, + exitPrice, + reason, + return: tradeReturn(entry.direction, trade.entryPrice, exitPrice, costs.fee), + marks: trade.marks, + ...(trade.stops ? { stops: trade.stops } : {}), + }; +} + +function openTrade(entry, exit, context) { + const { candles, ind, costs, end, trace } = context; + const { direction, entryBar } = entry; + const refPrice = candles[entryBar].open; const levels = exit.levels({ direction, refPrice, signalBar: entry.signalBar, ind, candles }); if (!levels) return null; - const entryPrice = fillPrice('entry', direction, refPrice, costs.slippage); - let { stop } = levels; - const { target } = levels; - const stops = trace ? [] : undefined; - const marks = []; - - for (let i = e; i < end; i++) { - const bar = candles[i]; - stops?.push(stop); - let level; - let reason; - if (i > e) { - // The bar opened beyond a level: the order fills at the open, the worse price for a stop. - if (long ? bar.open <= stop : bar.open >= stop) [level, reason] = [bar.open, 'stop_gap']; - else if (target !== null && (long ? bar.open >= target : bar.open <= target)) [level, reason] = [bar.open, 'target_gap']; - } - if (level === undefined) { - const hitStop = long ? bar.low <= stop : bar.high >= stop; - const hitTarget = target !== null && (long ? bar.high >= target : bar.low <= target); - // Intrabar order is unknown: when both are touched, assume the stop came first. - if (hitStop && hitTarget) [level, reason] = [stop, 'stop_and_target_same_bar']; - else if (hitStop) [level, reason] = [stop, exit.trail ? 'trailing_stop' : 'stop']; - else if (hitTarget) [level, reason] = [target, 'target']; - } - if (level === undefined && i === end - 1) [level, reason] = [bar.close, 'segment_end']; - if (level !== undefined) { - const exitPrice = fillPrice('exit', direction, level, costs.slippage); - return { - ...entry, - exitBar: i, - entryOpen: refPrice, - entryPrice, - initialStop: levels.stop, - target, - exitLevel: level, - exitPrice, - reason, - return: tradeReturn(direction, entryPrice, exitPrice, costs.fee), - marks, - ...(trace ? { stops } : {}), - }; - } + return { + candles, direction, entryBar, end, refPrice, + entryPrice: fillPrice('entry', direction, refPrice, costs.slippage), + initialStop: levels.stop, + stop: levels.stop, + target: levels.target, + trailing: Boolean(exit.trail), + stops: trace ? [] : undefined, + marks: [], + }; +} + +function runTrade(entry, exit, context) { + const { candles, ind, costs } = context; + const trade = openTrade(entry, exit, context); + if (!trade) return null; + const { direction } = trade; + for (let i = trade.entryBar; i < trade.end; i++) { + trade.stops?.push(trade.stop); + const exitFill = barExit(i, trade); + if (exitFill) return closedTrade(entry, trade, i, exitFill, costs); // Still open at the close: mark to market as if closed there (with exit costs). - marks.push(tradeReturn(direction, entryPrice, fillPrice('exit', direction, bar.close, costs.slippage), costs.fee)); - if (exit.trail) stop = exit.trail(stop, i, { direction, candles, ind }); + const markPrice = fillPrice('exit', direction, candles[i].close, costs.slippage); + trade.marks.push(tradeReturn(direction, trade.entryPrice, markPrice, costs.fee)); + if (exit.trail) trade.stop = exit.trail(trade.stop, i, { direction, candles, ind }); } return null; // Only reached when the entry bar is outside the segment }