Backtesting Engine¶
Standard backtesting on higher timeframes has a fundamental flaw: signals are computed on completed bars, and fills execute at bar close. A MACD crossover on a 4-hour bar may trigger 2 hours into the bar at $49,800, but the backtest records the fill at bar close — $51,200. The error grows with timeframe: negligible on 1-minute data, $1,000+ on 4h, $5,000+ on daily (BTC-scale assets). The magnifier technique solves this by simulating intra-bar execution at sub-bar resolution, filling at the actual moment the signal fires.
Two Execution Modes¶
| Standard Mode | Magnifier Mode | |
|---|---|---|
| Flag | --no-magnify |
Default (TF > 1m) |
| Signal computation | Once over full DataFrame | Per sub-bar, progressively |
| Fill price | Bar close | Sub-bar close at trigger time |
| Speed | Very fast (single vectorized call) | Moderate (~10K compute calls for 1,000 bars) |
| Fill realism | Poor on higher TF | Good (fills at sub-bar resolution) |
| Use case | 1m data, parameter sweeps, quick screening | Production backtests on 1h+ timeframes |
| Compute path | _compute(df, params) — pandas |
_compute_fast() preferred, _compute() fallback |
flowchart TB
SRC["PineScript Strategy"] --> COMPILE["Compile<br/>(Tokenizer → Parser → CodeGen)"]
COMPILE --> DECIDE{Timeframe > 1m<br/>AND magnify enabled?}
DECIDE -->|Yes| MAG["Magnifier Mode<br/>Sub-bar iteration"]
DECIDE -->|No| STD["Standard Mode<br/>Single vectorized call"]
STD --> VBT["vectorbt<br/>Portfolio.from_signals()"]
MAG --> VBT
VBT --> RESULT["BacktestResult<br/>Stats + Equity + Trades"]
Magnifier Technique¶
Concept¶
For each chart-timeframe bar, the magnifier iterates through sub-bars at a finer resolution, progressively building a "forming" OHLCV bar. At each sub-bar tick, it runs the strategy with a window of completed bars plus the forming bar. The moment a signal triggers, the entry/exit is recorded at that sub-bar's close price — not the chart bar's close.
Chart bar (4h): [════════════════════════════════════]
Open $50,000 Close $51,200
Magnifier (15m): [══|══|══|══|══|══|══|══|══|══|══|══|══|══|══|══]
↑
Tick 4: MACD crossover triggers
Fill at $49,800 (sub-bar close)
Skip remaining 12 ticks
Detailed Flow¶
sequenceDiagram
participant B as Backtester
participant D as DataSource
participant M as Magnifier Loop
participant S as Strategy (compute)
participant V as vectorbt
B->>D: Load 1m base data
D-->>B: df_1m (raw candles)
B->>B: Resample to chart TF (e.g., 4h)
B->>B: Resample to magnifier TF (e.g., 15m)
B->>B: Pick resolution: 4h → 15m (16 ticks)
loop For each chart bar (after warmup)
B->>M: Process bar [bar_start, bar_end]
M->>M: Get magnifier ticks within bar
M->>M: Initialize forming bar (open = first tick open)
loop For each sub-bar tick
M->>M: Update forming OHLCV<br/>high = max, low = min, close = tick close
M->>M: Build window = completed bars + forming bar
M->>S: compute(window, params)
S-->>M: (long_entry, long_exit, short_entry, short_exit)
alt Signal triggered on last bar of window
M->>M: Record signal at tick index
M->>M: Update position state (in_long / in_short)
M->>M: Break — one signal per chart bar
end
end
end
B->>V: Portfolio.from_signals(mag_close, signals)
V-->>B: Portfolio with realistic fills
B->>B: Extract BacktestResult
Algorithm¶
for bar_idx in range(warmup, len(df_chart)):
bar_start = df_chart.index[bar_idx]
bar_end = bar_start + chart_timedelta
# Magnifier ticks within this chart bar
mag_start = df_mag.index.searchsorted(bar_start, side="left")
mag_end = df_mag.index.searchsorted(bar_end, side="left")
# Completed chart-TF bars as indicator context
completed = df_chart.iloc[max(0, bar_idx - warmup*3) : bar_idx]
# Progressive forming bar
forming_open = df_mag["open"].iloc[mag_start]
forming_high, forming_low, forming_vol = -inf, inf, 0.0
for pos in range(mag_start, mag_end):
forming_high = max(forming_high, df_mag["high"].iloc[pos])
forming_low = min(forming_low, df_mag["low"].iloc[pos])
forming_close = df_mag["close"].iloc[pos]
forming_vol += df_mag["volume"].iloc[pos]
window = concat([completed, forming_bar])
le, lx, se, sx = strategy.compute(window, params)
if signal_triggered(le, lx, se, sx):
record_signal_at(pos)
break # One signal per chart bar
Critical: break after signal
Only one signal per chart bar is allowed. After recording a signal, the magnifier breaks out of the sub-bar loop. Without this break, the same bar could generate multiple conflicting entries and exits.
Dynamic Resolution¶
The magnifier picks a sub-bar resolution that divides evenly into the chart timeframe, targeting approximately 10 ticks per bar (maximum ~16):
| Chart TF | Magnifier TF | Ticks per Bar |
|---|---|---|
| 5m | 1m | 5 |
| 15m | 1m | 15 |
| 30m | 3m | 10 |
| 1h | 5m | 12 |
| 4h | 15m | 16 |
| 1d | 1h | 24 |
VALID_RESOLUTIONS = [1, 3, 5, 15, 30, 60, 240] # minutes
def pick_magnifier_resolution(chart_tf, target_ticks=10):
chart_min = TF_MINUTES[chart_tf]
if chart_min <= 1:
return "1m"
max_ticks = int(target_ticks * 1.6) # ~16
best = "1m"
for res in VALID_RESOLUTIONS:
if res >= chart_min or chart_min % res != 0:
continue
ticks = chart_min // res
if ticks > max_ticks:
continue
if abs(ticks - target_ticks) < best_dist:
best = minutes_to_tf(res)
return best
Requirement: 1m base data
The magnifier requires 1-minute candle data as the base resolution. All higher timeframes are resampled from 1m. If 1m data is unavailable, the backtester falls back to standard mode automatically.
Performance Optimization¶
Fast Path vs. Slow Path¶
| Path | When Used | Per-Call Overhead |
|---|---|---|
Fast (_compute_fast) |
Strategy has a NumPy-only implementation | ~0.1ms |
Slow (_compute) |
Complex strategies, fallback | ~2ms |
For a 4h backtest over 1,000 chart bars with 16 magnifier ticks each:
- Fast path: 16,000 calls × 0.1ms = 1.6 seconds
- Slow path: 16,000 calls × 2ms = 32 seconds
The PineScript compiler generates both paths when possible. The backtester attempts _compute_fast first and falls back to _compute transparently.
Performance Matrix¶
| Configuration | Speed | Fill Realism | Best For |
|---|---|---|---|
| Standard, 1m | Fast | Excellent | Production (1m already has bar-level precision) |
| Standard, 4h | Very fast | Poor | Parameter sweeps, screening |
| Magnifier, 1h (fast path) | ~2s | Good (5m fills) | Production backtests |
| Magnifier, 4h (fast path) | ~4s | Good (15m fills) | Production backtests |
| Magnifier, 1d (fast path) | ~6s | Good (1h fills) | Daily strategies |
BacktestResult Model¶
The backtester extracts all results into a structured, JSON-serializable model:
Metadata¶
| Field | Type | Description |
|---|---|---|
strategy_name |
str |
From strategy("name", ...) |
symbol |
str |
Traded instrument (e.g., BTCUSDT) |
exchange |
str |
Data source exchange |
timeframe |
str |
Chart timeframe (1h, 4h, 1d) |
period |
str |
Date range (2024-01-01 to 2025-01-01) |
mode |
str |
standard or magnifier |
params |
dict |
Strategy input parameters used |
Performance Metrics¶
| Field | Type | Example |
|---|---|---|
total_return_pct |
float |
+28.82 |
annualized_return_pct |
float |
+34.15 |
sharpe_ratio |
float \| null |
2.15 |
sortino_ratio |
float \| null |
3.20 |
calmar_ratio |
float \| null |
1.85 |
max_drawdown_pct |
float |
-12.50 |
Trade Statistics¶
| Field | Type | Example |
|---|---|---|
total_trades |
int |
28 |
win_rate_pct |
float |
57.14 |
profit_factor |
float |
1.85 |
expectancy |
float |
106.75 |
best_trade_pct |
float |
+8.32 |
worst_trade_pct |
float |
-4.15 |
avg_trade_pct |
float |
+1.03 |
Time Series (sampled)¶
| Field | Points | Format |
|---|---|---|
equity_curve |
~1,000 | [{timestamp, value}, ...] |
returns |
~1,000 | [{timestamp, return}, ...] |
drawdown_curve |
~1,000 | [{timestamp, drawdown_pct}, ...] |
Detail Records¶
| Field | Type | Key Fields |
|---|---|---|
trades |
list[TradeRecord] |
direction, entry/exit time+price, PnL, return%, duration |
orders |
list[OrderRecord] |
timestamp, side, price, size, fees |
drawdowns |
list[DrawdownRecord] |
peak_time, valley_time, drawdown_pct, duration |
Chart Data¶
| Field | Points | Purpose |
|---|---|---|
ohlcv_bars |
~5,000 | OHLCV data for chart rendering |
trade_markers |
per trade | Entry/exit overlay markers on the price chart |
NaN → null
Many stats return NaN when undefined (Sharpe with zero variance, Profit Factor with no losing trades). The result extractor converts all NaN and Inf values to null before JSON serialization. The frontend displays null metrics as —, never as 0.
Data Sources¶
The backtester loads data through a DataSource protocol:
class DataSource(Protocol):
def load_1m(self, symbol: str, exchange: str,
start: datetime, end: datetime) -> pd.DataFrame:
"""Load 1-minute OHLCV candles."""
...
| Source | Status | Storage | Notes |
|---|---|---|---|
DiskSource |
Active | .npz files (via CCXT) |
Compressed NumPy archives, fast local reads |
TimescaleSource |
Planned | PostgreSQL / TimescaleDB | Hypertable with continuous aggregates, shared across users |
All higher timeframes are resampled from the 1-minute base:
def resample_ohlcv(df_1m: pd.DataFrame, target_tf: str) -> pd.DataFrame:
rule = TF_RESAMPLE_RULES[target_tf] # "5T", "1H", "4H", "1D"
return df_1m.resample(rule).agg({
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}).dropna()
Usage Examples¶
# Standard magnifier mode (default for TF > 1m)
python -m backtest --script strategies/macd_crossover.pine --timeframe 4h
# Disable magnifier for faster parameter sweeps
python -m backtest --script strategies/supertrend.pine --timeframe 1h --no-magnify
# JSON output for programmatic consumption
python -m backtest --script strategies/rsi_overbought.pine --timeframe 1d --json
# Specify date range and initial capital
python -m backtest --script strategies/bb_squeeze.pine --timeframe 4h \
--start 2024-01-01 --end 2025-01-01 --capital 50000
# Override strategy parameters
python -m backtest --script strategies/macd_crossover.pine --timeframe 1h \
--param "Fast Length=8" --param "Slow Length=21"
File Map¶
| Concept | File |
|---|---|
| Backtester class | backtest/backtester.py (Backtester) |
| Standard mode execution | backtest/backtester.py (_run_standard) |
| Magnifier mode execution | backtest/backtester.py (_run_magnified) |
| Result extraction | backtest/backtester.py (_extract_result) |
| BacktestResult dataclass | backtest/strategy.py (BacktestResult) |
| TradeRecord / OrderRecord / DrawdownRecord | backtest/strategy.py |
| Dynamic resolution picker | backtest/backtester.py (pick_magnifier_resolution) |
| DataSource protocol | backtest/backtester.py |
| CLI entry point | backtest/__main__.py |