Skip to content

examples: honest backtest protocol on getCandles data - #351

Merged
Mathieu2301 merged 2 commits into
mainfrom
examples/honest-backtest
Oct 23, 2026
Merged

Mathieu2301 merged 2 commits into
mainfrom
examples/honest-backtest

Conversation

@Mathieu2301

Copy link
Copy Markdown
Owner

Summary

Adds examples/honest-backtest/, a self-contained reference for testing a strategy on v4 data, so that people and coding agents can copy it. It uses only getCandles and timeframeSeconds (root export). It adds no dependency, needs no account and places no orders. It makes no claim of profitability: the README says that a negative or inconclusive result is a valid outcome.

The strategy is a regular RSI(14) divergence with an EMA(200) trend filter. It runs on BINANCE:BTCUSDT 4H by default, and every parameter is a CLI option (--help).

Protocol point Where
Snapshot frozen once: forming bar dropped; symbol, timeframe, first/last bar, count, gaps and SHA-256 printed; saved for --snapshot replay; warns if history is shorter than 2 tradable years freezeSnapshot, index.js
Warm-up bars never traded, 70/30 chronological split with exact dates, signal and entry in the same segment, open position closed on the last bar of its segment splitSegments, simulate
Pivot with lookback N confirmed N bars later; signal on the confirmation close; entry at the next open findPivots, findSignals
Fee 0.1% and adverse slippage 0.05% per side, in the strategy and the baseline fillPrice, tradeReturn
Stop and target touched in the same candle count as a loss; a gap through the stop fills at the open runTrade
Exits that can be swapped without changing entries: fixed 2% stop with 1.5:1 and 2:1 targets, ATR 1.5x stop with a 2R target, ATR trailing stop updated on closed bars only fixedExit, atrExit, atrTrailingExit
Seeded random-entry baseline (1,000 runs) for each exit and split, with the same bars, exits, costs, sizing, one-position rule, trade count and long/short mix. Reports the percentile (share strictly lower) and p5/p50/p95 randomBaseline, percentileRank
Net return, win rate, profit factor (N/A when undefined), max drawdown (marked to market at bar closes), and trade count, in a compact table computeMetrics, formatTable
Trades CSV with pivot, confirmation, entry and exit times and prices, plus the exit reason tradesToCsv

Docs: examples/honest-backtest/README.md, a row and a section in examples/README.md, a short section in docs/data-api.md, and one line in llms.txt. honest-backtest-output/ is git-ignored.

Tests

tests/unit/honest-backtest.test.ts has 15 deterministic tests on synthetic candles, with no network. They cover:

  • pivot confirmation timing, the signal disappearing when the confirmation bar is missing, and the next-open entry;
  • the same-bar stop and target rule;
  • a gap through the stop;
  • costs on both sides, long and short, including the random baseline;
  • no trade crossing the split;
  • the trailing stop using only closed bars, unchanged when later bars change;
  • a reproducible seeded baseline;
  • metric definitions.

To check that the tests catch real bugs, I made 7 deliberate code mutations: a backdated signal, entry at the signal close, a trailing look-ahead, a missing exit fee, the same-bar rule counted as a win, a gap filled at the stop, and a position allowed across the split. Each one made at least one test fail.

  • npm run typecheck: pass
  • npm run lint: pass
  • npm test: 18 files, 191 passed, 2 skipped (unchanged)
  • npm run test:bun: 190 pass, 3 skip, 0 fail
  • npm run check (including build and package smoke under Node and Bun): pass

The engine's tests import examples/honest-backtest/engine.js through engine.d.ts, so tsconfig.json is unchanged.

Real run (TradingView public data, no account)

node examples/honest-backtest/index.js ran on 2026-10-23 at 01:40 UTC. Replaying the saved snapshot with Bun gave identical output.

== Snapshot (frozen once, reused for every run) ==
BINANCE:BTCUSDT 240: 4583 closed bars, 2024-09-19T04:00:00Z -> 2026-10-22T20:00:00Z (bar open times, UTC)
Forming bar dropped: yes. sha256 aad2d411215408fd
Gaps: 0 (0 missing bars)
History: 2.00 years tradable after warm-up (>= 2 wanted).

== Split (chronological, no position crosses it) ==
Warm-up:       2024-09-19T04:00:00Z -> 2024-10-22T04:00:00Z (199 bars, indicators only, never traded)
In-sample:     2024-10-22T08:00:00Z -> 2026-03-17T12:00:00Z (3068 bars)
Out-of-sample: 2026-03-17T16:00:00Z -> 2026-10-22T20:00:00Z (1316 bars)

== Signals: RSI divergence + EMA trend filter ==
Pivot lookback 5: a pivot is confirmed 5 bars later; signal at that bar's close, entry at the NEXT bar's open.
RSI 14, EMA 200, pivots 5-60 bars apart. IS signals: 10 (5 long, 5 short). OOS signals: 7 (2 long, 5 short).
Costs per side: fee 0.10%, adverse slippage 0.05%. 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: 1000 seeded runs per row, seed 42) ==
Exit        Split  Trades    Net  Win    PF  MaxDD  Rand p5  Rand p50  Rand p95  Pctl
fixed_1.5R  IS         10  -3.3%  40%  0.77  11.0%   -12.5%     -3.2%     12.4%  33.7
fixed_1.5R  OOS         6   1.0%  50%  1.14   6.9%    -8.6%     -3.9%      6.2%  66.9
fixed_2R    IS         10   0.5%  40%  1.04  11.0%   -15.9%     -5.2%     13.3%  50.6
fixed_2R    OOS         6  -7.7%  17%  0.32  11.5%   -13.0%     -2.0%      5.2%  33.6
atr1.5_2R   IS         10  -0.8%  40%  0.95  10.9%   -16.6%     -2.7%     15.9%  57.0
atr1.5_2R   OOS         6  -3.7%  33%  0.53   9.1%   -11.7%     -3.0%      9.0%  44.9
trail_atr3  IS          9  37.2%  44%  2.53  17.1%   -20.8%     -4.2%     24.3%  97.7
trail_atr3  OOS         6   6.1%  33%  1.71  11.0%   -14.6%     -6.0%     13.2%  86.4
Exits: fixed_1.5R = Fixed 2.0% stop, 1.5:1 target; fixed_2R = Fixed 2.0% stop, 2:1 target; atr1.5_2R = ATR x1.5 stop, 2R target; trail_atr3 = ATR x3 trailing stop.
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.
WARNING: as few as 6 trades in a row. With so few trades, net return and percentile are mostly noise.
A percentile near 50, a negative OOS return or few trades is a valid, inconclusive outcome, not a bug.

Wrote 63 trades to /tmp/hb-final/trades.csv and the snapshot to /tmp/hb-final/snapshot.json.

The result is inconclusive. Each segment has 6 to 10 trades, which is far too few to conclude anything, and the out-of-sample percentiles are spread between 34 and 86 depending on the exit.

Limits and open points

  • Anonymous history allowed about 2.0 tradable years of 4H data, which just meets the target. Longer windows (--minYears 3) may be cut short by the server; the run prints a warning when that happens.
  • Two live runs a few minutes apart produced different snapshot hashes. This is why the snapshot is saved: replay it with --snapshot to reproduce a result exactly.
  • When positions overlap, the random baseline redraws the skipped entries among the free bars. This is close to uniform, not exactly uniform. The report says how many runs reached the strategy's trade count; all of them did in this run.
  • Not modelled: funding, spreads wider than the slippage, partial fills, latency.

Add examples/honest-backtest, a self-contained RSI divergence + EMA 200
trend filter backtest (BINANCE:BTCUSDT 4H by default, all parameters
configurable) built only on getCandles:

- candles fetched once, validated and frozen (forming bar dropped, gaps
  and SHA-256 recorded, saved for offline replay with --snapshot)
- warm-up bars never traded, chronological 70/30 IS/OOS split, no
  position across the split
- pivots confirmed N bars later, signal on the confirmation close, entry
  at the next bar open
- fee and adverse slippage on both sides; same-bar stop and target counts
  as a loss; gaps through a stop fill at the open
- interchangeable exits: fixed 1.5:1 and 2:1, ATR 1.5x stop with 2R
  target, ATR trailing stop updated on closed bars only
- seeded random-entry baseline per exit and split with percentile and
  p5/p50/p95, compact metrics table and trades CSV

Deterministic unit tests on synthetic candles cover pivot timing,
next-open entry, same-bar fills, costs, the split, the trailing stop and
baseline reproducibility. Link the example from examples/README.md,
docs/data-api.md and llms.txt.
Split runTrade into small helpers (openTrade, barExit, gapExit,
intrabarExit, closedTrade, reachedStop, reachedTarget) to reduce its
complexity. Behaviour is unchanged: fills, exit reasons, costs and the
trailing stop update follow the same rules and order as before.
@Mathieu2301
Mathieu2301 marked this pull request as ready for review October 23, 2026 01:46
@Mathieu2301
Mathieu2301 merged commit 4952972 into main Oct 23, 2026
12 checks passed
Mathieu2301 added a commit that referenced this pull request Oct 23, 2026
New entry point @mathieuc/tradingview/backtest built from examples/honest-backtest (#351): the caller writes only the entry rule; the module freezes candles, rejects look-ahead (LOOK_AHEAD), applies costs, pessimistic fills, the IS/OOS split, four exits and a seeded random baseline, and returns a JSON report. Adds chartData() and backtestMany(). Version 4.1.0-beta.2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant