SiddharthVenba's picture
Initial commit for HF Space
75d9b3c
Raw
History Blame Contribute Delete
7.66 kB
import logging
import time
import pandas as pd
import numpy as np
from datetime import datetime
from backend.agents.signal_agent import detect_signals
from backend.agents.regime_agent import detect_regime, filter_signals_by_regime
from backend.agents.risk_agent import evaluate_risk
from backend.agents.portfolio_agent import size_position
from backend.agents.alpha_scorer import compute_alpha_score
from backend.features.feature_store import compute_universe_features
from config import BACKTEST_PARAMS as BP
from config import RISK_PARAMS as RP
logger = logging.getLogger(__name__)
def run_portfolio_backtest(tickers: list[str], period: str = "2y", initial_capital: float = 10_00_000):
"""
Run a walk-forward portfolio backtest.
Simulates the daily scan pipeline over historical data.
"""
start_time = time.time()
logger.info(f"🚀 Starting Walk-Forward Backtest: {len(tickers)} tickers, period={period}")
# 1. Fetch data and compute all features upfront
# We get a list of dicts: {"ticker": str, "df": DataFrame, "latest": dict}
# For backtesting, we'll use the 'df' which contains the full history
feature_results = compute_universe_features(tickers, period=period)
if not feature_results:
return {"error": "No data found for backtest"}
# Find common date range across all tickers
all_dates = []
for r in feature_results:
all_dates.extend(r["df"].index.tolist())
unique_dates = sorted(list(set(all_dates)))
# Start from index 100 to ensure indicators (like SMA200) are warmed up
test_dates = unique_dates[100:]
logger.info(f"Backtesting over {len(test_dates)} trading days...")
# Portfolio State
capital = initial_capital
portfolio_value = initial_capital
active_trades = [] # List of trade dicts
equity_curve = []
trade_log = []
# 2. Walk-forward loop (Day by Day)
for i, current_date in enumerate(test_dates):
# Update current portfolio value based on latest prices
current_equity = capital
still_active = []
for trade in active_trades:
ticker = trade["ticker"]
# Get price at current_date for this ticker
ticker_data = next((r["df"] for r in feature_results if r["ticker"] == ticker), None)
if ticker_data is not None and current_date in ticker_data.index:
current_price = ticker_data.loc[current_date, "Close"]
high_price = ticker_data.loc[current_date, "High"]
low_price = ticker_data.loc[current_date, "Low"]
# Check Stop Loss / Targets
if low_price <= trade["stop_loss"]:
# SL Hit
exit_price = trade["stop_loss"]
pnl = (exit_price - trade["entry_price"]) * trade["quantity"]
capital += (trade["quantity"] * exit_price)
trade_log.append({**trade, "exit_date": current_date, "exit_price": exit_price, "pnl": pnl, "status": "SL"})
elif high_price >= trade["target_1"]:
# Target 1 Hit (Simplification: Exit 50% at T1, 50% at T2 or SL)
# For this V1, let's just exit full at Target 1
exit_price = trade["target_1"]
pnl = (exit_price - trade["entry_price"]) * trade["quantity"]
capital += (trade["quantity"] * exit_price)
trade_log.append({**trade, "exit_date": current_date, "exit_price": exit_price, "pnl": pnl, "status": "TP1"})
else:
# Trade still open
current_equity += (trade["quantity"] * current_price)
still_active.append(trade)
else:
# No data for this day, assume trade continues
still_active.append(trade)
active_trades = still_active
portfolio_value = current_equity
equity_curve.append({"date": current_date, "equity": portfolio_value})
# --- Daily Scan Simulation ---
# 1. Detect signals for this day
daily_signals = []
for r in feature_results:
ticker = r["ticker"]
df_until_now = r["df"].loc[:current_date]
if len(df_until_now) < 2 or current_date not in df_until_now.index:
continue
latest_row = df_until_now.iloc[-1].to_dict()
# Wrap detect_signals to only see history
signals = detect_signals(ticker, latest_row, df_until_now)
daily_signals.extend(signals)
if not daily_signals:
continue
# 2. Simple Regime (Backtest version)
# Note: In a real backtest, we'd fetch index data for each date.
# For V1, we assume a neutral regime or use a simplified index check if available.
regime = {"regime": "trend_up", "confidence": 0.8, "valid_signals": ["momentum_breakout", "golden_cross", "macd_bullish_cross", "volume_breakout"]}
# 3. Filter & Score
# (Filtering logic simplified for backtest)
scored_signals = []
for s in daily_signals:
ticker = s["ticker"]
r = next((res for res in feature_results if res["ticker"] == ticker), None)
latest_features = r["df"].loc[:current_date].iloc[-1].to_dict()
# Risk check
risk = evaluate_risk(s, latest_features, active_trades)
if risk["decision"] in ("approve", "reduce"):
s["alpha_score"] = compute_alpha_score(s, latest_features)
scored_signals.append(s)
scored_signals.sort(key=lambda x: x["alpha_score"], reverse=True)
# 4. Execute Top Signals (Limited by available capital)
for s in scored_signals[:3]: # Max 3 new trades per day
if any(t["ticker"] == s["ticker"] for t in active_trades):
continue # Already in this stock
# Size position
s = size_position(s, portfolio_value, active_trades)
cost = s["quantity"] * s["entry_price"]
if cost > 0 and capital >= cost:
capital -= cost
active_trades.append({**s, "entry_date": current_date})
# 3. Performance Metrics
duration = time.time() - start_time
performance = _calculate_metrics(equity_curve, trade_log, initial_capital)
logger.info(f"✅ Backtest Complete in {duration:.1f}s. Final Equity: {portfolio_value:,.0f}")
return {
"metrics": performance,
"equity_curve": equity_curve,
"trades": trade_log,
"duration_seconds": duration
}
def _calculate_metrics(equity_curve, trade_log, initial_capital):
if not equity_curve:
return {}
df_equity = pd.DataFrame(equity_curve)
final_equity = df_equity["equity"].iloc[-1]
total_return_pct = (final_equity / initial_capital - 1) * 100
# Drawdown
df_equity["peak"] = df_equity["equity"].cummax()
df_equity["drawdown"] = (df_equity["equity"] - df_equity["peak"]) / df_equity["peak"] * 100
max_drawdown = df_equity["drawdown"].min()
# Trade stats
df_trades = pd.DataFrame(trade_log)
win_rate = 0
if not df_trades.empty:
win_rate = (df_trades["pnl"] > 0).mean() * 100
return {
"total_return_pct": round(total_return_pct, 2),
"max_drawdown_pct": round(max_drawdown, 2),
"win_rate_pct": round(win_rate, 1),
"total_trades": len(trade_log),
"final_equity": round(final_equity, 2)
}