feat(attribution): dominant_feature per trade + /api/metrics/attribution
CI/CD / build-and-push (push) Successful in 1m52s

Adds alpha attribution by dominant signal feature — which feat_*_lo had
the largest absolute log-odds value on each trade.

Changes:
- _dominant_feature() helper in api/main.py: picks the winning feature
  from signal_components (threshold 0.0001, same as "triggered" in
  /api/metrics/features)
- _enrich_trade() refactored to single exit point; adds dominant_feature
  field to every open trade in /api/trades
- compute_attribution_from_db() in db.py: VALUES subquery finds dominant
  feature per trade in SQL, then aggregates trade_count/avg_edge_net/
  unrealized_pnl_est/realized_pnl/resolved_count/win_rate per group
- /api/metrics/attribution endpoint: returns attribution dict + total_attributed_trades

No schema changes, no strategy changes. Pure observability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-04-22 16:35:24 +00:00
parent 6d23e8042b
commit adf2917cda
2 changed files with 139 additions and 32 deletions
+64
View File
@@ -429,6 +429,70 @@ class Database:
return result
async def compute_attribution_from_db(self) -> dict:
"""Alpha attribution grouped by dominant signal feature.
For each Phase 6 trade, the dominant feature is the feat_*_lo with the
largest absolute value (> 0.0001). Trades are then aggregated per group.
Returns {feature_name: {trade_count, avg_edge_net, unrealized_pnl_est,
realized_pnl, resolved_count, win_rate}}.
"none" group collects trades where all features are below threshold.
"""
async with self._pool.acquire() as conn:
rows = await conn.fetch("""
WITH dominant_per_trade AS (
SELECT
edge_net, net_cost, fee_usdc, closed_at, close_pnl,
(
SELECT key
FROM (VALUES
('fg', ABS(COALESCE(feat_fg_lo, 0))),
('mom', ABS(COALESCE(feat_mom_lo, 0))),
('news', ABS(COALESCE(feat_news_lo, 0))),
('mfld', ABS(COALESCE(feat_mfld_lo, 0))),
('btc_dom', ABS(COALESCE(feat_btc_dom_lo, 0)))
) AS t(key, val)
WHERE val > 0.0001
ORDER BY val DESC
LIMIT 1
) AS dominant
FROM trades
WHERE feat_fg_lo IS NOT NULL
)
SELECT
COALESCE(dominant, 'none') AS dominant_feature,
COUNT(*) AS trade_count,
AVG(edge_net) AS avg_edge_net,
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,
COALESCE(SUM(close_pnl)
FILTER (WHERE close_pnl IS NOT NULL), 0) AS realized_pnl,
COUNT(*) FILTER (WHERE close_pnl IS NOT NULL) AS resolved_count,
COUNT(*) FILTER (WHERE close_pnl IS NOT NULL AND close_pnl > 0) AS wins
FROM dominant_per_trade
GROUP BY dominant_feature
ORDER BY trade_count DESC
""")
result: dict[str, dict] = {}
for r in rows:
d = dict(r)
feature = d["dominant_feature"]
resolved = int(d.get("resolved_count") or 0)
wins = int(d.get("wins") or 0)
result[feature] = {
"trade_count": int(d["trade_count"]),
"avg_edge_net": _f(d.get("avg_edge_net")),
"unrealized_pnl_est": float(d.get("unrealized_pnl_est") or 0),
"realized_pnl": float(d.get("realized_pnl") or 0),
"resolved_count": resolved,
"win_rate": (wins / resolved) if resolved >= 5 else None,
}
return result
def _f(v) -> Optional[float]:
"""None-safe float cast for asyncpg Decimal/None values."""
return float(v) if v is not None else None