trading-assistant / backtest.py
ruudshailesh's picture
Update backtest.py
1c0971d verified
Raw
History Blame Contribute Delete
19.8 kB
"""
backtest.py β€” Backtesting-lite engine.
Strict no-lookahead rules:
- For each simulated day T, features use ONLY data from T-15 to T-1
- Entry price = T's open (simulates realistic fill β€” not close)
- Volume average = mean(volume[T-11 : T-1]) β€” never includes T
- Momentum = (close[T-1] - close[T-6]) / close[T-6] β€” never includes T
- ATR = mean of True Ranges from T-14 to T-1 β€” never includes T
- SPY context evaluated using T-1 data only
- Outcome: close[T+1] vs stop/target (next-day hold assumption)
Design:
Uses same feature/scoring/risk logic as live system (no separate code paths).
Weights snapshot taken at run time β€” shown in results so user knows
backtest reflects current weights, not historical weights.
Results saved to metrics table (source='backtest') for Page 4 display.
Import chain: config -> database -> scoring -> risk -> backtest
"""
import logging
import traceback
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import config
import database as db
import scoring as sc
import risk as rk
logger = logging.getLogger("backtest")
app_logger = logging.getLogger("app")
# ─────────────────────────────────────────────────────────────────────────────
# SLICE OHLCV FOR A SIMULATED DAY
# Returns a sub-DataFrame ending at T-1 (so scoring sees only past data)
# ─────────────────────────────────────────────────────────────────────────────
def _slice_for_day(df: pd.DataFrame, sim_day_idx: int) -> Optional[pd.DataFrame]:
"""
Returns OHLCV slice ending at sim_day_idx - 1 (exclusive of sim day).
Minimum length enforced for feature computation.
sim_day_idx: integer position in the DataFrame (iloc index of simulated day T)
Returns None if insufficient history before T.
"""
min_history = max(
config.ATR_WINDOW,
config.VOL_AVG_WINDOW,
config.MOMENTUM_WINDOW,
config.SPY_MA_WINDOW,
) + 5 # buffer
end_idx = sim_day_idx # exclusive: slice goes up to but NOT including sim day
if end_idx < min_history:
return None
return df.iloc[:end_idx].copy()
def _compute_spy_context_for_day(
spy_df: pd.DataFrame,
sim_day_idx: int,
) -> Dict[str, Any]:
"""
Compute SPY market context using data strictly before sim_day T.
Mirrors data_fetch.compute_spy_context() but on a slice.
Returns neutral context on failure.
"""
neutral = {"bearish_flag": 0, "high_vol_flag": 0}
try:
sliced = spy_df.iloc[:sim_day_idx]
if len(sliced) < config.SPY_MA_WINDOW + 5:
return neutral
close = sliced["Close"].dropna()
spy_price = float(close.iloc[-1])
spy_20dma = float(close.rolling(config.SPY_MA_WINDOW).mean().iloc[-1])
daily_ret = close.pct_change().dropna()
spy_vol_20d = float(daily_ret.rolling(config.SPY_VOL_WINDOW).std().iloc[-1])
rolling_vol = daily_ret.rolling(config.SPY_VOL_WINDOW).std().dropna()
lookback = min(len(rolling_vol), config.SPY_HIST_WINDOW)
spy_vol_80pct = float(rolling_vol.iloc[-lookback:].quantile(0.80))
return {
"bearish_flag": int(spy_price < spy_20dma),
"high_vol_flag": int(
spy_vol_20d > config.SPY_HIGH_VOL_FIXED or
spy_vol_20d > spy_vol_80pct
),
"spy_price": spy_price,
"spy_20dma": spy_20dma,
}
except Exception as e:
logger.debug("_compute_spy_context_for_day failed at idx %d: %s", sim_day_idx, e)
return neutral
# ─────────────────────────────────────────────────────────────────────────────
# SIMULATE ONE DAY
# ─────────────────────────────────────────────────────────────────────────────
def _simulate_day(
ticker: str,
df: pd.DataFrame,
spy_df: pd.DataFrame,
sim_day_idx: int,
strategy: str,
weights: Dict[str, float],
) -> Optional[Dict[str, Any]]:
"""
Simulate one trade signal on day T and its outcome on day T+1.
Entry price: T's open (iloc[sim_day_idx]['Open'])
Outcome:
'success' if close[T+1] >= target
'failed' if close[T+1] <= stop
'open' if neither (excluded from win rate)
Returns dict with all trade details, or None if signal can't be generated.
"""
# We need T+1 to evaluate outcome
if sim_day_idx + 1 >= len(df):
return None
# Slice: data up to T-1 (strictly no lookahead)
hist = _slice_for_day(df, sim_day_idx)
if hist is None:
return None
# Compute features on historical slice
feats = sc.compute_features(ticker, hist)
if feats is None:
return None
# Override entry price: use T's open (not last close from hist)
# This simulates a realistic market-open fill
try:
entry_price = float(df.iloc[sim_day_idx]["Open"])
if pd.isna(entry_price) or entry_price <= 0:
return None
feats["entry"] = round(entry_price, 4)
except (IndexError, KeyError):
return None
# Recompute ATR-based stops using actual entry price
atr = feats["atr"]
stop, target, dollar_risk, rr = rk.calculate_trade_risk(entry_price, atr)
# SPY context at T-1
spy_ctx = _compute_spy_context_for_day(spy_df, sim_day_idx)
# Score with current weights
score = sc.compute_score(feats, strategy, weights)
# Apply SPY reduction
if spy_ctx.get("bearish_flag"):
score = round(score * config.SPY_SCORE_REDUCTION, 2)
if spy_ctx.get("high_vol_flag") and strategy == "filter_b":
return None # Signal disabled in high-vol regime
# Outcome: check T+1 close vs stop/target
try:
next_close = float(df.iloc[sim_day_idx + 1]["Close"])
if pd.isna(next_close):
outcome = "open"
elif next_close >= target:
outcome = "success"
elif next_close <= stop:
outcome = "failed"
else:
outcome = "open" # Neither hit β€” excluded from win rate
except (IndexError, KeyError):
outcome = "open"
outcome_pct = None
if outcome in ("success", "failed"):
outcome_pct = (next_close - entry_price) / entry_price
return {
"ticker": ticker,
"strategy": strategy,
"sim_date": df.index[sim_day_idx].strftime("%Y-%m-%d") if hasattr(df.index[sim_day_idx], 'strftime') else str(df.index[sim_day_idx]),
"entry": round(entry_price, 4),
"stop": stop,
"target": target,
"score": score,
"momentum": feats["momentum"],
"volume_spike": feats["volume_spike"],
"volatility": feats["volatility"],
"atr": atr,
"outcome": outcome,
"outcome_pct": round(outcome_pct, 6) if outcome_pct is not None else None,
"spy_bearish": spy_ctx.get("bearish_flag", 0),
"spy_high_vol": spy_ctx.get("high_vol_flag", 0),
}
# ─────────────────────────────────────────────────────────────────────────────
# METRICS COMPUTATION
# ─────────────────────────────────────────────────────────────────────────────
def _compute_metrics(
sim_trades: List[Dict[str, Any]],
strategy: str,
weights: Dict[str, float],
) -> Dict[str, Any]:
"""
Compute win rate, avg return, and max drawdown from simulated trades.
Only 'success' and 'failed' outcomes count toward win rate.
'open' outcomes are excluded (neither stop nor target was hit next day).
Max drawdown:
equity_curve = cumulative product of (1 + return_i) for closed trades
running_peak = expanding maximum of equity_curve
drawdown = (equity_curve - running_peak) / running_peak
max_drawdown = min(drawdown_series) β†’ negative number
Returns metrics dict.
"""
closed = [t for t in sim_trades if t["outcome"] in ("success", "failed")]
total = len(closed)
if total == 0:
return {
"win_rate": None,
"avg_return": None,
"max_drawdown": None,
"trades_sampled": 0,
"open_count": len(sim_trades) - total,
"note": "No closed simulated trades",
}
wins = sum(1 for t in closed if t["outcome"] == "success")
win_rate = wins / total
returns = [t["outcome_pct"] for t in closed if t["outcome_pct"] is not None]
avg_return = float(np.mean(returns)) if returns else None
# Max drawdown via equity curve
max_drawdown = None
if returns:
equity = np.cumprod(1 + np.array(returns))
peak = np.maximum.accumulate(equity)
# Guard against peak = 0 (degenerate)
with np.errstate(divide="ignore", invalid="ignore"):
dd = np.where(peak > 0, (equity - peak) / peak, 0)
max_drawdown = float(np.min(dd)) if len(dd) > 0 else None
open_count = len([t for t in sim_trades if t["outcome"] == "open"])
return {
"strategy": strategy,
"win_rate": round(win_rate, 4),
"avg_return": round(avg_return, 6) if avg_return is not None else None,
"max_drawdown": round(max_drawdown, 6) if max_drawdown is not None else None,
"trades_sampled": total,
"open_count": open_count,
"wins": wins,
"losses": total - wins,
"weights_used": weights.copy(),
"note": (
f"Backtest used weights: "
+ ", ".join(f"{k}={v:.3f}" for k, v in weights.items())
+ ". Results are hypothetical under current weights."
),
}
# ─────────────────────────────────────────────────────────────────────────────
# MAIN BACKTEST RUNNER
# ─────────────────────────────────────────────────────────────────────────────
def run_backtest(
ohlcv: Dict[str, pd.DataFrame],
spy_ohlcv: Optional[pd.DataFrame] = None,
) -> Dict[str, Any]:
"""
Run backtesting-lite over the last BACKTEST_DAYS of available data.
For each ticker Γ— each simulated day:
- Compute features using only past data
- Score using current DB weights
- Simulate outcome using next-day close
Then compute win rate, avg return, max drawdown per strategy.
Save results to metrics table (source='backtest').
Returns full backtest result dict for UI display.
"""
result: Dict[str, Any] = {
"run_date": date.today().isoformat(),
"run_timestamp": datetime.now().isoformat(timespec="seconds"),
"strategies": {},
"all_sim_trades": [],
"error": None,
}
try:
# Load current weights (snapshot for this backtest run)
weights = {s: db.get_weights(s) for s in config.BASE_WEIGHTS.keys()}
result["weights_snapshot"] = weights
# Build SPY DataFrame β€” use provided or extract from ohlcv
if spy_ohlcv is None:
spy_ohlcv = ohlcv.get("SPY")
if spy_ohlcv is None:
# Neutral SPY context if not available
spy_ohlcv = pd.DataFrame(
columns=["Open", "High", "Low", "Close", "Volume"]
)
# Load universe to know which strategy each ticker belongs to
from universe import load_universe
universe_df = load_universe()
ticker_strategy: Dict[str, str] = {}
if not universe_df.empty and "strategy" in universe_df.columns:
ticker_strategy = dict(zip(universe_df["ticker"], universe_df["strategy"]))
all_sim_trades: List[Dict[str, Any]] = []
for ticker, df in ohlcv.items():
if ticker == "SPY" or df is None or df.empty:
continue
strategy = ticker_strategy.get(ticker, "filter_a")
w = weights[strategy]
# Determine simulation window: last BACKTEST_DAYS calendar days
# Convert to approx trading days (5/7 ratio)
trading_days = int(config.BACKTEST_DAYS * 5 / 7)
start_idx = max(0, len(df) - trading_days - 1)
end_idx = len(df) - 1 # leave last bar as T+1 for final sim
for sim_idx in range(start_idx, end_idx):
trade = _simulate_day(ticker, df, spy_ohlcv, sim_idx, strategy, w)
if trade is not None:
all_sim_trades.append(trade)
result["all_sim_trades"] = all_sim_trades
app_logger.info(
"Backtest: %d simulated trades across %d tickers",
len(all_sim_trades), len(ohlcv),
)
# Compute metrics per strategy
today_str = date.today().isoformat()
for strategy in config.BASE_WEIGHTS.keys():
strat_trades = [t for t in all_sim_trades if t["strategy"] == strategy]
metrics = _compute_metrics(strat_trades, strategy, weights[strategy])
result["strategies"][strategy] = metrics
# Save to DB (source='backtest')
if metrics["trades_sampled"] > 0:
db.save_metrics({
"date": today_str,
"strategy": strategy,
"win_rate": metrics["win_rate"],
"avg_return": metrics["avg_return"],
"max_drawdown": metrics["max_drawdown"],
"trades_sampled": metrics["trades_sampled"],
"source": "backtest",
})
except Exception as e:
result["error"] = str(e)
logger.error("run_backtest failed: %s\n%s", e, traceback.format_exc())
return result
def get_latest_backtest_results() -> Dict[str, Any]:
"""
Returns most recent backtest metrics from DB for Page 4 display.
Returns empty dict if no backtest has been run.
"""
results = {}
for strategy in config.BASE_WEIGHTS.keys():
m = db.get_latest_metrics(strategy, source="backtest")
if m:
results[strategy] = m
return results
# ── Self-test ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import database as db
db.init_db()
print("backtest.py self-test (mock data β€” no network)")
print("=" * 55)
# Build realistic mock OHLCV data (200 bars for proper windowing)
np.random.seed(42)
def make_df(n=200, trend=0.001, vol_spike_days=None):
idx = pd.date_range("2023-01-01", periods=n, freq="B")
close = 50.0 * np.cumprod(1 + np.random.randn(n) * 0.012 + trend)
high = close * (1 + abs(np.random.randn(n) * 0.006))
low = close * (1 - abs(np.random.randn(n) * 0.006))
vol = np.full(n, 3_000_000.0)
if vol_spike_days:
for d in vol_spike_days:
if 0 <= d < n:
vol[d] = 9_000_000.0
return pd.DataFrame(
{"Open": close * 0.999, "High": high, "Low": low,
"Close": close, "Volume": vol},
index=idx,
)
mock_spy = make_df(n=200, trend=0.0005)
mock_ohlcv = {
"AAPL": make_df(trend=0.002),
"MSFT": make_df(trend=0.001),
"GOOGL": make_df(trend=0.0015),
"TSLA": make_df(trend=-0.001),
"NVDA": make_df(trend=0.003),
"SPY": mock_spy,
}
# Mock universe CSV
import universe as uni
import unittest.mock as mock
mock_universe = pd.DataFrame([
{"ticker": "AAPL", "strategy": "filter_a", "sector": "Technology"},
{"ticker": "MSFT", "strategy": "filter_a", "sector": "Technology"},
{"ticker": "GOOGL", "strategy": "filter_a", "sector": "Communication"},
{"ticker": "TSLA", "strategy": "filter_a", "sector": "Consumer Discretionary"},
{"ticker": "NVDA", "strategy": "filter_a", "sector": "Technology"},
])
with mock.patch.object(uni, "load_universe", return_value=mock_universe):
print("\n[1] Running backtest...")
result = run_backtest(
{k: v for k, v in mock_ohlcv.items() if k != "SPY"},
spy_ohlcv=mock_spy,
)
if result["error"]:
print(f" ERROR: {result['error']}")
else:
total_sims = len(result["all_sim_trades"])
print(f" Total simulated trades: {total_sims}")
for strategy, metrics in result["strategies"].items():
print(f"\n Strategy: {strategy}")
print(f" Trades sampled: {metrics['trades_sampled']}")
print(f" Open (neither hit): {metrics.get('open_count', 0)}")
if metrics["win_rate"] is not None:
print(f" Win rate: {metrics['win_rate']*100:.1f}%")
print(f" Avg return: {metrics['avg_return']*100:.3f}%" if metrics['avg_return'] else " Avg return: N/A")
print(f" Max drawdown:{metrics['max_drawdown']*100:.2f}%" if metrics['max_drawdown'] else " Max DD: N/A")
print(f" Note: {metrics['note']}")
# Assertions
assert 0 <= metrics["win_rate"] <= 1, "Win rate out of [0,1]"
if metrics["max_drawdown"] is not None:
assert metrics["max_drawdown"] <= 0, "Max drawdown must be <= 0"
else:
print(f" {metrics['note']}")
# [2] Slice test (no-lookahead verification)
print("\n[2] No-lookahead slice test:")
df_test = make_df(n=100)
for t_idx in [30, 50, 80]:
sliced = _slice_for_day(df_test, t_idx)
if sliced is not None:
assert len(sliced) == t_idx, f"Slice length should be t_idx={t_idx}"
assert sliced.index[-1] < df_test.index[t_idx], "Slice must not include sim day"
print(" βœ… Slices end strictly before sim day T")
# [3] SPY context at each sim day
print("\n[3] SPY context slicing:")
for t_idx in [50, 100, 150]:
ctx = _compute_spy_context_for_day(mock_spy, t_idx)
assert "bearish_flag" in ctx
assert "high_vol_flag" in ctx
print(" βœ… SPY context computed without lookahead")
# [4] Latest results from DB
print("\n[4] Latest backtest from DB:")
latest = get_latest_backtest_results()
for strat, m in latest.items():
print(f" {strat}: win_rate={m.get('win_rate')} samples={m.get('trades_sampled')}")
print("\nbacktest.py self-test complete.")