Spaces:
Sleeping
Sleeping
| """ | |
| backtest.py — walk-forward historical simulation for CryptoNAV Testnet. | |
| Implemented in pure pandas/numpy rather than backtesting.py or vectorbt: | |
| "simpler is better" — zero extra dependencies (vectorbt drags numba, | |
| backtesting.py drags bokeh, both flaky on HF Spaces), full control over | |
| the no-lookahead rules, and the engine is ~150 lines you can audit. | |
| Public API (as requested): | |
| generate_walkforward_forecast(df, retrain_every, min_train) | |
| -> df with a 'forecast' column (probability up, 0-100) + meta dict | |
| run_backtest(df, forecast_col, price_col, **params) | |
| -> (metrics: dict, equity_curve: DataFrame, trades: DataFrame) | |
| No-lookahead guarantees: | |
| * walk-forward: the model that scores candle t was trained ONLY on | |
| candles strictly before its block start | |
| * execution: a signal read at candle t's close executes at candle | |
| t+1's OPEN — you never trade a price the signal already saw | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| import engine | |
| try: | |
| from xgboost import XGBClassifier | |
| HAVE_XGB = True | |
| except ImportError: | |
| HAVE_XGB = False | |
| # =================================================================== | |
| # 1. walk-forward forecast generation | |
| # =================================================================== | |
| def generate_walkforward_forecast( | |
| df: pd.DataFrame, | |
| retrain_every: int = 168, # retrain cadence in candles (1h bars: weekly) | |
| min_train: int = 300, # candles required before the first model | |
| ) -> tuple[pd.DataFrame, dict]: | |
| """ | |
| Adds a 'forecast' column: P(up over next 4 candles) in percent. | |
| Real walk-forward: for each block of `retrain_every` candles, an | |
| XGBoost model is trained on everything BEFORE the block, then scores | |
| the block. Candles before `min_train` get NaN (skipped by the | |
| backtester). If xgboost is unavailable, falls back to a 5-day price | |
| momentum dummy forecast and flags it so the UI can warn the user. | |
| """ | |
| feat = engine.build_features(df).copy() | |
| fwd = feat["Close"].shift(-engine.FORECAST_CANDLES) / feat["Close"] - 1 | |
| feat["_y"] = (fwd * 100 > engine.FEE_BUFFER_PCT).astype(int) | |
| feat["forecast"] = np.nan | |
| meta = {"mode": "walkforward-xgb", "retrains": 0, "fallback": False} | |
| if not HAVE_XGB: | |
| meta.update(mode="momentum-fallback", fallback=True) | |
| bars_5d = _bars_per_day(df) * 5 | |
| mom = feat["Close"].pct_change(max(2, bars_5d)) | |
| # squash momentum into a 0-100 pseudo-probability | |
| feat["forecast"] = 50 + 35 * np.tanh(mom * 25) | |
| return feat, meta | |
| cols = engine.FEATURES | |
| n = len(feat) | |
| start = max(min_train, 60) | |
| for block_start in range(start, n, retrain_every): | |
| # training set: strictly before this block, with matured labels only | |
| train = feat.iloc[:block_start].dropna(subset=cols) | |
| train = train.iloc[: -engine.FORECAST_CANDLES] # drop unlabeled tail | |
| if len(train) < 150: | |
| continue | |
| model = XGBClassifier( | |
| n_estimators=150, max_depth=4, learning_rate=0.08, | |
| subsample=0.9, colsample_bytree=0.8, | |
| eval_metric="logloss", n_jobs=2, | |
| ) | |
| model.fit(train[cols], train["_y"]) | |
| meta["retrains"] += 1 | |
| block = feat.iloc[block_start: block_start + retrain_every] | |
| X = block[cols].fillna(0) | |
| feat.loc[block.index, "forecast"] = model.predict_proba(X)[:, 1] * 100 | |
| if feat["forecast"].notna().sum() == 0: # not enough history for WF | |
| meta.update(mode="momentum-fallback", fallback=True) | |
| bars_5d = _bars_per_day(df) * 5 | |
| mom = feat["Close"].pct_change(max(2, bars_5d)) | |
| feat["forecast"] = 50 + 35 * np.tanh(mom * 25) | |
| return feat, meta | |
| def _bars_per_day(df) -> int: | |
| if len(df) < 3: | |
| return 24 | |
| sec = (df.index[1] - df.index[0]).total_seconds() or 3600 | |
| return max(1, int(round(86400 / sec))) | |
| # =================================================================== | |
| # 2. the backtest engine | |
| # =================================================================== | |
| def run_backtest( | |
| df: pd.DataFrame, | |
| forecast_col: str = "forecast", | |
| price_col: str = "Close", | |
| initial_capital: float = 10_000.0, | |
| position_pct: float = 0.20, # fraction of equity per entry | |
| fee_pct: float = 0.001, # per side, includes slippage | |
| buy_threshold: float = 60.0, | |
| sell_threshold: float = 40.0, | |
| ): | |
| """ | |
| Long-only signal-following simulation. | |
| forecast > buy_threshold and flat -> BUY next bar's open | |
| forecast < sell_threshold and long -> SELL next bar's open | |
| NaN forecasts are skipped (no decision that bar). | |
| Returns (metrics dict, equity_curve DataFrame, trades DataFrame). | |
| """ | |
| d = df.dropna(subset=[price_col]).copy() | |
| if len(d) < 10: | |
| return _empty_result(initial_capital) | |
| has_open = "Open" in d.columns | |
| cash = initial_capital | |
| qty = 0.0 | |
| entry_price = entry_cost = 0.0 | |
| entry_time = None | |
| trades, eq_times, eq_vals, pos_flags = [], [], [], [] | |
| n = len(d) | |
| for i in range(n): | |
| price = float(d[price_col].iloc[i]) | |
| # mark-to-market equity at this bar | |
| eq_times.append(d.index[i]) | |
| eq_vals.append(cash + qty * price) | |
| pos_flags.append(1 if qty > 0 else 0) | |
| if i >= n - 1: | |
| break # last bar: nothing can execute "next bar" | |
| f = d[forecast_col].iloc[i] | |
| if pd.isna(f): | |
| continue # NaN forecast: skip this bar | |
| # execution price = NEXT bar's open (fallback: next close) | |
| nxt = float(d["Open"].iloc[i + 1]) if has_open \ | |
| else float(d[price_col].iloc[i + 1]) | |
| if qty == 0 and f > buy_threshold: | |
| stake = (cash + qty * price) * position_pct | |
| stake = min(stake, cash) | |
| if stake <= 0: | |
| continue | |
| qty = stake * (1 - fee_pct) / nxt | |
| cash -= stake | |
| entry_price, entry_cost, entry_time = nxt, stake, d.index[i + 1] | |
| elif qty > 0 and f < sell_threshold: | |
| proceeds = qty * nxt * (1 - fee_pct) | |
| pnl = proceeds - entry_cost | |
| trades.append({ | |
| "Entry Time": entry_time, "Exit Time": d.index[i + 1], | |
| "Entry": round(entry_price, 4), "Exit": round(nxt, 4), | |
| "PnL": round(pnl, 2), | |
| "PnL %": round(pnl / entry_cost * 100, 2), | |
| "Result": "WIN" if pnl >= 0 else "LOSS", | |
| }) | |
| cash += proceeds | |
| qty = 0.0 | |
| # close any open position at the final bar for accounting | |
| if qty > 0: | |
| last = float(d[price_col].iloc[-1]) | |
| proceeds = qty * last * (1 - fee_pct) | |
| pnl = proceeds - entry_cost | |
| trades.append({ | |
| "Entry Time": entry_time, "Exit Time": d.index[-1], | |
| "Entry": round(entry_price, 4), "Exit": round(last, 4), | |
| "PnL": round(pnl, 2), | |
| "PnL %": round(pnl / entry_cost * 100, 2), | |
| "Result": ("WIN" if pnl >= 0 else "LOSS") + " (open at end)", | |
| }) | |
| cash += proceeds | |
| eq_vals[-1] = cash | |
| equity = pd.DataFrame({"equity": eq_vals, "in_position": pos_flags}, | |
| index=pd.Index(eq_times, name="time")) | |
| # buy & hold benchmark on the same window, same fees both sides | |
| p0, p1 = float(d[price_col].iloc[0]), float(d[price_col].iloc[-1]) | |
| equity["buy_hold"] = ( | |
| initial_capital * (1 - fee_pct) | |
| * d[price_col].values[: len(equity)] / p0 | |
| ) | |
| bh_final = initial_capital * (1 - fee_pct) * (p1 / p0) * (1 - fee_pct) | |
| trades_df = pd.DataFrame(trades) | |
| metrics = _metrics(equity["equity"], trades_df, initial_capital, | |
| bh_final, _bars_per_day(d)) | |
| return metrics, equity, trades_df | |
| # =================================================================== | |
| # 3. performance metrics | |
| # =================================================================== | |
| def _metrics(eq: pd.Series, trades: pd.DataFrame, cap0: float, | |
| bh_final: float, bars_per_day: int) -> dict: | |
| final = float(eq.iloc[-1]) | |
| total_ret = (final / cap0 - 1) * 100 | |
| n_bars = len(eq) | |
| years = max(n_bars / (bars_per_day * 365), 1e-9) | |
| ann_ret = ((final / cap0) ** (1 / years) - 1) * 100 if final > 0 else -100.0 | |
| rets = eq.pct_change().dropna() | |
| sharpe = 0.0 | |
| if len(rets) > 2 and rets.std() > 0: | |
| sharpe = float(rets.mean() / rets.std() | |
| * np.sqrt(bars_per_day * 365)) | |
| dd = (eq / eq.cummax() - 1).min() * 100 | |
| n_tr = len(trades) | |
| if n_tr: | |
| wins = trades[trades["PnL"] >= 0]["PnL"] | |
| losses = trades[trades["PnL"] < 0]["PnL"] | |
| win_rate = len(wins) / n_tr * 100 | |
| gross_win, gross_loss = wins.sum(), -losses.sum() | |
| pf = (gross_win / gross_loss) if gross_loss > 0 else float("inf") | |
| else: | |
| win_rate, pf = 0.0, 0.0 | |
| return { | |
| "Total Return %": round(total_ret, 2), | |
| "Annualized Return %": round(ann_ret, 2), | |
| "Sharpe Ratio": round(sharpe, 2), | |
| "Max Drawdown %": round(float(dd), 2), | |
| "Win Rate %": round(win_rate, 1), | |
| "Profit Factor": (round(pf, 2) if pf != float("inf") else "∞"), | |
| "Trades": n_tr, | |
| "Final Equity": round(final, 2), | |
| "Buy & Hold Return %": round((bh_final / cap0 - 1) * 100, 2), | |
| "Strategy vs B&H (pp)": round(total_ret - (bh_final / cap0 - 1) * 100, 2), | |
| } | |
| def _empty_result(cap0): | |
| return ( | |
| {"Total Return %": 0, "Annualized Return %": 0, "Sharpe Ratio": 0, | |
| "Max Drawdown %": 0, "Win Rate %": 0, "Profit Factor": 0, | |
| "Trades": 0, "Final Equity": cap0, "Buy & Hold Return %": 0, | |
| "Strategy vs B&H (pp)": 0}, | |
| pd.DataFrame(columns=["equity", "in_position", "buy_hold"]), | |
| pd.DataFrame(), | |
| ) | |