From 4b332dc2681b929c2064a553c538f5fc66d51c9f Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 23 Oct 2026 04:41:54 +0200 Subject: [PATCH 1/5] Add @mathieuc/tradingview/backtest entry point (4.1.0-beta.1) Turn the honest-backtest example (#351) into a library module: the caller writes only the entry rule, the module applies the protocol and returns a JSON report. - backtest() downloads candles once and freezes them; runBacktest() runs synchronously on candles or a saved snapshot. - Signals are { bar, direction, refs? } decided at the close of bar; the entry is always the next bar's open. - New look-ahead check: the signal is rerun on truncated history and the run stops with the new LOOK_AHEAD error code when a signal depends on later bars or repaints. - Same rules as the example: costs on both sides, same-candle stop and target as a loss, gap fills at the open, warm-up, IS/OOS split without open positions across it, four exits, seeded random-entry baseline. - examples/honest-backtest uses the module (identical report on the same snapshot); new examples/backtest-custom-signal.js. - docs/backtest.md, data API guide, llms.txt, READMEs, release notes. --- README.md | 16 + docs/README.es.md | 16 + docs/README.fr.md | 16 + docs/README.pt.md | 16 + docs/backtest.md | 139 ++++++ docs/data-api.md | 15 +- docs/release-4.1.0-beta.1.md | 22 + examples/README.md | 5 +- examples/backtest-custom-signal.js | 27 ++ examples/honest-backtest/README.md | 19 +- examples/honest-backtest/engine.d.ts | 180 -------- examples/honest-backtest/engine.js | 650 --------------------------- examples/honest-backtest/index.js | 107 ++--- llms.txt | 13 +- package-lock.json | 4 +- package.json | 8 +- scripts/smoke.mjs | 15 +- src/backtest/exits.ts | 117 +++++ src/backtest/index.ts | 32 ++ src/backtest/indicators.ts | 101 +++++ src/backtest/report.ts | 88 ++++ src/backtest/run.ts | 516 +++++++++++++++++++++ src/backtest/signals.ts | 58 +++ src/backtest/simulate.ts | 194 ++++++++ src/backtest/snapshot.ts | 133 ++++++ src/backtest/stats.ts | 152 +++++++ src/backtest/types.ts | 129 ++++++ src/errors.ts | 4 +- tests/unit/backtest.test.ts | 403 +++++++++++++++++ tests/unit/exports.test.ts | 11 + tests/unit/honest-backtest.test.ts | 243 ---------- 31 files changed, 2273 insertions(+), 1176 deletions(-) create mode 100644 docs/backtest.md create mode 100644 docs/release-4.1.0-beta.1.md create mode 100644 examples/backtest-custom-signal.js delete mode 100644 examples/honest-backtest/engine.d.ts delete mode 100644 examples/honest-backtest/engine.js create mode 100644 src/backtest/exits.ts create mode 100644 src/backtest/index.ts create mode 100644 src/backtest/indicators.ts create mode 100644 src/backtest/report.ts create mode 100644 src/backtest/run.ts create mode 100644 src/backtest/signals.ts create mode 100644 src/backtest/simulate.ts create mode 100644 src/backtest/snapshot.ts create mode 100644 src/backtest/stats.ts create mode 100644 src/backtest/types.ts create mode 100644 tests/unit/backtest.test.ts delete mode 100644 tests/unit/honest-backtest.test.ts diff --git a/README.md b/README.md index cb4d7d2e..a0005d47 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,22 @@ const { values } = await getIndicatorData({ Websocket data functions accept `timeoutMs`, an `AbortSignal`, account `credentials`, and an optional shared `client`. HTTP lookups accept an `AbortSignal` through their options. Errors are `TradingViewError`s with a `code` such as `SYMBOL_ERROR`, `TIMEOUT` or `STUDY_ERROR`. Read the [data API guide](docs/data-api.md) for every option. +## Backtest API + +Write the entry rule; `@mathieuc/tradingview/backtest` does the rest: frozen candles, look-ahead rejection, entry at the next open, fees and slippage on both sides, pessimistic fills, in-sample/out-of-sample split, four exits and a seeded random-entry baseline. + +```ts +import { backtest, formatReport, rsiDivergence } from '@mathieuc/tradingview/backtest'; + +const { report } = await backtest({ + symbol: 'BINANCE:BTCUSDT', timeframe: '240', years: 2, + signal: rsiDivergence(), // Or your own: (candles) => [{ bar, direction: 'long' | 'short' }] +}); +console.log(formatReport(report)); // report is plain JSON: rows per exit and split, trades, warnings +``` + +It makes no claim of profitability: a negative or inconclusive result is a valid outcome. Read the [backtest guide](docs/backtest.md). + ## Low-level API For full control (several charts and studies on one connection, replay mode, raw packets), use the client and sessions directly: diff --git a/docs/README.es.md b/docs/README.es.md index ff1fb824..ac3f8a70 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -121,6 +121,22 @@ const { values } = await getIndicatorData({ Las funciones de datos WebSocket aceptan `timeoutMs`, un `AbortSignal`, credenciales de cuenta (`credentials`) y un `client` compartido opcional. Las consultas HTTP aceptan un `AbortSignal` en sus opciones. Los errores son `TradingViewError` con un `code` como `SYMBOL_ERROR`, `TIMEOUT` o `STUDY_ERROR`. Consulta todas las opciones en la [guía de la API de datos](data-api.md). +## API de backtest + +Escribe la regla de entrada; `@mathieuc/tradingview/backtest` hace el resto: velas congeladas, rechazo de la anticipación (look-ahead), entrada en la apertura siguiente, comisiones y slippage en ambos lados, ejecuciones pesimistas, división in-sample/out-of-sample, cuatro salidas y una referencia de entradas aleatorias con semilla. + +```ts +import { backtest, formatReport, rsiDivergence } from '@mathieuc/tradingview/backtest'; + +const { report } = await backtest({ + symbol: 'BINANCE:BTCUSDT', timeframe: '240', years: 2, + signal: rsiDivergence(), // O la tuya: (candles) => [{ bar, direction: 'long' | 'short' }] +}); +console.log(formatReport(report)); // report es JSON simple: filas por salida y segmento, operaciones, avisos +``` + +No promete ninguna rentabilidad: un resultado negativo o no concluyente es un resultado válido. Lee la [guía de backtest](backtest.md) (en inglés). + ## API de bajo nivel Para un control completo (varios gráficos y estudios en una conexión, modo replay, paquetes sin procesar), usa directamente el cliente y las sesiones: diff --git a/docs/README.fr.md b/docs/README.fr.md index 06af4b9c..c78964d9 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -117,6 +117,22 @@ const { values } = await getIndicatorData({ Les fonctions de données WebSocket acceptent `timeoutMs`, un `AbortSignal`, les identifiants du compte (`credentials`) et un `client` partagé facultatif. Les requêtes HTTP acceptent un `AbortSignal` via leurs options. Les erreurs sont des `TradingViewError` avec un `code` comme `SYMBOL_ERROR`, `TIMEOUT` ou `STUDY_ERROR`. Le [guide de l’API de données](data-api.md) détaille toutes les options. +## API de backtest + +Écrivez la règle d’entrée ; `@mathieuc/tradingview/backtest` fait le reste : bougies figées, rejet de l’anticipation (look-ahead), entrée à l’ouverture suivante, frais et slippage aux deux bouts, exécutions pessimistes, séparation in-sample/out-of-sample, quatre sorties et une référence d’entrées aléatoires seedées. + +```ts +import { backtest, formatReport, rsiDivergence } from '@mathieuc/tradingview/backtest'; + +const { report } = await backtest({ + symbol: 'BINANCE:BTCUSDT', timeframe: '240', years: 2, + signal: rsiDivergence(), // Ou la vôtre : (candles) => [{ bar, direction: 'long' | 'short' }] +}); +console.log(formatReport(report)); // report est du JSON simple : lignes par sortie et segment, trades, avertissements +``` + +Il ne promet aucune rentabilité : un résultat négatif ou non concluant est un résultat valable. Lire le [guide du backtest](backtest.md) (en anglais). + ## API bas niveau Pour un contrôle complet (plusieurs graphiques et études sur une connexion, mode replay, paquets bruts), utilisez directement le client et les sessions : diff --git a/docs/README.pt.md b/docs/README.pt.md index 36585ab7..90271113 100644 --- a/docs/README.pt.md +++ b/docs/README.pt.md @@ -121,6 +121,22 @@ const { values } = await getIndicatorData({ As funções de dados WebSocket aceitam `timeoutMs`, um `AbortSignal`, credenciais da conta (`credentials`) e um `client` compartilhado opcional. Consultas HTTP aceitam um `AbortSignal` nas opções. Os erros são `TradingViewError` com um `code` como `SYMBOL_ERROR`, `TIMEOUT` ou `STUDY_ERROR`. Veja todas as opções no [guia da API de dados](data-api.md). +## API de backtest + +Escreva a regra de entrada; `@mathieuc/tradingview/backtest` faz o resto: velas congeladas, rejeição de antecipação (look-ahead), entrada na abertura seguinte, taxas e slippage nas duas pontas, execuções pessimistas, divisão in-sample/out-of-sample, quatro saídas e uma referência de entradas aleatórias com semente. + +```ts +import { backtest, formatReport, rsiDivergence } from '@mathieuc/tradingview/backtest'; + +const { report } = await backtest({ + symbol: 'BINANCE:BTCUSDT', timeframe: '240', years: 2, + signal: rsiDivergence(), // Ou a sua: (candles) => [{ bar, direction: 'long' | 'short' }] +}); +console.log(formatReport(report)); // report é JSON simples: linhas por saída e segmento, trades, avisos +``` + +Não promete nenhuma rentabilidade: um resultado negativo ou inconclusivo é um resultado válido. Leia o [guia de backtest](backtest.md) (em inglês). + ## API de baixo nível Para controle completo (vários gráficos e estudos em uma conexão, modo replay, pacotes brutos), use o cliente e as sessões diretamente: diff --git a/docs/backtest.md b/docs/backtest.md new file mode 100644 index 00000000..fff57b7d --- /dev/null +++ b/docs/backtest.md @@ -0,0 +1,139 @@ +# Backtest guide + +`@mathieuc/tradingview/backtest` tests an entry rule on candles and checks whether it does better than random entries. You write the signal. The module downloads the candles once, freezes them, rejects look-ahead and applies the rest of the protocol: costs, pessimistic fills, an in-sample/out-of-sample split, four exits and a seeded random baseline. It returns a JSON report. + +**It makes no claim of profitability and places no orders.** A negative or inconclusive result is a valid outcome of this protocol, and the most common one. + +```ts +import { backtest, formatReport, tradesToCsv } from '@mathieuc/tradingview/backtest'; + +const { report, snapshot } = await backtest({ + symbol: 'BINANCE:BTCUSDT', + timeframe: '240', + years: 2, + // Decided at the close of bar i, with bars 0..i only. Entry: open of bar i + 1. + signal: (candles) => candles.flatMap((c, i) => ( + i > 0 && c.close > candles[i - 1].high ? [{ bar: i, direction: 'long' }] : [] + )), +}); + +console.log(formatReport(report)); // Data, split, signals, results table, warnings +writeFileSync('trades.csv', tradesToCsv(report.trades)); +writeFileSync('snapshot.json', JSON.stringify(snapshot)); // Replay later, exactly +``` + +No account is needed for public symbols. With `credentials`, history goes as far as the account allows (see [Accounts and limits](../README.md#accounts-and-limits)). A run on about 4 500 4H bars, with 4 exits × 2 splits × 1 000 random runs, takes under a second after the download. + +## Writing the signal + +A signal function receives the frozen candles (oldest first, `time` in Unix seconds) and returns every signal: + +```ts +type Signal = { bar: number; direction: 'long' | 'short'; refs?: Record }; +``` + +- `bar` is the index of the bar **at whose close** the decision is taken. The engine always enters at the **next bar's open**; there is no way to fill on the signal bar. +- `refs` is optional: bars worth showing next to the trade (a pivot, a crossover...). They are exported with their times in the report and the CSV, and never change the fills. +- Optional properties on the function: `warmupBars` (bars needed before the first valid signal, default 200) and `label` (shown in the report). + +Indicators that only use past bars are exported: `sma`, `ema`, `rsi` (Wilder), `atr` (Wilder) and `findPivots`. + +```ts +import { ema, type Bar, type SignalFunction } from '@mathieuc/tradingview/backtest'; + +const emaCross: SignalFunction = Object.assign((candles: readonly Bar[]) => { + const closes = candles.map((c) => c.close); + const fast = ema(closes, 20); + const slow = ema(closes, 50); + const signals = []; + for (let i = 50; i < candles.length; i++) { + if (fast[i - 1] <= slow[i - 1] && fast[i] > slow[i]) signals.push({ bar: i, direction: 'long' as const }); + if (fast[i - 1] >= slow[i - 1] && fast[i] < slow[i]) signals.push({ bar: i, direction: 'short' as const }); + } + return signals; +}, { warmupBars: 49, label: 'EMA(20) / EMA(50) cross' }); +``` + +The reference strategy is built in: `rsiDivergence({ rsiLength, emaLength, pivotLookback, minPivotDistance, maxPivotDistance })` (regular RSI divergence with an EMA trend filter; defaults 14, 200, 5, 5, 60). A pivot with lookback N is confirmed N bars later, so the signal is on the confirmation bar and `refs` holds both pivots. [examples/honest-backtest](../examples/honest-backtest) runs it with every parameter as a CLI option. + +### The look-ahead check + +Before simulating anything, the engine reruns your signal function on truncated history: once ending at the bar of each signal (up to `causalityChecks`, default 100, drawn with the seed when there are more) and at 8 evenly spaced bars. Signals up to each cut must be identical with and without the later bars. + +- A signal that exists only when later bars are present uses the future (`look_ahead`): a pivot dated at the pivot bar instead of its confirmation, `candles[i + 1]`, a centred moving average, a `max` over the whole series... +- A signal that exists on shorter history but disappears later repaints (`repaint`). + +Either way the run stops with a `TradingViewError` whose `code` is `LOOK_AHEAD`; `error.details` lists each violation (`cut`, `bar`, `direction`, `kind`). Fix the rule rather than disabling the check. `causalityChecks: 0` disables it, and the report then says so. + +The check covers the signal function. A custom exit (below) is not rerun; keep its `levels` to bars up to `signalBar` and its `trail` to the closed bar it receives. + +## Options + +`backtest(query)` downloads (or replays) and runs. `runBacktest(candlesOrSnapshot, options)` does the same synchronously on candles you already have, without network. + +| Option | Default | Meaning | +| --- | --- | --- | +| `symbol`, `timeframe` | `'240'` | Market to download (time-based timeframes only). | +| `years` | `2` | Tradable years wanted after the warm-up; the request size follows. A warning is added when the server returns less. | +| `count` or `from`/`to` | | Explicit range instead of `years`. | +| `snapshot` | | Replay a saved snapshot (`loadSnapshot`) instead of downloading. | +| `credentials`, `client`, `clientOptions`, `abortSignal`, `provider` | | Passed to `getCandles`. `timeoutMs` defaults to 120 000 ms (deep history takes several batches). | +| `signal` | required | Your entry rule. | +| `exits` | `defaultExits()` | Exits compared on the same entries. | +| `fee` | `0.001` | Fee per side, fraction of the notional. | +| `slippage` | `0.0005` | Adverse slippage per side, fraction of the price. | +| `splitRatio` | `0.7` | In-sample share of the tradable bars. | +| `warmupBars` | signal's, else 200 | Bars used only to warm indicators up, never traded. The largest need of the signal and the exits wins. | +| `runs` | `1000` | Random-entry runs per exit and split. `0` disables the baseline. | +| `seed` | `42` | Base seed of the baseline and of the causality draw. | +| `causalityChecks` | `100` | Signals checked at their own bar. `0` disables the check. | + +## Exits + +Entries never depend on the exit, so every exit runs on the same entries. `defaultExits({ stopPct, atrStopMult, atrTargetR, trailAtrMult, atrLength })` returns the four reference exits: + +| Id | Rule | +| --- | --- | +| `fixed_1.5R`, `fixed_2R` | Stop 2% from the entry bar open, target 1.5× or 2× that distance (`fixedExit({ stopPct, targetR })`). | +| `atr1.5_2R` | Stop 1.5 × ATR(14) of the signal bar, target 2R (`atrExit({ mult, targetR, length })`). | +| `trail_atr3` | Initial stop 3 × ATR(14), then close − 3 × ATR after each **closed** bar, applied from the next bar (`atrTrailingExit({ mult, length })`). | + +`targetR: null` gives a stop-only exit. A custom exit is an object `{ id, label, warmupBars?, levels(ctx), trail?(stop, bar, ctx) }`. `levels` receives `{ direction, entryOpen, signalBar, candles }` and returns `{ stop, target | null }` or `null` to skip the entry; `trail` runs after bar `bar` has closed. + +## Protocol + +1. **Frozen data.** Candles are validated (ordered, consistent OHLC) and frozen once; the bar still forming is dropped. The report records symbol, timeframe, first and last bar, count, gaps and a SHA-256. A live rerun gets new bars: save the snapshot and replay it with `loadSnapshot` to reproduce a result exactly (the checksum is verified). +2. **Chronological split.** After the warm-up, bars are split 70% in-sample (IS) / 30% out-of-sample (OOS), with exact dates. 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.** Signals at a bar's close, entry at the next open, and the causality check above. +4. **Costs.** Fee and adverse slippage 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); beyond the target, at its open too. +6. **Sizing.** 100% of equity per trade, no leverage, compounded, one position at a time: a signal while a position is open is skipped. +7. **Random-entry baseline.** For each exit and split, `runs` seeded runs enter at random bars of the same segment with the same exit, costs, sizing, one-position rule, the strategy's trade count 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 trade count. **Percentile** = 100 × share of random runs with a strictly lower net return. p5, p50 and p95 of the random returns are given (linear interpolation). +8. **Metrics** per exit and split: compounded net return, win rate (trades with a positive net return), profit factor (gross profit / gross loss, `null` 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. + +## The report + +`report` is plain JSON (`JSON.stringify` it, render it in a page, send it to a model): + +| Field | Content | +| --- | --- | +| `snapshot` | Symbol, timeframe, `barSeconds`, `count`, `firstTime`, `lastTime`, `gaps`, `droppedFormingBar`, `sha256` (no candles). | +| `settings` | Signal label, costs, split ratio, warm-up, runs, seed, exits. | +| `segments` | `warmup`, `inSample`, `outOfSample`: `start`, `end` (exclusive), `bars`, `startTime`, `endTime`. | +| `signals` | Every signal: `bar`, `time`, `direction`, `entryBar`, `entryTime`, `split` (`IS`, `OOS` or `null` when in the warm-up, across the split or on the last bar), `refs` as times. | +| `rows` | One per exit and split: `metrics`, `longs`, `shorts`, `skippedInPosition`, `baseline` (`runs`, `matchedRuns`, `meanTrades`, `p5`, `p50`, `p95`) and `percentile`. | +| `trades` | Every trade with bar indices **and** times: signal, entry, exit, `refs`, entry open and fill, initial stop, target, exit level and fill, `reason` (`stop`, `trailing_stop`, `stop_gap`, `target`, `target_gap`, `stop_and_target_same_bar`, `segment_end`) and net `return`. | +| `causality` | `reruns`, `checkedSignals`, `totalSignals`. | +| `warnings` | Plain sentences: short history, few trades, unmatched random runs, gaps, disabled check. | + +`snapshot` (returned next to `report` by `backtest`) holds the candles for charts. Bar indices in the report point into `snapshot.candles`. `formatReport(report)` prints the whole report as text, `formatTable(report.rows)` only the table, and `tradesToCsv(report.trades)` one CSV line per trade (each `refs` name becomes a `_time` column). + +Every step is also exported for custom protocols: `freezeSnapshot`, `fetchSnapshot`, `checkCausality`, `splitSegments`, `simulate`, `fillPrice`, `tradeReturn`, `computeMetrics`, `randomBaseline`, `percentileRank`, `quantile`, `createRng`, `deriveSeed`. + +## 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 report warns below 30 trades in a row. +- 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/docs/data-api.md b/docs/data-api.md index d0632ae8..11b02907 100644 --- a/docs/data-api.md +++ b/docs/data-api.md @@ -210,12 +210,20 @@ 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. +`@mathieuc/tradingview/backtest` runs a complete protocol on `getCandles` data: you write the entry rule, it freezes the candles, rejects signals that use later bars (`LOOK_AHEAD`), enters at the next bar's open, charges fees and slippage on both sides, counts a candle touching stop and target as a loss, splits 70/30 in-sample/out-of-sample and compares four exits with a seeded random-entry baseline. It makes no claim of profitability: a negative or inconclusive result is a valid outcome. -```bash -npm run build && node examples/honest-backtest/index.js --symbol BINANCE:BTCUSDT --timeframe 240 +```ts +import { backtest, formatReport } from '@mathieuc/tradingview/backtest'; + +const { report } = await backtest({ + symbol: 'BINANCE:BTCUSDT', timeframe: '240', years: 2, + signal: (candles) => candles.flatMap((c, i) => (i > 0 && c.close > candles[i - 1].high ? [{ bar: i, direction: 'long' }] : [])), +}); +console.log(formatReport(report)); ``` +Read the [backtest guide](backtest.md) for the signal contract, options, exits and report fields. [examples/honest-backtest](../examples/honest-backtest) runs the reference RSI divergence strategy with every parameter as a CLI option. + ## Search and technical analysis ```ts @@ -289,6 +297,7 @@ Every failure is a `TradingViewError` with a `code`: | `NOT_FOUND` | Unknown indicator, layout... | | `TIMEOUT`, `ABORTED`, `DISCONNECTED`, `CONNECTION_ERROR`, `PROTOCOL_ERROR`, `HTTP_ERROR`, `PARSE_ERROR` | Transport and decoding failures. | | `CALLBACK_ERROR` | A watcher callback threw; the stream remains active. | +| `LOOK_AHEAD` | A backtest signal used bars after the bar it was decided on (see the [backtest guide](backtest.md#the-look-ahead-check)). | `error.details` keeps the raw server payload when there is one. diff --git a/docs/release-4.1.0-beta.1.md b/docs/release-4.1.0-beta.1.md new file mode 100644 index 00000000..d382414b --- /dev/null +++ b/docs/release-4.1.0-beta.1.md @@ -0,0 +1,22 @@ +# 4.1.0-beta.1 + +Release channel: npm dist-tag **beta**. `latest` stays on 4.0.1. Minor release: one new entry point, no breaking change. + +## Backtest API + +New entry point `@mathieuc/tradingview/backtest`, built from the [honest-backtest](../examples/honest-backtest) example (#351). You write the entry rule; the module applies the protocol and returns a JSON report. See the [backtest guide](backtest.md). + +- `backtest({ symbol, timeframe, years, signal, ... })` downloads candles once with `getCandles`, freezes them and runs; `runBacktest(candles | snapshot, options)` does the same synchronously, without network. +- A signal is `(candles) => [{ bar, direction, refs? }]`, decided at the close of `bar`. The entry is always the next bar's open. +- New look-ahead check: the engine reruns the signal on history truncated at each signal and at 8 evenly spaced bars, and throws a `TradingViewError` with the new code `LOOK_AHEAD` if a signal depends on later bars or repaints. +- Same rules as the example: fees and adverse slippage on both sides (in the baseline too), stop and target in the same candle = loss, gap through the stop = open, warm-up never traded, 70/30 chronological split with no position across it, four interchangeable exits, 1 000 seeded random-entry runs per exit and split with percentile and p5/p50/p95, metrics per segment (net return, win rate, profit factor or `null`, max drawdown, trades). +- Building blocks are exported too: `rsiDivergence`, `defaultExits`, `fixedExit`, `atrExit`, `atrTrailingExit`, `sma`, `ema`, `rsi`, `atr`, `findPivots`, `freezeSnapshot`, `loadSnapshot`, `checkCausality`, `simulate`, `randomBaseline`, `computeMetrics`, `formatReport`, `tradesToCsv`... +- `examples/honest-backtest` now uses the module (same options and output; its `engine.js` is removed, and snapshots saved by the previous version still replay). New `examples/backtest-custom-signal.js`. + +The module makes no claim of profitability: a negative or inconclusive result is a valid outcome. + +## Validation + +- Same report as the #351 engine on the same BINANCE:BTCUSDT 4H snapshot (4 583 bars): identical rows and 63 identical trades, under Node and Bun. +- 26 deterministic tests on synthetic candles. Each of these deliberate mutations makes at least one fail: backdated signal, entry at the signal close, entry on the signal bar, trailing stop look-ahead, missing exit fee, same-candle stop and target counted as a win, gap filled at the stop, position across the split, look-ahead check bypassed, baseline without costs. +- `npm run check` (typecheck, lint, tests, build, packed-package smoke under Node and Bun, including `require()` and TypeScript consumers of the new entry point) and `npm run test:bun`. diff --git a/examples/README.md b/examples/README.md index 11bd7162..9da83d93 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,7 +14,8 @@ 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 | +| [honest-backtest/](honest-backtest) | Backtest API with the reference RSI divergence strategy, every parameter as a CLI option | No | +| [backtest-custom-signal.js](backtest-custom-signal.js) | Backtest API with your own entry rule (EMA cross) | 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 | @@ -39,4 +40,4 @@ Examples that need an account read the `SESSION` and `SIGNATURE` cookies from th ## 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. +The [backtest API](../docs/backtest.md) tests an entry rule on `getCandles` data: frozen snapshot, look-ahead rejection, chronological in-sample/out-of-sample split, fees and slippage, pessimistic fills, interchangeable exits and a seeded random-entry baseline. [backtest-custom-signal.js](backtest-custom-signal.js) shows a custom rule in a few lines; [honest-backtest/](honest-backtest) runs the reference strategy. Neither makes a claim of profitability: a negative or inconclusive result is a valid outcome. diff --git a/examples/backtest-custom-signal.js b/examples/backtest-custom-signal.js new file mode 100644 index 00000000..ef5289eb --- /dev/null +++ b/examples/backtest-custom-signal.js @@ -0,0 +1,27 @@ +// Backtest your own entry rule: write the signal, the library applies the whole protocol +// (no look-ahead check, costs, IS/OOS split, four exits, random-entry baseline). +// Run: npm run build && node examples/backtest-custom-signal.js +// Research example only: it places no orders and says nothing about future returns. +import { writeFileSync } from 'node:fs'; +import { + backtest, ema, formatReport, tradesToCsv, +} from '@mathieuc/tradingview/backtest'; + +// Decided at the close of bar i with bars 0..i only; the engine enters at the next open. +function emaCross(candles) { + const closes = candles.map((c) => c.close); + const fast = ema(closes, 20); + const slow = ema(closes, 50); + const signals = []; + for (let i = 50; i < candles.length; i++) { + if (fast[i - 1] <= slow[i - 1] && fast[i] > slow[i]) signals.push({ bar: i, direction: 'long' }); + if (fast[i - 1] >= slow[i - 1] && fast[i] < slow[i]) signals.push({ bar: i, direction: 'short' }); + } + return signals; +} +emaCross.warmupBars = 49; +emaCross.label = 'EMA(20) / EMA(50) cross'; + +const { report } = await backtest({ symbol: 'BINANCE:ETHUSDT', timeframe: '240', years: 2, signal: emaCross }); +console.log(formatReport(report)); +writeFileSync('ema-cross-trades.csv', tradesToCsv(report.trades)); diff --git a/examples/honest-backtest/README.md b/examples/honest-backtest/README.md index c243f38e..17391e04 100644 --- a/examples/honest-backtest/README.md +++ b/examples/honest-backtest/README.md @@ -1,6 +1,6 @@ # 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. +An example that backtests a simple strategy **and checks whether it does better than chance**, with the [backtest API](../../docs/backtest.md) (`@mathieuc/tradingview/backtest`) on `getCandles` data. The protocol lives in the library; this folder only exposes every parameter as a CLI option. To test your own entry rule, pass your own `signal` function: see [backtest-custom-signal.js](../backtest-custom-signal.js). **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. @@ -12,29 +12,22 @@ node examples/honest-backtest/index.js --snapshot honest-backtest-output/snapsho 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`). +No account is needed. The run writes `snapshot.json`, `report.json` and `trades.csv` to `honest-backtest-output/` (change it with `--out`). Snapshots saved by the earlier version of this example still replay. -## 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. | +The deterministic tests of every rule below are in [tests/unit/backtest.test.ts](../../tests/unit/backtest.test.ts). ## 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. +This is `rsiDivergence()` from the backtest API. Every parameter is a command-line option. ## 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. +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. The engine also reruns the signal on truncated history and stops with `LOOK_AHEAD` if a signal depends on later bars. 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: @@ -43,7 +36,7 @@ Every parameter is a command-line option. Replace `findSignals` in `engine.js` t - `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. +9. **Trades CSV** with, per trade: exit variant, split, direction, previous pivot, pivot, signal (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 diff --git a/examples/honest-backtest/engine.d.ts b/examples/honest-backtest/engine.d.ts deleted file mode 100644 index eeffb4b8..00000000 --- a/examples/honest-backtest/engine.d.ts +++ /dev/null @@ -1,180 +0,0 @@ -// 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 deleted file mode 100644 index 34015e66..00000000 --- a/examples/honest-backtest/engine.js +++ /dev/null @@ -1,650 +0,0 @@ -// 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; -} - -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 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; - 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). - 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 -} - -/** - * 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 index 29a9736d..47726667 100644 --- a/examples/honest-backtest/index.js +++ b/examples/honest-backtest/index.js @@ -1,4 +1,5 @@ // Honest backtest of an RSI divergence + EMA trend filter strategy, with a random-entry baseline. +// The protocol lives in the library (@mathieuc/tradingview/backtest); this file only wires the options. // 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 @@ -6,13 +7,16 @@ 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'; + backtest, defaultExits, formatReport, loadSnapshot, rsiDivergence, tradesToCsv, +} from '@mathieuc/tradingview/backtest'; -const numeric = ['minYears', 'splitRatio', 'pivotLookback', 'minPivotDistance', 'maxPivotDistance', 'rsiLength', - 'emaLength', 'atrLength', 'fee', 'slippage', 'stopPct', 'atrStopMult', 'atrTargetR', 'trailAtrMult', 'runs', 'seed']; +const DEFAULTS = { + symbol: 'BINANCE:BTCUSDT', timeframe: '240', minYears: 2, splitRatio: 0.7, + pivotLookback: 5, minPivotDistance: 5, maxPivotDistance: 60, rsiLength: 14, emaLength: 200, atrLength: 14, + fee: 0.001, slippage: 0.0005, stopPct: 0.02, atrStopMult: 1.5, atrTargetR: 2, trailAtrMult: 3, runs: 1000, seed: 42, +}; +const numeric = Object.keys(DEFAULTS).filter((name) => typeof DEFAULTS[name] === 'number'); const { values: args } = parseArgs({ options: { symbol: { type: 'string', default: DEFAULTS.symbol }, @@ -29,89 +33,40 @@ 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 + --out [honest-backtest-output] folder for snapshot.json, report.json and trades.csv ${numeric.map((name) => `--${name} [${DEFAULTS[name]}]`).join(' ')}`); process.exit(0); } -const options = { ...DEFAULTS, symbol: args.symbol, timeframe: args.timeframe }; +const o = { ...DEFAULTS }; 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; + o[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.'); +const { report, snapshot } = await backtest({ + symbol: args.symbol, + timeframe: args.timeframe, + years: o.minYears, + ...(args.count ? { count: Number(args.count) } : {}), + ...(args.snapshot ? { snapshot: loadSnapshot(readFileSync(args.snapshot, 'utf8')) } : {}), + signal: rsiDivergence(o), + exits: defaultExits(o), + fee: o.fee, + slippage: o.slippage, + splitRatio: o.splitRatio, + runs: o.runs, + seed: o.seed, +}); -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.'); +console.log(formatReport(report)); -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')}`}.`); +if (!args.snapshot) writeFileSync(join(outDir, 'snapshot.json'), JSON.stringify(snapshot)); +writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 2)); +writeFileSync(join(outDir, 'trades.csv'), tradesToCsv(report.trades)); +console.log(`\nWrote ${report.trades.length} trades to ${join(outDir, 'trades.csv')}, the report to report.json${args.snapshot ? '' : ' and the snapshot to snapshot.json'}.`); diff --git a/llms.txt b/llms.txt index f78c3bbb..c512bb89 100644 --- a/llms.txt +++ b/llms.txt @@ -21,7 +21,14 @@ Simple data API (import from '@mathieuc/tradingview/data' or the root entry): Low-level API: TradingViewClient, client.createChart() (ChartSession: setMarket, setTimeframe, fetchMore, replay, createStudy), client.createQuoteSession(), PineIndicator, BuiltInIndicator, HTTP functions (loginUser, getUser, getIndicator, getPrivateIndicators, getDrawings, PinePermissionManager). 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. +Docs: docs/data-api.md, docs/backtest.md, docs/low-level-api.md, docs/migration-v4.md, docs/v4-coverage.md, docs/v4-reliability.md, docs/protocol-contributing.md. Examples: examples/. +Backtest API (import from '@mathieuc/tradingview/backtest'; since 4.1.0-beta.1, npm tag beta): write only the entry rule, do not write an engine. +- backtest({ symbol, timeframe, years?, credentials?, signal, exits?, fee?, slippage?, splitRatio?, runs?, seed? }) -> { report, snapshot }. runBacktest(candles | snapshot, options) -> report, synchronous, no network. +- signal(candles) -> [{ bar, direction: 'long' | 'short', refs? }]: decided at the close of `bar` using bars 0..bar only; entry is always the next bar's open. Optional signal.warmupBars (default 200) and signal.label. +- The engine reruns signal on truncated history and throws TradingViewError code LOOK_AHEAD if a signal depends on later bars (or repaints). +- Built in: rsiDivergence(options), defaultExits() (fixed_1.5R, fixed_2R, atr1.5_2R, trail_atr3), fixedExit, atrExit, atrTrailingExit, indicators sma/ema/rsi/atr/findPivots. +- Protocol: frozen snapshot with SHA-256 (loadSnapshot to replay), warm-up never traded, 70/30 IS/OOS split with no position across it, fee 0.1% + slippage 0.05% per side, same-candle stop+target = loss, gap = open, 1000 seeded random-entry runs per exit and split with percentile and p5/p50/p95. +- report is plain JSON: snapshot info, segments with dates, signals, rows (metrics: trades, netReturn, winRate, profitFactor or null, maxDrawdown; baseline; percentile), trades with bar indices and times, causality, warnings. formatReport(report), tradesToCsv(report.trades). +- No claim of profitability: a negative or inconclusive result is a valid outcome. Docs: docs/backtest.md. Example: examples/honest-backtest/. +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 or hosted trading API is shipped, and the library places no orders. Questions and bugs: https://github.com/Mathieu2301/TradingView-API/issues/new/choose diff --git a/package-lock.json b/package-lock.json index 1a9ff9ea..924467ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mathieuc/tradingview", - "version": "4.0.1", + "version": "4.1.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mathieuc/tradingview", - "version": "4.0.1", + "version": "4.1.0-beta.1", "license": "ISC", "dependencies": { "ws": "^8.18.0" diff --git a/package.json b/package.json index 610f0cd3..5bdae142 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mathieuc/tradingview", - "version": "4.0.1", + "version": "4.1.0-beta.1", "description": "TradingView market data for JavaScript and TypeScript: candles, quotes, indicators and strategies, with a simple data API and full low-level access.", "type": "module", "main": "./dist/index.js", @@ -16,6 +16,11 @@ "import": "./dist/data/index.js", "default": "./dist/data/index.js" }, + "./backtest": { + "types": "./dist/backtest/index.d.ts", + "import": "./dist/backtest/index.js", + "default": "./dist/backtest/index.js" + }, "./package.json": "./package.json" }, "files": [ @@ -63,6 +68,7 @@ "stocks", "crypto", "forex", + "backtest", "indicator", "pine", "websocket", diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 14f3c2c2..c64e25fe 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -21,7 +21,7 @@ const dir = mkdtempSync(join(tmpdir(), 'tv-smoke-')); try { const packed = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', dir], root))[0]; const files = packed.files.map((f) => f.path); - for (const required of ['bin/tradingview.mjs', 'dist/quick-start.js', 'dist/index.js', 'dist/index.d.ts', 'dist/data/index.js', 'dist/data/index.d.ts', 'README.md', 'llms.txt', 'scripts/endurance.mjs', 'scripts/probe-account.mjs', 'scripts/parity.mjs', 'examples/screener.js']) { + for (const required of ['bin/tradingview.mjs', 'dist/quick-start.js', 'dist/index.js', 'dist/index.d.ts', 'dist/data/index.js', 'dist/data/index.d.ts', 'dist/backtest/index.js', 'dist/backtest/index.d.ts', 'examples/honest-backtest/index.js', 'examples/backtest-custom-signal.js', 'README.md', 'llms.txt', 'scripts/endurance.mjs', 'scripts/probe-account.mjs', 'scripts/parity.mjs', 'examples/screener.js']) { if (!files.includes(required)) throw new Error(`Missing ${required} in package`); } if (files.some((f) => f.startsWith('src/') || f.startsWith('tests/'))) throw new Error('Sources or tests leaked into package'); @@ -71,6 +71,12 @@ writeFileSync(process.env.TV_CAPTURE, JSON.stringify({ args: process.argv.slice( if (typeof tv[name] !== 'function') throw new Error('missing export: ' + name); } if (tv.getCandles !== getCandles) throw new Error('entry points disagree'); + const bt = await import('@mathieuc/tradingview/backtest'); + const bars = Array.from({ length: 400 }, (_, i) => ({ time: i * 3600, open: 100 + Math.sin(i / 7) * 5, high: 107 + Math.sin(i / 7) * 5, low: 93 + Math.sin(i / 7) * 5, close: 100 + Math.sin((i + 1) / 7) * 5 })); + const report = bt.runBacktest(bars, { signal: (c) => c.flatMap((x, i) => (i > 20 && x.close > c[i - 1].close && c[i - 1].close <= c[i - 2].close ? [{ bar: i, direction: 'long' }] : [])), runs: 20, warmupBars: 20 }); + if (report.rows.length !== 8 || !report.trades.length || !bt.formatReport(report).includes('no look-ahead found')) throw new Error('backtest entry failed'); + const peek = await Promise.resolve().then(() => bt.runBacktest(bars, { signal: (c) => c.flatMap((x, i) => (c[i + 1] && c[i + 1].close > x.close ? [{ bar: i, direction: 'long' }] : [])), runs: 0, warmupBars: 20 })).catch((e) => e); + if (!(peek instanceof bt.TradingViewError) || peek.code !== 'LOOK_AHEAD') throw new Error('backtest look-ahead guard failed'); const frame = tv.protocol.encodePacket('set_auth_token', ['x']); if (tv.protocol.decodeFrames(frame)[0].packet.m !== 'set_auth_token') throw new Error('protocol roundtrip failed'); const error = await getCandles({ symbol: '' }).catch((e) => e); @@ -86,7 +92,8 @@ writeFileSync(process.env.TV_CAPTURE, JSON.stringify({ args: process.argv.slice( writeFileSync(join(dir, 'check.cjs'), ` const { getCandles } = require('@mathieuc/tradingview/data'); const { TradingViewClient } = require('@mathieuc/tradingview'); - if (typeof getCandles !== 'function' || typeof TradingViewClient !== 'function') throw new Error('require failed'); + const { runBacktest } = require('@mathieuc/tradingview/backtest'); + if (typeof getCandles !== 'function' || typeof TradingViewClient !== 'function' || typeof runBacktest !== 'function') throw new Error('require failed'); console.log('require ok'); `); process.stdout.write(run('node', ['check.cjs'], dir)); @@ -112,6 +119,10 @@ writeFileSync(process.env.TV_CAPTURE, JSON.stringify({ args: process.argv.slice( const ranked: Promise = getHotlist({ kind: 'gainers' }); const lists: Promise = getWatchlists({ credentials: { session: 'fixture' } }); void scan; void ranked; void lists; void candles; void row; void provider; + import { backtest, rsiDivergence, defaultExits, type BacktestReport, type SignalFunction } from '@mathieuc/tradingview/backtest'; + const rule: SignalFunction = (bars) => bars.flatMap((b, i) => (b.close > b.open ? [{ bar: i, direction: 'long' as const }] : [])); + const run: Promise<{ report: BacktestReport }> = backtest({ symbol: 'BINANCE:BTCUSDT', timeframe: '240', years: 2, signal: rule, exits: defaultExits() }); + void run; void rsiDivergence; `); writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { module: 'NodeNext', moduleResolution: 'NodeNext', target: 'ES2022', strict: true, noEmit: true, types: [], lib: ['ES2022', 'DOM'] }, diff --git a/src/backtest/exits.ts b/src/backtest/exits.ts new file mode 100644 index 00000000..c35ae95c --- /dev/null +++ b/src/backtest/exits.ts @@ -0,0 +1,117 @@ +import { TradingViewError } from '../errors.js'; +import { cachedAtr } from './indicators.js'; +import type { Direction, ExitModel } from './types.js'; + +const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const offset = (direction: Direction, price: number, distance: number) => (direction === 'long' ? price + distance : price - distance); + +function positive(name: string, value: unknown): number { + if (!isNumber(value) || value <= 0) throw new TradingViewError('INVALID_ARGUMENT', `${name} must be a positive number`); + return value; +} + +export interface FixedExitOptions { + /** Stop distance from the entry bar open, as a fraction (0.02 = 2%). */ + stopPct: number; + /** Target at this multiple of the stop distance; null for a stop only. */ + targetR: number | null; +} + +/** Fixed stop at `stopPct` from the entry bar open, target at `targetR` times that distance. */ +export function fixedExit({ stopPct, targetR }: FixedExitOptions): ExitModel { + positive('stopPct', stopPct); + if (targetR !== null) positive('targetR', targetR); + return { + id: targetR === null ? `fixed_stop${stopPct * 100}%` : `fixed_${targetR}R`, + label: `Fixed ${(stopPct * 100).toFixed(1)}% stop, ${targetR === null ? 'no target' : `${targetR}:1 target`}`, + levels({ direction, entryOpen }) { + const d = entryOpen * stopPct; + return { stop: offset(direction, entryOpen, -d), target: targetR === null ? null : offset(direction, entryOpen, targetR * d) }; + }, + }; +} + +export interface AtrExitOptions { + /** Stop at `mult` x ATR of the signal bar. */ + mult: number; + /** Target at this multiple of the stop distance; null for a stop only. */ + targetR: number | null; + /** ATR length (Wilder). Default 14. */ + length?: number; +} + +/** Stop at `mult` x ATR of the signal bar (last closed bar before entry), target at `targetR` R. */ +export function atrExit({ mult, targetR, length = 14 }: AtrExitOptions): ExitModel { + positive('mult', mult); + positive('length', length); + if (targetR !== null) positive('targetR', targetR); + return { + id: `atr${mult}_${targetR === null ? 'stop' : `${targetR}R`}`, + label: `ATR(${length}) x${mult} stop, ${targetR === null ? 'no target' : `${targetR}R target`}`, + warmupBars: length - 1, + levels({ direction, entryOpen, signalBar, candles }) { + const d = mult * cachedAtr(candles, length)[signalBar]; + if (!isNumber(d) || d <= 0) return null; + return { stop: offset(direction, entryOpen, -d), target: targetR === null ? null : offset(direction, entryOpen, targetR * d) }; + }, + }; +} + +export interface AtrTrailingExitOptions { + /** Distance from the close, in ATR. */ + mult: number; + /** ATR length (Wilder). Default 14. */ + length?: number; +} + +/** + * ATR trailing stop, no target. Initial stop at `mult` x ATR of the signal bar, then, after + * each CLOSED bar, ratcheted to close -/+ `mult` x ATR of that bar; the new stop applies from + * the next bar. + */ +export function atrTrailingExit({ mult, length = 14 }: AtrTrailingExitOptions): ExitModel { + positive('mult', mult); + positive('length', length); + return { + id: `trail_atr${mult}`, + label: `ATR(${length}) x${mult} trailing stop`, + warmupBars: length - 1, + levels({ direction, entryOpen, signalBar, candles }) { + const d = mult * cachedAtr(candles, length)[signalBar]; + if (!isNumber(d) || d <= 0) return null; + return { stop: offset(direction, entryOpen, -d), target: null }; + }, + trail(stop, bar, { direction, candles }) { + const candidate = offset(direction, candles[bar].close, -mult * cachedAtr(candles, length)[bar]); + if (!isNumber(candidate)) return stop; + return direction === 'long' ? Math.max(stop, candidate) : Math.min(stop, candidate); + }, + }; +} + +export interface DefaultExitOptions { + /** Stop distance of the two fixed exits. Default 0.02. */ + stopPct?: number; + /** ATR exit stop multiplier. Default 1.5. */ + atrStopMult?: number; + /** ATR exit target in R. Default 2. */ + atrTargetR?: number; + /** ATR trailing stop multiplier. Default 3. */ + trailAtrMult?: number; + /** ATR length. Default 14. */ + atrLength?: number; +} + +/** + * The four reference exits: fixed 2% stop with 1.5:1 and 2:1 targets, 1.5 x ATR stop with a + * 2R target, and a 3 x ATR trailing stop. + */ +export function defaultExits(options: DefaultExitOptions = {}): ExitModel[] { + const { stopPct = 0.02, atrStopMult = 1.5, atrTargetR = 2, trailAtrMult = 3, atrLength = 14 } = options; + return [ + fixedExit({ stopPct, targetR: 1.5 }), + fixedExit({ stopPct, targetR: 2 }), + atrExit({ mult: atrStopMult, targetR: atrTargetR, length: atrLength }), + atrTrailingExit({ mult: trailAtrMult, length: atrLength }), + ]; +} diff --git a/src/backtest/index.ts b/src/backtest/index.ts new file mode 100644 index 00000000..be43ca49 --- /dev/null +++ b/src/backtest/index.ts @@ -0,0 +1,32 @@ +/** + * Honest backtests on candles: you write the entry rule, the module applies the protocol + * (frozen data, no look-ahead, costs, pessimistic fills, IS/OOS split, interchangeable exits, + * seeded random-entry baseline) and returns a JSON report. It makes no claim of profitability. + * + * @module @mathieuc/tradingview/backtest + */ +export { backtest, BACKTEST_DEFAULTS, checkCausality, fetchSnapshot, resolveWarmupBars, runBacktest } from './run.js'; +export type { + BacktestOptions, BacktestQuery, BacktestReport, CausalityCheck, CausalityViolation, FetchSnapshotQuery, ReportRow, + SegmentInfo, SignalRecord, TradeRecord, +} from './run.js'; +export { freezeSnapshot, loadSnapshot, snapshotInfo } from './snapshot.js'; +export type { Gap, Snapshot, SnapshotInfo, SnapshotMeta } from './snapshot.js'; +export { rsiDivergence } from './signals.js'; +export type { RsiDivergenceOptions } from './signals.js'; +export { atrExit, atrTrailingExit, defaultExits, fixedExit } from './exits.js'; +export type { AtrExitOptions, AtrTrailingExitOptions, DefaultExitOptions, FixedExitOptions } from './exits.js'; +export { fillPrice, simulate, splitSegments, tradeReturn } from './simulate.js'; +export type { SimulateInput, SimulateResult } from './simulate.js'; +export { + computeMetrics, createRng, deriveSeed, percentileRank, quantile, randomBaseline, +} from './stats.js'; +export type { BaselineInput } from './stats.js'; +export { atr, ema, findPivots, rsi, sma } from './indicators.js'; +export type { Pivot } from './indicators.js'; +export { formatReport, formatTable, isoTime, tradesToCsv } from './report.js'; +export type { + Bar, Baseline, Costs, Direction, Entry, ExitContext, ExitModel, ExitReason, Metrics, Segment, Signal, SignalFunction, Trade, +} from './types.js'; +export { TradingViewError } from '../errors.js'; +export type { TradingViewErrorCode } from '../errors.js'; diff --git a/src/backtest/indicators.ts b/src/backtest/indicators.ts new file mode 100644 index 00000000..dbefeb28 --- /dev/null +++ b/src/backtest/indicators.ts @@ -0,0 +1,101 @@ +import type { Bar } from './types.js'; + +// Causal indicators: the value at bar i only uses bars 0..i. NaN until defined. + +/** Simple moving average. First value at index `length - 1`. */ +export function sma(values: readonly number[], length: number): number[] { + const out = new Array(values.length).fill(NaN); + let sum = 0; + for (let i = 0; i < values.length; i++) { + sum += values[i]; + if (i >= length) sum -= values[i - length]; + if (i >= length - 1) out[i] = sum / length; + } + return out; +} + +/** EMA seeded with the SMA of the first `length` values. First value at index `length - 1`. */ +export function ema(values: readonly number[], length: number): number[] { + 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: readonly number[], length: number): number[] { + 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: readonly Bar[], length: number): number[] { + 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; +} + +export interface Pivot { + /** Bar of the pivot itself. */ + bar: number; + /** First bar at whose close the pivot is known: `bar + lookback`. */ + confirmBar: number; + price: number; +} + +/** + * 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: readonly number[], lookback: number, kind: 'low' | 'high'): Pivot[] { + const pivots: Pivot[] = []; + 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; +} + +const atrCache = new WeakMap>(); + +/** ATR memoised per candle array, so that several exits share one computation. */ +export function cachedAtr(candles: readonly Bar[], length: number): number[] { + let byLength = atrCache.get(candles); + if (!byLength) atrCache.set(candles, byLength = new Map()); + let values = byLength.get(length); + if (!values) byLength.set(length, values = atr(candles, length)); + return values; +} diff --git a/src/backtest/report.ts b/src/backtest/report.ts new file mode 100644 index 00000000..4090f4a8 --- /dev/null +++ b/src/backtest/report.ts @@ -0,0 +1,88 @@ +import type { BacktestReport, ReportRow, TradeRecord } from './run.js'; + +/** Unix seconds -> ISO 8601 UTC without milliseconds. */ +export const isoTime = (seconds: number): string => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z'); + +const BASE_COLUMNS = [ + 'exit', 'split', 'direction', 'signal_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. Each signal + * `refs` name becomes a `_time` column, placed before `signal_time`. + */ +export function tradesToCsv(trades: readonly TradeRecord[]): string { + const refNames = [...new Set(trades.flatMap((t) => Object.keys(t.refs)))]; + const time = (value: number | undefined) => (value === undefined ? '' : isoTime(value)); + const num = (value: number | null) => (value === null ? '' : String(Number(value.toPrecision(10)))); + const header = [...BASE_COLUMNS.slice(0, 3), ...refNames.map((n) => `${n}_time`), ...BASE_COLUMNS.slice(3)]; + const lines = trades.map((t) => [ + t.exit, t.split, t.direction, ...refNames.map((n) => time(t.refs[n])), time(t.signalTime), time(t.entryTime), + num(t.entryOpen), num(t.entryPrice), num(t.initialStop), num(t.target), time(t.exitTime), num(t.exitLevel), + num(t.exitPrice), t.reason, (t.return * 100).toFixed(4), + ].join(',')); + return [header.join(','), ...lines].join('\n') + '\n'; +} + +const pct = (value: number | null | undefined, digits = 1) => (value === null || value === undefined ? 'N/A' : `${(value * 100).toFixed(digits)}%`); + +/** Compact side-by-side table: one row per exit and split. */ +export function formatTable(rows: readonly ReportRow[]): string { + 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'); +} + +/** The whole report as plain text: data, split, signals, costs, results, definitions, warnings. */ +export function formatReport(report: BacktestReport): string { + const { snapshot: s, settings, segments, causality } = report; + const range = (seg: BacktestReport['segments']['inSample']) => (seg.startTime === null || seg.endTime === null + ? '(empty)' + : `${isoTime(seg.startTime)} -> ${isoTime(seg.endTime)} (${seg.bars} bars)`); + const count = (split: 'IS' | 'OOS') => { + const list = report.signals.filter((sig) => sig.split === split); + return `${list.length} (${list.filter((sig) => sig.direction === 'long').length} long, ${list.filter((sig) => sig.direction === 'short').length} short)`; + }; + const missing = s.gaps.reduce((sum, gap) => sum + gap.missingBars, 0); + const lines = [ + '== Data (frozen once, reused for every run) ==', + `${s.symbol || 'candles'} ${s.timeframe || `${s.barSeconds}s`}: ${s.count} closed bars, ${isoTime(s.firstTime)} -> ${isoTime(s.lastTime)} (bar open times, UTC)`, + `Forming bar dropped: ${s.droppedFormingBar ? 'yes' : 'no'}. sha256 ${s.sha256.slice(0, 16)}`, + `Gaps: ${s.gaps.length} (${missing} missing bars)${s.gaps.slice(0, 5).map((g) => `\n ${isoTime(g.afterTime)} -> ${isoTime(g.beforeTime)} (${g.missingBars} bars)`).join('')}`, + `History: ${report.tradableYears.toFixed(2)} years tradable after warm-up.`, + '', + '== Split (chronological, no position crosses it) ==', + `Warm-up: ${range(segments.warmup)}, indicators only, never traded`, + `In-sample: ${range(segments.inSample)}`, + `Out-of-sample: ${range(segments.outOfSample)}`, + '', + '== Signals ==', + `Rule: ${settings.signal}. Signal at a bar's close, entry at the NEXT bar's open.`, + `IS signals: ${count('IS')}. OOS signals: ${count('OOS')}.`, + causality.reruns + ? `Causality: rule rerun on ${causality.reruns} truncated histories (${causality.checkedSignals}/${causality.totalSignals} signals checked at their own bar): no look-ahead found.` + : 'Causality: NOT checked.', + `Costs per side: fee ${pct(settings.fee, 2)}, adverse slippage ${pct(settings.slippage, 2)}. Sizing: 100% of equity, no leverage, one position at a time.`, + 'Same-bar stop and target: counted as a stop. Gap through a level: filled at the open.', + '', + `== Results (random baseline: ${settings.runs} seeded runs per row, seed ${settings.seed}) ==`, + formatTable(report.rows), + `Exits: ${settings.exits.map((e) => `${e.id} = ${e.label}`).join('; ')}.`, + 'Net: compounded net return. Win: share of trades with a positive net return. PF: gross profit / gross loss (N/A without a losing trade).', + '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.', + ...report.warnings.map((w) => `WARNING: ${w}`), + 'Read the OOS rows first. A percentile near 50, a negative OOS return or few trades is a valid, inconclusive outcome, not a bug.', + ]; + return lines.join('\n'); +} diff --git a/src/backtest/run.ts b/src/backtest/run.ts new file mode 100644 index 00000000..ed59d187 --- /dev/null +++ b/src/backtest/run.ts @@ -0,0 +1,516 @@ +import { timeframeSeconds } from '../chart/timeframes.js'; +import type { Timeframe } from '../chart/types.js'; +import { getCandles, type CandleQuery } from '../data/candles.js'; +import type { MarketDataProvider } from '../data/provider.js'; +import { TradingViewError } from '../errors.js'; +import { defaultExits } from './exits.js'; +import { simulate, splitSegments } from './simulate.js'; +import { + freezeSnapshot, snapshotInfo, type Snapshot, type SnapshotInfo, +} from './snapshot.js'; +import { + computeMetrics, createRng, deriveSeed, percentileRank, randomBaseline, +} from './stats.js'; +import type { + Bar, Baseline, Costs, Direction, Entry, ExitModel, ExitReason, Metrics, Segment, Signal, SignalFunction, +} from './types.js'; + +export interface BacktestOptions { + /** Your entry rule. See `SignalFunction`. */ + signal: SignalFunction; + /** Exits compared on the same entries. Default: `defaultExits()` (four reference exits). */ + exits?: readonly ExitModel[]; + /** Fee per side. Default 0.001 (0.1%). */ + fee?: number; + /** Adverse slippage per side. Default 0.0005 (0.05%). */ + slippage?: number; + /** In-sample share of the tradable bars. Default 0.7. */ + splitRatio?: number; + /** + * Bars never traded, used only to warm the indicators up. Default: the largest + * `warmupBars` of the signal and the exits, or 200 when the signal does not declare one. + */ + warmupBars?: number; + /** Random-entry runs per exit and split. Default 1000; 0 disables the baseline. */ + runs?: number; + /** Base seed of the random baseline. Default 42. */ + seed?: number; + /** + * Maximum number of signals whose causality is checked by rerunning `signal` on the history + * that ends at their bar (plus 8 evenly spaced cuts). Default 100; 0 disables the check. + */ + causalityChecks?: number; + /** Tradable history wanted, in years: only used for the warning. */ + minYears?: number; +} + +export const BACKTEST_DEFAULTS = Object.freeze({ + fee: 0.001, + slippage: 0.0005, + splitRatio: 0.7, + runs: 1000, + seed: 42, + causalityChecks: 100, + signalWarmupBars: 200, + minYears: 2, +}); + +export interface SegmentInfo extends Segment { + bars: number; + /** Open time of the first bar, Unix seconds. */ + startTime: number | null; + /** Open time of the last bar, Unix seconds. */ + endTime: number | null; +} + +export interface SignalRecord { + bar: number; + time: number; + direction: Direction; + entryBar: number; + entryTime: number | null; + /** Segment containing both the signal and the entry, or null (warm-up, straddling the split, last bar). */ + split: 'IS' | 'OOS' | null; + refs: Record; +} + +export interface TradeRecord { + exit: string; + split: string; + direction: Direction; + signalBar: number; + signalTime: number; + entryBar: number; + entryTime: number; + /** Times of the signal's `refs` bars. */ + refs: Record; + entryOpen: number; + entryPrice: number; + initialStop: number; + target: number | null; + exitBar: number; + exitTime: number; + exitLevel: number; + exitPrice: number; + reason: ExitReason; + return: number; +} + +export interface ReportRow { + exit: string; + exitLabel: string; + split: string; + metrics: Metrics; + longs: number; + shorts: number; + skippedInPosition: number; + /** Random-entry baseline without the raw returns; null without trades or with `runs: 0`. */ + baseline: Omit | null; + /** 100 x share of random runs with a strictly lower net return; null without a baseline. */ + percentile: number | null; +} + +export interface CausalityViolation { + /** History length the signal function was rerun on (bars `0..cut-1`). */ + cut: number; + bar: number; + direction: Direction; + /** `look_ahead`: present with full history, absent when history ends at `cut - 1`. `repaint`: the reverse. */ + kind: 'look_ahead' | 'repaint'; +} + +export interface CausalityCheck { + /** Number of truncated reruns. 0 when disabled. */ + reruns: number; + /** Signals whose own cut was checked, out of `totalSignals`. */ + checkedSignals: number; + totalSignals: number; +} + +/** Plain JSON: save it, render it or send it as it is. */ +export interface BacktestReport { + snapshot: SnapshotInfo; + settings: { + signal: string; + fee: number; + slippage: number; + splitRatio: number; + warmupBars: number; + runs: number; + seed: number; + exits: { id: string; label: string }[]; + }; + segments: { warmup: SegmentInfo; inSample: SegmentInfo; outOfSample: SegmentInfo }; + /** Tradable years after warm-up. */ + tradableYears: number; + signals: SignalRecord[]; + rows: ReportRow[]; + trades: TradeRecord[]; + causality: CausalityCheck; + /** Plain-language warnings: few trades, short history, unmatched random runs... */ + warnings: string[]; +} + +const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + +function invalid(message: string): TradingViewError { + return new TradingViewError('INVALID_ARGUMENT', message); +} + +function checkOption(name: string, value: number, ok: boolean): number { + if (!isNumber(value) || !ok) throw invalid(`${name} is out of range: ${value}`); + return value; +} + +/** Validates and sorts (stable, by bar) what the signal function returned. */ +function normaliseSignals(raw: unknown, barCount: number): Signal[] { + if (!Array.isArray(raw)) throw invalid('signal() must return an array of { bar, direction }'); + raw.forEach((s: Signal, i) => { + if (!s || !Number.isInteger(s.bar) || s.bar < 0 || s.bar >= barCount) { + throw invalid(`signal() returned an invalid bar at index ${i}: ${s?.bar} (expected an integer bar index from 0 to ${barCount - 1})`); + } + if (s.direction !== 'long' && s.direction !== 'short') throw invalid(`signal() returned an invalid direction at index ${i}: ${s.direction}`); + }); + return [...(raw as Signal[])].sort((a, b) => a.bar - b.bar); +} + +function signalCounts(signals: readonly Signal[], before: number): Map { + const counts = new Map(); + for (const s of signals) { + if (s.bar >= before) break; + const key = `${s.bar}:${s.direction}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +} + +function violationsAt(cut: number, full: readonly Signal[], truncated: readonly Signal[]): CausalityViolation[] { + const expected = signalCounts(full, cut); + const actual = signalCounts(truncated, cut); + const out: CausalityViolation[] = []; + const diff = (a: Map, b: Map, kind: CausalityViolation['kind']) => { + for (const [key, count] of a) { + if ((b.get(key) ?? 0) < count) { + const [bar, direction] = key.split(':'); + out.push({ cut, bar: Number(bar), direction: direction as Direction, kind }); + } + } + }; + diff(expected, actual, 'look_ahead'); + diff(actual, expected, 'repaint'); + return out.sort((a, b) => a.bar - b.bar); +} + +/** + * Reruns `signal` on truncated copies of the candles and compares the signals it gives up to + * each cut with those of the full run. A signal must be identical whether or not later bars + * exist; otherwise it used information from after its bar (look-ahead) or changes when new + * bars arrive (repaint). Cuts: the bar right after each checked signal (at most `maxSignals`, + * picked with a seeded draw when there are more) and 8 evenly spaced bars after `fromBar`. + */ +export function checkCausality( + signal: SignalFunction, + candles: readonly Readonly>[], + options: { fullSignals?: readonly Signal[]; maxSignals?: number; fromBar?: number; seed?: number } = {}, +): { violations: CausalityViolation[]; check: CausalityCheck } { + const full = options.fullSignals ?? normaliseSignals(signal(candles), candles.length); + const maxSignals = options.maxSignals ?? BACKTEST_DEFAULTS.causalityChecks; + const fromBar = Math.max(1, options.fromBar ?? 0); + const candidates = [...new Set(full.map((s) => s.bar + 1))].filter((cut) => cut < candles.length); + let signalCuts = candidates; + if (candidates.length > maxSignals) { + const rng = createRng(options.seed ?? BACKTEST_DEFAULTS.seed); + const picked = new Set(); + while (picked.size < maxSignals) picked.add(candidates[Math.floor(rng() * candidates.length)]); + signalCuts = [...picked]; + } + const cuts = new Set(signalCuts); + if (maxSignals > 0) { + for (let j = 1; j <= 8; j++) { + const cut = fromBar + Math.round(((candles.length - fromBar) * j) / 9); + if (cut > 1 && cut < candles.length) cuts.add(cut); + } + } + const violations: CausalityViolation[] = []; + for (const cut of [...cuts].sort((a, b) => a - b)) { + const prefix = Object.freeze(candles.slice(0, cut)); + violations.push(...violationsAt(cut, full, normaliseSignals(signal(prefix), cut))); + } + const checkedBars = new Set(signalCuts.map((cut) => cut - 1)); + return { + violations, + check: { + reruns: cuts.size, + checkedSignals: full.filter((s) => checkedBars.has(s.bar)).length, + totalSignals: full.length, + }, + }; +} + +function lookAheadError(violations: readonly CausalityViolation[], candles: readonly Bar[]): TradingViewError { + const first = violations[0]; + const time = new Date(candles[first.bar].time * 1000).toISOString(); + const what = first.kind === 'look_ahead' + ? `appears with the full history but not when the history ends at bar ${first.cut - 1}: it uses bars after its own bar` + : `appears when the history ends at bar ${first.cut - 1} but not with the full history: it changes when later bars arrive (repaint)`; + return new TradingViewError('LOOK_AHEAD', `The ${first.direction} signal at bar ${first.bar} (${time}) ${what}. ` + + `${violations.length} causality violation(s) found. A signal decided at the close of bar i may only use bars 0..i ` + + '(a pivot with lookback N is only known N bars later). Results would be unreliable, so the backtest stopped.', { details: violations }); +} + +function toSnapshot(input: Snapshot | readonly Bar[]): Snapshot { + if (Array.isArray(input)) return freezeSnapshot(input); + const snapshot = input as Snapshot; + if (!snapshot || !Array.isArray(snapshot.candles) || !Object.isFrozen(snapshot.candles) || typeof snapshot.sha256 !== 'string') { + throw invalid('runBacktest() expects candles or a snapshot from freezeSnapshot() / loadSnapshot()'); + } + return snapshot; +} + +function segmentInfo(segment: Segment, candles: readonly Bar[]): SegmentInfo { + const empty = segment.end <= segment.start; + return { + ...segment, + bars: Math.max(0, segment.end - segment.start), + startTime: empty ? null : candles[segment.start].time, + endTime: empty ? null : candles[segment.end - 1].time, + }; +} + +const refTimes = (refs: Readonly> | undefined, candles: readonly Bar[]) => Object.fromEntries( + Object.entries(refs ?? {}).filter(([, bar]) => Number.isInteger(bar) && candles[bar]).map(([name, bar]) => [name, candles[bar].time]), +); + +/** Resolves the warm-up: explicit option, else the largest need of the signal and exits. */ +export function resolveWarmupBars(signal: SignalFunction, exits: readonly ExitModel[], warmupBars?: number): number { + if (warmupBars !== undefined) return checkOption('warmupBars', warmupBars, Number.isInteger(warmupBars) && warmupBars >= 0); + return Math.max(signal.warmupBars ?? BACKTEST_DEFAULTS.signalWarmupBars, ...exits.map((e) => e.warmupBars ?? 0)); +} + +/** + * Runs the full protocol on frozen candles, synchronously and without network: + * causality check of the signal, warm-up, chronological IS/OOS split, every exit on the same + * entries, costs on both sides, metrics per exit and split, seeded random-entry baseline. + * Throws a `LOOK_AHEAD` TradingViewError when the signal uses future bars. + * + * @example + * const report = runBacktest(candles, { signal: (bars) => bars.flatMap((b, i) => ...) }); + * console.log(formatReport(report)); + */ +export function runBacktest(input: Snapshot | readonly Bar[], options: BacktestOptions): BacktestReport { + if (typeof options?.signal !== 'function') throw invalid('options.signal must be a function (candles) => [{ bar, direction }]'); + const snapshot = toSnapshot(input); + const { candles } = snapshot; + const o = { ...BACKTEST_DEFAULTS, ...options }; + checkOption('fee', o.fee, o.fee >= 0 && o.fee < 1); + checkOption('slippage', o.slippage, o.slippage >= 0 && o.slippage < 1); + checkOption('splitRatio', o.splitRatio, o.splitRatio > 0 && o.splitRatio < 1); + checkOption('runs', o.runs, Number.isInteger(o.runs) && o.runs >= 0); + checkOption('seed', o.seed, Number.isInteger(o.seed)); + checkOption('causalityChecks', o.causalityChecks, Number.isInteger(o.causalityChecks) && o.causalityChecks >= 0); + const exits = options.exits ?? defaultExits(); + if (!Array.isArray(exits) || exits.length === 0) throw invalid('exits must be a non-empty array'); + if (new Set(exits.map((e) => e.id)).size !== exits.length) throw invalid('exit ids must be unique'); + const warmupBars = resolveWarmupBars(options.signal, exits, options.warmupBars); + const costs: Costs = { fee: o.fee, slippage: o.slippage }; + + // 1. Signals, then their causality: the same rule on shorter history must agree. + const signals = normaliseSignals(options.signal(candles), candles.length); + let causality: CausalityCheck = { reruns: 0, checkedSignals: 0, totalSignals: signals.length }; + if (o.causalityChecks > 0) { + const result = checkCausality(options.signal, candles, { + fullSignals: signals, maxSignals: o.causalityChecks, fromBar: warmupBars, seed: o.seed, + }); + if (result.violations.length) throw lookAheadError(result.violations, candles); + causality = result.check; + } + + // 2. Chronological split and entries (always the open of the bar after the signal). + const { inSample, outOfSample } = splitSegments(candles.length, warmupBars, o.splitRatio); + const segmentOf = (s: Signal) => [inSample, outOfSample].find((seg) => s.bar >= seg.start && s.bar + 1 < seg.end) ?? null; + const entries: Entry[] = signals.map((s) => ({ + direction: s.direction, signalBar: s.bar, entryBar: s.bar + 1, ...(s.refs ? { refs: s.refs } : {}), + })); + + // 3. Every exit on every segment, with its random baseline. + const rows: ReportRow[] = []; + const trades: TradeRecord[] = []; + for (const exit of exits) { + for (const segment of [inSample, outOfSample]) { + const result = simulate({ candles, entries, exit, costs, segment }); + const metrics = computeMetrics(result.trades); + const longs = result.trades.filter((t) => t.direction === 'long').length; + let baseline: Baseline | null = null; + if (metrics.trades > 0 && o.runs > 0) { + baseline = randomBaseline({ + candles, exit, costs, segment, + tradeCount: metrics.trades, + longShare: longs / metrics.trades, + runs: o.runs, + seed: deriveSeed(o.seed, exit.id, segment.name), + }); + } + rows.push({ + exit: exit.id, + exitLabel: exit.label, + split: segment.name, + metrics, + longs, + shorts: metrics.trades - longs, + skippedInPosition: result.skippedInPosition, + baseline: baseline && { + runs: baseline.runs, seed: baseline.seed, matchedRuns: baseline.matchedRuns, meanTrades: baseline.meanTrades, + p5: baseline.p5, p50: baseline.p50, p95: baseline.p95, + }, + percentile: baseline && percentileRank(metrics.netReturn, baseline.netReturns), + }); + for (const t of result.trades) { + trades.push({ + exit: exit.id, + split: segment.name, + direction: t.direction, + signalBar: t.signalBar, + signalTime: candles[t.signalBar].time, + entryBar: t.entryBar, + entryTime: candles[t.entryBar].time, + refs: refTimes(t.refs, candles), + entryOpen: t.entryOpen, + entryPrice: t.entryPrice, + initialStop: t.initialStop, + target: t.target, + exitBar: t.exitBar, + exitTime: candles[t.exitBar].time, + exitLevel: t.exitLevel, + exitPrice: t.exitPrice, + reason: t.reason, + return: t.return, + }); + } + } + } + + // 4. Warnings in plain language. + const tradableYears = ((candles.length - inSample.start) * snapshot.barSeconds) / (365.25 * 86_400); + const warnings: string[] = []; + if (tradableYears < o.minYears) { + warnings.push(`Only ${tradableYears.toFixed(2)} years of tradable history after warm-up (${o.minYears} wanted): results are even less conclusive.`); + } + if (signals.length === 0) warnings.push('The signal returned no signal at all.'); + const fewest = Math.min(...rows.map((r) => r.metrics.trades)); + if (fewest < 30) warnings.push(`As few as ${fewest} trades in a row: with so few trades, net return and percentile are mostly noise.`); + for (const row of rows) { + if (row.baseline && row.baseline.matchedRuns < row.baseline.runs) { + warnings.push(`${row.exit} ${row.split}: ${row.baseline.matchedRuns}/${row.baseline.runs} random runs reached ${row.metrics.trades} trades (mean ${row.baseline.meanTrades.toFixed(1)}).`); + } + } + if (o.causalityChecks === 0) warnings.push('Causality check disabled: look-ahead in the signal would go unnoticed.'); + if (snapshot.gaps.length) warnings.push(`${snapshot.gaps.length} gap(s) in the data (market closures also count).`); + + return { + snapshot: snapshotInfo(snapshot), + settings: { + signal: options.signal.label ?? (options.signal.name || 'custom signal'), + fee: o.fee, + slippage: o.slippage, + splitRatio: o.splitRatio, + warmupBars, + runs: o.runs, + seed: o.seed, + exits: exits.map((e) => ({ id: e.id, label: e.label })), + }, + segments: { + warmup: segmentInfo({ name: 'warmup', start: 0, end: inSample.start }, candles), + inSample: segmentInfo(inSample, candles), + outOfSample: segmentInfo(outOfSample, candles), + }, + tradableYears, + signals: signals.map((s) => ({ + bar: s.bar, + time: candles[s.bar].time, + direction: s.direction, + entryBar: s.bar + 1, + entryTime: candles[s.bar + 1]?.time ?? null, + split: (segmentOf(s)?.name ?? null) as SignalRecord['split'], + refs: refTimes(s.refs, candles), + })), + rows, + trades, + causality, + warnings, + }; +} + +export interface FetchSnapshotQuery { + symbol: string; + timeframe?: Timeframe; + /** Tradable years wanted after warm-up. Default 2. Ignored with `count` or `from`. */ + years?: number; + /** Warm-up bars added to the request. Default 200. */ + warmupBars?: number; + count?: number; + from?: number; + to?: number; + credentials?: CandleQuery['credentials']; + client?: CandleQuery['client']; + clientOptions?: CandleQuery['clientOptions']; + /** Cancels the download. */ + abortSignal?: AbortSignal; + /** Default 120000: deep history takes several server batches. */ + timeoutMs?: number; + provider?: MarketDataProvider; +} + +/** + * Downloads candles once with `getCandles` and freezes them (forming bar dropped, gaps and + * SHA-256 recorded). Save the result with `JSON.stringify` and replay it with `loadSnapshot`. + */ +export async function fetchSnapshot(query: FetchSnapshotQuery): Promise { + const timeframe = query.timeframe ?? '240'; + const barSeconds = timeframeSeconds(timeframe); + if (!barSeconds) throw invalid(`Backtests need a time-based timeframe, got ${timeframe}`); + const years = query.years ?? BACKTEST_DEFAULTS.minYears; + const warmup = query.warmupBars ?? BACKTEST_DEFAULTS.signalWarmupBars; + const range = query.from !== undefined + ? { from: query.from, to: query.to } + : { count: query.count ?? Math.ceil((years * 365.25 * 86_400) / barSeconds) + warmup + 1 }; + const candles = await getCandles({ + symbol: query.symbol, + timeframe, + ...range, + timeoutMs: query.timeoutMs ?? 120_000, + ...(query.credentials ? { credentials: query.credentials } : {}), + ...(query.client ? { client: query.client } : {}), + ...(query.clientOptions ? { clientOptions: query.clientOptions } : {}), + ...(query.abortSignal ? { signal: query.abortSignal } : {}), + }, query.provider); + return freezeSnapshot(candles, { symbol: query.symbol, timeframe, barSeconds }); +} + +export type BacktestQuery = FetchSnapshotQuery & Omit & { + /** Replay a saved snapshot instead of downloading candles. */ + snapshot?: Snapshot; +}; + +/** + * One call: download (or replay) the candles, freeze them, run the whole protocol. + * Returns the JSON report and the frozen snapshot (candles included, for charts and replays). + * + * @example + * import { backtest, formatReport } from '@mathieuc/tradingview/backtest'; + * const { report, snapshot } = await backtest({ + * symbol: 'BINANCE:BTCUSDT', timeframe: '240', years: 2, + * signal: (candles) => candles.flatMap((c, i) => (i > 0 && c.close > candles[i - 1].high ? [{ bar: i, direction: 'long' }] : [])), + * }); + * console.log(formatReport(report)); + * writeFileSync('snapshot.json', JSON.stringify(snapshot)); // Replay later with { snapshot: loadSnapshot(...) } + */ +export async function backtest(query: BacktestQuery): Promise<{ report: BacktestReport; snapshot: Snapshot }> { + if (typeof query?.signal !== 'function') throw invalid('signal must be a function (candles) => [{ bar, direction }]'); + const exits = query.exits ?? defaultExits(); + const warmupBars = resolveWarmupBars(query.signal, exits, query.warmupBars); + const snapshot = query.snapshot ?? await fetchSnapshot({ ...query, warmupBars }); + const report = runBacktest(snapshot, { + ...query, exits, warmupBars, minYears: query.years ?? BACKTEST_DEFAULTS.minYears, + }); + return { report, snapshot }; +} diff --git a/src/backtest/signals.ts b/src/backtest/signals.ts new file mode 100644 index 00000000..3fa8be45 --- /dev/null +++ b/src/backtest/signals.ts @@ -0,0 +1,58 @@ +import { ema, findPivots, rsi } from './indicators.js'; +import type { Direction, Signal, SignalFunction } from './types.js'; + +export interface RsiDivergenceOptions { + /** Default 14. */ + rsiLength?: number; + /** Trend filter EMA length. Default 200. */ + emaLength?: number; + /** Bars on each side of a pivot; it is confirmed this many bars later. Default 5. */ + pivotLookback?: number; + /** Bars between the two pivots of a divergence. Default 5 to 60. */ + minPivotDistance?: number; + maxPivotDistance?: number; +} + +const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + +/** + * Regular RSI divergence with an EMA trend filter, the reference strategy of the protocol. + * 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 (`pivot + pivotLookback`), never on + * the pivot itself. `refs` carries the two pivots. + */ +export function rsiDivergence(options: RsiDivergenceOptions = {}): SignalFunction { + const { + rsiLength = 14, emaLength = 200, pivotLookback = 5, minPivotDistance = 5, maxPivotDistance = 60, + } = options; + const find = (candles: Parameters[0]): Signal[] => { + const closes = candles.map((c) => c.close); + const rsiValues = rsi(closes, rsiLength); + const trend = ema(closes, emaLength); + const signals: Signal[] = []; + const scan = (kind: 'low' | 'high', direction: 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, level, close] = [rsiValues[prev.bar], rsiValues[cur.bar], trend[c], candles[c].close]; + if (![r1, r2, level].every(isNumber)) continue; + const divergence = kind === 'low' ? cur.price < prev.price && r2 > r1 : cur.price > prev.price && r2 < r1; + const trendOk = direction === 'long' ? close > level : close < level; + if (divergence && trendOk) signals.push({ bar: c, direction, refs: { prev_pivot: prev.bar, pivot: cur.bar } }); + } + }; + scan('low', 'long'); + scan('high', 'short'); + return signals.sort((a, b) => a.bar - b.bar); + }; + return Object.assign(find, { + warmupBars: Math.max(rsiLength, emaLength - 1), + label: `RSI(${rsiLength}) divergence, EMA(${emaLength}) trend filter, pivot lookback ${pivotLookback}, pivots ${minPivotDistance}-${maxPivotDistance} bars apart`, + }); +} diff --git a/src/backtest/simulate.ts b/src/backtest/simulate.ts new file mode 100644 index 00000000..024f1d8d --- /dev/null +++ b/src/backtest/simulate.ts @@ -0,0 +1,194 @@ +import { TradingViewError } from '../errors.js'; +import type { + Bar, Costs, Direction, Entry, ExitModel, ExitReason, Segment, Trade, +} from './types.js'; + +/** + * 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: number, warmupBars: number, ratio: number): { inSample: Segment; outOfSample: Segment } { + if (!(ratio > 0 && ratio < 1)) throw new TradingViewError('INVALID_ARGUMENT', 'splitRatio must be between 0 and 1'); + const start = Math.min(Math.max(0, warmupBars), barCount); + const splitBar = start + Math.floor((barCount - start) * ratio); + return { + inSample: { name: 'IS', start, end: splitBar }, + outOfSample: { name: 'OOS', start: splitBar, end: barCount }, + }; +} + +/** Fill price after adverse slippage. */ +export function fillPrice(side: 'entry' | 'exit', direction: Direction, price: number, slippage: number): number { + 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: Direction, entryFill: number, exitFill: number, fee: number): number { + const ratio = exitFill / entryFill; + const gross = direction === 'long' ? ratio - 1 : 1 - ratio; + return gross - fee - fee * ratio; +} + +const reachedStop = (direction: Direction, price: number, stop: number) => (direction === 'long' ? price <= stop : price >= stop); +const reachedTarget = (direction: Direction, price: number, target: number | null) => target !== null + && (direction === 'long' ? price >= target : price <= target); + +interface ExitFill { level: number; reason: ExitReason } + +interface OpenTrade { + candles: readonly Bar[]; + direction: Direction; + entryBar: number; + end: number; + entryOpen: number; + entryPrice: number; + initialStop: number; + stop: number; + target: number | null; + trailing: boolean; + stops: number[] | undefined; + marks: number[]; +} + +/** The bar opened beyond a level: the order fills at the open, the worse price for a stop. */ +function gapExit(bar: Bar, direction: Direction, stop: number, target: number | null): ExitFill | null { + 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: Bar, direction: Direction, stop: number, target: number | null, trailing: boolean): ExitFill | null { + const long = direction === 'long'; + 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 as number, 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: number, trade: OpenTrade): ExitFill | null { + 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: Entry, trade: OpenTrade, i: number, { level, reason }: ExitFill, costs: Costs): Trade { + const exitPrice = fillPrice('exit', entry.direction, level, costs.slippage); + return { + ...entry, + exitBar: i, + entryOpen: trade.entryOpen, + 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 } : {}), + }; +} + +interface SimContext { candles: readonly Bar[]; costs: Costs; end: number; trace: boolean } + +function openTrade(entry: Entry, exit: ExitModel, { candles, costs, end, trace }: SimContext): OpenTrade | null { + const { direction, entryBar } = entry; + const entryOpen = candles[entryBar].open; + const levels = exit.levels({ direction, entryOpen, signalBar: entry.signalBar, candles }); + if (!levels || !Number.isFinite(levels.stop)) return null; + return { + candles, direction, entryBar, end, entryOpen, + entryPrice: fillPrice('entry', direction, entryOpen, costs.slippage), + initialStop: levels.stop, + stop: levels.stop, + target: levels.target, + trailing: Boolean(exit.trail), + stops: trace ? [] : undefined, + marks: [], + }; +} + +function runTrade(entry: Entry, exit: ExitModel, context: SimContext): Trade | null { + const { candles, 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). + 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 }); + } + return null; // Only reached when the entry bar is outside the segment +} + +export interface SimulateInput { + candles: readonly Bar[]; + /** Sorted by `entryBar`. */ + entries: readonly Entry[]; + exit: ExitModel; + costs: Costs; + segment: Segment; + maxTrades?: number; + /** Record the stop in force on every bar of each trade. */ + trace?: boolean; +} + +export interface SimulateResult { + trades: Trade[]; + /** Entries skipped because a position was already open. */ + skippedInPosition: number; + /** Entries whose signal bar or entry bar is outside the segment. */ + outsideSegment: number; + /** Entries for which the exit returned no levels (ATR not defined yet...). */ + invalidLevels: number; +} + +/** + * Simulates entries inside one segment, one position at a time, 100% of equity, no leverage. + * 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 (`segment_end`), so no position crosses a segment boundary. + */ +export function simulate({ + candles, entries, exit, costs, segment, maxTrades = Infinity, trace = false, +}: SimulateInput): SimulateResult { + const context: SimContext = { candles, costs, end: segment.end, trace }; + const trades: Trade[] = []; + 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 }; +} diff --git a/src/backtest/snapshot.ts b/src/backtest/snapshot.ts new file mode 100644 index 00000000..028d64d5 --- /dev/null +++ b/src/backtest/snapshot.ts @@ -0,0 +1,133 @@ +import { createHash } from 'node:crypto'; +import { TradingViewError } from '../errors.js'; +import type { Bar } from './types.js'; + +export interface Gap { afterTime: number; beforeTime: number; missingBars: number } + +export interface SnapshotInfo { + 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; + /** SHA-256 of the frozen candles: two runs on the same hash saw the same data. */ + readonly sha256: string; +} + +/** Validated, frozen candles. Save it (`JSON.stringify`) to replay a run exactly. */ +export interface Snapshot extends SnapshotInfo { + readonly candles: readonly Readonly>[]; +} + +export interface SnapshotMeta { + symbol?: string; + timeframe?: string; + /** Bar duration in seconds. Inferred from the most common interval when omitted. */ + barSeconds?: number; + /** Current time in Unix seconds, to detect the bar still forming. Defaults to now. */ + now?: number; +} + +const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + +function freezeDeep(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const key of Object.keys(value)) freezeDeep((value as Record)[key]); + Object.freeze(value); + } + return value; +} + +function invalid(message: string): TradingViewError { + return new TradingViewError('INVALID_ARGUMENT', message); +} + +/** Most common interval between consecutive bars. */ +function inferBarSeconds(candles: readonly Bar[]): number { + const counts = new Map(); + for (let i = 1; i < candles.length; i++) { + const diff = candles[i].time - candles[i - 1].time; + counts.set(diff, (counts.get(diff) ?? 0) + 1); + } + let best = 0; + let bestCount = 0; + for (const [diff, count] of counts) if (count > bestCount) [best, bestCount] = [diff, count]; + return best; +} + +/** + * 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: readonly Bar[], meta: SnapshotMeta = {}): Snapshot { + if (!Array.isArray(rawCandles)) throw invalid('candles must be an array'); + 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 invalid(`Invalid candle at index ${i}`); + if (c.high < Math.max(c.open, c.close) || c.low > Math.min(c.open, c.close)) throw invalid(`Inconsistent OHLC at index ${i}`); + if (i > 0 && c.time <= candles[i - 1].time) throw invalid(`Candles are not strictly ordered at index ${i}`); + }); + const barSeconds = meta.barSeconds ?? inferBarSeconds(candles); + if (!isNumber(barSeconds) || barSeconds <= 0) throw invalid('barSeconds must be a positive number'); + const now = meta.now ?? Date.now() / 1000; + + let droppedFormingBar = false; + if (candles.length > 0 && candles[candles.length - 1].time + barSeconds > now) { + candles.pop(); + droppedFormingBar = true; + } + if (candles.length < 2) throw invalid('Not enough closed candles'); + + const gaps: Gap[] = []; + 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: meta.symbol ?? '', + timeframe: String(meta.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, + }); +} + +/** + * Restores a snapshot saved with `JSON.stringify(snapshot)` and checks its SHA-256, + * so that a replay runs on exactly the same data. + */ +export function loadSnapshot(saved: Snapshot | string): Snapshot { + const data: Snapshot = typeof saved === 'string' ? JSON.parse(saved) : saved; + if (!data || !Array.isArray(data.candles)) throw invalid('Not a saved snapshot'); + const snapshot = freezeSnapshot(data.candles, { + symbol: data.symbol, timeframe: data.timeframe, barSeconds: data.barSeconds, now: Infinity, + }); + if (snapshot.sha256 !== data.sha256) throw invalid('Snapshot checksum mismatch'); + // Keep the forming-bar flag recorded at capture time. + return freezeDeep({ ...snapshot, droppedFormingBar: Boolean(data.droppedFormingBar) }); +} + +/** Snapshot metadata without the candles. */ +export function snapshotInfo(snapshot: Snapshot): SnapshotInfo { + const info: Record = { ...snapshot }; + delete info.candles; + return info as unknown as SnapshotInfo; +} diff --git a/src/backtest/stats.ts b/src/backtest/stats.ts new file mode 100644 index 00000000..51e34e8d --- /dev/null +++ b/src/backtest/stats.ts @@ -0,0 +1,152 @@ +import { simulate } from './simulate.js'; +import type { + Bar, Baseline, Costs, Direction, Entry, ExitModel, Metrics, Segment, Trade, +} from './types.js'; + +/** + * - 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: readonly Pick[]): Metrics { + let equity = 1; + let peak = 1; + let maxDrawdown = 0; + let grossProfit = 0; + let grossLoss = 0; + let wins = 0; + const visit = (value: number) => { + 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, + }; +} + +/** mulberry32: small, fast, seedable PRNG returning floats in [0, 1). */ +export function createRng(seed: number): () => number { + 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: (string | number)[]): number { + let h = 0x811c9dc5; + for (const ch of parts.join('|')) { + h ^= ch.charCodeAt(0); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +} + +function sample(pool: readonly T[], k: number, rng: () => number): T[] { + 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: readonly number[], q: number): number | null { + 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); +} + +export interface BaselineInput { + candles: readonly Bar[]; + exit: ExitModel; + costs: Costs; + segment: Segment; + /** Trades each run tries to reach (the strategy's trade count). */ + tradeCount: number; + /** Probability that a random entry is long. */ + longShare: number; + runs: number; + seed: number; +} + +/** + * 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, exit, costs, segment, tradeCount, longShare, runs, seed, +}: BaselineInput): Baseline { + const eligible: number[] = []; + for (let e = segment.start + 1; e < segment.end; e++) eligible.push(e); + const rng = createRng(seed); + const netReturns: number[] = []; + let matchedRuns = 0; + let totalTrades = 0; + for (let run = 0; run < runs; run++) { + const direction = new Map(); + const draw = (bars: number[]) => bars.forEach((bar) => direction.set(bar, rng() < longShare ? 'long' : 'short')); + draw(sample(eligible, tradeCount, rng)); + let trades: Trade[] = []; + for (let round = 0; round < 20; round++) { + const entries: Entry[] = [...direction.keys()].sort((a, b) => a - b) + .map((bar) => ({ direction: direction.get(bar) as Direction, signalBar: bar - 1, entryBar: bar })); + trades = simulate({ candles, entries, exit, costs, segment, maxTrades: tradeCount }).trades; + const missing = tradeCount - trades.length; + if (missing <= 0) break; + const busy = new Set(direction.keys()); + for (const t of 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 (trades.length === tradeCount) matchedRuns++; + totalTrades += trades.length; + netReturns.push(computeMetrics(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: number, randomReturns: readonly number[]): number | null { + if (randomReturns.length === 0) return null; + return (100 * randomReturns.filter((r) => r < value).length) / randomReturns.length; +} diff --git a/src/backtest/types.ts b/src/backtest/types.ts new file mode 100644 index 00000000..efc779e5 --- /dev/null +++ b/src/backtest/types.ts @@ -0,0 +1,129 @@ +/** One OHLC bar, oldest first; `time` is the bar open time in Unix seconds. */ +export interface Bar { + time: number; + open: number; + high: number; + low: number; + close: number; + volume?: number; +} + +export type Direction = 'long' | 'short'; + +/** + * A decision taken at the CLOSE of bar `bar`. The engine enters at the open of `bar + 1`; + * there is no way to enter on the signal bar itself. + */ +export interface Signal { + bar: number; + direction: Direction; + /** + * Optional bars worth showing next to the trade (a pivot, a crossover...), by name. + * They are exported with their times in the report and the CSV; they never change the fills. + */ + refs?: Readonly>; +} + +/** + * Your entry rule: receives the frozen candles (oldest first) and returns every signal. + * It must only use bars up to each signal's bar: the engine reruns it on truncated + * history and rejects any signal that depends on later bars. + */ +export interface SignalFunction { + (candles: readonly Readonly>[]): readonly Signal[]; + /** Bars needed before the first valid signal (indicator warm-up). */ + readonly warmupBars?: number; + /** Shown in the report. */ + readonly label?: string; +} + +/** A signal turned into an order: decided at the close of `signalBar`, entered at the open of `entryBar`. */ +export interface Entry { + direction: Direction; + signalBar: number; + entryBar: number; + refs?: Readonly>; +} + +export interface Segment { + name: string; + /** First bar (inclusive). */ + start: number; + /** Last bar + 1 (exclusive). */ + end: number; +} + +export interface Costs { + /** Fee per side, as a fraction of the notional (0.001 = 0.1%). */ + fee: number; + /** Adverse slippage per side, as a fraction of the price. */ + slippage: number; +} + +export interface ExitContext { + direction: Direction; + candles: readonly Bar[]; +} + +/** + * An exit model. Entries never depend on it, so exits can be swapped freely. + * - `levels` returns the initial stop and target (target may be null) from information known + * at entry: the entry bar open and bars up to `signalBar`. Return null to skip the entry. + * - `trail`, if present, runs after bar `bar` has CLOSED and returns the stop used from `bar + 1`. + */ +export interface ExitModel { + id: string; + label: string; + /** Bars needed before `levels` can work (ATR length - 1...). */ + warmupBars?: number; + levels(ctx: ExitContext & { entryOpen: number; signalBar: number }): { stop: number; target: number | null } | null; + trail?(stop: number, bar: number, ctx: ExitContext): number; +} + +export type ExitReason = + | 'stop' | 'trailing_stop' | 'stop_gap' | 'target' | 'target_gap' | 'stop_and_target_same_bar' | 'segment_end'; + +export interface Trade extends Entry { + exitBar: number; + /** Open of the entry bar, before slippage. */ + entryOpen: number; + /** Entry fill, after slippage. */ + entryPrice: number; + initialStop: number; + target: number | null; + /** Level that triggered the exit (stop, target, gap open or segment close), before slippage. */ + exitLevel: number; + /** Exit fill, after slippage. */ + exitPrice: number; + reason: ExitReason; + /** Net return on equity, after fees and slippage on both sides. */ + return: number; + /** Mark-to-market return at each bar close while the position was open (exit costs included). */ + marks: number[]; + /** Stop in force on each bar from entry to exit (only with `trace: true`). */ + stops?: number[]; +} + +export interface Metrics { + trades: number; + /** Compounded net return of the closed trades. */ + netReturn: number; + /** Share of trades with a positive net return; null without trades. */ + winRate: number | null; + /** Gross profit / gross loss; null without trades or without a losing trade. */ + profitFactor: number | null; + /** Largest peak-to-trough fall of equity, marked to market at bar closes (positive fraction). */ + maxDrawdown: number; +} + +export interface Baseline { + runs: number; + seed: number; + netReturns: number[]; + /** Runs that reached the strategy's trade count. */ + matchedRuns: number; + meanTrades: number; + p5: number | null; + p50: number | null; + p95: number | null; +} diff --git a/src/errors.ts b/src/errors.ts index 90ac42b8..484593cb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -35,7 +35,9 @@ export type TradingViewErrorCode = /** An operation was cancelled through an `AbortSignal`. */ | 'ABORTED' /** A user-supplied callback threw while processing data. */ - | 'CALLBACK_ERROR'; + | 'CALLBACK_ERROR' + /** A backtest signal used bars after the bar it was decided on. */ + | 'LOOK_AHEAD'; export interface TradingViewErrorOptions { /** Raw details sent by the server, when available. */ diff --git a/tests/unit/backtest.test.ts b/tests/unit/backtest.test.ts new file mode 100644 index 00000000..cc6b9fd6 --- /dev/null +++ b/tests/unit/backtest.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from 'vitest'; +import { + atrExit, atrTrailingExit, backtest, checkCausality, computeMetrics, createRng, defaultExits, ema, fillPrice, findPivots, + fixedExit, formatReport, freezeSnapshot, loadSnapshot, percentileRank, quantile, randomBaseline, rsiDivergence, + runBacktest, simulate, sma, splitSegments, tradeReturn, tradesToCsv, TradingViewError, + type Bar, type Entry, type Signal, type SignalFunction, +} from '../../src/backtest/index.js'; +import type { MarketDataProvider } from '../../src/data/provider.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, with a small gap between each close and the next open. */ +function randomWalk(n: number, seed: number): Bar[] { + const rng = createRng(seed); + let price = 100; + return Array.from({ length: n }, (_, i) => { + const open = price * (1 + (rng() - 0.5) * 0.002); + 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 longAt = (entryBar: number): Entry => ({ direction: 'long', signalBar: entryBar - 1, entryBar }); +const whole = (bars: Bar[]) => ({ name: 'ALL', start: 0, end: bars.length }); +const fixed2R = fixedExit({ stopPct: 0.02, targetR: 2 }); +const SMALL = { rsiLength: 14, emaLength: 50, pivotLookback: 3 }; + +/** Long when the close crosses above its SMA(20): causal. */ +const smaCross: SignalFunction = Object.assign((candles: readonly Bar[]) => { + const closes = candles.map((c) => c.close); + const avg = sma(closes, 20); + const out: Signal[] = []; + for (let i = 20; i < candles.length; i++) { + if (closes[i - 1] <= avg[i - 1] && closes[i] > avg[i]) out.push({ bar: i, direction: 'long', refs: { cross: i } }); + } + return out; +}, { warmupBars: 19, label: 'SMA(20) cross' }); + +describe('backtest: 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 or inconsistent candles', () => { + const bars = flatBars(5); + [bars[1], bars[2]] = [bars[2]!, bars[1]!]; + expect(() => freezeSnapshot(bars, { barSeconds: 4 * HOUR, now: 1e12 })).toThrow(/ordered/); + const broken = flatBars(5); + broken[2] = { ...broken[2]!, high: 50 }; + expect(() => freezeSnapshot(broken, { now: 1e12 })).toThrow(/OHLC/); + }); + + it('infers the bar duration and replays a saved snapshot only with a matching checksum', () => { + const snapshot = freezeSnapshot(flatBars(20), { symbol: 'X:Y', timeframe: '240', now: 1e12 }); + expect(snapshot.barSeconds).toBe(4 * HOUR); + const saved = JSON.stringify(snapshot); + const replay = loadSnapshot(saved); + expect(replay.sha256).toBe(snapshot.sha256); + expect(replay.candles).toEqual(snapshot.candles); + const tampered = JSON.parse(saved); + tampered.candles[3].close = 100.5; + expect(() => loadSnapshot(tampered)).toThrow(/checksum/); + }); +}); + +describe('backtest: 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 the divergence on the confirmation bar, never on the pivot', () => { + const bars = freezeSnapshot(randomWalk(1500, 11), { now: 1e12 }).candles; + const signal = rsiDivergence(SMALL); + const signals = signal(bars); + expect(signals.length).toBeGreaterThan(5); + for (const s of signals) { + expect(s.bar).toBe(s.refs!.pivot! + 3); + const key = (list: readonly Signal[]) => list.some((x) => x.bar === s.bar && x.direction === s.direction); + expect(key(signal(bars.slice(0, s.bar + 1)))).toBe(true); // Known at the close of its bar... + expect(key(signal(bars.slice(0, s.bar)))).toBe(false); // ...and not one bar earlier. + } + }); + + it('enters at the open of the bar after the signal', () => { + const report = runBacktest(randomWalk(1200, 7), { signal: rsiDivergence(SMALL), runs: 0 }); + const candles = freezeSnapshot(randomWalk(1200, 7)).candles; + expect(report.trades.length).toBeGreaterThan(0); + for (const trade of report.trades) { + expect(trade.entryBar).toBe(trade.signalBar + 1); + expect(trade.entryOpen).toBe(candles[trade.entryBar]!.open); + expect(trade.entryOpen).not.toBe(candles[trade.signalBar]!.close); + } + const costs = { fee: 0, slippage: 0.0005 }; + const bars = randomWalk(40, 1); + const { trades } = simulate({ candles: bars, entries: [longAt(24)], exit: fixed2R, costs, segment: whole(bars) }); + expect(trades[0]!.entryOpen).toBe(bars[24]!.open); + expect(trades[0]!.entryPrice).toBeCloseTo(bars[24]!.open * 1.0005, 10); + }); + + it('stops the run with LOOK_AHEAD when a signal uses later bars', () => { + const bars = randomWalk(1200, 7); + const honest = rsiDivergence(SMALL); + const backdated: SignalFunction = (candles) => honest(candles).map((s) => ({ ...s, bar: s.refs!.pivot! })); + const error = (() => { + try { + runBacktest(bars, { signal: backdated, runs: 0 }); + } catch (caught) { + return caught; + } + return null; + })(); + expect(error).toBeInstanceOf(TradingViewError); + expect((error as TradingViewError).code).toBe('LOOK_AHEAD'); + expect((error as TradingViewError).message).toMatch(/uses bars after its own bar/); + // Peeking at the next close is caught too. + const peek: SignalFunction = (candles) => candles.flatMap((c, i) => (candles[i + 1] && candles[i + 1]!.close > c.close * 1.015 ? [{ bar: i, direction: 'long' as const }] : [])); + expect(() => runBacktest(bars, { signal: peek, runs: 0, warmupBars: 10 })).toThrow(/LOOK_AHEAD|after its own bar/); + }); + + it('flags a signal that changes when later bars arrive (repaint)', () => { + const candles = freezeSnapshot(randomWalk(400, 5), { now: 1e12 }).candles; + // Signals on the 10 last bars of whatever history it gets: they move when history grows. + const repaint: SignalFunction = (bars) => bars.slice(-10).map((_, k) => ({ bar: bars.length - 10 + k, direction: 'long' })); + const { violations } = checkCausality(repaint, candles, { fromBar: 50 }); + expect(violations.some((v) => v.kind === 'repaint')).toBe(true); + expect(checkCausality(smaCross, candles).violations).toEqual([]); + }); + + it('validates what the signal function returns', () => { + const bars = flatBars(300); + expect(() => runBacktest(bars, { signal: () => [{ bar: 1.5, direction: 'long' }] })).toThrow(/invalid bar/); + expect(() => runBacktest(bars, { signal: () => [{ bar: 400, direction: 'long' }] })).toThrow(/invalid bar/); + expect(() => runBacktest(bars, { signal: () => [{ bar: 10, direction: 'up' as never }] })).toThrow(/invalid direction/); + expect(() => runBacktest(bars, { signal: (() => 'long') as never })).toThrow(/array/); + expect(() => runBacktest(bars, {} as never)).toThrow(/signal must be a function/); + }); +}); + +describe('backtest: 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, entries: [longAt(2)], exit: fixed2R, 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, and a gap through the target at the open', () => { + const bars = flatBars(10); + bars[3] = { ...bars[3]!, open: 95, high: 96, low: 94, close: 95 }; + const { trades } = simulate({ candles: bars, entries: [longAt(2)], exit: fixed2R, costs: NO_COSTS, segment: whole(bars) }); + expect(trades[0]).toMatchObject({ exitBar: 3, exitLevel: 95, reason: 'stop_gap' }); + const up = flatBars(10); + up[3] = { ...up[3]!, open: 106, high: 107, low: 105, close: 106 }; + const win = simulate({ candles: up, entries: [longAt(2)], exit: fixed2R, costs: NO_COSTS, segment: whole(up) }).trades[0]!; + expect(win).toMatchObject({ exitBar: 3, exitLevel: 106, reason: 'target_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 long = simulate({ candles: bars, entries: [longAt(2)], exit: fixed2R, 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, entries: [{ ...longAt(2), direction: 'short' }], exit: fixed2R, 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, exit: fixed2R, 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); + }); + + it('passes the fee and slippage options through the full run', () => { + const bars = randomWalk(800, 21); + const free = runBacktest(bars, { signal: smaCross, runs: 0, fee: 0, slippage: 0 }); + const paid = runBacktest(bars, { signal: smaCross, runs: 0, fee: 0.002, slippage: 0.001 }); + expect(paid.settings).toMatchObject({ fee: 0.002, slippage: 0.001 }); + const first = (r: typeof free) => r.trades.find((t) => t.exit === 'fixed_2R')!; + expect(first(paid).entryPrice).toBeCloseTo(first(free).entryOpen * 1.001, 10); + expect(first(paid).return).toBeLessThan(first(free).return); + }); +}); + +describe('backtest: split', () => { + it('never lets a position cross a segment boundary', () => { + const bars = flatBars(20); + const segments = splitSegments(20, 0, 0.5); + // 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, entries: [straddling], exit: fixed2R, costs: NO_COSTS, segment: segments.inSample }).trades).toEqual([]); + expect(simulate({ candles: bars, entries: [straddling], exit: fixed2R, 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, entries: [longAt(5)], exit: fixed2R, 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(bars, { signal: rsiDivergence(SMALL), runs: 10 }); + const { warmup, inSample, outOfSample } = report.segments; + expect(report.settings.warmupBars).toBe(49); + expect(warmup).toMatchObject({ start: 0, end: 49, bars: 49 }); + expect(inSample.start).toBe(49); + expect(inSample.end).toBe(outOfSample.start); + expect(outOfSample.end).toBe(bars.length); + expect(inSample.startTime).toBe(bars[49]!.time); + 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.exitBar).toBeLessThan(segment.end); + expect(trade.exitTime).toBe(bars[trade.exitBar]!.time); + } + expect(report.rows).toHaveLength(8); + expect(report.rows.map((r) => `${r.exit}/${r.split}`)).toEqual([ + 'fixed_1.5R/IS', 'fixed_1.5R/OOS', 'fixed_2R/IS', 'fixed_2R/OOS', 'atr1.5_2R/IS', 'atr1.5_2R/OOS', 'trail_atr3/IS', 'trail_atr3/OOS', + ]); + for (const signal of report.signals.filter((s) => s.split !== null)) { + const segment = signal.split === 'IS' ? inSample : outOfSample; + expect(signal.bar >= segment.start && signal.entryBar < segment.end).toBe(true); + } + }); +}); + +describe('backtest: trailing stop', () => { + // Rising bars: close = 100.5 + i, ATR(1) = 1.5 on every bar, 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 exit = atrTrailingExit({ mult: 2, length: 1 }); + const atrOf = (bars: Bar[], i: number) => Math.max(bars[i]!.high - bars[i]!.low, i ? Math.abs(bars[i]!.high - bars[i - 1]!.close) : 0); + + it('ratchets from closed bars only', () => { + const bars = rising(30); + const { trades } = simulate({ candles: bars, 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 * atrOf(bars, 4)); // 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 * atrOf(bars, 5 + j - 1))); + } + }); + + 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, 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('backtest: exits', () => { + it('builds the four reference exits and stop-only variants', () => { + expect(defaultExits().map((e) => e.id)).toEqual(['fixed_1.5R', 'fixed_2R', 'atr1.5_2R', 'trail_atr3']); + const bars = flatBars(30); + const ctx = { direction: 'short' as const, entryOpen: 100, signalBar: 20, candles: bars }; + expect(fixedExit({ stopPct: 0.05, targetR: null }).levels(ctx)).toEqual({ stop: 105, target: null }); + expect(atrExit({ mult: 2, targetR: 3, length: 14 }).levels(ctx)).toEqual({ stop: 104, target: 88 }); + expect(atrExit({ mult: 2, targetR: 3, length: 14 }).levels({ ...ctx, signalBar: 5 })).toBeNull(); // ATR not defined yet + expect(() => fixedExit({ stopPct: -1, targetR: 2 })).toThrow(/stopPct/); + }); + + it('rejects duplicate exit ids and bad options', () => { + const bars = flatBars(300); + expect(() => runBacktest(bars, { signal: smaCross, exits: [fixed2R, fixed2R] })).toThrow(/unique/); + expect(() => runBacktest(bars, { signal: smaCross, splitRatio: 1 })).toThrow(/splitRatio/); + expect(() => runBacktest(bars, { signal: smaCross, runs: -1 })).toThrow(/runs/); + }); +}); + +describe('backtest: random baseline and metrics', () => { + const bars = randomWalk(600, 3); + const input = { + candles: bars, exit: fixedExit({ stopPct: 0.02, targetR: 1.5 }), 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([]).winRate).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); + }); + + it('gives the same report twice and a percentile per row', () => { + const run = () => runBacktest(randomWalk(900, 9), { signal: smaCross, runs: 30 }); + const a = run(); + expect(JSON.stringify(run())).toBe(JSON.stringify(a)); + for (const row of a.rows.filter((r) => r.metrics.trades > 0)) { + expect(row.baseline).toMatchObject({ runs: 30 }); + expect(row.baseline).not.toHaveProperty('netReturns'); + expect(row.percentile).toBeGreaterThanOrEqual(0); + } + const none = runBacktest(randomWalk(900, 9), { signal: smaCross, runs: 0 }); + expect(none.rows.every((r) => r.baseline === null && r.percentile === null)).toBe(true); + }); +}); + +describe('backtest: report and one-call API', () => { + it('exports JSON, text and CSV with the signal refs', () => { + const bars = randomWalk(900, 9); + const report = runBacktest(bars, { signal: smaCross, runs: 10 }); + expect(JSON.parse(JSON.stringify(report))).toEqual(report); + expect(report.settings.signal).toBe('SMA(20) cross'); + expect(report.causality.reruns).toBeGreaterThan(0); + expect(report.causality.checkedSignals).toBe(Math.min(100, report.causality.totalSignals)); + const text = formatReport(report); + for (const part of ['== Split', 'Out-of-sample:', 'no look-ahead found', 'Rand p50', 'WARNING: Only']) expect(text).toContain(part); + const csv = tradesToCsv(report.trades).trim().split('\n'); + expect(csv[0]).toBe('exit,split,direction,cross_time,signal_time,entry_time,entry_open,entry_price,initial_stop,target,exit_time,exit_level,exit_price,exit_reason,return_pct'); + expect(csv).toHaveLength(report.trades.length + 1); + expect(csv[1]!.split(',')[3]).toBe(csv[1]!.split(',')[4]); // The cross is the signal bar + }); + + it('downloads candles once through a provider, drops the forming bar and runs', async () => { + const now = Math.floor(Date.now() / 1000); + const bars = randomWalk(900, 4).map((bar, i) => ({ ...bar, time: now - (899 - i) * 4 * HOUR - 60, volume: 1 })); + const queries: unknown[] = []; + const provider: MarketDataProvider = { + async watchCandles(query, handlers) { + queries.push(query); + handlers.onData(bars); + return { latest: bars, symbolInfo: undefined, stop: async () => undefined } as never; + }, + }; + const { report, snapshot } = await backtest({ symbol: 'TEST:X', timeframe: '240', years: 0.1, signal: smaCross, runs: 5, provider }); + expect(queries).toHaveLength(1); + expect(queries[0]).toMatchObject({ symbol: 'TEST:X', timeframe: '240', count: Math.ceil((0.1 * 365.25 * 86_400) / (4 * HOUR)) + 19 + 1 }); // Warm-up from the signal + expect(snapshot.droppedFormingBar).toBe(true); + expect(snapshot.count).toBe(899); + expect(report.snapshot).toMatchObject({ symbol: 'TEST:X', timeframe: '240', sha256: snapshot.sha256 }); + expect(report.snapshot).not.toHaveProperty('candles'); + const replay = await backtest({ symbol: 'TEST:X', snapshot: loadSnapshot(JSON.stringify(snapshot)), signal: smaCross, runs: 5 }); + expect(replay.report.rows).toEqual(report.rows); + await expect(backtest({ symbol: 'TEST:X', timeframe: '1T' as never, signal: smaCross })).rejects.toThrow(/time-based/); + }); + + it('keeps ema and sma causal', () => { + const values = [1, 2, 3, 4, 5, 6, 7, 8]; + expect(sma(values, 3).slice(2)).toEqual([2, 3, 4, 5, 6, 7]); + expect(ema(values.slice(0, 5), 3)).toEqual(ema(values, 3).slice(0, 5)); + }); +}); diff --git a/tests/unit/exports.test.ts b/tests/unit/exports.test.ts index 71c17be2..9072dba0 100644 --- a/tests/unit/exports.test.ts +++ b/tests/unit/exports.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import * as root from '../../src/index.js'; import * as data from '../../src/data/index.js'; +import * as backtest from '../../src/backtest/index.js'; describe('public API', () => { it('root entry exposes the high-level, low-level and HTTP APIs', () => { @@ -26,4 +27,14 @@ describe('public API', () => { ]); expect(data.getCandles).toBe(root.getCandles); }); + + it('backtest entry exposes the protocol, its building blocks and the report helpers', () => { + expect(Object.keys(backtest).sort()).toEqual([ + 'BACKTEST_DEFAULTS', 'TradingViewError', 'atr', 'atrExit', 'atrTrailingExit', 'backtest', 'checkCausality', 'computeMetrics', + 'createRng', 'defaultExits', 'deriveSeed', 'ema', 'fetchSnapshot', 'fillPrice', 'findPivots', 'fixedExit', 'formatReport', + 'formatTable', 'freezeSnapshot', 'isoTime', 'loadSnapshot', 'percentileRank', 'quantile', 'randomBaseline', 'resolveWarmupBars', + 'rsi', 'rsiDivergence', 'runBacktest', 'simulate', 'sma', 'snapshotInfo', 'splitSegments', 'tradeReturn', 'tradesToCsv', + ]); + expect(backtest.TradingViewError).toBe(root.TradingViewError); + }); }); diff --git a/tests/unit/honest-backtest.test.ts b/tests/unit/honest-backtest.test.ts deleted file mode 100644 index e2236097..00000000 --- a/tests/unit/honest-backtest.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -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 77d1d88c00dbceb4a8bfa93b783b08064fbe1997 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 23 Oct 2026 04:45:20 +0200 Subject: [PATCH 2/5] Split runBacktest into smaller steps --- src/backtest/run.ts | 230 ++++++++++++++++++++++++-------------------- 1 file changed, 125 insertions(+), 105 deletions(-) diff --git a/src/backtest/run.ts b/src/backtest/run.ts index ed59d187..227c2f5b 100644 --- a/src/backtest/run.ts +++ b/src/backtest/run.ts @@ -12,7 +12,7 @@ import { computeMetrics, createRng, deriveSeed, percentileRank, randomBaseline, } from './stats.js'; import type { - Bar, Baseline, Costs, Direction, Entry, ExitModel, ExitReason, Metrics, Segment, Signal, SignalFunction, + Bar, Baseline, Costs, Direction, Entry, ExitModel, ExitReason, Metrics, Segment, Signal, SignalFunction, Trade, } from './types.js'; export interface BacktestOptions { @@ -287,21 +287,11 @@ export function resolveWarmupBars(signal: SignalFunction, exits: readonly ExitMo return Math.max(signal.warmupBars ?? BACKTEST_DEFAULTS.signalWarmupBars, ...exits.map((e) => e.warmupBars ?? 0)); } -/** - * Runs the full protocol on frozen candles, synchronously and without network: - * causality check of the signal, warm-up, chronological IS/OOS split, every exit on the same - * entries, costs on both sides, metrics per exit and split, seeded random-entry baseline. - * Throws a `LOOK_AHEAD` TradingViewError when the signal uses future bars. - * - * @example - * const report = runBacktest(candles, { signal: (bars) => bars.flatMap((b, i) => ...) }); - * console.log(formatReport(report)); - */ -export function runBacktest(input: Snapshot | readonly Bar[], options: BacktestOptions): BacktestReport { +type ResolvedOptions = Required> & { exits: readonly ExitModel[]; warmupBars: number }; + +function resolveOptions(options: BacktestOptions): ResolvedOptions { if (typeof options?.signal !== 'function') throw invalid('options.signal must be a function (candles) => [{ bar, direction }]'); - const snapshot = toSnapshot(input); - const { candles } = snapshot; - const o = { ...BACKTEST_DEFAULTS, ...options }; + const o = { ...BACKTEST_DEFAULTS, ...options } as Required>; checkOption('fee', o.fee, o.fee >= 0 && o.fee < 1); checkOption('slippage', o.slippage, o.slippage >= 0 && o.slippage < 1); checkOption('splitRatio', o.splitRatio, o.splitRatio > 0 && o.splitRatio < 1); @@ -311,86 +301,80 @@ export function runBacktest(input: Snapshot | readonly Bar[], options: BacktestO const exits = options.exits ?? defaultExits(); if (!Array.isArray(exits) || exits.length === 0) throw invalid('exits must be a non-empty array'); if (new Set(exits.map((e) => e.id)).size !== exits.length) throw invalid('exit ids must be unique'); - const warmupBars = resolveWarmupBars(options.signal, exits, options.warmupBars); - const costs: Costs = { fee: o.fee, slippage: o.slippage }; + return { ...o, exits, warmupBars: resolveWarmupBars(options.signal, exits, options.warmupBars) }; +} - // 1. Signals, then their causality: the same rule on shorter history must agree. - const signals = normaliseSignals(options.signal(candles), candles.length); - let causality: CausalityCheck = { reruns: 0, checkedSignals: 0, totalSignals: signals.length }; - if (o.causalityChecks > 0) { - const result = checkCausality(options.signal, candles, { - fullSignals: signals, maxSignals: o.causalityChecks, fromBar: warmupBars, seed: o.seed, - }); - if (result.violations.length) throw lookAheadError(result.violations, candles); - causality = result.check; - } +/** Signals of the full history, rejected when the same rule on shorter history disagrees. */ +function causalSignals(o: ResolvedOptions, candles: Snapshot['candles']): { signals: Signal[]; causality: CausalityCheck } { + const signals = normaliseSignals(o.signal(candles), candles.length); + if (o.causalityChecks === 0) return { signals, causality: { reruns: 0, checkedSignals: 0, totalSignals: signals.length } }; + const result = checkCausality(o.signal, candles, { + fullSignals: signals, maxSignals: o.causalityChecks, fromBar: o.warmupBars, seed: o.seed, + }); + if (result.violations.length) throw lookAheadError(result.violations, candles); + return { signals, causality: result.check }; +} - // 2. Chronological split and entries (always the open of the bar after the signal). - const { inSample, outOfSample } = splitSegments(candles.length, warmupBars, o.splitRatio); - const segmentOf = (s: Signal) => [inSample, outOfSample].find((seg) => s.bar >= seg.start && s.bar + 1 < seg.end) ?? null; - const entries: Entry[] = signals.map((s) => ({ - direction: s.direction, signalBar: s.bar, entryBar: s.bar + 1, ...(s.refs ? { refs: s.refs } : {}), - })); +function tradeRecord(t: Trade, exit: string, split: string, candles: readonly Bar[]): TradeRecord { + return { + exit, + split, + direction: t.direction, + signalBar: t.signalBar, + signalTime: candles[t.signalBar].time, + entryBar: t.entryBar, + entryTime: candles[t.entryBar].time, + refs: refTimes(t.refs, candles), + entryOpen: t.entryOpen, + entryPrice: t.entryPrice, + initialStop: t.initialStop, + target: t.target, + exitBar: t.exitBar, + exitTime: candles[t.exitBar].time, + exitLevel: t.exitLevel, + exitPrice: t.exitPrice, + reason: t.reason, + return: t.return, + }; +} - // 3. Every exit on every segment, with its random baseline. - const rows: ReportRow[] = []; - const trades: TradeRecord[] = []; - for (const exit of exits) { - for (const segment of [inSample, outOfSample]) { - const result = simulate({ candles, entries, exit, costs, segment }); - const metrics = computeMetrics(result.trades); - const longs = result.trades.filter((t) => t.direction === 'long').length; - let baseline: Baseline | null = null; - if (metrics.trades > 0 && o.runs > 0) { - baseline = randomBaseline({ - candles, exit, costs, segment, - tradeCount: metrics.trades, - longShare: longs / metrics.trades, - runs: o.runs, - seed: deriveSeed(o.seed, exit.id, segment.name), - }); - } - rows.push({ - exit: exit.id, - exitLabel: exit.label, - split: segment.name, - metrics, - longs, - shorts: metrics.trades - longs, - skippedInPosition: result.skippedInPosition, - baseline: baseline && { - runs: baseline.runs, seed: baseline.seed, matchedRuns: baseline.matchedRuns, meanTrades: baseline.meanTrades, - p5: baseline.p5, p50: baseline.p50, p95: baseline.p95, - }, - percentile: baseline && percentileRank(metrics.netReturn, baseline.netReturns), - }); - for (const t of result.trades) { - trades.push({ - exit: exit.id, - split: segment.name, - direction: t.direction, - signalBar: t.signalBar, - signalTime: candles[t.signalBar].time, - entryBar: t.entryBar, - entryTime: candles[t.entryBar].time, - refs: refTimes(t.refs, candles), - entryOpen: t.entryOpen, - entryPrice: t.entryPrice, - initialStop: t.initialStop, - target: t.target, - exitBar: t.exitBar, - exitTime: candles[t.exitBar].time, - exitLevel: t.exitLevel, - exitPrice: t.exitPrice, - reason: t.reason, - return: t.return, - }); - } - } - } +/** One exit on one segment: strategy trades, metrics and the random baseline. */ +function evaluate( + o: ResolvedOptions, candles: readonly Bar[], entries: readonly Entry[], exit: ExitModel, segment: Segment, +): { row: ReportRow; trades: TradeRecord[] } { + const costs: Costs = { fee: o.fee, slippage: o.slippage }; + const result = simulate({ candles, entries, exit, costs, segment }); + const metrics = computeMetrics(result.trades); + const longs = result.trades.filter((t) => t.direction === 'long').length; + const baseline = metrics.trades > 0 && o.runs > 0 + ? randomBaseline({ + candles, exit, costs, segment, + tradeCount: metrics.trades, + longShare: longs / metrics.trades, + runs: o.runs, + seed: deriveSeed(o.seed, exit.id, segment.name), + }) + : null; + const row: ReportRow = { + exit: exit.id, + exitLabel: exit.label, + split: segment.name, + metrics, + longs, + shorts: metrics.trades - longs, + skippedInPosition: result.skippedInPosition, + baseline: baseline && { + runs: baseline.runs, seed: baseline.seed, matchedRuns: baseline.matchedRuns, meanTrades: baseline.meanTrades, + p5: baseline.p5, p50: baseline.p50, p95: baseline.p95, + }, + percentile: baseline && percentileRank(metrics.netReturn, baseline.netReturns), + }; + return { row, trades: result.trades.map((t) => tradeRecord(t, exit.id, segment.name, candles)) }; +} - // 4. Warnings in plain language. - const tradableYears = ((candles.length - inSample.start) * snapshot.barSeconds) / (365.25 * 86_400); +function reportWarnings( + o: ResolvedOptions, snapshot: Snapshot, tradableYears: number, signals: readonly Signal[], rows: readonly ReportRow[], +): string[] { const warnings: string[] = []; if (tradableYears < o.minYears) { warnings.push(`Only ${tradableYears.toFixed(2)} years of tradable history after warm-up (${o.minYears} wanted): results are even less conclusive.`); @@ -405,18 +389,62 @@ export function runBacktest(input: Snapshot | readonly Bar[], options: BacktestO } if (o.causalityChecks === 0) warnings.push('Causality check disabled: look-ahead in the signal would go unnoticed.'); if (snapshot.gaps.length) warnings.push(`${snapshot.gaps.length} gap(s) in the data (market closures also count).`); + return warnings; +} + +function signalRecord(s: Signal, candles: readonly Bar[], segments: readonly Segment[]): SignalRecord { + const segment = segments.find((seg) => s.bar >= seg.start && s.bar + 1 < seg.end); + return { + bar: s.bar, + time: candles[s.bar].time, + direction: s.direction, + entryBar: s.bar + 1, + entryTime: candles[s.bar + 1]?.time ?? null, + split: (segment?.name ?? null) as SignalRecord['split'], + refs: refTimes(s.refs, candles), + }; +} + +/** + * Runs the full protocol on frozen candles, synchronously and without network: + * causality check of the signal, warm-up, chronological IS/OOS split, every exit on the same + * entries, costs on both sides, metrics per exit and split, seeded random-entry baseline. + * Throws a `LOOK_AHEAD` TradingViewError when the signal uses future bars. + * + * @example + * const report = runBacktest(candles, { signal: (bars) => bars.flatMap((b, i) => ...) }); + * console.log(formatReport(report)); + */ +export function runBacktest(input: Snapshot | readonly Bar[], options: BacktestOptions): BacktestReport { + const o = resolveOptions(options); + const snapshot = toSnapshot(input); + const { candles } = snapshot; + + // 1. Signals, then their causality: the same rule on shorter history must agree. + const { signals, causality } = causalSignals(o, candles); + + // 2. Chronological split and entries (always the open of the bar after the signal). + const { inSample, outOfSample } = splitSegments(candles.length, o.warmupBars, o.splitRatio); + const entries: Entry[] = signals.map((s) => ({ + direction: s.direction, signalBar: s.bar, entryBar: s.bar + 1, ...(s.refs ? { refs: s.refs } : {}), + })); + + // 3. Every exit on every segment, with its random baseline. + const results = o.exits.flatMap((exit) => [inSample, outOfSample].map((segment) => evaluate(o, candles, entries, exit, segment))); + const rows = results.map((r) => r.row); + const tradableYears = ((candles.length - inSample.start) * snapshot.barSeconds) / (365.25 * 86_400); return { snapshot: snapshotInfo(snapshot), settings: { - signal: options.signal.label ?? (options.signal.name || 'custom signal'), + signal: o.signal.label ?? (o.signal.name || 'custom signal'), fee: o.fee, slippage: o.slippage, splitRatio: o.splitRatio, - warmupBars, + warmupBars: o.warmupBars, runs: o.runs, seed: o.seed, - exits: exits.map((e) => ({ id: e.id, label: e.label })), + exits: o.exits.map((e) => ({ id: e.id, label: e.label })), }, segments: { warmup: segmentInfo({ name: 'warmup', start: 0, end: inSample.start }, candles), @@ -424,19 +452,11 @@ export function runBacktest(input: Snapshot | readonly Bar[], options: BacktestO outOfSample: segmentInfo(outOfSample, candles), }, tradableYears, - signals: signals.map((s) => ({ - bar: s.bar, - time: candles[s.bar].time, - direction: s.direction, - entryBar: s.bar + 1, - entryTime: candles[s.bar + 1]?.time ?? null, - split: (segmentOf(s)?.name ?? null) as SignalRecord['split'], - refs: refTimes(s.refs, candles), - })), + signals: signals.map((s) => signalRecord(s, candles, [inSample, outOfSample])), rows, - trades, + trades: results.flatMap((r) => r.trades), causality, - warnings, + warnings: reportWarnings(o, snapshot, tradableYears, signals, rows), }; } From 38f37a9dadbc08f5585cf800d256df64604eaa85 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 23 Oct 2026 04:49:03 +0200 Subject: [PATCH 3/5] docs(backtest): give measured run times --- docs/backtest.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backtest.md b/docs/backtest.md index fff57b7d..f5d75a79 100644 --- a/docs/backtest.md +++ b/docs/backtest.md @@ -22,7 +22,7 @@ writeFileSync('trades.csv', tradesToCsv(report.trades)); writeFileSync('snapshot.json', JSON.stringify(snapshot)); // Replay later, exactly ``` -No account is needed for public symbols. With `credentials`, history goes as far as the account allows (see [Accounts and limits](../README.md#accounts-and-limits)). A run on about 4 500 4H bars, with 4 exits × 2 splits × 1 000 random runs, takes under a second after the download. +No account is needed for public symbols. With `credentials`, history goes as far as the account allows (see [Accounts and limits](../README.md#accounts-and-limits)). After the download, the reference strategy on about 4 500 4H bars (4 exits × 2 splits × 1 000 random runs) runs in about 0.2 s; a rule with a thousand signals takes about 10 s, mostly in the random baseline (lower `runs` while iterating). ## Writing the signal From 19a5d3526228b276d3facd9289b4d59695ef78b4 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 23 Oct 2026 06:03:39 +0200 Subject: [PATCH 4/5] Freeze the backtest report format for pages and charts (4.1.0-beta.2) - Report holds identifiers and numbers only: warnings become codes (formatWarning gives the English sentence), rows drop exitLabel, settings.signal and settings.exits carry id, kind and params. - Add createdAt, library, snapshot.fetchedAt, buyHold per segment and refBars on signals and trades. - Add chartData() for price charts and backtestMany() for several markets, with exits per market and a series column in the CSV. - rsiDivergence exposes id, params, indicators and a trendFilter option. --- docs/backtest.md | 38 +++++++-- docs/release-4.1.0-beta.2.md | 19 +++++ llms.txt | 5 +- package-lock.json | 4 +- package.json | 2 +- src/backtest/chart.ts | 42 ++++++++++ src/backtest/exits.ts | 6 ++ src/backtest/index.ts | 12 ++- src/backtest/report.ts | 27 ++++-- src/backtest/run.ts | 154 ++++++++++++++++++++++++++++------- src/backtest/signals.ts | 18 ++-- src/backtest/snapshot.ts | 7 +- src/backtest/types.ts | 13 ++- tests/unit/backtest.test.ts | 88 +++++++++++++++++++- tests/unit/exports.test.ts | 4 +- 15 files changed, 376 insertions(+), 63 deletions(-) create mode 100644 docs/release-4.1.0-beta.2.md create mode 100644 src/backtest/chart.ts diff --git a/docs/backtest.md b/docs/backtest.md index f5d75a79..0b94d071 100644 --- a/docs/backtest.md +++ b/docs/backtest.md @@ -117,17 +117,43 @@ Entries never depend on the exit, so every exit runs on the same entries. `defau | Field | Content | | --- | --- | -| `snapshot` | Symbol, timeframe, `barSeconds`, `count`, `firstTime`, `lastTime`, `gaps`, `droppedFormingBar`, `sha256` (no candles). | -| `settings` | Signal label, costs, split ratio, warm-up, runs, seed, exits. | -| `segments` | `warmup`, `inSample`, `outOfSample`: `start`, `end` (exclusive), `bars`, `startTime`, `endTime`. | -| `signals` | Every signal: `bar`, `time`, `direction`, `entryBar`, `entryTime`, `split` (`IS`, `OOS` or `null` when in the warm-up, across the split or on the last bar), `refs` as times. | +| `createdAt`, `library` | Run time (Unix seconds) and `{ name, version }` of the library. | +| `snapshot` | Symbol, timeframe, `barSeconds`, `count`, `firstTime`, `lastTime`, `gaps`, `droppedFormingBar`, `sha256`, `fetchedAt` (no candles). | +| `settings` | `signal` (`id`, `params`), fee, slippage, split ratio, warm-up, runs, seed, `exits` (`id`, `kind`, `params`). | +| `segments` | `warmup`, `inSample`, `outOfSample`: `start`, `end` (exclusive), `bars`, `startTime`, `endTime`, and `buyHold` (long from the first open to the last close, same fee and slippage). | +| `signals` | Every signal: `bar`, `time`, `direction`, `entryBar`, `entryTime`, `split` (`IS`, `OOS` or `null` when in the warm-up, across the split or on the last bar), `refs` as times and `refBars` as bar indices. | | `rows` | One per exit and split: `metrics`, `longs`, `shorts`, `skippedInPosition`, `baseline` (`runs`, `matchedRuns`, `meanTrades`, `p5`, `p50`, `p95`) and `percentile`. | -| `trades` | Every trade with bar indices **and** times: signal, entry, exit, `refs`, entry open and fill, initial stop, target, exit level and fill, `reason` (`stop`, `trailing_stop`, `stop_gap`, `target`, `target_gap`, `stop_and_target_same_bar`, `segment_end`) and net `return`. | +| `trades` | Every trade with bar indices **and** times: signal, entry, exit, `refs` and `refBars`, entry open and fill, initial stop, target, exit level and fill, `reason` (`stop`, `trailing_stop`, `stop_gap`, `target`, `target_gap`, `stop_and_target_same_bar`, `segment_end`) and net `return`. | | `causality` | `reruns`, `checkedSignals`, `totalSignals`. | -| `warnings` | Plain sentences: short history, few trades, unmatched random runs, gaps, disabled check. | +| `warnings` | Codes with their numbers: `short_history`, `no_signals`, `few_trades` (below 30 trades in a row), `baseline_unmatched`, `causality_disabled`, `data_gaps`. `formatWarning(w)` gives the English sentence. | + +The report holds identifiers and numbers only: exit ids and `kind`, exit reasons, segment ids (`IS`, `OOS`), warning codes. Build interface text from them in your own language. The `label` fields of `settings` are English descriptions for `formatReport`, not interface text. `snapshot` (returned next to `report` by `backtest`) holds the candles for charts. Bar indices in the report point into `snapshot.candles`. `formatReport(report)` prints the whole report as text, `formatTable(report.rows)` only the table, and `tradesToCsv(report.trades)` one CSV line per trade (each `refs` name becomes a `_time` column). +### Pages and charts + +`chartData(report, snapshot, signal)` returns one JSON object for a price chart: `bars`, `indicators` (from `signal.indicators`, e.g. `rsi` and `ema` for `rsiDivergence`, `null` where undefined), `warmupEndTime`, `splitTime`, `signals` with their `refBars`, and `trades` grouped by exit id. A custom signal can add `indicators(candles) => ({ name: number[] })`. + +```ts +import { backtestMany, chartData, fixedExit, rsiDivergence, atrExit, atrTrailingExit, tradesToCsv } from '@mathieuc/tradingview/backtest'; + +const signal = rsiDivergence(); +const results = await backtestMany({ + markets: [{ symbol: 'BINANCE:BTCUSDT', timeframe: '240' }, { symbol: 'BINANCE:BTCUSDT', timeframe: 'D' }], + exits: (m) => { + const stopPct = m.timeframe === 'D' ? 0.07 : 0.03; // Wider stop on daily bars + return [fixedExit({ stopPct, targetR: 1.5 }), fixedExit({ stopPct, targetR: 2 }), atrExit({ mult: 1.5, targetR: 2 }), atrTrailingExit({ mult: 2.5 })]; + }, + signal, +}); +const summary = results.map(({ id, report }) => ({ id, report })); // Tables and stats +const charts = Object.fromEntries(results.map((r) => [r.id, chartData(r.report, r.snapshot, signal)])); +const csv = tradesToCsv(results.flatMap((r) => r.report.trades)); // `series` column first +``` + +`backtestMany` runs the same signal on each market in turn; each result has `id` (`_`), `report` and `snapshot`, and every trade carries `series` = `id`. To count setups before a filter, rerun the rule without it, e.g. `rsiDivergence({ trendFilter: false })(snapshot.candles).length`. + Every step is also exported for custom protocols: `freezeSnapshot`, `fetchSnapshot`, `checkCausality`, `splitSegments`, `simulate`, `fillPrice`, `tradeReturn`, `computeMetrics`, `randomBaseline`, `percentileRank`, `quantile`, `createRng`, `deriveSeed`. ## Reading the result diff --git a/docs/release-4.1.0-beta.2.md b/docs/release-4.1.0-beta.2.md new file mode 100644 index 00000000..13b42b32 --- /dev/null +++ b/docs/release-4.1.0-beta.2.md @@ -0,0 +1,19 @@ +# 4.1.0-beta.2 + +Release channel: npm dist-tag **beta**. `latest` stays on 4.0.1. Freezes the report format of the backtest API for pages and charts. + +## Backtest API + +- The report holds identifiers and numbers only. Warnings are now codes with their numbers (`short_history`, `no_signals`, `few_trades`, `baseline_unmatched`, `causality_disabled`, `data_gaps`), turned into English by `formatWarning`. Rows no longer carry `exitLabel`. `settings.signal` is `{ id, params, label }` and each exit in `settings.exits` is `{ id, kind, params, label }`; the `label` fields are English descriptions for `formatReport` only. +- New fields: `createdAt`, `library` (`{ name, version }`), `snapshot.fetchedAt`, `buyHold` per segment (same fee and slippage), `refBars` (bar indices of `refs`) on signals and trades. +- `chartData(report, snapshot, signal)`: candles, indicator series (`rsi`, `ema` for `rsiDivergence`, or a custom `signal.indicators`), warm-up and split times, signals and trades grouped by exit. +- `backtestMany({ markets, exits, signal })`: the same signal on several markets, with exits per market if needed; trades carry `series`, and `tradesToCsv` then adds a `series` column. +- `rsiDivergence` gets `id`, `params`, `indicators` and the `trendFilter` option (false to count raw divergences). + +Breaking for 4.1.0-beta.1 users: `report.warnings` (strings → objects), `settings.signal` (string → object), `rows[].exitLabel` removed. Results are unchanged: same rows and trades as beta.1 on the same snapshot. + +## Validation + +- Same rows and 63 trades as the #351 engine on the BINANCE:BTCUSDT 4H snapshot. +- 33 backtest tests (207 in total under Node, 206 under Bun); the 10 deliberate mutations still each fail a test. +- `npm run check` including the packed-package smoke under Node and Bun. diff --git a/llms.txt b/llms.txt index c512bb89..4788c905 100644 --- a/llms.txt +++ b/llms.txt @@ -22,13 +22,14 @@ 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/backtest.md, docs/low-level-api.md, docs/migration-v4.md, docs/v4-coverage.md, docs/v4-reliability.md, docs/protocol-contributing.md. Examples: examples/. -Backtest API (import from '@mathieuc/tradingview/backtest'; since 4.1.0-beta.1, npm tag beta): write only the entry rule, do not write an engine. +Backtest API (import from '@mathieuc/tradingview/backtest'; since 4.1.0-beta.1, npm tag beta; report format frozen in 4.1.0-beta.2): write only the entry rule, do not write an engine. - backtest({ symbol, timeframe, years?, credentials?, signal, exits?, fee?, slippage?, splitRatio?, runs?, seed? }) -> { report, snapshot }. runBacktest(candles | snapshot, options) -> report, synchronous, no network. - signal(candles) -> [{ bar, direction: 'long' | 'short', refs? }]: decided at the close of `bar` using bars 0..bar only; entry is always the next bar's open. Optional signal.warmupBars (default 200) and signal.label. - The engine reruns signal on truncated history and throws TradingViewError code LOOK_AHEAD if a signal depends on later bars (or repaints). - Built in: rsiDivergence(options), defaultExits() (fixed_1.5R, fixed_2R, atr1.5_2R, trail_atr3), fixedExit, atrExit, atrTrailingExit, indicators sma/ema/rsi/atr/findPivots. - Protocol: frozen snapshot with SHA-256 (loadSnapshot to replay), warm-up never traded, 70/30 IS/OOS split with no position across it, fee 0.1% + slippage 0.05% per side, same-candle stop+target = loss, gap = open, 1000 seeded random-entry runs per exit and split with percentile and p5/p50/p95. -- report is plain JSON: snapshot info, segments with dates, signals, rows (metrics: trades, netReturn, winRate, profitFactor or null, maxDrawdown; baseline; percentile), trades with bar indices and times, causality, warnings. formatReport(report), tradesToCsv(report.trades). +- report is plain JSON with identifiers only (exit ids, reasons, IS/OOS, warning codes): createdAt, library, snapshot info, settings (signal id/params, exits id/kind/params), segments with dates and buyHold, signals, rows (metrics: trades, netReturn, winRate, profitFactor or null, maxDrawdown; baseline; percentile), trades with bar indices, refBars and times, causality, warnings. formatReport(report), formatWarning(w), tradesToCsv(trades). +- backtestMany({ markets: [{ symbol, timeframe }], exits?: list or (market) => list, signal }) -> [{ id, report, snapshot }]. chartData(report, snapshot, signal) -> { bars, indicators, warmupEndTime, splitTime, signals, trades by exit } for a price chart page. - No claim of profitability: a negative or inconclusive result is a valid outcome. Docs: docs/backtest.md. Example: examples/honest-backtest/. 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 or hosted trading API is shipped, and the library places no orders. Questions and bugs: https://github.com/Mathieu2301/TradingView-API/issues/new/choose diff --git a/package-lock.json b/package-lock.json index 924467ef..507a53bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mathieuc/tradingview", - "version": "4.1.0-beta.1", + "version": "4.1.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mathieuc/tradingview", - "version": "4.1.0-beta.1", + "version": "4.1.0-beta.2", "license": "ISC", "dependencies": { "ws": "^8.18.0" diff --git a/package.json b/package.json index 5bdae142..b86793de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mathieuc/tradingview", - "version": "4.1.0-beta.1", + "version": "4.1.0-beta.2", "description": "TradingView market data for JavaScript and TypeScript: candles, quotes, indicators and strategies, with a simple data API and full low-level access.", "type": "module", "main": "./dist/index.js", diff --git a/src/backtest/chart.ts b/src/backtest/chart.ts new file mode 100644 index 00000000..0a1f7204 --- /dev/null +++ b/src/backtest/chart.ts @@ -0,0 +1,42 @@ +import { TradingViewError } from '../errors.js'; +import type { BacktestReport, SignalRecord, TradeRecord } from './run.js'; +import type { Snapshot } from './snapshot.js'; +import type { SignalFunction } from './types.js'; + +export interface ChartData { + /** Frozen candles, oldest first; every `*Bar` index in the report points into this array. */ + bars: { time: number; open: number; high: number; low: number; close: number; volume: number }[]; + /** Indicator series from `signal.indicators`, one value per bar, null when undefined. */ + indicators: Record; + /** Open time of the first traded bar (end of the warm-up), Unix seconds. */ + warmupEndTime: number | null; + /** Open time of the first out-of-sample bar, Unix seconds. */ + splitTime: number | null; + signals: SignalRecord[]; + /** Trades by exit id, in entry order. */ + trades: Record; +} + +/** + * Everything a price chart needs, in one JSON object: candles, indicator lines, warm-up and + * split times, signals with their `refBars`, and the trades of each exit. Pass the same signal + * function as the run to get its indicators (for `rsiDivergence`: `rsi` and `ema`). + */ +export function chartData(report: BacktestReport, snapshot: Snapshot, signal?: SignalFunction): ChartData { + if (snapshot.sha256 !== report.snapshot.sha256) throw new TradingViewError('INVALID_ARGUMENT', 'chartData(): the snapshot is not the one this report ran on'); + const raw = signal?.indicators?.(snapshot.candles) ?? {}; + const indicators = Object.fromEntries(Object.entries(raw).map(([name, values]) => [ + name, Array.from({ length: snapshot.candles.length }, (_, i) => (Number.isFinite(values[i]) ? values[i] : null)), + ])); + const trades: Record = Object.fromEntries(report.settings.exits.map((e) => [e.id, []])); + for (const trade of report.trades) (trades[trade.exit] ??= []).push(trade); + for (const list of Object.values(trades)) list.sort((a, b) => a.entryBar - b.entryBar); + return { + bars: snapshot.candles.map(({ time, open, high, low, close, volume }) => ({ time, open, high, low, close, volume })), + indicators, + warmupEndTime: report.segments.inSample.startTime, + splitTime: report.segments.outOfSample.startTime, + signals: report.signals, + trades, + }; +} diff --git a/src/backtest/exits.ts b/src/backtest/exits.ts index c35ae95c..470dcaad 100644 --- a/src/backtest/exits.ts +++ b/src/backtest/exits.ts @@ -24,6 +24,8 @@ export function fixedExit({ stopPct, targetR }: FixedExitOptions): ExitModel { return { id: targetR === null ? `fixed_stop${stopPct * 100}%` : `fixed_${targetR}R`, label: `Fixed ${(stopPct * 100).toFixed(1)}% stop, ${targetR === null ? 'no target' : `${targetR}:1 target`}`, + kind: 'fixed', + params: { stopPct, targetR }, levels({ direction, entryOpen }) { const d = entryOpen * stopPct; return { stop: offset(direction, entryOpen, -d), target: targetR === null ? null : offset(direction, entryOpen, targetR * d) }; @@ -48,6 +50,8 @@ export function atrExit({ mult, targetR, length = 14 }: AtrExitOptions): ExitMod return { id: `atr${mult}_${targetR === null ? 'stop' : `${targetR}R`}`, label: `ATR(${length}) x${mult} stop, ${targetR === null ? 'no target' : `${targetR}R target`}`, + kind: 'atr', + params: { mult, targetR, length }, warmupBars: length - 1, levels({ direction, entryOpen, signalBar, candles }) { const d = mult * cachedAtr(candles, length)[signalBar]; @@ -75,6 +79,8 @@ export function atrTrailingExit({ mult, length = 14 }: AtrTrailingExitOptions): return { id: `trail_atr${mult}`, label: `ATR(${length}) x${mult} trailing stop`, + kind: 'atr_trailing', + params: { mult, length }, warmupBars: length - 1, levels({ direction, entryOpen, signalBar, candles }) { const d = mult * cachedAtr(candles, length)[signalBar]; diff --git a/src/backtest/index.ts b/src/backtest/index.ts index be43ca49..20102e5e 100644 --- a/src/backtest/index.ts +++ b/src/backtest/index.ts @@ -5,10 +5,12 @@ * * @module @mathieuc/tradingview/backtest */ -export { backtest, BACKTEST_DEFAULTS, checkCausality, fetchSnapshot, resolveWarmupBars, runBacktest } from './run.js'; +export { + backtest, BACKTEST_DEFAULTS, backtestMany, checkCausality, fetchSnapshot, resolveWarmupBars, runBacktest, +} from './run.js'; export type { - BacktestOptions, BacktestQuery, BacktestReport, CausalityCheck, CausalityViolation, FetchSnapshotQuery, ReportRow, - SegmentInfo, SignalRecord, TradeRecord, + BacktestManyQuery, BacktestOptions, BacktestQuery, BacktestReport, CausalityCheck, CausalityViolation, FetchSnapshotQuery, + Market, MarketResult, ReportRow, ReportWarning, SegmentInfo, SignalRecord, TradeRecord, } from './run.js'; export { freezeSnapshot, loadSnapshot, snapshotInfo } from './snapshot.js'; export type { Gap, Snapshot, SnapshotInfo, SnapshotMeta } from './snapshot.js'; @@ -24,7 +26,9 @@ export { export type { BaselineInput } from './stats.js'; export { atr, ema, findPivots, rsi, sma } from './indicators.js'; export type { Pivot } from './indicators.js'; -export { formatReport, formatTable, isoTime, tradesToCsv } from './report.js'; +export { formatReport, formatTable, formatWarning, isoTime, tradesToCsv } from './report.js'; +export { chartData } from './chart.js'; +export type { ChartData } from './chart.js'; export type { Bar, Baseline, Costs, Direction, Entry, ExitContext, ExitModel, ExitReason, Metrics, Segment, Signal, SignalFunction, Trade, } from './types.js'; diff --git a/src/backtest/report.ts b/src/backtest/report.ts index 4090f4a8..b8930c35 100644 --- a/src/backtest/report.ts +++ b/src/backtest/report.ts @@ -1,4 +1,4 @@ -import type { BacktestReport, ReportRow, TradeRecord } from './run.js'; +import type { BacktestReport, ReportRow, ReportWarning, TradeRecord } from './run.js'; /** Unix seconds -> ISO 8601 UTC without milliseconds. */ export const isoTime = (seconds: number): string => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z'); @@ -10,15 +10,17 @@ const BASE_COLUMNS = [ /** * Trades as CSV. Times are bar open times (ISO, UTC); prices include slippage. Each signal - * `refs` name becomes a `_time` column, placed before `signal_time`. + * `refs` name becomes a `_time` column, placed before `signal_time`. A `series` column + * comes first when trades carry one (`backtestMany`). */ export function tradesToCsv(trades: readonly TradeRecord[]): string { const refNames = [...new Set(trades.flatMap((t) => Object.keys(t.refs)))]; const time = (value: number | undefined) => (value === undefined ? '' : isoTime(value)); const num = (value: number | null) => (value === null ? '' : String(Number(value.toPrecision(10)))); - const header = [...BASE_COLUMNS.slice(0, 3), ...refNames.map((n) => `${n}_time`), ...BASE_COLUMNS.slice(3)]; + const series = trades.some((t) => t.series !== undefined); + const header = [...(series ? ['series'] : []), ...BASE_COLUMNS.slice(0, 3), ...refNames.map((n) => `${n}_time`), ...BASE_COLUMNS.slice(3)]; const lines = trades.map((t) => [ - t.exit, t.split, t.direction, ...refNames.map((n) => time(t.refs[n])), time(t.signalTime), time(t.entryTime), + ...(series ? [t.series ?? ''] : []), t.exit, t.split, t.direction, ...refNames.map((n) => time(t.refs[n])), time(t.signalTime), time(t.entryTime), num(t.entryOpen), num(t.entryPrice), num(t.initialStop), num(t.target), time(t.exitTime), num(t.exitLevel), num(t.exitPrice), t.reason, (t.return * 100).toFixed(4), ].join(',')); @@ -44,6 +46,19 @@ export function formatTable(rows: readonly ReportRow[]): string { return all.map((r) => r.map((v, c) => (c < 2 ? v.padEnd(widths[c]) : v.padStart(widths[c]))).join(' ')).join('\n'); } +/** One warning as an English sentence. */ +export function formatWarning(w: ReportWarning): string { + switch (w.code) { + case 'short_history': return `Only ${w.years.toFixed(2)} years of tradable history after warm-up (${w.wantedYears} wanted): results are even less conclusive.`; + case 'no_signals': return 'The signal returned no signal at all.'; + case 'few_trades': return `As few as ${w.trades} trades in a row: with so few trades, net return and percentile are mostly noise.`; + case 'baseline_unmatched': return `${w.exit} ${w.split}: ${w.matchedRuns}/${w.runs} random runs reached ${w.trades} trades (mean ${w.meanTrades.toFixed(1)}).`; + case 'causality_disabled': return 'Causality check disabled: look-ahead in the signal would go unnoticed.'; + case 'data_gaps': return `${w.gaps} gap(s) in the data, ${w.missingBars} missing bars (market closures also count).`; + default: return JSON.stringify(w); + } +} + /** The whole report as plain text: data, split, signals, costs, results, definitions, warnings. */ export function formatReport(report: BacktestReport): string { const { snapshot: s, settings, segments, causality } = report; @@ -68,7 +83,7 @@ export function formatReport(report: BacktestReport): string { `Out-of-sample: ${range(segments.outOfSample)}`, '', '== Signals ==', - `Rule: ${settings.signal}. Signal at a bar's close, entry at the NEXT bar's open.`, + `Rule: ${settings.signal.label}. Signal at a bar's close, entry at the NEXT bar's open.`, `IS signals: ${count('IS')}. OOS signals: ${count('OOS')}.`, causality.reruns ? `Causality: rule rerun on ${causality.reruns} truncated histories (${causality.checkedSignals}/${causality.totalSignals} signals checked at their own bar): no look-ahead found.` @@ -81,7 +96,7 @@ export function formatReport(report: BacktestReport): string { `Exits: ${settings.exits.map((e) => `${e.id} = ${e.label}`).join('; ')}.`, 'Net: compounded net return. Win: share of trades with a positive net return. PF: gross profit / gross loss (N/A without a losing trade).', '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.', - ...report.warnings.map((w) => `WARNING: ${w}`), + ...report.warnings.map((w) => `WARNING: ${formatWarning(w)}`), 'Read the OOS rows first. A percentile near 50, a negative OOS return or few trades is a valid, inconclusive outcome, not a bug.', ]; return lines.join('\n'); diff --git a/src/backtest/run.ts b/src/backtest/run.ts index 227c2f5b..760ad3b1 100644 --- a/src/backtest/run.ts +++ b/src/backtest/run.ts @@ -1,10 +1,13 @@ +import { createRequire } from 'node:module'; import { timeframeSeconds } from '../chart/timeframes.js'; import type { Timeframe } from '../chart/types.js'; import { getCandles, type CandleQuery } from '../data/candles.js'; import type { MarketDataProvider } from '../data/provider.js'; import { TradingViewError } from '../errors.js'; import { defaultExits } from './exits.js'; -import { simulate, splitSegments } from './simulate.js'; +import { + fillPrice, simulate, splitSegments, tradeReturn, +} from './simulate.js'; import { freezeSnapshot, snapshotInfo, type Snapshot, type SnapshotInfo, } from './snapshot.js'; @@ -61,6 +64,8 @@ export interface SegmentInfo extends Segment { startTime: number | null; /** Open time of the last bar, Unix seconds. */ endTime: number | null; + /** Buy and hold over the segment: long at the first open, out at the last close, with the same fee and slippage. */ + buyHold: number | null; } export interface SignalRecord { @@ -71,12 +76,17 @@ export interface SignalRecord { entryTime: number | null; /** Segment containing both the signal and the entry, or null (warm-up, straddling the split, last bar). */ split: 'IS' | 'OOS' | null; + /** Times of the signal's `refs` bars. */ refs: Record; + /** Bar indices of the signal's `refs` (into `snapshot.candles`). */ + refBars: Record; } export interface TradeRecord { + /** Market id, set by `backtestMany` (`_`). */ + series?: string; exit: string; - split: string; + split: 'IS' | 'OOS'; direction: Direction; signalBar: number; signalTime: number; @@ -84,6 +94,8 @@ export interface TradeRecord { entryTime: number; /** Times of the signal's `refs` bars. */ refs: Record; + /** Bar indices of the signal's `refs` (into `snapshot.candles`). */ + refBars: Record; entryOpen: number; entryPrice: number; initialStop: number; @@ -98,8 +110,7 @@ export interface TradeRecord { export interface ReportRow { exit: string; - exitLabel: string; - split: string; + split: 'IS' | 'OOS'; metrics: Metrics; longs: number; shorts: number; @@ -127,18 +138,34 @@ export interface CausalityCheck { totalSignals: number; } -/** Plain JSON: save it, render it or send it as it is. */ +/** Machine-readable warning; `formatReport` turns it into an English sentence. */ +export type ReportWarning = + | { code: 'short_history'; years: number; wantedYears: number } + | { code: 'no_signals' } + | { code: 'few_trades'; trades: number } + | { code: 'baseline_unmatched'; exit: string; split: 'IS' | 'OOS'; matchedRuns: number; runs: number; trades: number; meanTrades: number } + | { code: 'causality_disabled' } + | { code: 'data_gaps'; gaps: number; missingBars: number }; + +/** + * Plain JSON: save it, render it or send it as it is. It holds identifiers and numbers only + * (exit ids, exit reasons, segment ids, warning codes); the `label` fields are English + * descriptions for `formatReport`, not interface text. + */ export interface BacktestReport { + /** Run time, Unix seconds. */ + createdAt: number; + library: { name: string; version: string }; snapshot: SnapshotInfo; settings: { - signal: string; + signal: { id: string; params: Record; label: string }; fee: number; slippage: number; splitRatio: number; warmupBars: number; runs: number; seed: number; - exits: { id: string; label: string }[]; + exits: { id: string; kind: string | null; params: Record; label: string }[]; }; segments: { warmup: SegmentInfo; inSample: SegmentInfo; outOfSample: SegmentInfo }; /** Tradable years after warm-up. */ @@ -147,10 +174,12 @@ export interface BacktestReport { rows: ReportRow[]; trades: TradeRecord[]; causality: CausalityCheck; - /** Plain-language warnings: few trades, short history, unmatched random runs... */ - warnings: string[]; + /** Few trades, short history, unmatched random runs, gaps... */ + warnings: ReportWarning[]; } +const pkg = createRequire(import.meta.url)('../../package.json') as { name: string; version: string }; + const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); function invalid(message: string): TradingViewError { @@ -267,19 +296,29 @@ function toSnapshot(input: Snapshot | readonly Bar[]): Snapshot { return snapshot; } -function segmentInfo(segment: Segment, candles: readonly Bar[]): SegmentInfo { +function segmentInfo(segment: Segment, candles: readonly Bar[], costs: Costs): SegmentInfo { const empty = segment.end <= segment.start; + const buyHold = () => tradeReturn( + 'long', + fillPrice('entry', 'long', candles[segment.start].open, costs.slippage), + fillPrice('exit', 'long', candles[segment.end - 1].close, costs.slippage), + costs.fee, + ); return { ...segment, bars: Math.max(0, segment.end - segment.start), startTime: empty ? null : candles[segment.start].time, endTime: empty ? null : candles[segment.end - 1].time, + buyHold: empty ? null : buyHold(), }; } +const validRefs = (refs: Readonly> | undefined, candles: readonly Bar[]) => Object.entries(refs ?? {}) + .filter(([, bar]) => Number.isInteger(bar) && candles[bar]); const refTimes = (refs: Readonly> | undefined, candles: readonly Bar[]) => Object.fromEntries( - Object.entries(refs ?? {}).filter(([, bar]) => Number.isInteger(bar) && candles[bar]).map(([name, bar]) => [name, candles[bar].time]), + validRefs(refs, candles).map(([name, bar]) => [name, candles[bar].time]), ); +const refBars = (refs: Readonly> | undefined, candles: readonly Bar[]) => Object.fromEntries(validRefs(refs, candles)); /** Resolves the warm-up: explicit option, else the largest need of the signal and exits. */ export function resolveWarmupBars(signal: SignalFunction, exits: readonly ExitModel[], warmupBars?: number): number { @@ -315,7 +354,7 @@ function causalSignals(o: ResolvedOptions, candles: Snapshot['candles']): { sign return { signals, causality: result.check }; } -function tradeRecord(t: Trade, exit: string, split: string, candles: readonly Bar[]): TradeRecord { +function tradeRecord(t: Trade, exit: string, split: 'IS' | 'OOS', candles: readonly Bar[]): TradeRecord { return { exit, split, @@ -325,6 +364,7 @@ function tradeRecord(t: Trade, exit: string, split: string, candles: readonly Ba entryBar: t.entryBar, entryTime: candles[t.entryBar].time, refs: refTimes(t.refs, candles), + refBars: refBars(t.refs, candles), entryOpen: t.entryOpen, entryPrice: t.entryPrice, initialStop: t.initialStop, @@ -355,10 +395,10 @@ function evaluate( seed: deriveSeed(o.seed, exit.id, segment.name), }) : null; + const split = segment.name as 'IS' | 'OOS'; const row: ReportRow = { exit: exit.id, - exitLabel: exit.label, - split: segment.name, + split, metrics, longs, shorts: metrics.trades - longs, @@ -369,26 +409,29 @@ function evaluate( }, percentile: baseline && percentileRank(metrics.netReturn, baseline.netReturns), }; - return { row, trades: result.trades.map((t) => tradeRecord(t, exit.id, segment.name, candles)) }; + return { row, trades: result.trades.map((t) => tradeRecord(t, exit.id, split, candles)) }; } function reportWarnings( o: ResolvedOptions, snapshot: Snapshot, tradableYears: number, signals: readonly Signal[], rows: readonly ReportRow[], -): string[] { - const warnings: string[] = []; - if (tradableYears < o.minYears) { - warnings.push(`Only ${tradableYears.toFixed(2)} years of tradable history after warm-up (${o.minYears} wanted): results are even less conclusive.`); - } - if (signals.length === 0) warnings.push('The signal returned no signal at all.'); +): ReportWarning[] { + const warnings: ReportWarning[] = []; + if (tradableYears < o.minYears) warnings.push({ code: 'short_history', years: tradableYears, wantedYears: o.minYears }); + if (signals.length === 0) warnings.push({ code: 'no_signals' }); const fewest = Math.min(...rows.map((r) => r.metrics.trades)); - if (fewest < 30) warnings.push(`As few as ${fewest} trades in a row: with so few trades, net return and percentile are mostly noise.`); + if (fewest < 30) warnings.push({ code: 'few_trades', trades: fewest }); for (const row of rows) { if (row.baseline && row.baseline.matchedRuns < row.baseline.runs) { - warnings.push(`${row.exit} ${row.split}: ${row.baseline.matchedRuns}/${row.baseline.runs} random runs reached ${row.metrics.trades} trades (mean ${row.baseline.meanTrades.toFixed(1)}).`); + warnings.push({ + code: 'baseline_unmatched', exit: row.exit, split: row.split, matchedRuns: row.baseline.matchedRuns, + runs: row.baseline.runs, trades: row.metrics.trades, meanTrades: row.baseline.meanTrades, + }); } } - if (o.causalityChecks === 0) warnings.push('Causality check disabled: look-ahead in the signal would go unnoticed.'); - if (snapshot.gaps.length) warnings.push(`${snapshot.gaps.length} gap(s) in the data (market closures also count).`); + if (o.causalityChecks === 0) warnings.push({ code: 'causality_disabled' }); + if (snapshot.gaps.length) { + warnings.push({ code: 'data_gaps', gaps: snapshot.gaps.length, missingBars: snapshot.gaps.reduce((sum, g) => sum + g.missingBars, 0) }); + } return warnings; } @@ -402,6 +445,7 @@ function signalRecord(s: Signal, candles: readonly Bar[], segments: readonly Seg entryTime: candles[s.bar + 1]?.time ?? null, split: (segment?.name ?? null) as SignalRecord['split'], refs: refTimes(s.refs, candles), + refBars: refBars(s.refs, candles), }; } @@ -434,22 +478,29 @@ export function runBacktest(input: Snapshot | readonly Bar[], options: BacktestO const rows = results.map((r) => r.row); const tradableYears = ((candles.length - inSample.start) * snapshot.barSeconds) / (365.25 * 86_400); + const costs: Costs = { fee: o.fee, slippage: o.slippage }; return { + createdAt: Math.floor(Date.now() / 1000), + library: { name: pkg.name, version: pkg.version }, snapshot: snapshotInfo(snapshot), settings: { - signal: o.signal.label ?? (o.signal.name || 'custom signal'), + signal: { + id: o.signal.id ?? (o.signal.name || 'custom'), + params: { ...(o.signal.params ?? {}) }, + label: o.signal.label ?? (o.signal.name || 'custom signal'), + }, fee: o.fee, slippage: o.slippage, splitRatio: o.splitRatio, warmupBars: o.warmupBars, runs: o.runs, seed: o.seed, - exits: o.exits.map((e) => ({ id: e.id, label: e.label })), + exits: o.exits.map((e) => ({ id: e.id, kind: e.kind ?? null, params: { ...(e.params ?? {}) }, label: e.label })), }, segments: { - warmup: segmentInfo({ name: 'warmup', start: 0, end: inSample.start }, candles), - inSample: segmentInfo(inSample, candles), - outOfSample: segmentInfo(outOfSample, candles), + warmup: segmentInfo({ name: 'warmup', start: 0, end: inSample.start }, candles, costs), + inSample: segmentInfo(inSample, candles, costs), + outOfSample: segmentInfo(outOfSample, candles, costs), }, tradableYears, signals: signals.map((s) => signalRecord(s, candles, [inSample, outOfSample])), @@ -534,3 +585,46 @@ export async function backtest(query: BacktestQuery): Promise<{ report: Backtest }); return { report, snapshot }; } + +export interface Market { + symbol: string; + timeframe: Timeframe; + /** Replay a saved snapshot for this market instead of downloading it. */ + snapshot?: Snapshot; +} + +export type BacktestManyQuery = Omit & { + markets: readonly Market[]; + /** Same exits everywhere, or exits per market (a wider stop on daily bars...). */ + exits?: readonly ExitModel[] | ((market: Market) => readonly ExitModel[]); +}; + +export interface MarketResult { + /** `_`, also set as `series` on each trade. */ + id: string; + symbol: string; + timeframe: Timeframe; + report: BacktestReport; + snapshot: Snapshot; +} + +/** + * The same signal on several markets, one after the other (one download each, or a replay). + * Every trade gets `series` = the market id, so `tradesToCsv(results.flatMap((r) => r.report.trades))` + * gives one CSV for all of them. + */ +export async function backtestMany(query: BacktestManyQuery): Promise { + if (!Array.isArray(query?.markets) || query.markets.length === 0) throw invalid('markets must be a non-empty array of { symbol, timeframe }'); + const results: MarketResult[] = []; + for (const market of query.markets) { + const id = `${market.symbol}_${market.timeframe}`; + if (results.some((r) => r.id === id)) throw invalid(`Duplicate market ${id}`); + const exits = typeof query.exits === 'function' ? query.exits(market) : query.exits; + const { report, snapshot } = await backtest({ + ...query, symbol: market.symbol, timeframe: market.timeframe, exits, snapshot: market.snapshot, + }); + for (const trade of report.trades) trade.series = id; + results.push({ id, symbol: market.symbol, timeframe: market.timeframe, report, snapshot }); + } + return results; +} diff --git a/src/backtest/signals.ts b/src/backtest/signals.ts index 3fa8be45..1ba53e90 100644 --- a/src/backtest/signals.ts +++ b/src/backtest/signals.ts @@ -11,6 +11,8 @@ export interface RsiDivergenceOptions { /** Bars between the two pivots of a divergence. Default 5 to 60. */ minPivotDistance?: number; maxPivotDistance?: number; + /** Apply the EMA trend filter. Default true; false gives every divergence (to count the raw setups). */ + trendFilter?: boolean; } const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); @@ -25,7 +27,7 @@ const isNumber = (value: unknown): value is number => typeof value === 'number' */ export function rsiDivergence(options: RsiDivergenceOptions = {}): SignalFunction { const { - rsiLength = 14, emaLength = 200, pivotLookback = 5, minPivotDistance = 5, maxPivotDistance = 60, + rsiLength = 14, emaLength = 200, pivotLookback = 5, minPivotDistance = 5, maxPivotDistance = 60, trendFilter = true, } = options; const find = (candles: Parameters[0]): Signal[] => { const closes = candles.map((c) => c.close); @@ -41,9 +43,9 @@ export function rsiDivergence(options: RsiDivergenceOptions = {}): SignalFunctio if (distance < minPivotDistance || distance > maxPivotDistance) continue; const c = cur.confirmBar; const [r1, r2, level, close] = [rsiValues[prev.bar], rsiValues[cur.bar], trend[c], candles[c].close]; - if (![r1, r2, level].every(isNumber)) continue; + if (![r1, r2].every(isNumber) || (trendFilter && !isNumber(level))) continue; const divergence = kind === 'low' ? cur.price < prev.price && r2 > r1 : cur.price > prev.price && r2 < r1; - const trendOk = direction === 'long' ? close > level : close < level; + const trendOk = !trendFilter || (direction === 'long' ? close > level : close < level); if (divergence && trendOk) signals.push({ bar: c, direction, refs: { prev_pivot: prev.bar, pivot: cur.bar } }); } }; @@ -52,7 +54,13 @@ export function rsiDivergence(options: RsiDivergenceOptions = {}): SignalFunctio return signals.sort((a, b) => a.bar - b.bar); }; return Object.assign(find, { - warmupBars: Math.max(rsiLength, emaLength - 1), - label: `RSI(${rsiLength}) divergence, EMA(${emaLength}) trend filter, pivot lookback ${pivotLookback}, pivots ${minPivotDistance}-${maxPivotDistance} bars apart`, + id: 'rsi_divergence', + params: { rsiLength, emaLength, pivotLookback, minPivotDistance, maxPivotDistance, trendFilter }, + warmupBars: trendFilter ? Math.max(rsiLength, emaLength - 1) : rsiLength, + label: `RSI(${rsiLength}) divergence${trendFilter ? `, EMA(${emaLength}) trend filter` : ''}, pivot lookback ${pivotLookback}, pivots ${minPivotDistance}-${maxPivotDistance} bars apart`, + indicators: (candles: Parameters[0]) => { + const closes = candles.map((c) => c.close); + return { rsi: rsi(closes, rsiLength), ema: ema(closes, emaLength) }; + }, }); } diff --git a/src/backtest/snapshot.ts b/src/backtest/snapshot.ts index 028d64d5..a30c6b13 100644 --- a/src/backtest/snapshot.ts +++ b/src/backtest/snapshot.ts @@ -16,6 +16,8 @@ export interface SnapshotInfo { readonly droppedFormingBar: boolean; /** SHA-256 of the frozen candles: two runs on the same hash saw the same data. */ readonly sha256: string; + /** When the candles were downloaded or frozen, Unix seconds; null for older saved snapshots. */ + readonly fetchedAt: number | null; } /** Validated, frozen candles. Save it (`JSON.stringify`) to replay a run exactly. */ @@ -30,6 +32,8 @@ export interface SnapshotMeta { barSeconds?: number; /** Current time in Unix seconds, to detect the bar still forming. Defaults to now. */ now?: number; + /** Download time, Unix seconds. Defaults to now. */ + fetchedAt?: number | null; } const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); @@ -105,6 +109,7 @@ export function freezeSnapshot(rawCandles: readonly Bar[], meta: SnapshotMeta = spanYears: (lastTime + barSeconds - firstTime) / (365.25 * 86_400), gaps, droppedFormingBar, + fetchedAt: meta.fetchedAt === undefined ? Math.floor(Date.now() / 1000) : meta.fetchedAt, sha256: createHash('sha256').update(JSON.stringify(candles)).digest('hex'), candles, }); @@ -118,7 +123,7 @@ export function loadSnapshot(saved: Snapshot | string): Snapshot { const data: Snapshot = typeof saved === 'string' ? JSON.parse(saved) : saved; if (!data || !Array.isArray(data.candles)) throw invalid('Not a saved snapshot'); const snapshot = freezeSnapshot(data.candles, { - symbol: data.symbol, timeframe: data.timeframe, barSeconds: data.barSeconds, now: Infinity, + symbol: data.symbol, timeframe: data.timeframe, barSeconds: data.barSeconds, now: Infinity, fetchedAt: data.fetchedAt ?? null, }); if (snapshot.sha256 !== data.sha256) throw invalid('Snapshot checksum mismatch'); // Keep the forming-bar flag recorded at capture time. diff --git a/src/backtest/types.ts b/src/backtest/types.ts index efc779e5..498555c1 100644 --- a/src/backtest/types.ts +++ b/src/backtest/types.ts @@ -33,8 +33,14 @@ export interface SignalFunction { (candles: readonly Readonly>[]): readonly Signal[]; /** Bars needed before the first valid signal (indicator warm-up). */ readonly warmupBars?: number; - /** Shown in the report. */ + /** Stable identifier copied to `report.settings.signal.id` (defaults to the function name). */ + readonly id?: string; + /** Parameters copied to `report.settings.signal.params`, for reports and UIs. */ + readonly params?: Readonly>; + /** English description used by `formatReport` only; UIs should build their own text from `id` and `params`. */ readonly label?: string; + /** Causal indicator series to draw next to the candles (one value per bar, NaN when undefined). Used by `chartData`. */ + indicators?(candles: readonly Readonly>[]): Record; } /** A signal turned into an order: decided at the close of `signalBar`, entered at the open of `entryBar`. */ @@ -73,7 +79,12 @@ export interface ExitContext { */ export interface ExitModel { id: string; + /** English description used by `formatReport` only; UIs should build their own text from `kind` and `params`. */ label: string; + /** Exit family: `fixed`, `atr`, `atr_trailing` for the built-in exits. */ + kind?: string; + /** Parameters of the exit (stop distance, target in R, ATR multiplier and length...). */ + params?: Readonly>; /** Bars needed before `levels` can work (ATR length - 1...). */ warmupBars?: number; levels(ctx: ExitContext & { entryOpen: number; signalBar: number }): { stop: number; target: number | null } | null; diff --git a/tests/unit/backtest.test.ts b/tests/unit/backtest.test.ts index cc6b9fd6..623057d5 100644 --- a/tests/unit/backtest.test.ts +++ b/tests/unit/backtest.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - atrExit, atrTrailingExit, backtest, checkCausality, computeMetrics, createRng, defaultExits, ema, fillPrice, findPivots, - fixedExit, formatReport, freezeSnapshot, loadSnapshot, percentileRank, quantile, randomBaseline, rsiDivergence, + atrExit, atrTrailingExit, backtest, backtestMany, chartData, checkCausality, computeMetrics, createRng, defaultExits, ema, fillPrice, findPivots, + fixedExit, formatReport, formatWarning, freezeSnapshot, loadSnapshot, percentileRank, quantile, randomBaseline, rsiDivergence, runBacktest, simulate, sma, splitSegments, tradeReturn, tradesToCsv, TradingViewError, type Bar, type Entry, type Signal, type SignalFunction, } from '../../src/backtest/index.js'; @@ -361,7 +361,7 @@ describe('backtest: report and one-call API', () => { const bars = randomWalk(900, 9); const report = runBacktest(bars, { signal: smaCross, runs: 10 }); expect(JSON.parse(JSON.stringify(report))).toEqual(report); - expect(report.settings.signal).toBe('SMA(20) cross'); + expect(report.settings.signal).toEqual({ id: 'custom', params: {}, label: 'SMA(20) cross' }); expect(report.causality.reruns).toBeGreaterThan(0); expect(report.causality.checkedSignals).toBe(Math.min(100, report.causality.totalSignals)); const text = formatReport(report); @@ -401,3 +401,85 @@ describe('backtest: report and one-call API', () => { expect(ema(values.slice(0, 5), 3)).toEqual(ema(values, 3).slice(0, 5)); }); }); + +describe('backtest: page and chart data', () => { + const bars = randomWalk(1200, 7); + const signal = rsiDivergence(SMALL); + const run = () => runBacktest(bars, { signal, runs: 5 }); + + it('returns identifiers and parameters, not interface text', () => { + const report = run(); + expect(report.library).toEqual({ name: '@mathieuc/tradingview', version: expect.stringMatching(/^4\./) }); + expect(report.createdAt).toBeGreaterThan(1.7e9); + expect(report.snapshot.fetchedAt).toBeGreaterThan(1.7e9); + expect(report.settings.signal).toMatchObject({ + id: 'rsi_divergence', params: { rsiLength: 14, emaLength: 50, pivotLookback: 3, minPivotDistance: 5, maxPivotDistance: 60, trendFilter: true }, + }); + expect(report.settings.exits.map((e) => [e.id, e.kind, e.params])).toEqual([ + ['fixed_1.5R', 'fixed', { stopPct: 0.02, targetR: 1.5 }], + ['fixed_2R', 'fixed', { stopPct: 0.02, targetR: 2 }], + ['atr1.5_2R', 'atr', { mult: 1.5, targetR: 2, length: 14 }], + ['trail_atr3', 'atr_trailing', { mult: 3, length: 14 }], + ]); + expect(report.rows[0]).not.toHaveProperty('exitLabel'); + expect(report.warnings.map((w) => w.code)).toContain('short_history'); + for (const w of report.warnings) expect(formatWarning(w)).not.toMatch(/^\{/); + expect(new Set(report.trades.map((t) => t.reason)).size).toBeGreaterThan(0); + }); + + it('gives buy and hold per segment with the same costs, and refs as bar indices', () => { + const report = run(); + const { inSample, outOfSample } = report.segments; + const expected = (s: typeof inSample) => (bars[s.end - 1]!.close * 0.9995) / (bars[s.start]!.open * 1.0005) - 1 - 0.001 - 0.001 * ((bars[s.end - 1]!.close * 0.9995) / (bars[s.start]!.open * 1.0005)); + expect(inSample.buyHold).toBeCloseTo(expected(inSample), 12); + expect(outOfSample.buyHold).toBeCloseTo(expected(outOfSample), 12); + for (const t of report.trades) { + expect(t.refBars.pivot).toBe(t.signalBar - 3); + expect(t.refs.pivot).toBe(bars[t.refBars.pivot!]!.time); + } + const raw = rsiDivergence({ ...SMALL, trendFilter: false })(freezeSnapshot(bars).candles).length; + expect(raw).toBeGreaterThanOrEqual(report.signals.length); + }); + + it('builds chart data: candles, indicators, split times, signals and trades by exit', () => { + const snapshot = freezeSnapshot(bars); + const report = runBacktest(snapshot, { signal, runs: 5 }); + const chart = chartData(report, snapshot, signal); + expect(chart.bars).toHaveLength(snapshot.count); + expect(Object.keys(chart.indicators)).toEqual(['rsi', 'ema']); + expect(chart.indicators.ema![10]).toBeNull(); + expect(chart.indicators.ema![100]).toBeCloseTo(ema(snapshot.candles.map((c) => c.close), 50)[100]!, 12); + expect(chart.splitTime).toBe(bars[report.segments.outOfSample.start]!.time); + expect(chart.warmupEndTime).toBe(bars[49]!.time); + expect(Object.keys(chart.trades)).toEqual(['fixed_1.5R', 'fixed_2R', 'atr1.5_2R', 'trail_atr3']); + expect(Object.values(chart.trades).flat()).toHaveLength(report.trades.length); + expect(JSON.parse(JSON.stringify(chart))).toEqual(chart); + expect(chartData(report, snapshot).indicators).toEqual({}); + expect(() => chartData(report, freezeSnapshot(randomWalk(300, 1)))).toThrow(/not the one/); + }); + + it('runs several markets, with exits per market, and one CSV for all', async () => { + const now = Math.floor(Date.now() / 1000); + const series = (seed: number, step: number) => randomWalk(700, seed).map((bar, i) => ({ ...bar, time: now - (700 - i) * step, volume: 1 })); + const provider: MarketDataProvider = { + async watchCandles(query, handlers) { + const data = series(query.symbol === 'TEST:A' ? 1 : 2, query.timeframe === 'D' ? 86_400 : 4 * HOUR); + handlers.onData(data); + return { latest: data, symbolInfo: undefined, stop: async () => undefined } as never; + }, + }; + const results = await backtestMany({ + markets: [{ symbol: 'TEST:A', timeframe: '240' }, { symbol: 'TEST:B', timeframe: 'D' }], + exits: (m) => [fixedExit({ stopPct: m.timeframe === 'D' ? 0.07 : 0.03, targetR: 2 })], + signal: smaCross, runs: 5, provider, + }); + expect(results.map((r) => r.id)).toEqual(['TEST:A_240', 'TEST:B_D']); + expect(results[1]!.report.settings.exits[0]!.params).toEqual({ stopPct: 0.07, targetR: 2 }); + const trades = results.flatMap((r) => r.report.trades); + expect(trades.every((t) => t.series === 'TEST:A_240' || t.series === 'TEST:B_D')).toBe(true); + const csv = tradesToCsv(trades).split('\n'); + expect(csv[0]!.startsWith('series,exit,split,direction,cross_time')).toBe(true); + await expect(backtestMany({ markets: [], signal: smaCross })).rejects.toThrow(/markets/); + }); +}); + diff --git a/tests/unit/exports.test.ts b/tests/unit/exports.test.ts index 9072dba0..ff60022e 100644 --- a/tests/unit/exports.test.ts +++ b/tests/unit/exports.test.ts @@ -30,9 +30,9 @@ describe('public API', () => { it('backtest entry exposes the protocol, its building blocks and the report helpers', () => { expect(Object.keys(backtest).sort()).toEqual([ - 'BACKTEST_DEFAULTS', 'TradingViewError', 'atr', 'atrExit', 'atrTrailingExit', 'backtest', 'checkCausality', 'computeMetrics', + 'BACKTEST_DEFAULTS', 'TradingViewError', 'atr', 'atrExit', 'atrTrailingExit', 'backtest', 'backtestMany', 'chartData', 'checkCausality', 'computeMetrics', 'createRng', 'defaultExits', 'deriveSeed', 'ema', 'fetchSnapshot', 'fillPrice', 'findPivots', 'fixedExit', 'formatReport', - 'formatTable', 'freezeSnapshot', 'isoTime', 'loadSnapshot', 'percentileRank', 'quantile', 'randomBaseline', 'resolveWarmupBars', + 'formatTable', 'formatWarning', 'freezeSnapshot', 'isoTime', 'loadSnapshot', 'percentileRank', 'quantile', 'randomBaseline', 'resolveWarmupBars', 'rsi', 'rsiDivergence', 'runBacktest', 'simulate', 'sma', 'snapshotInfo', 'splitSegments', 'tradeReturn', 'tradesToCsv', ]); expect(backtest.TradingViewError).toBe(root.TradingViewError); From 7e48ee90e66e855146da1917cf8873a5cf45f73f Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 23 Oct 2026 06:04:55 +0200 Subject: [PATCH 5/5] test(backtest): ignore run and freeze times when comparing reports --- tests/unit/backtest.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/backtest.test.ts b/tests/unit/backtest.test.ts index 623057d5..cf6ae889 100644 --- a/tests/unit/backtest.test.ts +++ b/tests/unit/backtest.test.ts @@ -344,8 +344,10 @@ describe('backtest: random baseline and metrics', () => { it('gives the same report twice and a percentile per row', () => { const run = () => runBacktest(randomWalk(900, 9), { signal: smaCross, runs: 30 }); + // Same data and seed => same report, apart from the run and freeze times. + const stable = (r: ReturnType) => JSON.stringify({ ...r, createdAt: 0, snapshot: { ...r.snapshot, fetchedAt: 0 } }); const a = run(); - expect(JSON.stringify(run())).toBe(JSON.stringify(a)); + expect(stable(run())).toBe(stable(a)); for (const row of a.rows.filter((r) => r.metrics.trades > 0)) { expect(row.baseline).toMatchObject({ runs: 30 }); expect(row.baseline).not.toHaveProperty('netReturns');