Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules/
dist/
coverage/
*.tgz
honest-backtest-output/
8 changes: 8 additions & 0 deletions docs/data-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ for the tested symbol/timeframe; UI exports and Deep Backtesting have separate
subscription requirements.


## Backtesting a strategy on candles

The library ships no backtesting engine. [examples/honest-backtest](../examples/honest-backtest) shows a protocol to copy: candles fetched once with `getCandles` and frozen, a chronological 70/30 in-sample/out-of-sample split, signals confirmed without look-ahead and entered at the next bar's open, fees and slippage on both sides, a pessimistic rule when stop and target fall in the same candle, and a seeded random-entry baseline for each exit. A negative or inconclusive result is a valid outcome.

```bash
npm run build && node examples/honest-backtest/index.js --symbol BINANCE:BTCUSDT --timeframe 240
```

## Search and technical analysis

```ts
Expand Down
5 changes: 5 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Examples that need an account read the `SESSION` and `SIGNATURE` cookies from th
| Example | API | Account |
| --- | --- | --- |
| [candles.js](candles.js) | `getCandles` (count, from/to) | No |
| [honest-backtest/](honest-backtest) | `getCandles` → backtest protocol: no look-ahead, costs, IS/OOS split, random-entry baseline | No |
| [watch-candles.js](watch-candles.js) | `watchCandles` | No |
| [quotes.js](quotes.js) | `getQuote`, `getQuotes`, `watchQuotes` | No |
| [indicator-data.js](indicator-data.js) | `getIndicatorData` | Pine scripts only |
Expand All @@ -35,3 +36,7 @@ Examples that need an account read the `SESSION` and `SIGNATURE` cookies from th
| [drawings.js](drawings.js) | `getDrawings` | Private layouts |
| [pine-permissions.js](pine-permissions.js) | `PinePermissionManager` | Yes (script owner) |
| [user-login.js](user-login.js) | `loginUser`, `getUser` | Username/password |

## Backtesting honestly

[honest-backtest/](honest-backtest) is a reference protocol for testing a strategy on `getCandles` data: frozen snapshot, chronological in-sample/out-of-sample split, no look-ahead, fees and slippage, pessimistic fills, interchangeable exits and a seeded random-entry baseline. It makes no claim of profitability: a negative or inconclusive result is a valid outcome.
54 changes: 54 additions & 0 deletions examples/honest-backtest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Honest backtest (reference protocol)

A self-contained example that backtests a simple strategy **and checks whether it does better than chance**, using only `getCandles` from the v4 data API. Copy it as a starting point for your own strategy, by hand or with a coding agent.

**This is not trading advice and makes no claim of profitability.** A negative or inconclusive result is a valid outcome of this protocol, and the most common one. The point is to find out honestly, not to produce a nice equity curve.

```bash
npm ci && npm run build
node examples/honest-backtest/index.js # BINANCE:BTCUSDT, 4H, about 2 years
node examples/honest-backtest/index.js --symbol BINANCE:ETHUSDT --timeframe D
node examples/honest-backtest/index.js --snapshot honest-backtest-output/snapshot.json # Offline replay
node examples/honest-backtest/index.js --help # Every parameter
```

No account is needed. The run writes `snapshot.json` and `trades.csv` to `honest-backtest-output/` (change it with `--out`).

## Files

| File | Role |
| --- | --- |
| [engine.js](engine.js) | Pure functions: snapshot, indicators, signals, split, exits, simulation, metrics, random baseline. No network, no I/O. |
| [index.js](index.js) | Downloads candles once with `getCandles`, runs the protocol, prints the report, writes the CSV. |
| [engine.d.ts](engine.d.ts) | Types for the engine. |
| [tests/unit/honest-backtest.test.ts](../../tests/unit/honest-backtest.test.ts) | Deterministic tests on synthetic candles for every rule below. |

## Strategy

- **Long**: regular bullish RSI(14) divergence (a lower price pivot low with a higher RSI value than the previous pivot low, 5 to 60 bars apart) while the close is **above** the EMA(200).
- **Short**: regular bearish divergence (higher price pivot high, lower RSI) while the close is **below** the EMA(200).

Every parameter is a command-line option. Replace `findSignals` in `engine.js` to test your own entries; the rest of the protocol stays the same.

## Protocol

1. **Frozen snapshot.** Candles are downloaded once, validated (ordered, consistent OHLC), and frozen. The bar still forming is dropped. The report prints the symbol, timeframe, first and last bar time, bar count, gaps and a SHA-256 of the data. By default it requests enough bars for 2 years of tradable history after warm-up and warns when the server returns less. A live rerun gets new bars; replay `snapshot.json` to reproduce a result exactly.
2. **Chronological split.** The first bars only warm the indicators up and are never traded. The remaining bars are split 70% in-sample (IS) / 30% out-of-sample (OOS), with exact dates printed. A signal and its entry must be in the same segment, and a position still open on the last bar of a segment is closed at that bar's close (`segment_end`), so no position crosses the split.
3. **No look-ahead.** A pivot with lookback N (default 5) needs N later bars, so it is only confirmed N bars after it formed. The signal fires at the close of the confirmation bar and the entry is the **next bar's open**. Nothing is backdated. Indicators at bar i only use bars up to i.
4. **Costs.** A fee (default 0.1%) and an adverse slippage (default 0.05%) on every entry and every exit, in the strategy and in the random baseline.
5. **Pessimistic fills.** When the stop and the target are both inside the same candle, the trade is a loss. A bar that opens beyond the stop fills at its open (worse than the stop).
6. **Interchangeable exits.** Entries never depend on the exit. Four exits run on the same entries:
- `fixed_1.5R` and `fixed_2R`: stop 2% from the entry bar open (`--stopPct`), target 1.5x or 2x that distance;
- `atr1.5_2R`: stop 1.5x ATR(14) of the signal bar, target 2R;
- `trail_atr3`: initial stop 3x ATR(14), then trailed at close minus 3x ATR on every **closed** bar (`--trailAtrMult`); the new stop applies from the next bar.
7. **Random-entry baseline.** For each exit and each split, 1,000 seeded runs (`--runs`, `--seed`) enter at random bars of the same segment, with the same exit, costs, sizing (100% of equity, no leverage, compounded) and one-position-at-a-time rule, the strategy's number of trades and its long/short mix. Entries skipped because a position was open are redrawn among the free bars; the report says when a run could not reach the strategy's trade count. **Percentile** = 100 x share of random runs with a strictly lower net return. p5, p50 and p95 of the random returns are printed (linear interpolation).
8. **Metrics** per exit and split: net return (compounded), win rate (trades with a positive net return), profit factor (gross profit / gross loss, `N/A` without a losing trade), max drawdown (largest peak-to-trough fall of equity, marked to market at every bar close while in a position) and the number of closed trades.
9. **Trades CSV** with, per trade: exit variant, split, direction, previous pivot, pivot, confirmation and entry bar times, entry open and fill, initial stop, target, exit time, level and fill, exit reason and net return. Times are bar open times in UTC.

## Reading the result

- Look at the **out-of-sample** rows first. In-sample results were seen while choosing the parameters.
- A percentile around 50 means "no better than random entries with the same exits". Above 95 on OOS is weak evidence, not proof: with four exits and two splits, one of them will look good by chance from time to time.
- With a few dozen trades or fewer, returns and percentiles are mostly noise. The run prints a warning.
- Changing parameters until the OOS rows look good turns OOS into in-sample. Decide the parameters first, or keep a final untouched period.
- Not modelled: funding rates, spreads wider than the slippage, partial fills, latency, exchange outages, taxes.
180 changes: 180 additions & 0 deletions examples/honest-backtest/engine.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Type declarations for engine.js (used by the TypeScript unit tests and editors).

export interface Bar {
time: number;
open: number;
high: number;
low: number;
close: number;
volume?: number;
}

export type Direction = 'long' | 'short';

export interface Options {
symbol: string;
timeframe: string;
minYears: number;
splitRatio: number;
pivotLookback: number;
minPivotDistance: number;
maxPivotDistance: number;
rsiLength: number;
emaLength: number;
atrLength: number;
fee: number;
slippage: number;
stopPct: number;
atrStopMult: number;
atrTargetR: number;
trailAtrMult: number;
runs: number;
seed: number;
}

export declare const DEFAULTS: Readonly<Options>;

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<Required<Bar>>[];
}

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<Options>): Required<Indicators>;

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<Indicators, 'rsi' | 'ema'>, options?: Partial<Options>): 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<Options>): 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<Trade, 'return' | 'marks'>[]): 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<Baseline, 'netReturns'> | null;
percentile: number | null;
}
export declare function runBacktest(snapshot: { candles: readonly Bar[] }, options?: Partial<Options> & { 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;
Loading
Loading