5a3df975d9
CI/CD / build-and-push (push) Failing after 1m20s
total_pnl now uses edge_net × net_cost instead of (0.5 - entry_price) × shares. The old formula overestimated BUY_NO trades at low entry prices by 3–10× because buying at price 0.158 yields 3164 shares — any exit-at-0.5 assumption produced $1072 PnL on $500 deployed. edge_net × net_cost is bounded by net_cost per trade and uses the model's own signal, giving $122 for the same position. calibration_score is now None (null in API) instead of 1 - 2×|avg_edge|. That formula was not a real calibration: it requires knowing market resolutions (YES=1/NO=0) which we do not store yet. Returning null is more honest than returning 0.0 or a meaningless proxy. Fix 3 will compute it from closed trades. check_promotion_thresholds updated to handle None calibration (null → not ready). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
132 lines
4.5 KiB
Python
132 lines
4.5 KiB
Python
"""
|
||
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()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
await db.connect()
|
||
yield
|
||
await db.disconnect()
|
||
|
||
|
||
app = FastAPI(title="Polymarket Bot API", lifespan=lifespan)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_methods=["GET"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
return {"status": "ok", "paper_mode": os.getenv("PAPER_MODE", "true")}
|
||
|
||
|
||
@app.get("/api/metrics")
|
||
async def get_metrics():
|
||
history = await db.get_metrics_history(days=42)
|
||
if not history:
|
||
return {"history": [], "latest": None}
|
||
return {"history": history, "latest": history[0]}
|
||
|
||
|
||
@app.get("/api/trades")
|
||
async def get_trades(limit: int = 50, status: str = "open"):
|
||
"""
|
||
status: "open" (default) | "closed" | "all"
|
||
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}
|
||
|
||
|
||
@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(
|
||
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 open_trades)
|
||
|
||
return {
|
||
"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
|
||
"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
|
||
and len(all_trades) >= 50
|
||
),
|
||
}
|