From 46f8f4b79aa69fd5a822d40a5df94ae9b73df82c Mon Sep 17 00:00:00 2001 From: chemavx Date: Tue, 21 Apr 2026 09:48:31 +0000 Subject: [PATCH] feat(observability): fine-grained metrics for summary, trades, and cycle log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/main.py | 53 ++++++++++++++++++++++++++++++++++++---- bot/data/db.py | 8 ++++++ bot/main.py | 15 +++++++++++- bot/strategy/bayesian.py | 13 ++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/api/main.py b/api/main.py index 0a88158..4c31e25 100644 --- a/api/main.py +++ b/api/main.py @@ -1,13 +1,44 @@ """ FastAPI Backend — serves metrics and trade data to the React dashboard. """ +import asyncio from contextlib import asynccontextmanager +from datetime import datetime, timezone import os +import re + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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() @@ -45,12 +76,14 @@ async def get_metrics(): async def get_trades(limit: int = 50, status: str = "open"): """ 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"): status = "open" filter_status = None if status == "all" else 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} @@ -58,17 +91,27 @@ async def get_trades(limit: int = 50, status: str = "open"): async def get_summary(): """Dashboard summary card data.""" 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 {} 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 { "paper_mode": os.getenv("PAPER_MODE", "true") == "true", "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, + "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), "win_rate": latest.get("win_rate", 0), "sharpe_ratio": latest.get("sharpe_ratio", 0), @@ -77,6 +120,6 @@ async def get_summary(): latest.get("sharpe_ratio", 0) >= 0.5 and latest.get("win_rate", 0) >= 0.52 and latest.get("calibration_score", 0) >= 0.7 - and len(trades) >= 50 + and len(all_trades) >= 50 ), } diff --git a/bot/data/db.py b/bot/data/db.py index db0d72f..3a56896 100644 --- a/bot/data/db.py +++ b/bot/data/db.py @@ -120,6 +120,14 @@ class Database: 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]: """Return market_ids closed for inversion bug within the last N hours. diff --git a/bot/main.py b/bot/main.py index dc922e6..d07dbe3 100644 --- a/bot/main.py +++ b/bot/main.py @@ -100,6 +100,7 @@ async def run_trading_loop( len(inverted_guard), sorted(inverted_guard), ) + reentry_guard_count = 0 cycle_trades = 0 for market in markets: 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", market.id, market.question[:60], ) + reentry_guard_count += 1 continue # 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 stats = strategy.get_cycle_stats() + legacy_incomplete_count = await db.get_legacy_incomplete_count() n_total = len(markets) n_uncertainty = sum(1 for m in markets if 0.35 <= m.yes_price <= 0.65) 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_below_regime:%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_uncertainty, stats["max_edge_gross"], @@ -177,6 +185,11 @@ async def run_trading_loop( stats["skip_edge_net_below_regime"], cycle_trades, 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 diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py index f67a82b..c90ca28 100644 --- a/bot/strategy/bayesian.py +++ b/bot/strategy/bayesian.py @@ -201,6 +201,8 @@ class BayesianStrategy: self._skip_prior_extreme: int = 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._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 computation stage (passed prior-extreme, family, unsupported filters) self._evaluated_edges: list[tuple[float, float, float]] = [] @@ -212,6 +214,8 @@ class BayesianStrategy: self._skip_prior_extreme = 0 self._skip_edge_net_nonpositive = 0 self._skip_edge_net_below_regime = 0 + self._manifold_fetched = 0 + self._manifold_on_trade = 0 self._evaluated_edges = [] def get_cycle_stats(self) -> dict: @@ -230,6 +234,8 @@ class BayesianStrategy: "evaluated_count": len(edges), "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), + "manifold_matches_accepted": self._manifold_on_trade, + "manifold_matches_rejected": self._manifold_fetched - self._manifold_on_trade, } async def evaluate( @@ -401,9 +407,12 @@ class BayesianStrategy: # Applies a log-odds adjustment proportional to divergence from prior. # No query budget — 30 min cache means network cost is paid once per cycle. manifold_log_adj = 0.0 + manifold_used = False if (is_politics or is_tech) and self._manifold is not None: manifold_prob = await self._manifold.get_probability(market.question) if manifold_prob is not None: + manifold_used = True + self._manifold_fetched += 1 m_clamped = max(0.05, min(0.95, manifold_prob)) m_log = math.log(m_clamped / (1 - m_clamped)) p_log = math.log(prior / (1 - prior)) @@ -487,6 +496,8 @@ class BayesianStrategy: f"regime_min={regime_min:.2f} | days={days} | " f"family={family} | " 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:])}" ) @@ -501,6 +512,8 @@ class BayesianStrategy: ) self._signal_count += 1 + if manifold_used: + self._manifold_on_trade += 1 return TradingSignal( market_id=market.id, question=market.question,