feat(observability): fine-grained metrics for summary, trades, and cycle log
CI/CD / build-and-push (push) Successful in 1m51s
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:
+48
-5
@@ -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
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user