feat(observability): fine-grained metrics for summary, trades, and cycle log
CI/CD / build-and-push (push) Successful in 1m51s

api/summary — new fields:
  open_trades_count, closed_trades_count, cash_available (bankroll−deployed),
  legacy_incomplete_count, reentry_guard_blocks_24h
  parallel fetch via asyncio.gather for sub-ms overhead

api/trades?status=open — trade enrichment:
  days_open (float, rounded to 1 decimal)
  signal_components {fg, mom, news, mfld} parsed from reasoning via regex
  Old trades without feat_str in reasoning return signal_components: null

bayesian.py — reasoning now embeds feat_str:
  "fg=+0.0600 mom=+0.0000 news=+0.0000 mfld=-0.7483 |"
  Manifold counters: _manifold_fetched / _manifold_on_trade per cycle
  get_cycle_stats() exposes manifold_matches_accepted / manifold_matches_rejected

bot/main.py — CYCLE SUMMARY 4 new fields:
  reentry_guard_blocked, legacy_incomplete_seen,
  family_conflicts_prevented, manifold_matches_accepted/rejected
  legacy_incomplete_count queried from DB once per cycle

db.py — get_legacy_incomplete_count(): open trades with NULL edge_net

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-04-21 09:48:31 +00:00
parent e2fb697c0c
commit 46f8f4b79a
4 changed files with 83 additions and 6 deletions
+48 -5
View File
@@ -1,13 +1,44 @@
""" """
FastAPI Backend — serves metrics and trade data to the React dashboard. FastAPI Backend — serves metrics and trade data to the React dashboard.
""" """
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone
import os import os
import re
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from bot.data.db import Database from bot.data.db import Database
# Matches the feat_str embedded in reasoning for trades from bayesian.py v2+:
# "fg=+0.0600 mom=+0.0000 news=+0.0000 mfld=-0.7483"
_FEAT_RE = re.compile(
r"fg=([+-]?[\d.]+).*?mom=([+-]?[\d.]+).*?news=([+-]?[\d.]+).*?mfld=([+-]?[\d.]+)"
)
def _enrich_trade(trade: dict) -> dict:
"""Add days_open and signal_components to an open trade dict."""
ts = trade.get("timestamp")
if ts is not None:
now = datetime.now(timezone.utc)
if getattr(ts, "tzinfo", None) is None:
ts = ts.replace(tzinfo=timezone.utc)
trade["days_open"] = round((now - ts).total_seconds() / 86400, 1)
else:
trade["days_open"] = None
reasoning = trade.get("reasoning") or ""
m = _FEAT_RE.search(reasoning)
trade["signal_components"] = (
{"fg": float(m.group(1)), "mom": float(m.group(2)),
"news": float(m.group(3)), "mfld": float(m.group(4))}
if m else None
)
return trade
db = Database() db = Database()
@@ -45,12 +76,14 @@ async def get_metrics():
async def get_trades(limit: int = 50, status: str = "open"): async def get_trades(limit: int = 50, status: str = "open"):
""" """
status: "open" (default) | "closed" | "all" status: "open" (default) | "closed" | "all"
Each trade includes a computed "status" field. Open trades include days_open and signal_components {fg, mom, news, mfld}.
""" """
if status not in ("open", "closed", "all"): if status not in ("open", "closed", "all"):
status = "open" status = "open"
filter_status = None if status == "all" else status filter_status = None if status == "all" else status
trades = await db.get_recent_trades(limit=limit, status=filter_status) trades = await db.get_recent_trades(limit=limit, status=filter_status)
if filter_status == "open":
trades = [_enrich_trade(t) for t in trades]
return {"trades": trades, "count": len(trades), "status_filter": status} return {"trades": trades, "count": len(trades), "status_filter": status}
@@ -58,17 +91,27 @@ async def get_trades(limit: int = 50, status: str = "open"):
async def get_summary(): async def get_summary():
"""Dashboard summary card data.""" """Dashboard summary card data."""
history = await db.get_metrics_history(days=1) history = await db.get_metrics_history(days=1)
trades = await db.get_recent_trades(limit=500) open_trades, all_trades, inverted, legacy_count = await asyncio.gather(
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 = history[0] if history 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 trades) total_deployed = sum(t.get("net_cost", 0) for t in open_trades)
return { return {
"paper_mode": os.getenv("PAPER_MODE", "true") == "true", "paper_mode": os.getenv("PAPER_MODE", "true") == "true",
"paper_bankroll": paper_bankroll, "paper_bankroll": paper_bankroll,
"total_trades": len(trades), "total_trades": len(all_trades),
"open_trades_count": len(open_trades),
"closed_trades_count": len(all_trades) - len(open_trades),
"total_deployed": total_deployed, "total_deployed": total_deployed,
"cash_available": max(0.0, paper_bankroll - total_deployed),
"legacy_incomplete_count": legacy_count,
"reentry_guard_blocks_24h": len(inverted),
"total_pnl": latest.get("total_pnl", 0), "total_pnl": latest.get("total_pnl", 0),
"win_rate": latest.get("win_rate", 0), "win_rate": latest.get("win_rate", 0),
"sharpe_ratio": latest.get("sharpe_ratio", 0), "sharpe_ratio": latest.get("sharpe_ratio", 0),
@@ -77,6 +120,6 @@ async def get_summary():
latest.get("sharpe_ratio", 0) >= 0.5 latest.get("sharpe_ratio", 0) >= 0.5
and latest.get("win_rate", 0) >= 0.52 and latest.get("win_rate", 0) >= 0.52
and latest.get("calibration_score", 0) >= 0.7 and latest.get("calibration_score", 0) >= 0.7
and len(trades) >= 50 and len(all_trades) >= 50
), ),
} }
+8
View File
@@ -120,6 +120,14 @@ class Database:
market_id, new_key, market_id, new_key,
) )
async def get_legacy_incomplete_count(self) -> int:
"""Return count of open trades with NULL edge_net (legacy data without signal values)."""
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT COUNT(*) FROM trades WHERE closed_at IS NULL AND edge_net IS NULL"
)
return int(row[0])
async def get_recently_closed_inverted(self, hours: int = 24) -> set[str]: async def get_recently_closed_inverted(self, hours: int = 24) -> set[str]:
"""Return market_ids closed for inversion bug within the last N hours. """Return market_ids closed for inversion bug within the last N hours.
+14 -1
View File
@@ -100,6 +100,7 @@ async def run_trading_loop(
len(inverted_guard), sorted(inverted_guard), len(inverted_guard), sorted(inverted_guard),
) )
reentry_guard_count = 0
cycle_trades = 0 cycle_trades = 0
for market in markets: for market in markets:
if market.id in inverted_guard: if market.id in inverted_guard:
@@ -107,6 +108,7 @@ async def run_trading_loop(
"reentry_guard_triggered market=%s | skipping — closed for inversion within 24h | %s", "reentry_guard_triggered market=%s | skipping — closed for inversion within 24h | %s",
market.id, market.question[:60], market.id, market.question[:60],
) )
reentry_guard_count += 1
continue continue
# evaluate() returns None for all skips — reasons are logged internally # evaluate() returns None for all skips — reasons are logged internally
@@ -142,6 +144,7 @@ async def run_trading_loop(
# 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare # 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare
stats = strategy.get_cycle_stats() stats = strategy.get_cycle_stats()
legacy_incomplete_count = await db.get_legacy_incomplete_count()
n_total = len(markets) n_total = len(markets)
n_uncertainty = sum(1 for m in markets if 0.35 <= m.yes_price <= 0.65) n_uncertainty = sum(1 for m in markets if 0.35 <= m.yes_price <= 0.65)
n_eval = stats["evaluated_count"] n_eval = stats["evaluated_count"]
@@ -164,7 +167,12 @@ async def run_trading_loop(
" blocked_by_edge_net_nonpositive:%d\n" " blocked_by_edge_net_nonpositive:%d\n"
" blocked_by_edge_net_below_regime:%d\n" " blocked_by_edge_net_below_regime:%d\n"
" trades_executed: %d\n" " trades_executed: %d\n"
" gnews_queries_used: %d/%d", " gnews_queries_used: %d/%d\n"
" reentry_guard_blocked: %d\n"
" legacy_incomplete_seen: %d\n"
" family_conflicts_prevented: %d\n"
" manifold_matches_accepted: %d\n"
" manifold_matches_rejected: %d",
n_total, n_total,
n_uncertainty, n_uncertainty,
stats["max_edge_gross"], stats["max_edge_gross"],
@@ -177,6 +185,11 @@ async def run_trading_loop(
stats["skip_edge_net_below_regime"], stats["skip_edge_net_below_regime"],
cycle_trades, cycle_trades,
stats["gnews_queries_used"], MAX_NEWS_QUERIES_PER_CYCLE, stats["gnews_queries_used"], MAX_NEWS_QUERIES_PER_CYCLE,
reentry_guard_count,
legacy_incomplete_count,
stats["skip_family"],
stats["manifold_matches_accepted"],
stats["manifold_matches_rejected"],
) )
# 9. Update daily metrics # 9. Update daily metrics
+13
View File
@@ -201,6 +201,8 @@ class BayesianStrategy:
self._skip_prior_extreme: int = 0 self._skip_prior_extreme: int = 0
self._skip_edge_net_nonpositive: int = 0 # edge_net <= 0 self._skip_edge_net_nonpositive: int = 0 # edge_net <= 0
self._skip_edge_net_below_regime: int = 0 # 0 < edge_net < regime_min self._skip_edge_net_below_regime: int = 0 # 0 < edge_net < regime_min
self._manifold_fetched: int = 0 # markets where Manifold prob was retrieved
self._manifold_on_trade: int = 0 # subset of above that ended in a trade signal
# (edge_gross, edge_net, regime_min) for every market that reached the # (edge_gross, edge_net, regime_min) for every market that reached the
# edge computation stage (passed prior-extreme, family, unsupported filters) # edge computation stage (passed prior-extreme, family, unsupported filters)
self._evaluated_edges: list[tuple[float, float, float]] = [] self._evaluated_edges: list[tuple[float, float, float]] = []
@@ -212,6 +214,8 @@ class BayesianStrategy:
self._skip_prior_extreme = 0 self._skip_prior_extreme = 0
self._skip_edge_net_nonpositive = 0 self._skip_edge_net_nonpositive = 0
self._skip_edge_net_below_regime = 0 self._skip_edge_net_below_regime = 0
self._manifold_fetched = 0
self._manifold_on_trade = 0
self._evaluated_edges = [] self._evaluated_edges = []
def get_cycle_stats(self) -> dict: def get_cycle_stats(self) -> dict:
@@ -230,6 +234,8 @@ class BayesianStrategy:
"evaluated_count": len(edges), "evaluated_count": len(edges),
"gross_gt_002": sum(1 for g in all_gross if g > 0.02), "gross_gt_002": sum(1 for g in all_gross if g > 0.02),
"gross_gt_004": sum(1 for g in all_gross if g > 0.04), "gross_gt_004": sum(1 for g in all_gross if g > 0.04),
"manifold_matches_accepted": self._manifold_on_trade,
"manifold_matches_rejected": self._manifold_fetched - self._manifold_on_trade,
} }
async def evaluate( async def evaluate(
@@ -401,9 +407,12 @@ class BayesianStrategy:
# Applies a log-odds adjustment proportional to divergence from prior. # Applies a log-odds adjustment proportional to divergence from prior.
# No query budget — 30 min cache means network cost is paid once per cycle. # No query budget — 30 min cache means network cost is paid once per cycle.
manifold_log_adj = 0.0 manifold_log_adj = 0.0
manifold_used = False
if (is_politics or is_tech) and self._manifold is not None: if (is_politics or is_tech) and self._manifold is not None:
manifold_prob = await self._manifold.get_probability(market.question) manifold_prob = await self._manifold.get_probability(market.question)
if manifold_prob is not None: if manifold_prob is not None:
manifold_used = True
self._manifold_fetched += 1
m_clamped = max(0.05, min(0.95, manifold_prob)) m_clamped = max(0.05, min(0.95, manifold_prob))
m_log = math.log(m_clamped / (1 - m_clamped)) m_log = math.log(m_clamped / (1 - m_clamped))
p_log = math.log(prior / (1 - prior)) p_log = math.log(prior / (1 - prior))
@@ -487,6 +496,8 @@ class BayesianStrategy:
f"regime_min={regime_min:.2f} | days={days} | " f"regime_min={regime_min:.2f} | days={days} | "
f"family={family} | " f"family={family} | "
f"Direction={direction} | " f"Direction={direction} | "
f"fg={_fg_contribution:+.4f} mom={_momentum_contribution:+.4f} "
f"news={news_log_adj:+.4f} mfld={manifold_log_adj:+.4f} | "
f"Signals: {', '.join(sources[1:])}" f"Signals: {', '.join(sources[1:])}"
) )
@@ -501,6 +512,8 @@ class BayesianStrategy:
) )
self._signal_count += 1 self._signal_count += 1
if manifold_used:
self._manifold_on_trade += 1
return TradingSignal( return TradingSignal(
market_id=market.id, market_id=market.id,
question=market.question, question=market.question,