""" Metrics Tracker — computes and persists trading performance metrics from the DB. All metrics are derived directly from the `trades` table on every cycle call. No in-memory trade state is kept: the tracker is stateless across pod restarts. Metric definitions ────────────────── unrealized_pnl_est Estimated PnL for OPEN positions: edge_net × net_cost − fee. Source: open trades with edge_net. Estimated (model signal). realized_pnl Exact PnL for CLOSED positions: computed from resolution. Source: closed trades with known resolution. Exact. total_pnl unrealized_pnl_est + realized_pnl. win_rate Fraction of resolved closed trades with close_pnl > 0. NULL if fewer than 5 resolved trades. calibration_score 1 − AVG((final_prob − resolution)²) on resolved trades. Brier score (higher = better calibration). NULL if < 10 resolved. sharpe_ratio 0.0 — requires a daily-return time series, not yet tracked. """ import logging from datetime import datetime, UTC from bot.data.db import Database from bot.executor.paper import Trade log = logging.getLogger(__name__) class MetricsTracker: def __init__(self, db: Database) -> None: self._db = db async def record_trade(self, trade: Trade) -> None: """Persist a trade to the DB. No in-memory accumulation.""" await self._db.save_trade(trade) log.info("Trade recorded: %s", trade) async def update_daily_summary(self) -> None: """Compute metrics from DB and write a metrics_daily snapshot. Called every cycle by the trading loop. Safe after pod restarts: reads the full trade history from DB, not from in-memory state. """ raw = await self._db.compute_metrics_from_db() if not raw["total_trades"]: return open_count = int(raw["open_count"] or 0) closed_count = int(raw["closed_count"] or 0) resolved = int(raw["resolved_count"] or 0) wins = int(raw["wins_realized"] or 0) unrealized = float(raw["unrealized_pnl_est"] or 0) realized = float(raw["realized_pnl"] or 0) total_deployed = float(raw["total_deployed"] or 0) total_fees = float(raw["total_fees"] or 0) total_pnl = unrealized + realized # win_rate: only over resolved closed trades; null if sample too small win_rate = (wins / resolved) if resolved >= 5 else None # calibration: Brier score from DB; null if sample too small calibration = ( float(raw["calibration_score"]) if raw["calibration_score"] is not None and resolved >= 10 else None ) avg_edge = total_pnl / total_deployed if total_deployed > 0 else 0.0 metrics = { "timestamp": datetime.now(UTC), "total_trades": int(raw["total_trades"]), "open_count": open_count, "closed_count": closed_count, "resolved_count": resolved, "total_deployed": total_deployed, "total_fees": total_fees, "unrealized_pnl_est": unrealized, "realized_pnl": realized, "total_pnl": total_pnl, "win_rate": win_rate, "avg_edge": avg_edge, "sharpe_ratio": 0.0, # requires daily-return series (not yet tracked) "calibration_score": calibration, "paper_mode": True, } await self._db.save_daily_metrics(metrics) log.info( "Daily metrics | trades=%d (open=%d closed=%d resolved=%d) | " "unrealized=$%.2f realized=$%.2f total=$%.2f | " "win_rate=%s calibration=%s", metrics["total_trades"], open_count, closed_count, resolved, unrealized, realized, total_pnl, f"{win_rate:.1%}" if win_rate is not None else "n/a (<5)", f"{calibration:.3f}" if calibration is not None else "n/a (<10)", )