feat(metrics): Fix 3 — DB-computed metrics, stateless tracker, resolution tracking
CI/CD / build-and-push (push) Successful in 1m47s

schema.sql
  trades:        + close_pnl, resolution (market outcome storage)
  metrics_daily: + unrealized_pnl_est, realized_pnl, open/closed/resolved_count

db.py
  close_paper_position(): accepts resolution; computes close_pnl in SQL
    BUY_YES: (resolution − entry_price) × shares
    BUY_NO:  ((1 − resolution) − entry_price) × shares
  save_daily_metrics(): persists new columns
  compute_metrics_from_db(): single DB query for all metrics; no in-memory state

tracker.py — complete rewrite (stateless)
  Removed self._trades, self._daily_returns, compute_metrics(), _compute_sharpe(),
  check_promotion_thresholds(), _empty_metrics()
  update_daily_summary() now reads compute_metrics_from_db() every cycle
  Safe across pod restarts: always reflects full DB history

paper.py
  close_position(): passes resolution to close_paper_position()

api/main.py  /api/summary
  Added unrealized_pnl_est (estimated, open trades) and realized_pnl (exact,
  closed+resolved) as separate fields alongside total_pnl
  win_rate: null if < 5 resolved trades (was proxy on entry_price < 0.5)
  calibration_score: Brier-based, null if < 10 resolved trades
  resolved_count exposed as field
  Each field annotated with: exact/estimated, source, null conditions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-04-21 17:34:48 +00:00
parent 9b62636a3e
commit 9a5be27532
5 changed files with 268 additions and 160 deletions
+51 -26
View File
@@ -89,43 +89,68 @@ async def get_trades(limit: int = 50, status: str = "open"):
@app.get("/api/summary") @app.get("/api/summary")
async def get_summary(): async def get_summary():
"""Dashboard summary card data.""" """Dashboard summary card data.
history = await db.get_metrics_history(days=1)
open_trades, all_trades, inverted, legacy_count = await asyncio.gather( All portfolio counts (total_trades, open_trades_count, total_deployed,
cash_available) are computed live from the DB on every request.
PnL and performance metrics come from the latest metrics_daily snapshot,
which is written by the bot every cycle via MetricsTracker.update_daily_summary().
After Fix 3, that snapshot is also DB-computed — not dependent on pod restarts.
"""
latest_metrics, open_trades, all_trades, inverted, legacy_count = await asyncio.gather(
db.get_metrics_history(days=1),
db.get_recent_trades(limit=500, status="open"), db.get_recent_trades(limit=500, status="open"),
db.get_recent_trades(limit=500), db.get_recent_trades(limit=500),
db.get_recently_closed_inverted(hours=24), db.get_recently_closed_inverted(hours=24),
db.get_legacy_incomplete_count(), db.get_legacy_incomplete_count(),
) )
latest = history[0] if history else {} latest = latest_metrics[0] if latest_metrics else {}
paper_bankroll = float(os.getenv("PAPER_BANKROLL", "10000")) paper_bankroll = float(os.getenv("PAPER_BANKROLL", "10000"))
total_deployed = sum(t.get("net_cost", 0) for t in open_trades) total_deployed = sum(t.get("net_cost", 0) for t in open_trades)
return { return {
"paper_mode": os.getenv("PAPER_MODE", "true") == "true", # ── Portfolio state (live from DB) ──────────────────────────────────
"paper_bankroll": paper_bankroll, "paper_mode": os.getenv("PAPER_MODE", "true") == "true",
"total_trades": len(all_trades), "paper_bankroll": paper_bankroll,
"open_trades_count": len(open_trades), "total_trades": len(all_trades), # exact, from DB
"closed_trades_count": len(all_trades) - len(open_trades), "open_trades_count": len(open_trades), # exact, from DB
"total_deployed": total_deployed, "closed_trades_count": len(all_trades) - len(open_trades), # exact
"cash_available": max(0.0, paper_bankroll - total_deployed), "total_deployed": total_deployed, # exact, from DB
"legacy_incomplete_count": legacy_count, "cash_available": max(0.0, paper_bankroll - total_deployed), # exact
"reentry_guard_blocks_24h": len(inverted), "legacy_incomplete_count": legacy_count, # exact, from DB
# Metrics from latest metrics_daily snapshot (computed by MetricsTracker). "reentry_guard_blocks_24h": len(inverted), # exact, from DB
# total_pnl: estimated unrealized PnL for open trades in the current bot
# session — uses edge_net × net_cost (model edge on deployed # ── P&L (from latest metrics_daily snapshot) ────────────────────────
# capital). Resets to 0 on pod restart until Fix 3 is applied. # unrealized_pnl_est: open positions, edge_net × net_cost fee.
# calibration_score: null until market resolution data is available # Estimated — uses model signal, not live price. Source: open trades.
# (requires close_price / outcome per closed trade). # realized_pnl: closed positions with known resolution.
"total_pnl": latest.get("total_pnl", 0), # Exact — computed from (resolution entry_price) × shares.
"win_rate": latest.get("win_rate", 0), # total_pnl: sum of both.
"sharpe_ratio": latest.get("sharpe_ratio", 0), "unrealized_pnl_est": latest.get("unrealized_pnl_est") or 0,
"calibration_score": latest.get("calibration_score"), # null if unavailable "realized_pnl": latest.get("realized_pnl") or 0,
"total_pnl": latest.get("total_pnl") or 0,
# ── Performance metrics (from latest metrics_daily snapshot) ─────────
# win_rate: fraction of resolved closed trades where close_pnl > 0.
# null if fewer than 5 resolved trades. Source: closed+resolved trades.
# sharpe_ratio: 0.0 — requires daily-return time series (not yet tracked).
# calibration_score: 1 Brier score on resolved trades (higher = better).
# null if fewer than 10 resolved trades. Source: closed+resolved trades.
"win_rate": latest.get("win_rate"), # null if < 5 resolved
"sharpe_ratio": latest.get("sharpe_ratio") or 0, # 0.0 until tracked
"calibration_score": latest.get("calibration_score"), # null if < 10 resolved
# ── Counters from snapshot ───────────────────────────────────────────
"resolved_count": latest.get("resolved_count") or 0,
# ── Promotion gate ───────────────────────────────────────────────────
# All thresholds must pass; null metrics count as not-ready.
"promotion_ready": ( "promotion_ready": (
latest.get("sharpe_ratio", 0) >= 0.5 (latest.get("sharpe_ratio") or 0) >= 0.5
and latest.get("win_rate", 0) >= 0.52 and (latest.get("win_rate") or 0) >= 0.52
and (latest.get("calibration_score") or 0) >= 0.7 # null → not ready and (latest.get("calibration_score") or 0) >= 0.7
and len(all_trades) >= 50 and len(all_trades) >= 50
), ),
} }
+105 -12
View File
@@ -55,13 +55,26 @@ class Database:
await conn.execute(""" await conn.execute("""
INSERT INTO metrics_daily ( INSERT INTO metrics_daily (
timestamp, total_trades, total_deployed, total_fees, timestamp, total_trades, total_deployed, total_fees,
total_pnl, win_rate, avg_edge, sharpe_ratio, calibration_score, paper_mode unrealized_pnl_est, realized_pnl, total_pnl,
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) win_rate, avg_edge, sharpe_ratio, calibration_score, paper_mode,
open_count, closed_count, resolved_count
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
""", """,
metrics["timestamp"], metrics["total_trades"], metrics["total_deployed"], metrics["timestamp"],
metrics["total_fees"], metrics["total_pnl"], metrics["win_rate"], metrics["total_trades"],
metrics["avg_edge"], metrics["sharpe_ratio"], metrics["calibration_score"], metrics["total_deployed"],
metrics["total_fees"],
metrics["unrealized_pnl_est"],
metrics["realized_pnl"],
metrics["total_pnl"],
metrics["win_rate"],
metrics["avg_edge"],
metrics["sharpe_ratio"],
metrics["calibration_score"],
metrics["paper_mode"], metrics["paper_mode"],
metrics["open_count"],
metrics["closed_count"],
metrics["resolved_count"],
) )
async def get_open_positions(self) -> dict[str, float]: async def get_open_positions(self) -> dict[str, float]:
@@ -103,14 +116,30 @@ class Database:
""") """)
return [dict(r) for r in rows] return [dict(r) for r in rows]
async def close_paper_position(self, market_id: str, reason: str = "") -> None: async def close_paper_position(
"""Mark a paper position as closed (sets closed_at timestamp).""" self, market_id: str, reason: str = "", resolution: Optional[float] = None
) -> None:
"""Mark a paper position as closed.
resolution: 1.0 if YES resolved, 0.0 if NO resolved, None if unknown
(legacy closes, inversion fixes). When resolution is provided, close_pnl
is computed in SQL so it matches the stored entry_price and shares exactly.
"""
async with self._pool.acquire() as conn: async with self._pool.acquire() as conn:
await conn.execute( await conn.execute("""
"UPDATE trades SET closed_at = NOW(), close_reason = $2 " UPDATE trades
"WHERE market_id = $1 AND closed_at IS NULL", SET closed_at = NOW(),
market_id, reason, close_reason = $2,
) resolution = $3,
close_pnl = CASE
WHEN $3 IS NOT NULL AND direction = 'BUY_YES'
THEN ($3::double precision - entry_price) * shares
WHEN $3 IS NOT NULL AND direction = 'BUY_NO'
THEN ((1.0 - $3::double precision) - entry_price) * shares
ELSE NULL
END
WHERE market_id = $1 AND closed_at IS NULL
""", market_id, reason, resolution)
async def update_family_key(self, market_id: str, new_key: str) -> None: async def update_family_key(self, market_id: str, new_key: str) -> None:
"""Persist a corrected family_key for all open trades of a market.""" """Persist a corrected family_key for all open trades of a market."""
@@ -142,6 +171,70 @@ class Database:
""", str(hours)) """, str(hours))
return {r["market_id"] for r in rows} return {r["market_id"] for r in rows}
async def compute_metrics_from_db(self) -> dict:
"""Compute all trading metrics directly from the trades table.
This is the single source of truth for MetricsTracker — no in-memory
state required. Safe to call after pod restarts: always reflects the
full DB history.
Returns a dict with keys:
total_trades, open_count, closed_count, resolved_count,
total_deployed, total_fees,
unrealized_pnl_est — estimated, open trades with edge_net
realized_pnl — exact, closed trades with resolution
wins_realized — closed trades where close_pnl > 0
calibration_score — Brier-based (1 MSE), null if resolved < 10
"""
async with self._pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT
COUNT(*) AS total_trades,
COUNT(*) FILTER (WHERE closed_at IS NULL) AS open_count,
COUNT(*) FILTER (WHERE closed_at IS NOT NULL) AS closed_count,
COUNT(*) FILTER (WHERE resolution IS NOT NULL
AND final_prob IS NOT NULL) AS resolved_count,
COALESCE(SUM(net_cost)
FILTER (WHERE closed_at IS NULL), 0) AS total_deployed,
COALESCE(SUM(fee_usdc), 0) AS total_fees,
-- Estimated unrealized PnL: open trades with known edge.
-- Formula: edge_net × net_cost fee_usdc.
-- Trades with NULL edge_net (legacy data) are excluded.
COALESCE(SUM(edge_net * net_cost - fee_usdc)
FILTER (WHERE closed_at IS NULL
AND edge_net IS NOT NULL), 0) AS unrealized_pnl_est,
-- Realized PnL: closed trades with a known resolution.
-- close_pnl is computed at close time from actual resolution.
COALESCE(SUM(close_pnl)
FILTER (WHERE closed_at IS NOT NULL
AND close_pnl IS NOT NULL), 0) AS realized_pnl,
COUNT(*) FILTER (WHERE closed_at IS NOT NULL
AND close_pnl IS NOT NULL
AND close_pnl > 0) AS wins_realized,
-- Calibration (Brier score transformed to higher-is-better):
-- 1 AVG((final_prob resolution)²) on resolved trades.
-- final_prob is the model's estimated YES probability at entry.
-- resolution is 1.0 (YES won) or 0.0 (NO won).
-- Perfect calibration → 1.0 | Random → ~0.75 | Worst → 0.0
-- Returns NULL if fewer than 10 resolved trades with final_prob.
CASE
WHEN COUNT(*) FILTER (WHERE resolution IS NOT NULL
AND final_prob IS NOT NULL) >= 10
THEN 1.0 - AVG((final_prob - resolution) * (final_prob - resolution))
FILTER (WHERE resolution IS NOT NULL
AND final_prob IS NOT NULL)
ELSE NULL
END AS calibration_score
FROM trades
""")
return dict(row)
async def get_recent_trades(self, limit: int = 100, status: Optional[str] = None) -> list[dict]: async def get_recent_trades(self, limit: int = 100, status: Optional[str] = None) -> list[dict]:
"""Return trades ordered by timestamp DESC. """Return trades ordered by timestamp DESC.
+27
View File
@@ -108,3 +108,30 @@ ALTER TABLE trades ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
ALTER TABLE trades ADD COLUMN IF NOT EXISTS close_reason TEXT; ALTER TABLE trades ADD COLUMN IF NOT EXISTS close_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_trades_closed ON trades(closed_at) WHERE closed_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_trades_closed ON trades(closed_at) WHERE closed_at IS NOT NULL;
-- ─────────────────────────────────────────────────────────────────────────────
-- Fix 3: market resolution and realized P&L per trade
--
-- resolution: 1.0 if YES resolved, 0.0 if NO resolved, NULL if not yet settled.
-- close_pnl: realized P&L in USDC at close time.
-- BUY_YES: (resolution - entry_price) * shares
-- BUY_NO: ((1 - resolution) - entry_price) * shares
-- NULL if closed without a known resolution (legacy closes, inversion fixes).
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE trades ADD COLUMN IF NOT EXISTS close_pnl DOUBLE PRECISION;
ALTER TABLE trades ADD COLUMN IF NOT EXISTS resolution DOUBLE PRECISION;
-- ─────────────────────────────────────────────────────────────────────────────
-- Fix 3: extended metrics_daily columns for DB-computed metrics
--
-- unrealized_pnl_est: SUM(edge_net * net_cost - fee) on open trades with edge_net.
-- Estimated — uses model's own edge signal, not live market price.
-- realized_pnl: SUM(close_pnl) on closed trades with a known resolution.
-- Exact — derived from actual market outcome.
-- open_count / closed_count / resolved_count: trade counts at snapshot time.
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS unrealized_pnl_est DOUBLE PRECISION;
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS realized_pnl DOUBLE PRECISION;
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS open_count INTEGER;
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS closed_count INTEGER;
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS resolved_count INTEGER;
+13 -6
View File
@@ -177,16 +177,23 @@ class PaperExecutor:
return cost return cost
async def close_position(self, market_id: str, resolution: float) -> Optional[float]: async def close_position(self, market_id: str, resolution: float) -> Optional[float]:
""" """Close a paper position after market resolution.
Close a paper position after market resolution.
resolution: 1.0 if YES won, 0.0 if NO won. resolution: 1.0 if YES won, 0.0 if NO won.
Returns P&L in USDC. Persists resolution and close_pnl to DB (computed via SQL from stored
entry_price and shares). Returns approximate P&L for logging.
""" """
if market_id not in self._portfolio.positions: if market_id not in self._portfolio.positions:
return None return None
# This would be called by a settlement watcher (future feature)
# For now, positions auto-expire at market end date
position_cost = self._portfolio.positions.pop(market_id) position_cost = self._portfolio.positions.pop(market_id)
log.info("Closed position in %s, resolution=%.0f", market_id, resolution) self._portfolio.cash += position_cost * resolution # pay out winnings
await self._db.close_paper_position(
market_id,
reason=f"market_resolved resolution={resolution:.1f}",
resolution=resolution,
)
log.info("Closed position in %s, resolution=%.1f", market_id, resolution)
# Approximate PnL: settlement value minus cost. Exact value is in close_pnl.
return position_cost * resolution - position_cost return position_cost * resolution - position_cost
+72 -116
View File
@@ -1,21 +1,27 @@
""" """
Metrics Tracker Computes trading performance metrics. Metrics Tracker computes and persists trading performance metrics from the DB.
Key metrics tracked: All metrics are derived directly from the `trades` table on every cycle call.
- P&L (cumulative and daily) No in-memory trade state is kept: the tracker is stateless across pod restarts.
- Sharpe Ratio (annualized)
- Win Rate Metric definitions
- Calibration Score (how accurate our probability estimates are)
- Max Drawdown unrealized_pnl_est Estimated PnL for OPEN positions: edge_net × net_cost fee.
- Average Edge realized 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 import logging
import math
from datetime import datetime, UTC from datetime import datetime, UTC
from typing import Optional
from bot.executor.paper import Trade
from bot.data.db import Database from bot.data.db import Database
from bot.executor.paper import Trade
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -23,119 +29,69 @@ log = logging.getLogger(__name__)
class MetricsTracker: class MetricsTracker:
def __init__(self, db: Database) -> None: def __init__(self, db: Database) -> None:
self._db = db self._db = db
self._trades: list[Trade] = []
self._daily_returns: list[float] = []
async def record_trade(self, trade: Trade) -> None: async def record_trade(self, trade: Trade) -> None:
self._trades.append(trade) """Persist a trade to the DB. No in-memory accumulation."""
await self._db.save_trade(trade) await self._db.save_trade(trade)
log.info("Trade recorded. Total trades: %d", len(self._trades)) log.info("Trade recorded: %s", trade)
async def update_daily_summary(self) -> None: async def update_daily_summary(self) -> None:
"""Compute and store daily metrics snapshot.""" """Compute metrics from DB and write a metrics_daily snapshot.
if not self._trades:
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 return
metrics = self.compute_metrics() 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) await self._db.save_daily_metrics(metrics)
log.info( log.info(
"Daily metrics | Trades: %d | P&L: $%.2f | Win: %.1f%% | Sharpe: %.2f", "Daily metrics | trades=%d (open=%d closed=%d resolved=%d) | "
metrics["total_trades"], "unrealized=$%.2f realized=$%.2f total=$%.2f | "
metrics["total_pnl"], "win_rate=%s calibration=%s",
metrics["win_rate"] * 100, metrics["total_trades"], open_count, closed_count, resolved,
metrics["sharpe_ratio"], 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)",
) )
def compute_metrics(self) -> dict:
if not self._trades:
return self._empty_metrics()
trades = self._trades
n = len(trades)
# ── Capital: all in-session trades (open + closed this session) ────────
# NOTE: self._trades is in-memory; resets on pod restart.
# Fix 3 (planned): replace with DB-computed metrics so restarts don't
# truncate history. Until then, these numbers reflect the current session.
total_deployed = sum(t.net_cost for t in trades)
total_fees = sum(t.fee_usdc for t in trades)
# ── Win rate ─────────────────────────────────────────────────────────
# Proxy for open trades: fraction where edge_net > 0.
# Not a realized win rate (no market resolutions available yet).
wins = sum(1 for t in trades if t.entry_price < 0.5)
win_rate = wins / n if n > 0 else 0
# ── Estimated unrealized P&L (open positions only) ───────────────────
# Formula: model_edge × deployed_capital per trade.
# Conservative bound: edge_net ∈ [-1, 1] → max PnL = net_cost per trade.
# Previous formula (0.5 entry_price) × shares inflated BUY_NO trades
# at low entry prices by 310× (e.g. entry=0.158 → 3164 shares → $1072
# PnL on $500 deployed, vs $122 with edge_net=0.2589 here).
# Trades with NULL edge_net (legacy data) contribute only fee_usdc.
total_pnl = sum(
(t.edge_net or 0.0) * t.net_cost - t.fee_usdc
for t in trades
)
avg_edge = total_pnl / total_deployed if total_deployed > 0 else 0
sharpe = self._compute_sharpe()
# ── Calibration score: not available ─────────────────────────────────
# Real calibration (Brier score) requires knowing how each market
# resolved (YES=1 or NO=0). Until close_price / resolution is stored
# per trade, any formula here is a proxy, not a calibration.
# Returns None so the API can surface "unavailable" rather than a
# misleading number. Will be computed from closed trades in Fix 3.
calibration = None # type: ignore[assignment]
return {
"timestamp": datetime.now(UTC),
"total_trades": n,
"total_deployed": total_deployed,
"total_fees": total_fees,
"total_pnl": total_pnl, # estimated unrealized (open trades, current session)
"win_rate": win_rate, # proxy: fraction with entry_price < 0.5
"avg_edge": avg_edge,
"sharpe_ratio": sharpe,
"calibration_score": calibration, # None — requires market resolution data
"paper_mode": True,
}
def _compute_sharpe(self) -> float:
"""Annualized Sharpe ratio from daily returns."""
if len(self._daily_returns) < 2:
return 0.0
mean_r = sum(self._daily_returns) / len(self._daily_returns)
variance = sum((r - mean_r) ** 2 for r in self._daily_returns) / len(self._daily_returns)
std_r = math.sqrt(variance) if variance > 0 else 1e-9
return (mean_r / std_r) * math.sqrt(365) # Annualize
def check_promotion_thresholds(self) -> tuple[bool, dict]:
"""Check if metrics qualify for real money trading."""
metrics = self.compute_metrics()
cal = metrics["calibration_score"] # may be None
checks = {
"sharpe_ratio": (metrics["sharpe_ratio"], 0.5, metrics["sharpe_ratio"] >= 0.5),
"win_rate": (metrics["win_rate"], 0.52, metrics["win_rate"] >= 0.52),
"calibration_score": (cal, 0.7, cal is not None and cal >= 0.7),
"min_trades": (metrics["total_trades"], 50, metrics["total_trades"] >= 50),
}
all_pass = all(v[2] for v in checks.values())
return all_pass, checks
def _empty_metrics(self) -> dict:
return {
"timestamp": datetime.now(UTC),
"total_trades": 0,
"total_deployed": 0,
"total_fees": 0,
"total_pnl": 0,
"win_rate": 0,
"avg_edge": 0,
"sharpe_ratio": 0,
"calibration_score": None, # requires market resolution data
"paper_mode": True,
}