Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB rebuild zip, the Windows .venv, and .env are excluded via .gitignore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
3.7 KiB
Python
90 lines
3.7 KiB
Python
"""Backtest engine regression tests.
|
|
|
|
The engine must evaluate setups against each bar's own timestamp, not the
|
|
wall clock. If it used datetime.now(), every setup in a session replayed
|
|
outside ~09:30-11:30 ET would be marked "late" past the entry cutoff and the
|
|
backtest would report zero trades — making the whole tool silently useless on
|
|
evenings and weekends.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
import pandas as pd
|
|
|
|
from strategy.backtest.engine import BacktestEngine
|
|
|
|
|
|
def _bars(specs, symbol="TEST"):
|
|
# 09:30 ET expressed UTC-naive (matches how bars are stored), Friday session
|
|
start = datetime(2026, 6, 12, 13, 30)
|
|
rows = []
|
|
cum_pv = cum_v = 0.0
|
|
for i, (o, h, l, c, v) in enumerate(specs):
|
|
ts = start + timedelta(minutes=i)
|
|
typ = (h + l + c) / 3.0
|
|
cum_pv += typ * v
|
|
cum_v += v
|
|
rows.append(
|
|
dict(symbol=symbol, timestamp=ts, session_date=start.date(),
|
|
is_premarket=False, is_regular_hours=True,
|
|
open=o, high=h, low=l, close=c, volume=v,
|
|
vwap=cum_pv / cum_v if cum_v else c, quality_score=1.0)
|
|
)
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
def _momentum_day():
|
|
"""Gap-up base (warmup) -> impulse -> pullback -> breakout after bar 15."""
|
|
specs = [
|
|
(12.50, 12.62, 12.45, 12.55, 60_000), (12.55, 12.66, 12.50, 12.60, 52_000),
|
|
(12.60, 12.70, 12.54, 12.64, 48_000), (12.64, 12.72, 12.58, 12.66, 46_000),
|
|
(12.66, 12.74, 12.60, 12.68, 44_000), (12.68, 12.76, 12.62, 12.70, 43_000),
|
|
(12.70, 12.78, 12.64, 12.72, 42_000), (12.72, 12.80, 12.66, 12.74, 41_000),
|
|
(12.74, 12.82, 12.68, 12.76, 40_000), (12.76, 12.84, 12.70, 12.78, 40_000),
|
|
(12.78, 12.86, 12.72, 12.80, 39_000), (12.80, 12.88, 12.74, 12.82, 38_000),
|
|
(12.82, 12.90, 12.76, 12.84, 38_000), (12.84, 12.92, 12.78, 12.86, 37_000),
|
|
(12.86, 12.94, 12.80, 12.88, 37_000), (12.88, 12.96, 12.82, 12.90, 36_000),
|
|
(12.90, 13.20, 12.88, 13.16, 130_000), (13.16, 13.50, 13.14, 13.46, 165_000),
|
|
(13.46, 13.80, 13.44, 13.76, 195_000), (13.76, 14.05, 13.74, 14.00, 210_000),
|
|
(14.00, 14.04, 13.82, 13.86, 72_000), (13.86, 13.90, 13.74, 13.80, 58_000),
|
|
(13.80, 13.86, 13.72, 13.78, 48_000), (13.78, 14.15, 13.76, 14.12, 235_000),
|
|
]
|
|
price = specs[-1][3]
|
|
for _ in range(16):
|
|
o = price
|
|
c = round(price + 0.10, 2)
|
|
specs.append((o, round(max(o, c) + 0.04, 2), round(min(o, c) - 0.04, 2), c, 95_000))
|
|
price = c
|
|
return _bars(specs, "GAPR")
|
|
|
|
|
|
def test_backtest_signals_fire_regardless_of_wall_clock():
|
|
# Runs at whatever real time the suite executes (often evenings/weekends).
|
|
# If the engine used now(), this would be 0 signals / 0 trades.
|
|
engine = BacktestEngine(equity=100_000.0)
|
|
result = engine.run(
|
|
_momentum_day(), "GAPR", previous_close=10.0, avg_daily_volume=500_000
|
|
)
|
|
assert result.signals >= 1, "engine found no setups — wall-clock cutoff bug?"
|
|
assert len(result.trades) >= 1
|
|
# each trade is a complete round trip with an exit reason
|
|
for trade in result.trades:
|
|
assert trade.exit_reason in {"target", "stop_loss", "session_end"}
|
|
assert trade.entry_price > 0
|
|
|
|
|
|
def test_backtest_fader_produces_no_signal():
|
|
"""A steady downtrend below VWAP should never trigger a long."""
|
|
specs = []
|
|
price = 9.0
|
|
for _ in range(34):
|
|
o = price
|
|
c = round(price - 0.07, 2)
|
|
specs.append((o, round(o + 0.02, 2), round(c - 0.03, 2), c, 40_000))
|
|
price = c
|
|
engine = BacktestEngine(equity=100_000.0)
|
|
result = engine.run(_bars(specs, "FADE"), "FADE",
|
|
previous_close=9.2, avg_daily_volume=400_000)
|
|
assert result.signals == 0
|
|
assert len(result.trades) == 0
|