feat(metrics): Fix 3 — DB-computed metrics, stateless tracker, resolution tracking
CI/CD / build-and-push (push) Successful in 1m47s
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:
+49
-24
@@ -89,43 +89,68 @@ async def get_trades(limit: int = 50, status: str = "open"):
|
||||
|
||||
@app.get("/api/summary")
|
||||
async def get_summary():
|
||||
"""Dashboard summary card data."""
|
||||
history = await db.get_metrics_history(days=1)
|
||||
open_trades, all_trades, inverted, legacy_count = await asyncio.gather(
|
||||
"""Dashboard summary card data.
|
||||
|
||||
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),
|
||||
db.get_recently_closed_inverted(hours=24),
|
||||
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"))
|
||||
total_deployed = sum(t.get("net_cost", 0) for t in open_trades)
|
||||
|
||||
return {
|
||||
# ── Portfolio state (live from DB) ──────────────────────────────────
|
||||
"paper_mode": os.getenv("PAPER_MODE", "true") == "true",
|
||||
"paper_bankroll": paper_bankroll,
|
||||
"total_trades": len(all_trades),
|
||||
"open_trades_count": len(open_trades),
|
||||
"closed_trades_count": len(all_trades) - len(open_trades),
|
||||
"total_deployed": total_deployed,
|
||||
"cash_available": max(0.0, paper_bankroll - total_deployed),
|
||||
"legacy_incomplete_count": legacy_count,
|
||||
"reentry_guard_blocks_24h": len(inverted),
|
||||
# Metrics from latest metrics_daily snapshot (computed by MetricsTracker).
|
||||
# total_pnl: estimated unrealized PnL for open trades in the current bot
|
||||
# session — uses edge_net × net_cost (model edge on deployed
|
||||
# capital). Resets to 0 on pod restart until Fix 3 is applied.
|
||||
# calibration_score: null until market resolution data is available
|
||||
# (requires close_price / outcome per closed trade).
|
||||
"total_pnl": latest.get("total_pnl", 0),
|
||||
"win_rate": latest.get("win_rate", 0),
|
||||
"sharpe_ratio": latest.get("sharpe_ratio", 0),
|
||||
"calibration_score": latest.get("calibration_score"), # null if unavailable
|
||||
"total_trades": len(all_trades), # exact, from DB
|
||||
"open_trades_count": len(open_trades), # exact, from DB
|
||||
"closed_trades_count": len(all_trades) - len(open_trades), # exact
|
||||
"total_deployed": total_deployed, # exact, from DB
|
||||
"cash_available": max(0.0, paper_bankroll - total_deployed), # exact
|
||||
"legacy_incomplete_count": legacy_count, # exact, from DB
|
||||
"reentry_guard_blocks_24h": len(inverted), # exact, from DB
|
||||
|
||||
# ── P&L (from latest metrics_daily snapshot) ────────────────────────
|
||||
# unrealized_pnl_est: open positions, edge_net × net_cost − fee.
|
||||
# Estimated — uses model signal, not live price. Source: open trades.
|
||||
# realized_pnl: closed positions with known resolution.
|
||||
# Exact — computed from (resolution − entry_price) × shares.
|
||||
# total_pnl: sum of both.
|
||||
"unrealized_pnl_est": latest.get("unrealized_pnl_est") or 0,
|
||||
"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": (
|
||||
latest.get("sharpe_ratio", 0) >= 0.5
|
||||
and latest.get("win_rate", 0) >= 0.52
|
||||
and (latest.get("calibration_score") or 0) >= 0.7 # null → not ready
|
||||
(latest.get("sharpe_ratio") or 0) >= 0.5
|
||||
and (latest.get("win_rate") or 0) >= 0.52
|
||||
and (latest.get("calibration_score") or 0) >= 0.7
|
||||
and len(all_trades) >= 50
|
||||
),
|
||||
}
|
||||
|
||||
+105
-12
@@ -55,13 +55,26 @@ class Database:
|
||||
await conn.execute("""
|
||||
INSERT INTO metrics_daily (
|
||||
timestamp, total_trades, total_deployed, total_fees,
|
||||
total_pnl, win_rate, avg_edge, sharpe_ratio, calibration_score, paper_mode
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
unrealized_pnl_est, realized_pnl, total_pnl,
|
||||
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["total_fees"], metrics["total_pnl"], metrics["win_rate"],
|
||||
metrics["avg_edge"], metrics["sharpe_ratio"], metrics["calibration_score"],
|
||||
metrics["timestamp"],
|
||||
metrics["total_trades"],
|
||||
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["open_count"],
|
||||
metrics["closed_count"],
|
||||
metrics["resolved_count"],
|
||||
)
|
||||
|
||||
async def get_open_positions(self) -> dict[str, float]:
|
||||
@@ -103,14 +116,30 @@ class Database:
|
||||
""")
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def close_paper_position(self, market_id: str, reason: str = "") -> None:
|
||||
"""Mark a paper position as closed (sets closed_at timestamp)."""
|
||||
async def close_paper_position(
|
||||
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:
|
||||
await conn.execute(
|
||||
"UPDATE trades SET closed_at = NOW(), close_reason = $2 "
|
||||
"WHERE market_id = $1 AND closed_at IS NULL",
|
||||
market_id, reason,
|
||||
)
|
||||
await conn.execute("""
|
||||
UPDATE trades
|
||||
SET closed_at = NOW(),
|
||||
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:
|
||||
"""Persist a corrected family_key for all open trades of a market."""
|
||||
@@ -142,6 +171,70 @@ class Database:
|
||||
""", str(hours))
|
||||
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]:
|
||||
"""Return trades ordered by timestamp DESC.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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
@@ -177,16 +177,23 @@ class PaperExecutor:
|
||||
return cost
|
||||
|
||||
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.
|
||||
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:
|
||||
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)
|
||||
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
|
||||
|
||||
+72
-116
@@ -1,21 +1,27 @@
|
||||
"""
|
||||
Metrics Tracker — Computes trading performance metrics.
|
||||
Metrics Tracker — computes and persists trading performance metrics from the DB.
|
||||
|
||||
Key metrics tracked:
|
||||
- P&L (cumulative and daily)
|
||||
- Sharpe Ratio (annualized)
|
||||
- Win Rate
|
||||
- Calibration Score (how accurate our probability estimates are)
|
||||
- Max Drawdown
|
||||
- Average Edge realized
|
||||
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
|
||||
import math
|
||||
from datetime import datetime, UTC
|
||||
from typing import Optional
|
||||
|
||||
from bot.executor.paper import Trade
|
||||
from bot.data.db import Database
|
||||
from bot.executor.paper import Trade
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,119 +29,69 @@ log = logging.getLogger(__name__)
|
||||
class MetricsTracker:
|
||||
def __init__(self, db: Database) -> None:
|
||||
self._db = db
|
||||
self._trades: list[Trade] = []
|
||||
self._daily_returns: list[float] = []
|
||||
|
||||
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)
|
||||
log.info("Trade recorded. Total trades: %d", len(self._trades))
|
||||
log.info("Trade recorded: %s", trade)
|
||||
|
||||
async def update_daily_summary(self) -> None:
|
||||
"""Compute and store daily metrics snapshot."""
|
||||
if not self._trades:
|
||||
"""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
|
||||
|
||||
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)
|
||||
|
||||
log.info(
|
||||
"Daily metrics | Trades: %d | P&L: $%.2f | Win: %.1f%% | Sharpe: %.2f",
|
||||
metrics["total_trades"],
|
||||
metrics["total_pnl"],
|
||||
metrics["win_rate"] * 100,
|
||||
metrics["sharpe_ratio"],
|
||||
"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)",
|
||||
)
|
||||
|
||||
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 3–10× (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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user