Component attribution: per-stage scorecard (CLI + /api/attribution + dashboard + board pulse)
The owner's modularity/monitoring ask: rate each pipeline component so what's earning (and what's cutting) is visible instead of inferred. Everything derives from the existing event stream — no pipeline changes. - research/attribution.py: compute_attribution(days) -> per-day per-stage metrics: DISCOVERY (symbols found + precision = fraction that ever go ready), SIGNALS (ready split EARLY-vs-LATE against the confirmation window — the only +EV ones), GATES (risk_rule histogram: what each gate cut), EXECUTION (submissions vs cancels = fill efficiency), OUTCOMES (realized P&L, win rate, per-close detail). CLI: python -m research.attribution. All queries filter the native timestamp column first (payload casts on the 676k-row events table time out). - api/main.py: GET /api/attribution?days=N. - dashboard.html: "Component Attribution" panel, lazy-loads on open, per-day table with gate-histogram hover. - momentum_cli board header: live pulse line — today's early/late signal split + top gate cuts (two tiny date-scoped queries). Verified live: CLI scorecard over 7 days, endpoint returns real ratings (e.g. 2026-07-02: discovery 45%, early 38%, fill 39%, win 50%, P&L +$29), board renders the pulse line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2239ccbe6
commit
6c36d04f7a
4 files changed
+222
-1
No files matched your search
@@ -391,6 +391,12 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|||||||
symbol = (q.get("symbol") or [""])[0]
|
symbol = (q.get("symbol") or [""])[0]
|
||||||
minutes = int((q.get("minutes") or ["60"])[0])
|
minutes = int((q.get("minutes") or ["60"])[0])
|
||||||
self._send_json(self.state.bars(symbol, minutes))
|
self._send_json(self.state.bars(symbol, minutes))
|
||||||
|
elif route == "/api/attribution":
|
||||||
|
# per-component stage ratings (research/attribution.py); computed on
|
||||||
|
# demand — a handful of indexed event queries, fine per click
|
||||||
|
from research.attribution import compute_attribution
|
||||||
|
days = int((self._query().get("days") or ["7"])[0])
|
||||||
|
self._send_json(compute_attribution(days=days))
|
||||||
elif route == "/api/health":
|
elif route == "/api/health":
|
||||||
self._send_json({"ok": True, "mode": self.state.execution_mode})
|
self._send_json({"ok": True, "mode": self.state.execution_mode})
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -506,8 +506,46 @@
|
|||||||
<summary><h2>Order Lifecycle <span id="ol-count" class="count">0</span></h2></summary>
|
<summary><h2>Order Lifecycle <span id="ol-count" class="count">0</span></h2></summary>
|
||||||
<div id="order-lifecycle"></div>
|
<div id="order-lifecycle"></div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="panel full" id="p-attrib">
|
||||||
|
<summary><h2>Component Attribution <span class="hint">per-stage ratings: discovery → signals (early vs late) → gates → execution → P&L · loads on open</span></h2></summary>
|
||||||
|
<div id="attrib"><span class="empty">Open to load the last 7 days.</span></div>
|
||||||
|
</details>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
(function(){
|
||||||
|
// Component Attribution — lazy-load on first open (it queries the event store)
|
||||||
|
var ap = document.getElementById('p-attrib'), loadedAttrib = false;
|
||||||
|
function esc(s){ return String(s == null ? '' : s).replace(/[&<>]/g, function(c){ return {'&':'&','<':'<','>':'>'}[c]; }); }
|
||||||
|
function renderAttrib(a){
|
||||||
|
var days = Object.keys(a.days || {}).sort().reverse();
|
||||||
|
if (!days.length) { document.getElementById('attrib').innerHTML = '<span class="empty">No events in window.</span>'; return; }
|
||||||
|
var h = '<table><tr><th>day</th><th>disc</th><th>ready</th><th>early%</th><th>gate cuts</th><th>subm</th><th>fill%</th><th>trades</th><th>win%</th><th>P&L</th></tr>';
|
||||||
|
days.forEach(function(d){
|
||||||
|
var st = a.days[d], c = st.counts || {}, r = st.rating || {}, oc = st.outcomes || {};
|
||||||
|
var gates = Object.entries(st.gates || {}).sort(function(x, y){ return y[1] - x[1]; })
|
||||||
|
.map(function(kv){ return esc(kv[0]) + '×' + kv[1]; }).join(', ');
|
||||||
|
var pnl = (r.pnl == null) ? '-' : ('$' + (r.pnl >= 0 ? '+' : '') + r.pnl.toFixed(0));
|
||||||
|
var cls = (r.pnl == null) ? '' : (r.pnl >= 0 ? ' style="color:#2f9e44"' : ' style="color:#e03131"');
|
||||||
|
h += '<tr title="' + gates + '"><td>' + d + '</td><td>' + (c.symbol_discovered || 0) + '</td><td>' + (c.signal_ready || 0) +
|
||||||
|
'</td><td>' + (r.signals_early_pct == null ? '-' : r.signals_early_pct + '%') +
|
||||||
|
'</td><td>' + Object.values(st.gates || {}).reduce(function(s, v){ return s + v; }, 0) +
|
||||||
|
'</td><td>' + (c.order_submitted || 0) +
|
||||||
|
'</td><td>' + (r.fill_efficiency_pct == null ? '-' : r.fill_efficiency_pct + '%') +
|
||||||
|
'</td><td>' + (oc.trades || 0) + '</td><td>' + (r.win_rate_pct == null ? '-' : r.win_rate_pct + '%') +
|
||||||
|
'</td><td' + cls + '>' + pnl + '</td></tr>';
|
||||||
|
});
|
||||||
|
h += '</table><div class="hint" style="margin-top:6px">early% = ready signals inside the ' + a.confirm_minute + 'm confirmation window (the only +EV ones) · hover a row for its gate histogram</div>';
|
||||||
|
document.getElementById('attrib').innerHTML = h;
|
||||||
|
}
|
||||||
|
if (ap) ap.addEventListener('toggle', function(){
|
||||||
|
if (!ap.open || loadedAttrib) return;
|
||||||
|
loadedAttrib = true;
|
||||||
|
document.getElementById('attrib').innerHTML = '<span class="empty">Loading…</span>';
|
||||||
|
fetch('/api/attribution?days=7').then(function(r){ return r.json(); }).then(renderAttrib)
|
||||||
|
.catch(function(e){ document.getElementById('attrib').innerHTML = '<span class="empty">failed: ' + esc(e) + '</span>'; loadedAttrib = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
(function(){
|
(function(){
|
||||||
// Collapsible panels — remember each panel's open/closed state across refreshes,
|
// Collapsible panels — remember each panel's open/closed state across refreshes,
|
||||||
// and an expand/collapse-all toggle. Pure UI; the live app JS keys off the inner ids.
|
// and an expand/collapse-all toggle. Pure UI; the live app JS keys off the inner ids.
|
||||||
|
|||||||
+21
-1
@@ -716,6 +716,26 @@ def _render_board(con):
|
|||||||
rk.add_row(str(ts)[11:19], rule)
|
rk.add_row(str(ts)[11:19], rule)
|
||||||
|
|
||||||
n_eval = len(_latest_eval_board(con))
|
n_eval = len(_latest_eval_board(con))
|
||||||
|
# component-attribution pulse: today's signal timing (early = inside the
|
||||||
|
# confirmation window, the only +EV ones) + what each gate cut. Two tiny
|
||||||
|
# date-scoped queries; full history lives at /api/attribution + the CLI.
|
||||||
|
from datetime import date as _date
|
||||||
|
_today = _date.today().isoformat()
|
||||||
|
gates: dict = {}
|
||||||
|
for (pj,) in con.execute(
|
||||||
|
"SELECT payload_json FROM events WHERE event_type='risk_rule_triggered' "
|
||||||
|
"AND timestamp >= ?", [_today]).fetchall():
|
||||||
|
rt = (json.loads(pj or "{}").get("rule_type")) or "?"
|
||||||
|
gates[rt] = gates.get(rt, 0) + 1
|
||||||
|
early = late = 0
|
||||||
|
for (ts,) in con.execute(
|
||||||
|
"SELECT timestamp FROM events WHERE event_type='signal_ready' "
|
||||||
|
"AND timestamp >= ?", [_today]).fetchall():
|
||||||
|
m = (ts.hour - 9) * 60 + ts.minute - 30
|
||||||
|
early, late = (early + 1, late) if 0 <= m <= 15 else (early, late + 1)
|
||||||
|
gate_bits = ", ".join(f"{k}×{v}" for k, v in sorted(gates.items(), key=lambda x: -x[1])[:4])
|
||||||
|
attrib_line = (f"\n[dim]signals {early} early / {late} late · gates: "
|
||||||
|
f"{gate_bits or 'none yet'}[/dim]")
|
||||||
# naked/under-protected alarm — the failure that cost us this session and the
|
# naked/under-protected alarm — the failure that cost us this session and the
|
||||||
# board never showed. A red banner the instant a held long lacks stop coverage.
|
# board never showed. A red banner the instant a held long lacks stop coverage.
|
||||||
naked = _unprotected_positions(con)
|
naked = _unprotected_positions(con)
|
||||||
@@ -731,7 +751,7 @@ def _render_board(con):
|
|||||||
f"day P&L [bold {dcol}]{day_pnl:+,.0f}[/] "
|
f"day P&L [bold {dcol}]{day_pnl:+,.0f}[/] "
|
||||||
f"[{hb_color}]♥ {hb_text}[/{hb_color}] "
|
f"[{hb_color}]♥ {hb_text}[/{hb_color}] "
|
||||||
f"[bold cyan]{armed_n} armed[/bold cyan] · {n_eval} scan"
|
f"[bold cyan]{armed_n} armed[/bold cyan] · {n_eval} scan"
|
||||||
f"{naked_line}",
|
f"{attrib_line}{naked_line}",
|
||||||
style="red" if naked else "cyan")
|
style="red" if naked else "cyan")
|
||||||
# order = safety-first: header (protected? green? live?) → armed (about to fire?) →
|
# order = safety-first: header (protected? green? live?) → armed (about to fire?) →
|
||||||
# risk/circuit-breaker (promoted above trades, it's a safety surface) → trades log.
|
# risk/circuit-breaker (promoted above trades, it's a safety surface) → trades log.
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Per-component performance attribution — rate each pipeline stage from the event stream.
|
||||||
|
|
||||||
|
The owner's modularity ask: "isolate or add monitoring to specific components to rate or
|
||||||
|
increase performance." Every stage already emits events (event-sourced architecture), so this
|
||||||
|
derives a per-stage scorecard without touching the pipeline:
|
||||||
|
|
||||||
|
DISCOVERY — symbols found/day, and what fraction ever produced a ready signal (precision)
|
||||||
|
SIGNALS — ready signals/day, split EARLY (within the confirmation window) vs LATE
|
||||||
|
GATES — what each entry gate cut (risk_rule_triggered histogram by rule_type)
|
||||||
|
EXECUTION — approvals -> broker submissions -> fills; backout rate (entry efficiency)
|
||||||
|
OUTCOMES — realized P&L, win rate, avg win/loss, trades/day (the only stage that pays)
|
||||||
|
|
||||||
|
CLI: python -m research.attribution [--days 14]
|
||||||
|
API: api/main.py serves compute_attribution() at /api/attribution for the dashboard.
|
||||||
|
|
||||||
|
All queries filter on the native `timestamp` column first (payload_json casts on the full
|
||||||
|
676k-row events table time out — learned the hard way)."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
try: # optional; .env for CLI use
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
CONFIRM_MINUTE_DEFAULT = 15 # mirror TRADING_ENTRY_CONFIRM_BY_MINUTE for the early/late split
|
||||||
|
|
||||||
|
|
||||||
|
def _connect():
|
||||||
|
u = urlparse(os.environ["DATABASE_URL"])
|
||||||
|
return psycopg2.connect(host=u.hostname, port=u.port, user=u.username,
|
||||||
|
password=u.password, dbname=u.path.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_attribution(days: int = 14, confirm_minute: int | None = None) -> dict:
|
||||||
|
"""Per-day, per-stage metrics for the last `days` calendar days. Pure read."""
|
||||||
|
confirm_minute = confirm_minute or int(
|
||||||
|
os.getenv("TRADING_ENTRY_CONFIRM_BY_MINUTE", str(CONFIRM_MINUTE_DEFAULT)))
|
||||||
|
since = (date.today() - timedelta(days=days)).isoformat()
|
||||||
|
out: dict = {"since": since, "confirm_minute": confirm_minute, "days": {}}
|
||||||
|
cx = _connect()
|
||||||
|
cur = cx.cursor()
|
||||||
|
|
||||||
|
def day_of(ts):
|
||||||
|
return str(ts)[:10]
|
||||||
|
|
||||||
|
def bump(d, stage, key, n=1):
|
||||||
|
out["days"].setdefault(d, {}).setdefault(stage, {})
|
||||||
|
out["days"][d][stage][key] = out["days"][d][stage].get(key, 0) + n
|
||||||
|
|
||||||
|
# DISCOVERY + SIGNALS + EXECUTION counts (cheap: event_type + timestamp only)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT event_type, timestamp FROM public.events
|
||||||
|
WHERE timestamp >= %s AND event_type IN
|
||||||
|
('symbol_discovered','signal_ready','order_approval_requested',
|
||||||
|
'order_approved','order_submitted','order_cancelled','order_rejected')""",
|
||||||
|
(since,))
|
||||||
|
for et, ts in cur.fetchall():
|
||||||
|
bump(day_of(ts), "counts", et)
|
||||||
|
|
||||||
|
# SIGNALS early/late split (uses event wall-clock vs the 09:30 open)
|
||||||
|
cur.execute("SELECT timestamp FROM public.events WHERE timestamp >= %s AND event_type='signal_ready'",
|
||||||
|
(since,))
|
||||||
|
for (ts,) in cur.fetchall():
|
||||||
|
minute = (ts.hour - 9) * 60 + ts.minute - 30
|
||||||
|
bump(day_of(ts), "signals", "early" if 0 <= minute <= confirm_minute else "late")
|
||||||
|
|
||||||
|
# GATES histogram (needs payload rule_type; scoped by type+time so it stays fast)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT timestamp, payload_json::jsonb->>'rule_type' FROM public.events
|
||||||
|
WHERE timestamp >= %s AND event_type='risk_rule_triggered'""", (since,))
|
||||||
|
for ts, rule in cur.fetchall():
|
||||||
|
bump(day_of(ts), "gates", rule or "?")
|
||||||
|
|
||||||
|
# OUTCOMES: realized P&L per close
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT timestamp, payload_json::jsonb->>'realized_pnl',
|
||||||
|
payload_json::jsonb->>'symbol'
|
||||||
|
FROM public.events WHERE timestamp >= %s AND event_type='position_closed'""",
|
||||||
|
(since,))
|
||||||
|
for ts, pnl, sym in cur.fetchall():
|
||||||
|
d = day_of(ts)
|
||||||
|
try:
|
||||||
|
v = float(pnl)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
st = out["days"].setdefault(d, {}).setdefault("outcomes", {})
|
||||||
|
st["trades"] = st.get("trades", 0) + 1
|
||||||
|
st["pnl"] = round(st.get("pnl", 0.0) + v, 2)
|
||||||
|
st["wins"] = st.get("wins", 0) + (1 if v > 0 else 0)
|
||||||
|
st.setdefault("closes", []).append({"symbol": sym, "pnl": round(v, 2)})
|
||||||
|
cx.close()
|
||||||
|
|
||||||
|
# stage RATINGS per day (0-100-ish, honest heuristics, documented inline)
|
||||||
|
for d, st in out["days"].items():
|
||||||
|
c = st.get("counts", {})
|
||||||
|
disc = c.get("symbol_discovered", 0)
|
||||||
|
ready = c.get("signal_ready", 0)
|
||||||
|
subm = c.get("order_submitted", 0)
|
||||||
|
canc = c.get("order_cancelled", 0)
|
||||||
|
oc = st.get("outcomes", {})
|
||||||
|
trades = oc.get("trades", 0)
|
||||||
|
rating = {}
|
||||||
|
# discovery precision: what fraction of discovered names produced a ready signal
|
||||||
|
rating["discovery_precision_pct"] = round(100 * min(1.0, ready / disc), 0) if disc else None
|
||||||
|
# signal timing: fraction of ready signals inside the tradeable window
|
||||||
|
sg = st.get("signals", {})
|
||||||
|
tot_sig = sg.get("early", 0) + sg.get("late", 0)
|
||||||
|
rating["signals_early_pct"] = round(100 * sg.get("early", 0) / tot_sig, 0) if tot_sig else None
|
||||||
|
# execution efficiency: submissions that didn't get cancelled/backed out
|
||||||
|
rating["fill_efficiency_pct"] = round(100 * max(0, subm - canc) / subm, 0) if subm else None
|
||||||
|
# outcomes: the ground truth
|
||||||
|
rating["win_rate_pct"] = round(100 * oc.get("wins", 0) / trades, 0) if trades else None
|
||||||
|
rating["pnl"] = oc.get("pnl")
|
||||||
|
st["rating"] = rating
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render(att: dict) -> str:
|
||||||
|
lines = [f"=== component attribution since {att['since']} "
|
||||||
|
f"(confirm window {att['confirm_minute']}m) ==="]
|
||||||
|
hdr = (f"{'day':11}{'disc':>5}{'ready':>6}{'early%':>7}{'gate-cuts':>10}"
|
||||||
|
f"{'subm':>5}{'fill%':>6}{'trades':>7}{'win%':>6}{'P&L':>9}")
|
||||||
|
lines.append(hdr)
|
||||||
|
lines.append("-" * len(hdr))
|
||||||
|
for d in sorted(att["days"]):
|
||||||
|
st = att["days"][d]
|
||||||
|
c, r = st.get("counts", {}), st.get("rating", {})
|
||||||
|
gates = sum(st.get("gates", {}).values())
|
||||||
|
lines.append(
|
||||||
|
f"{d:11}{c.get('symbol_discovered',0):>5}{c.get('signal_ready',0):>6}"
|
||||||
|
f"{str(r.get('signals_early_pct','-')):>7}{gates:>10}"
|
||||||
|
f"{c.get('order_submitted',0):>5}{str(r.get('fill_efficiency_pct','-')):>6}"
|
||||||
|
f"{st.get('outcomes',{}).get('trades',0):>7}{str(r.get('win_rate_pct','-')):>6}"
|
||||||
|
f"{('$%+.0f' % r['pnl']) if r.get('pnl') is not None else '-':>9}")
|
||||||
|
g = st.get("gates", {})
|
||||||
|
if g:
|
||||||
|
lines.append(" gates: " + ", ".join(f"{k}x{v}" for k, v in
|
||||||
|
sorted(g.items(), key=lambda x: -x[1])))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description="per-component pipeline attribution")
|
||||||
|
ap.add_argument("--days", type=int, default=14)
|
||||||
|
args = ap.parse_args()
|
||||||
|
print(render(compute_attribution(days=args.days)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user