TradeMind / ml /backtest.py
KSS-1227's picture
backend improved
e48dce6
Raw History Blame Contribute Delete
13 kB
# ml/backtest.py
import sys, os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import backtrader as bt
import pandas as pd
import numpy as np
import joblib
from datetime import datetime
from data.fetch_prices import fetch_prices, STOCKS
from ml.technical import add_technical_indicators
from ml.market_relative import fetch_nifty_benchmark, add_market_relative_features
from ml.rf_model import FEATURES # single source of truth β€” see the same
# note in agents/signal_agent.py and
# ml/explain.py. This file's own stale
# 12-feature copy was silently caught by
# the bare `except: label = "HOLD"` below,
# so every backtest run after
# market-relative features were added to
# the model produced a fake "never trade"
# result with NO visible error.
MODEL_PATH = "ml/rf_model.pkl"
SCALER_PATH = "ml/scaler.pkl"
def _build_relative_features_for_symbol(symbol: str, period: str) -> pd.DataFrame:
"""
Market-relative/cross-sectional features need the full peer universe
aligned by date (see ml/market_relative.py) β€” they can't be computed
from one symbol's history in isolation. Fetches all of STOCKS (the
model's training universe β€” same reasoning as agents/research_agent.py
for why STOCKS and not the larger screener universe) + Nifty for the
given period, computes the features across all of them, then returns
just `symbol`'s enriched rows for the rest of the backtest to use.
"""
universe = STOCKS if symbol in STOCKS else STOCKS + [symbol]
all_data = []
for s in universe:
df = fetch_prices(s, period=period)
if df.empty:
continue
df = add_technical_indicators(df)
all_data.append(df)
combined = pd.concat(all_data, ignore_index=True)
nifty_df = fetch_nifty_benchmark(period=period)
combined = add_market_relative_features(combined, nifty_df)
result = combined[combined["symbol"] == symbol].sort_values("Date").reset_index(drop=True)
return result
# ── 1. Generate signals for entire history ─────────────────
def generate_historical_signals(symbol: str, period: str = "2y") -> pd.DataFrame:
"""Run RF model on every row of historical data"""
model = joblib.load(MODEL_PATH)
scaler = joblib.load(SCALER_PATH)
df = _build_relative_features_for_symbol(symbol, period)
if df.empty:
return df
signals = []
fallback_count = 0
fallback_example = None
for i in range(len(df)):
row = df.iloc[i]
try:
X = np.array([[float(row.get(f, 0)) for f in FEATURES]])
X_scaled = scaler.transform(X)
pred = model.predict(X_scaled)[0]
label = {0: "SELL", 1: "HOLD", 2: "BUY"}[pred]
except Exception as e:
label = "HOLD"
fallback_count += 1
if fallback_example is None:
fallback_example = str(e)
signals.append(label)
# A handful of fallbacks is expected (e.g. the first ~20 rows before
# Beta_20d's rolling window has enough history β€” see ml/rf_model.py's
# create_labels() dropna). A LARGE fraction falling back is not normal
# warmup β€” it's the same class of silent bug this fix addresses, so
# it's surfaced loudly here instead of being swallowed again.
fallback_pct = fallback_count / len(df) if len(df) else 0
if fallback_pct > 0.10:
print(f"[Backtest] WARNING: {fallback_count}/{len(df)} rows "
f"({fallback_pct:.0%}) fell back to HOLD due to prediction "
f"errors β€” this is far more than expected warmup NaN loss. "
f"First error: {fallback_example}. Backtest results below "
f"are likely NOT meaningful until this is investigated.")
elif fallback_count > 0:
print(f"[Backtest] {fallback_count}/{len(df)} rows fell back to "
f"HOLD (expected warmup rows, e.g. before rolling features "
f"like Beta_20d have enough history).")
df["signal"] = signals
return df
# ── 2. Backtrader Strategy ─────────────────────────────────
class TradeMindStrategy(bt.Strategy):
params = dict(signals=None, stop_loss_pct=0.05, max_dd_pct=0.15)
def __init__(self):
self.signal_map = {}
if self.params.signals is not None:
for _, row in self.params.signals.iterrows():
date_str = str(row["Date"])[:10]
self.signal_map[date_str] = row["signal"]
self.trades = 0
self.wins = 0
self.trade_log = []
self.entry_price = None
self.peak_value = self.broker.getvalue()
def next(self):
date_str = self.datas[0].datetime.date(0).strftime("%Y-%m-%d")
signal = self.signal_map.get(date_str, "HOLD")
price = self.datas[0].close[0]
# Track portfolio peak for max drawdown check
current_value = self.broker.getvalue()
self.peak_value = max(self.peak_value, current_value)
current_dd = (self.peak_value - current_value) / self.peak_value
# Force exit if max drawdown limit breached
if self.position and current_dd > self.params.max_dd_pct:
self.sell(size=self.position.size)
self.trade_log.append({"date": date_str, "action": "FORCE_EXIT_DD", "price": round(price,2)})
self.entry_price = None
return
# Stop-loss check
if self.position and self.entry_price:
loss_pct = (self.entry_price - price) / self.entry_price
if loss_pct > self.params.stop_loss_pct:
self.sell(size=self.position.size)
self.trade_log.append({"date": date_str, "action": "STOP_LOSS", "price": round(price,2)})
self.entry_price = None
return
if signal == "BUY" and not self.position:
size = int(self.broker.getcash() * 0.95 / price)
if size > 0:
self.buy(size=size)
self.entry_price = price
self.trade_log.append({"date": date_str, "action": "BUY", "price": round(price,2)})
elif signal == "SELL" and self.position:
self.sell(size=self.position.size)
self.trades += 1
if price > (self.entry_price or price):
self.wins += 1
self.trade_log.append({"date": date_str, "action": "SELL", "price": round(price,2)})
self.entry_price = None
# ── 3. Run backtest from an already-built signal DataFrame ─
def run_backtest_from_signals(symbol: str, df: pd.DataFrame,
initial_cash: float = 100000.0,
period: str = "2y") -> dict:
"""
The ML-agnostic core of backtesting: takes a DataFrame that already
has Date/OHLCV columns AND a 'signal' column (BUY/SELL/HOLD per day),
and runs the actual portfolio simulation via backtrader.
Extracted out of run_backtest() so ml/strategy_builder.py's
NL-rule-based backtests can reuse this exact simulation logic
(stop-loss, max-drawdown circuit breaker, commission, Sharpe/drawdown/
win-rate calculation, benchmark comparison) instead of re-implementing
it β€” the simulation itself doesn't care whether the signal came from
the trained RF/XGBoost model or a user-typed rule.
"""
if df.empty:
return {"error": f"No data for {symbol}"}
bt_df = df[["Date", "Open", "High", "Low", "Close", "Volume"]].copy()
if isinstance(bt_df.columns, pd.MultiIndex):
bt_df.columns = bt_df.columns.get_level_values(0)
bt_df["Date"] = pd.to_datetime(bt_df["Date"])
bt_df = bt_df.set_index("Date")
bt_df = bt_df.astype(float)
bt_df = bt_df.dropna()
data_feed = bt.feeds.PandasData(dataname=bt_df)
cerebro = bt.Cerebro()
cerebro.adddata(data_feed)
cerebro.addstrategy(TradeMindStrategy, signals=df)
cerebro.broker.setcash(initial_cash)
cerebro.broker.setcommission(commission=0.001)
cerebro.addanalyzer(bt.analyzers.SharpeRatio,
_name="sharpe", riskfreerate=0.06,
annualize=True, timeframe=bt.TimeFrame.Days)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
cerebro.addanalyzer(bt.analyzers.TimeReturn, _name="returns",
timeframe=bt.TimeFrame.Days)
results = cerebro.run()
strategy = results[0]
final_val = cerebro.broker.getvalue()
sharpe_ratio = strategy.analyzers.sharpe.get_analysis().get("sharperatio", 0) or 0
dd_analysis = strategy.analyzers.drawdown.get_analysis()
max_drawdown = dd_analysis.get("max", {}).get("drawdown", 0)
trade_analysis = strategy.analyzers.trades.get_analysis()
total_trades = trade_analysis.get("total", {}).get("closed", 0)
won_trades = trade_analysis.get("won", {}).get("total", 0)
win_rate = (won_trades / total_trades * 100) if total_trades > 0 else 0
total_return = ((final_val - initial_cash) / initial_cash) * 100
daily_returns = strategy.analyzers.returns.get_analysis()
portfolio_curve = []
value = initial_cash
for date, ret in daily_returns.items():
value = value * (1 + ret)
portfolio_curve.append({
"date": str(date)[:10], "value": round(value, 2),
"return_pct": round((value - initial_cash) / initial_cash * 100, 2)
})
benchmark = generate_benchmark(period)
print(f"Backtest complete for {symbol}")
print(f" Total Return : {round(total_return, 2)}%")
print(f" Sharpe Ratio : {round(sharpe_ratio, 3)}")
print(f" Max Drawdown : {round(max_drawdown, 2)}%")
print(f" Win Rate : {round(win_rate, 2)}%")
print(f" Total Trades : {total_trades}")
return {
"symbol": symbol,
"initial_cash": initial_cash,
"final_value": round(final_val, 2),
"total_return": round(total_return, 2),
"sharpe_ratio": round(float(sharpe_ratio), 3),
"max_drawdown": round(float(max_drawdown), 2),
"win_rate": round(win_rate, 2),
"total_trades": total_trades,
"portfolio_curve": portfolio_curve,
"benchmark": benchmark,
"trade_log": strategy.trade_log[-10:],
}
def run_backtest(symbol: str, initial_cash: float = 100000.0,
period: str = "2y") -> dict:
"""
Run full backtest for a symbol using the TRAINED ML MODEL's signals.
For backtesting a user-typed natural-language rule instead, see
ml/strategy_builder.py's backtest_custom_rule() β€” it reuses
run_backtest_from_signals() below rather than duplicating this logic.
"""
print(f"\nRunning backtest for {symbol}...")
df = generate_historical_signals(symbol, period)
return run_backtest_from_signals(symbol, df, initial_cash, period)
# ── 4. Nifty50 benchmark ───────────────────────────────────
def generate_benchmark(period: str = "2y") -> list:
"""Buy and hold Nifty50 benchmark"""
try:
df = fetch_prices("^NSEI", period=period)
if df.empty:
return []
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
df = df.dropna(subset=["Close"])
initial = float(df["Close"].iloc[0])
result = []
for _, row in df.iterrows():
try:
close = float(row["Close"])
result.append({
"date": str(row["Date"])[:10],
"value": round(100000 * close / initial, 2),
"return_pct": round((close - initial) / initial * 100, 2)
})
except:
continue
return result
except Exception as e:
print(f"Benchmark error: {e}")
return []
if __name__ == "__main__":
result = run_backtest("RELIANCE.NS", period="5y")
print(f"\n{'='*50}")
print(f"BACKTEST RESULTS β€” {result['symbol']}")
print(f"{'='*50}")
print(f"Initial Capital : β‚Ή{result['initial_cash']:,.0f}")
print(f"Final Value : β‚Ή{result['final_value']:,.0f}")
print(f"Total Return : {result['total_return']}%")
print(f"Sharpe Ratio : {result['sharpe_ratio']}")
print(f"Max Drawdown : {result['max_drawdown']}%")
print(f"Win Rate : {result['win_rate']}%")
print(f"Total Trades : {result['total_trades']}")
print(f"\nLast 5 trades:")
for t in result["trade_log"][-5:]:
print(f" {t['date']} β€” {t['action']} @ β‚Ή{t['price']}")