Spaces:
Running on Zero
Running on Zero
| """Performance metrics, computed from the equity curve and trade list. | |
| Every number the UI shows comes from this module, so each one is traceable to | |
| an equity curve or a trade row rather than to a library's internal accounting. | |
| Metrics are pure functions of their inputs -- no globals, no randomness. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from dataclasses import dataclass, asdict, field | |
| import numpy as np | |
| import pandas as pd | |
| TRADING_DAYS = 252.0 | |
| class Metrics: | |
| """One performance summary. `segment` names the slice it describes.""" | |
| segment: str = "all" | |
| start_ts: str | None = None | |
| end_ts: str | None = None | |
| bars: int = 0 | |
| total_return: float = 0.0 | |
| cagr: float = 0.0 | |
| sharpe: float = 0.0 | |
| sortino: float = 0.0 | |
| max_drawdown: float = 0.0 | |
| volatility: float = 0.0 | |
| win_rate: float = 0.0 | |
| profit_factor: float = 0.0 | |
| exposure: float = 0.0 | |
| trade_count: int = 0 | |
| avg_win: float = 0.0 | |
| avg_loss: float = 0.0 | |
| avg_r: float = 0.0 | |
| best_trade: float = 0.0 | |
| worst_trade: float = 0.0 | |
| costs_paid: float = 0.0 | |
| gross_pnl: float = 0.0 | |
| net_pnl: float = 0.0 | |
| def to_dict(self) -> dict: | |
| return asdict(self) | |
| def _clean_returns(equity: pd.Series) -> pd.Series: | |
| r = equity.astype("float64").pct_change() | |
| return r.replace([np.inf, -np.inf], np.nan).dropna() | |
| def total_return(equity: pd.Series) -> float: | |
| if len(equity) < 2 or equity.iloc[0] == 0: | |
| return 0.0 | |
| return float(equity.iloc[-1] / equity.iloc[0] - 1.0) | |
| def cagr(equity: pd.Series, bars_per_year: float) -> float: | |
| if len(equity) < 2 or equity.iloc[0] <= 0 or equity.iloc[-1] <= 0: | |
| return 0.0 | |
| years = (len(equity) - 1) / bars_per_year | |
| if years <= 0: | |
| return 0.0 | |
| return float((equity.iloc[-1] / equity.iloc[0]) ** (1.0 / years) - 1.0) | |
| def sharpe(equity: pd.Series, bars_per_year: float, rf: float = 0.0) -> float: | |
| r = _clean_returns(equity) | |
| if len(r) < 2: | |
| return 0.0 | |
| excess = r - (rf / bars_per_year) | |
| sd = float(excess.std(ddof=1)) | |
| if sd == 0 or not math.isfinite(sd): | |
| return 0.0 | |
| return float(excess.mean() / sd * math.sqrt(bars_per_year)) | |
| def sortino(equity: pd.Series, bars_per_year: float, rf: float = 0.0) -> float: | |
| r = _clean_returns(equity) | |
| if len(r) < 2: | |
| return 0.0 | |
| excess = r - (rf / bars_per_year) | |
| downside = excess[excess < 0] | |
| if len(downside) == 0: | |
| return 0.0 | |
| dd = float(np.sqrt((downside ** 2).mean())) | |
| if dd == 0 or not math.isfinite(dd): | |
| return 0.0 | |
| return float(excess.mean() / dd * math.sqrt(bars_per_year)) | |
| def volatility(equity: pd.Series, bars_per_year: float) -> float: | |
| r = _clean_returns(equity) | |
| if len(r) < 2: | |
| return 0.0 | |
| return float(r.std(ddof=1) * math.sqrt(bars_per_year)) | |
| def drawdown_series(equity: pd.Series) -> pd.Series: | |
| if equity.empty: | |
| return equity | |
| peak = equity.cummax() | |
| return equity / peak - 1.0 | |
| def max_drawdown(equity: pd.Series) -> float: | |
| if len(equity) < 2: | |
| return 0.0 | |
| dd = drawdown_series(equity) | |
| return float(dd.min()) if len(dd) else 0.0 | |
| def rolling_sharpe(equity: pd.Series, window: int, bars_per_year: float) -> pd.Series: | |
| r = _clean_returns(equity) | |
| if len(r) < window: | |
| return pd.Series(dtype="float64", index=pd.DatetimeIndex([], tz="UTC")) | |
| mean = r.rolling(window).mean() | |
| sd = r.rolling(window).std(ddof=1) | |
| out = (mean / sd.replace(0.0, np.nan)) * math.sqrt(bars_per_year) | |
| return out.dropna() | |
| def underwater(equity: pd.Series) -> pd.Series: | |
| return drawdown_series(equity) | |
| def exposure(position: pd.Series) -> float: | |
| """Fraction of bars holding a non-zero position.""" | |
| if position is None or len(position) == 0: | |
| return 0.0 | |
| return float((position.abs() > 1e-12).mean()) | |
| # -------------------------------------------------------------------------- | |
| # Trade-derived statistics | |
| # -------------------------------------------------------------------------- | |
| def trade_stats(trades: pd.DataFrame) -> dict: | |
| """Win rate, profit factor and friends from the trade list.""" | |
| empty = { | |
| "trade_count": 0, "win_rate": 0.0, "profit_factor": 0.0, | |
| "avg_win": 0.0, "avg_loss": 0.0, "avg_r": 0.0, | |
| "best_trade": 0.0, "worst_trade": 0.0, | |
| "costs_paid": 0.0, "gross_pnl": 0.0, "net_pnl": 0.0, | |
| } | |
| if trades is None or trades.empty: | |
| return empty | |
| net = trades["net_pnl"].astype("float64") | |
| wins = net[net > 0] | |
| losses = net[net < 0] | |
| gross_profit = float(wins.sum()) | |
| gross_loss = float(-losses.sum()) | |
| if gross_loss > 0: | |
| pf = gross_profit / gross_loss | |
| elif gross_profit > 0: | |
| pf = float("inf") | |
| else: | |
| pf = 0.0 | |
| r_vals = trades["r_multiple"].replace([np.inf, -np.inf], np.nan).dropna() \ | |
| if "r_multiple" in trades.columns else pd.Series(dtype="float64") | |
| return { | |
| "trade_count": int(len(trades)), | |
| "win_rate": float(len(wins) / len(net)) if len(net) else 0.0, | |
| "profit_factor": float(pf), | |
| "avg_win": float(wins.mean()) if len(wins) else 0.0, | |
| "avg_loss": float(losses.mean()) if len(losses) else 0.0, | |
| "avg_r": float(r_vals.mean()) if len(r_vals) else 0.0, | |
| "best_trade": float(net.max()), | |
| "worst_trade": float(net.min()), | |
| "costs_paid": float(trades["costs"].sum()) if "costs" in trades.columns else 0.0, | |
| "gross_pnl": float(trades["gross_pnl"].sum()) if "gross_pnl" in trades.columns else 0.0, | |
| "net_pnl": float(net.sum()), | |
| } | |
| def compute_metrics( | |
| equity: pd.Series, | |
| trades: pd.DataFrame | None, | |
| bars_per_year: float, | |
| *, | |
| segment: str = "all", | |
| position: pd.Series | None = None, | |
| ) -> Metrics: | |
| """Assemble the full metric set for one equity slice.""" | |
| equity = equity.dropna() | |
| m = Metrics( | |
| segment=segment, | |
| start_ts=str(equity.index[0]) if len(equity) else None, | |
| end_ts=str(equity.index[-1]) if len(equity) else None, | |
| bars=int(len(equity)), | |
| total_return=total_return(equity), | |
| cagr=cagr(equity, bars_per_year), | |
| sharpe=sharpe(equity, bars_per_year), | |
| sortino=sortino(equity, bars_per_year), | |
| max_drawdown=max_drawdown(equity), | |
| volatility=volatility(equity, bars_per_year), | |
| exposure=exposure(position) if position is not None else 0.0, | |
| ) | |
| for k, v in trade_stats(trades).items(): | |
| setattr(m, k, v) | |
| return m | |
| # -------------------------------------------------------------------------- | |
| # Forecast quality (used by comparisons/ in Phase 2) | |
| # -------------------------------------------------------------------------- | |
| def calibration_coverage( | |
| actual: pd.Series, lower: pd.Series, upper: pd.Series | |
| ) -> float: | |
| """Empirical coverage: share of actuals inside [lower, upper]. | |
| A well-calibrated q10-q90 band should cover ~0.80 of outcomes. | |
| """ | |
| df = pd.concat([actual, lower, upper], axis=1).dropna() | |
| if df.empty: | |
| return float("nan") | |
| a, lo, hi = df.iloc[:, 0], df.iloc[:, 1], df.iloc[:, 2] | |
| return float(((a >= lo) & (a <= hi)).mean()) | |
| def calibration_error(coverage: float, nominal: float = 0.80) -> float: | |
| """Signed miss against the nominal band width. 0.0 is perfect.""" | |
| if not math.isfinite(coverage): | |
| return float("nan") | |
| return float(coverage - nominal) | |
| def directional_accuracy(actual_next: pd.Series, predicted_next: pd.Series, | |
| reference: pd.Series) -> float: | |
| """Share of *directional calls* that matched the realised direction. | |
| Bars where the forecast is exactly flat are excluded, not counted as | |
| misses. A random-walk forecast predicts "no change" every bar; it is never | |
| wrong about direction because it never claims one. Scoring it 0% would say | |
| it is always wrong, which is a different and false statement. When a model | |
| never takes a side, its directional accuracy is undefined and returns NaN. | |
| """ | |
| df = pd.concat([actual_next, predicted_next, reference], axis=1).dropna() | |
| if df.empty: | |
| return float("nan") | |
| a, p, ref = df.iloc[:, 0], df.iloc[:, 1], df.iloc[:, 2] | |
| actual_dir = np.sign(a - ref) | |
| pred_dir = np.sign(p - ref) | |
| mask = (actual_dir != 0) & (pred_dir != 0) | |
| if not mask.any(): | |
| return float("nan") | |
| return float((actual_dir[mask] == pred_dir[mask]).mean()) | |
| def pinball_loss(actual: pd.Series, pred: pd.Series, q: float) -> float: | |
| """Quantile (pinball) loss -- lower is better.""" | |
| df = pd.concat([actual, pred], axis=1).dropna() | |
| if df.empty: | |
| return float("nan") | |
| a, p = df.iloc[:, 0], df.iloc[:, 1] | |
| diff = a - p | |
| return float(np.maximum(q * diff, (q - 1) * diff).mean()) | |