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:
+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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user