Review sweep: money-path fixes + security + delete 805MB dead weight
Consolidated 4-tool review (2 correctness agents + Codex + over-eng audit). Money-path (regression-tested): - _flatten_all: moved the fresh-broker close from close_session INTO the shared function + added a broker-held guard on entry cancellation, so the daily-loss CIRCUIT BREAKER (which called _flatten_all raw) no longer strips a just-filled bracket's legs or leaves a sync-gap fill naked. One guard, all callers. - restart state rebuild: _fresh_entries cap, _requested_symbols dedup, and losing benches now rebuild from today's events on boot — a mid-session restart no longer resets the cap to 0 or re-fires a resting order into a duplicate (the 2026-07-06 incident). - POST /orders no longer retries a lost-response URLError (was re-sending duplicate live orders); GET/DELETE still retry. - expire_stale_entries now @_locked (raced the trigger thread). - _session_open: ET conversion instead of a hardcoded 13:30-13:33Z window that only matched EDT — the fast path armed nothing all winter. Security: - admin panel fail-CLOSED on empty ADMIN_TOKEN (was fail-open on a panel fronting the docker socket); header-only token, constant-time compare. - admin API responses redact secret override VALUES (live keys would have echoed after a paper->live switch). - dashboard: removed wildcard CORS (cross-origin drive-by could drive trade actions); optional DASHBOARD_TOKEN gate on POST. Deletions (~805MB + dead code shipped in the prod image): momentum- rebuilt.zip, archive venv/htmlcov, and schwab/ core/ telemetry/ models/ runtime/providers/ storage/replay.py controlroom/ + three dead evaluation stubs + package.json — all verified zero-importer. +4 regression tests. 99 money-path/trigger/exit tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
cad9051a96
commit
d54a2a930c
28 files changed
+210
-1789
No files matched your search
+16
-5
@@ -16,6 +16,7 @@ Run: python admin_api.py (ADMIN_HOST/ADMIN_PORT, default 0.0.0.0:8767)
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -70,6 +71,14 @@ SEED_PROFILES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_overrides() -> dict:
|
||||||
|
"""Overrides for API responses with secret VALUES redacted — a /api/mode live
|
||||||
|
switch writes ALPACA_LIVE_* into overrides.env; without this they'd echo back
|
||||||
|
in every /api/status and /api/config response."""
|
||||||
|
return {k: ("***" if any(m in k for m in SECRET_MARKERS) else v)
|
||||||
|
for k, v in env_overrides.read_overrides().items()}
|
||||||
|
|
||||||
|
|
||||||
def _editable(key: str) -> bool:
|
def _editable(key: str) -> bool:
|
||||||
if key in EDITABLE_EXACT:
|
if key in EDITABLE_EXACT:
|
||||||
return True
|
return True
|
||||||
@@ -194,10 +203,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(raw)
|
self.wfile.write(raw)
|
||||||
|
|
||||||
def _authed(self) -> bool:
|
def _authed(self) -> bool:
|
||||||
|
# FAIL CLOSED: no token configured => deny (this panel fronts the docker
|
||||||
|
# socket + the paper/live switch; an unset env var must not open it to the
|
||||||
|
# LAN). Header only — a ?token= query would leak into proxy/access logs.
|
||||||
if not ADMIN_TOKEN:
|
if not ADMIN_TOKEN:
|
||||||
return True # explicit opt-out: no token configured
|
return False
|
||||||
return (self.headers.get("X-Admin-Token") == ADMIN_TOKEN
|
return hmac.compare_digest(self.headers.get("X-Admin-Token", ""), ADMIN_TOKEN)
|
||||||
or f"token={ADMIN_TOKEN}" in (self.path.split("?", 1) + [""])[1])
|
|
||||||
|
|
||||||
def do_GET(self): # noqa: N802
|
def do_GET(self): # noqa: N802
|
||||||
path = self.path.split("?")[0]
|
path = self.path.split("?")[0]
|
||||||
@@ -208,7 +219,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._send(401, {"error": "bad or missing X-Admin-Token"})
|
return self._send(401, {"error": "bad or missing X-Admin-Token"})
|
||||||
if path == "/api/status":
|
if path == "/api/status":
|
||||||
return self._send(200, {"containers": list_containers(), "checks": checks(),
|
return self._send(200, {"containers": list_containers(), "checks": checks(),
|
||||||
"overrides": env_overrides.read_overrides()})
|
"overrides": _safe_overrides()})
|
||||||
if path == "/api/config":
|
if path == "/api/config":
|
||||||
eff = {k: v for k, v in sorted(os.environ.items()) if _editable(k)}
|
eff = {k: v for k, v in sorted(os.environ.items()) if _editable(k)}
|
||||||
secrets = {k: bool(os.environ.get(k)) for k in
|
secrets = {k: bool(os.environ.get(k)) for k in
|
||||||
@@ -216,7 +227,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"ALPACA_LIVE_SECRET_KEY", "TELEGRAM_BOT_TOKEN", "YOUTUBE_API_KEY",
|
"ALPACA_LIVE_SECRET_KEY", "TELEGRAM_BOT_TOKEN", "YOUTUBE_API_KEY",
|
||||||
"FMP_API_KEY")}
|
"FMP_API_KEY")}
|
||||||
return self._send(200, {"effective": eff, "secrets_present": secrets,
|
return self._send(200, {"effective": eff, "secrets_present": secrets,
|
||||||
"overrides": env_overrides.read_overrides()})
|
"overrides": _safe_overrides()})
|
||||||
if path == "/api/profiles":
|
if path == "/api/profiles":
|
||||||
return self._send(200, profiles())
|
return self._send(200, profiles())
|
||||||
return self._send(404, {"error": "unknown route"})
|
return self._send(404, {"error": "unknown route"})
|
||||||
|
|||||||
@@ -137,8 +137,14 @@ class AlpacaPaperClient:
|
|||||||
continue
|
continue
|
||||||
raise AlpacaApiError(exc.code, exc.read().decode(errors="replace")) from exc
|
raise AlpacaApiError(exc.code, exc.read().decode(errors="replace")) from exc
|
||||||
except urllib.error.URLError:
|
except urllib.error.URLError:
|
||||||
# transient network/DNS (e.g. gaierror) — retry a couple times
|
# transient network/DNS (e.g. gaierror) — retry a couple times.
|
||||||
if attempt >= self.max_retries:
|
# NOT for POST: urllib wraps a lost RESPONSE (read timeout/reset)
|
||||||
|
# in URLError too, so attempt 1 may have SUCCEEDED at the broker
|
||||||
|
# with the reply lost; re-sending POST /orders then rests a
|
||||||
|
# duplicate live order (or 422s and looks like a failure while a
|
||||||
|
# real bracket rests untracked). Idempotent GET/DELETE retry; POST
|
||||||
|
# raises so the caller reconciles against broker positions.
|
||||||
|
if method == "POST" or attempt >= self.max_retries:
|
||||||
raise
|
raise
|
||||||
_time.sleep(0.4 * (attempt + 1))
|
_time.sleep(0.4 * (attempt + 1))
|
||||||
raise RuntimeError("request retries exhausted") # unreachable
|
raise RuntimeError("request retries exhausted") # unreachable
|
||||||
|
|||||||
+7
-3
@@ -297,7 +297,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
@@ -346,7 +345,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", "text/event-stream")
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
self.send_header("Cache-Control", "no-cache")
|
self.send_header("Cache-Control", "no-cache")
|
||||||
self.send_header("Connection", "keep-alive")
|
self.send_header("Connection", "keep-alive")
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
shutdown = getattr(type(self), "shutdown_event", None)
|
shutdown = getattr(type(self), "shutdown_event", None)
|
||||||
@@ -410,6 +408,13 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json({"error": str(exc)}, 500)
|
self._send_json({"error": str(exc)}, 500)
|
||||||
|
|
||||||
def do_POST(self): # noqa: N802
|
def do_POST(self): # noqa: N802
|
||||||
|
# optional shared-secret gate on trade actions (approve/reject/exit/watch).
|
||||||
|
# Off by default (autonomous stack auto-approves); set DASHBOARD_TOKEN to
|
||||||
|
# require X-Dashboard-Token on every POST. CORS wildcard was removed so a
|
||||||
|
# cross-origin LAN page can't drive these either way.
|
||||||
|
_tok = os.environ.get("DASHBOARD_TOKEN")
|
||||||
|
if _tok and not hmac.compare_digest(self.headers.get("X-Dashboard-Token", ""), _tok):
|
||||||
|
return self._send_json({"error": "bad or missing X-Dashboard-Token"}, 401)
|
||||||
try:
|
try:
|
||||||
route = urlparse(self.path).path
|
route = urlparse(self.path).path
|
||||||
body = self._read_body()
|
body = self._read_body()
|
||||||
@@ -443,7 +448,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_OPTIONS(self): # noqa: N802
|
def do_OPTIONS(self): # noqa: N802
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
"""Momentum Trading Control Room — NiceGUI live-analysis dashboard (Milestone 5, core).
|
|
||||||
|
|
||||||
Phases 1+2 of the staged control-room plan, adapted to THIS backend: a real-time
|
|
||||||
operator view with LIVE TICKERS (SIP last-trade prices at 1s), per-symbol analysis
|
|
||||||
(state, gap, RVOL, trigger distance, quality, population-gate check), live positions
|
|
||||||
P&L, today's gate activity + attribution, and the approval queue with confirm-flow
|
|
||||||
actions. Runs as its OWN service on :8766 — the existing dashboard on :8765 stays
|
|
||||||
untouched until this reaches parity (their Phase 3-5: manual orders, settings dials,
|
|
||||||
audit trail).
|
|
||||||
|
|
||||||
Data flow: read-only projections from the shared Postgres event store + Alpaca SIP
|
|
||||||
for live prices; fully AUTONOMOUS ops (owner directive: no approvals/interaction needed) — the only
|
|
||||||
action is the emergency panic brake (cancel all open orders, typed confirmation)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import date, datetime
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
|
|
||||||
|
|
||||||
from nicegui import run, ui # noqa: E402
|
|
||||||
|
|
||||||
from alpaca_paper.client import AlpacaPaperClient # noqa: E402
|
|
||||||
from alpaca_paper.settings import AlpacaPaperSettings # noqa: E402
|
|
||||||
from momentum_cli import _market_clock, _triggers_snapshot, _unprotected_positions # noqa: E402
|
|
||||||
from storage.db_pg import get_connection # noqa: E402
|
|
||||||
from storage.event_store import EventStore # noqa: E402
|
|
||||||
from storage.projections import query_approval_queue, query_ready_signals_snapshot # noqa: E402
|
|
||||||
|
|
||||||
POP_GAP, POP_VOL = 0.10, 300_000 # display mirror of the live population gate
|
|
||||||
|
|
||||||
import threading
|
|
||||||
con = get_connection("momentum")
|
|
||||||
store = EventStore("momentum")
|
|
||||||
client = AlpacaPaperClient(AlpacaPaperSettings.from_env())
|
|
||||||
DB_LOCK = threading.Lock() # fetchers run in io_bound THREADS; one psycopg2 conn is not thread-safe
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- data fetchers (sync; called via run.io_bound) ----------------
|
|
||||||
def fetch_board() -> dict:
|
|
||||||
with DB_LOCK:
|
|
||||||
trig_rows, snap_ts, armed_n = _triggers_snapshot(con)
|
|
||||||
signals = {s["symbol"]: s for s in query_ready_signals_snapshot(store)}
|
|
||||||
rows = []
|
|
||||||
for r in trig_rows:
|
|
||||||
sym = r.get("symbol")
|
|
||||||
sig = signals.get(sym, {})
|
|
||||||
gap = r.get("gap") # percent units from the book
|
|
||||||
gap_frac = (gap / 100.0) if isinstance(gap, (int, float)) else sig.get("gap_pct")
|
|
||||||
vol = sig.get("cum_volume")
|
|
||||||
pop_ok = None
|
|
||||||
if gap_frac is not None:
|
|
||||||
pop_ok = bool(gap_frac >= POP_GAP and (vol is None or vol >= POP_VOL))
|
|
||||||
rows.append({
|
|
||||||
"symbol": sym, "state": r.get("state"), "price": r.get("price"),
|
|
||||||
"gap": gap, "rvol": r.get("rvol"), "trigger": r.get("trigger"),
|
|
||||||
"to_trig": r.get("→trigger") or r.get("to_trigger"), "stop": r.get("stop"),
|
|
||||||
"quality": sig.get("quality_score"), "vwap": sig.get("vwap"),
|
|
||||||
"pop": ("✓" if pop_ok else "✗" if pop_ok is not None else "—"),
|
|
||||||
"catalyst": (r.get("catalyst") or "")[:40],
|
|
||||||
})
|
|
||||||
return {"rows": rows, "ts": str(snap_ts)[11:19] if snap_ts else "—", "armed": armed_n}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_prices(symbols: list[str]) -> dict[str, float]:
|
|
||||||
if not symbols:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
trades = client.get_latest_trades(symbols)
|
|
||||||
return {s: float(t.get("p")) for s, t in (trades or {}).items() if t and t.get("p")}
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_account() -> dict:
|
|
||||||
try:
|
|
||||||
a = client.get_account()
|
|
||||||
eq = float(a.get("equity") or 0)
|
|
||||||
last = float(a.get("last_equity") or 0)
|
|
||||||
return {"equity": eq, "day_pnl": (eq - last) if last else 0.0}
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_positions() -> list[dict]:
|
|
||||||
try:
|
|
||||||
pos = client.get_positions(fresh=True)
|
|
||||||
with DB_LOCK:
|
|
||||||
naked = {n["symbol"] for n in (_unprotected_positions(con) or [])}
|
|
||||||
return [{
|
|
||||||
"symbol": p.get("symbol"), "qty": p.get("qty"),
|
|
||||||
"avg": float(p.get("avg_entry_price") or 0),
|
|
||||||
"price": float(p.get("current_price") or 0),
|
|
||||||
"pnl": float(p.get("unrealized_pl") or 0),
|
|
||||||
"protected": "⚠ NAKED" if p.get("symbol") in naked else "✓",
|
|
||||||
} for p in pos]
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_gates() -> list[dict]:
|
|
||||||
today = date.today().isoformat()
|
|
||||||
hist: dict[str, int] = {}
|
|
||||||
with DB_LOCK:
|
|
||||||
rows = con.execute(
|
|
||||||
"SELECT payload_json FROM events WHERE event_type='risk_rule_triggered' "
|
|
||||||
"AND timestamp >= ?", [today]).fetchall()
|
|
||||||
for (pj,) in rows:
|
|
||||||
rt = (json.loads(pj or "{}").get("rule_type")) or "?"
|
|
||||||
hist[rt] = hist.get(rt, 0) + 1
|
|
||||||
return [{"gate": k, "cuts": v} for k, v in sorted(hist.items(), key=lambda x: -x[1])]
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_attribution() -> list[dict]:
|
|
||||||
from research.attribution import compute_attribution
|
|
||||||
att = compute_attribution(days=7)
|
|
||||||
out = []
|
|
||||||
for d in sorted(att["days"], reverse=True):
|
|
||||||
st = att["days"][d]
|
|
||||||
c, r = st.get("counts", {}), st.get("rating", {})
|
|
||||||
out.append({"day": d, "disc": c.get("symbol_discovered", 0),
|
|
||||||
"ready": c.get("signal_ready", 0),
|
|
||||||
"early": f"{r.get('signals_early_pct', '—')}%",
|
|
||||||
"trades": st.get("outcomes", {}).get("trades", 0),
|
|
||||||
"win": f"{r.get('win_rate_pct', '—')}%",
|
|
||||||
"pnl": r.get("pnl")})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_activity(limit: int = 40) -> list[dict]:
|
|
||||||
"""Merged live event feed for autonomous ops: entries, exits, fills, gate cuts."""
|
|
||||||
kinds = ("position_closed", "order_submitted", "order_cancelled", "signal_ready",
|
|
||||||
"risk_rule_triggered", "order_filled")
|
|
||||||
ph = ",".join(["?"] * len(kinds))
|
|
||||||
with DB_LOCK:
|
|
||||||
rows = con.execute(
|
|
||||||
f"SELECT timestamp, event_type, message FROM events "
|
|
||||||
f"WHERE event_type IN ({ph}) ORDER BY timestamp DESC LIMIT ?",
|
|
||||||
[*kinds, limit]).fetchall()
|
|
||||||
return [{"time": str(ts)[11:19], "type": et, "message": (m or "")[:140]}
|
|
||||||
for ts, et, m in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_summary() -> dict:
|
|
||||||
today = date.today().isoformat()
|
|
||||||
with DB_LOCK:
|
|
||||||
closes = con.execute(
|
|
||||||
"SELECT payload_json FROM events WHERE event_type='position_closed' "
|
|
||||||
"AND timestamp >= ?", [today]).fetchall()
|
|
||||||
subs = con.execute(
|
|
||||||
"SELECT count(*) FROM events WHERE event_type='order_submitted' "
|
|
||||||
"AND timestamp >= ?", [today]).fetchone()[0]
|
|
||||||
cuts = con.execute(
|
|
||||||
"SELECT count(*) FROM events WHERE event_type='risk_rule_triggered' "
|
|
||||||
"AND timestamp >= ?", [today]).fetchone()[0]
|
|
||||||
hb = con.execute(
|
|
||||||
"SELECT max(timestamp) FROM events WHERE event_type='module_tick'").fetchone()[0]
|
|
||||||
pnls = []
|
|
||||||
for (pj,) in closes:
|
|
||||||
try:
|
|
||||||
pnls.append(float(json.loads(pj or "{}").get("realized_pnl") or 0))
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
age = (datetime.now() - hb).total_seconds() if hb else None
|
|
||||||
return {"trades": len(pnls), "wins": sum(1 for x in pnls if x > 0),
|
|
||||||
"realized": round(sum(pnls), 2), "submissions": subs, "cuts": cuts,
|
|
||||||
"loop_age": age}
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_all_orders() -> str:
|
|
||||||
return str(client._trading("DELETE", "/orders"))[:200]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- UI ----------------
|
|
||||||
@ui.page("/")
|
|
||||||
def main_page():
|
|
||||||
ui.add_head_html("""
|
|
||||||
<style>
|
|
||||||
body { background: #0d1520; }
|
|
||||||
.font-mono { font-family: ui-monospace, Menlo, monospace; }
|
|
||||||
.text-green { color: #22C55E !important; font-weight: bold; }
|
|
||||||
.text-red { color: #EF4444 !important; font-weight: bold; }
|
|
||||||
@keyframes flash-green { 0% { background:#052e16; } 100% { background:transparent; } }
|
|
||||||
@keyframes flash-red { 0% { background:#450a0a; } 100% { background:transparent; } }
|
|
||||||
.flash-green { animation: flash-green 1s ease-out; }
|
|
||||||
.flash-red { animation: flash-red 1s ease-out; }
|
|
||||||
</style>""")
|
|
||||||
prices: dict[str, float] = {}
|
|
||||||
board_syms: list[str] = []
|
|
||||||
|
|
||||||
with ui.header().classes("items-center justify-between px-4 bg-blue-900 text-white"):
|
|
||||||
with ui.row().classes("items-center gap-3"):
|
|
||||||
ui.label("Momentum Control Room").classes("text-lg font-bold")
|
|
||||||
ui.badge("🔵 PAPER — Alpaca (Simulated)").classes("text-sm px-2")
|
|
||||||
with ui.row().classes("items-center gap-4"):
|
|
||||||
phase_lbl = ui.label("—").classes("text-sm")
|
|
||||||
eq_lbl = ui.label("equity —").classes("font-mono text-sm")
|
|
||||||
hb_lbl = ui.label("♥ —").classes("font-mono text-xs")
|
|
||||||
|
|
||||||
async def panic():
|
|
||||||
with ui.dialog() as d, ui.card().classes("p-6"):
|
|
||||||
ui.label("⛔ Cancel ALL open orders?").classes("text-lg font-bold")
|
|
||||||
ui.label('Type "STOP" to confirm:').classes("mt-2")
|
|
||||||
typed = ui.input("")
|
|
||||||
with ui.row().classes("justify-end gap-2 mt-4"):
|
|
||||||
btn = ui.button("CANCEL ALL", on_click=lambda: d.submit(True)) \
|
|
||||||
.props("color=negative")
|
|
||||||
btn.bind_enabled_from(typed, "value", backward=lambda v: v == "STOP")
|
|
||||||
ui.button("Back", on_click=lambda: d.submit(False))
|
|
||||||
if await d:
|
|
||||||
res = await run.io_bound(cancel_all_orders)
|
|
||||||
ui.notify(f"cancel-all sent: {res}", type="warning")
|
|
||||||
ui.button("⛔ STOP ALL", on_click=panic).props("color=negative outline") \
|
|
||||||
.classes("font-bold")
|
|
||||||
clock_lbl = ui.label("--:--:--").classes("font-mono")
|
|
||||||
|
|
||||||
with ui.tabs().classes("w-full") as tabs:
|
|
||||||
t_live = ui.tab("Live")
|
|
||||||
t_pos = ui.tab("Positions")
|
|
||||||
t_gates = ui.tab("Gates & Attribution")
|
|
||||||
t_act = ui.tab("Activity")
|
|
||||||
|
|
||||||
with ui.tab_panels(tabs, value=t_live).classes("w-full"):
|
|
||||||
# ---- LIVE: real-time tickers + analysis ----
|
|
||||||
with ui.tab_panel(t_live):
|
|
||||||
with ui.row().classes("gap-4 mb-2"):
|
|
||||||
def _card(title):
|
|
||||||
with ui.card().classes("p-3 min-w-32"):
|
|
||||||
ui.label(title).classes("text-xs text-grey-6")
|
|
||||||
return ui.label("—").classes("text-lg font-bold font-mono")
|
|
||||||
c_pnl = _card("Day P&L")
|
|
||||||
c_trades = _card("Trades today")
|
|
||||||
c_win = _card("Win rate")
|
|
||||||
c_subs = _card("Submissions")
|
|
||||||
c_cuts = _card("Gate cuts")
|
|
||||||
c_loop = _card("Loop ♥")
|
|
||||||
|
|
||||||
async def refresh_summary():
|
|
||||||
sm = await run.io_bound(fetch_summary)
|
|
||||||
acct = await run.io_bound(fetch_account)
|
|
||||||
pnl = acct.get("day_pnl", 0.0)
|
|
||||||
c_pnl.text = f"${pnl:+,.0f}"
|
|
||||||
c_pnl.classes(replace="text-lg font-bold font-mono " +
|
|
||||||
("text-green" if pnl >= 0 else "text-red"))
|
|
||||||
c_trades.text = str(sm["trades"])
|
|
||||||
c_win.text = (f"{100*sm['wins']/sm['trades']:.0f}%" if sm["trades"] else "—")
|
|
||||||
c_subs.text = str(sm["submissions"])
|
|
||||||
c_cuts.text = str(sm["cuts"])
|
|
||||||
age = sm.get("loop_age")
|
|
||||||
c_loop.text = f"{age:.0f}s" if age is not None else "—"
|
|
||||||
c_loop.classes(replace="text-lg font-bold font-mono " +
|
|
||||||
("text-green" if age is not None and age < 180 else "text-red"))
|
|
||||||
ui.timer(5.0, refresh_summary)
|
|
||||||
|
|
||||||
with ui.row().classes("items-center gap-4"):
|
|
||||||
ui.label("Live tickers").classes("text-lg font-bold")
|
|
||||||
snap_lbl = ui.label("snapshot —").classes("text-xs font-mono text-grey")
|
|
||||||
armed_lbl = ui.label("").classes("text-xs font-mono")
|
|
||||||
live_grid = ui.aggrid({
|
|
||||||
"columnDefs": [
|
|
||||||
{"headerName": "Sym", "field": "symbol", "pinned": "left", "width": 90},
|
|
||||||
{"headerName": "State", "field": "state", "width": 95, "cellClassRules": {
|
|
||||||
"text-green": 'value === "armed" || value === "fired"',
|
|
||||||
}},
|
|
||||||
{"headerName": "Price", "field": "price", "width": 95,
|
|
||||||
"cellClass": "font-mono", "cellClassRules": {
|
|
||||||
"flash-green": "data._d > 0", "flash-red": "data._d < 0"}},
|
|
||||||
{"headerName": "Gap%", "field": "gap", "width": 80, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "RVOL", "field": "rvol", "width": 80, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "Trigger", "field": "trigger", "width": 95, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "→Trig", "field": "to_trig", "width": 85, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "Stop", "field": "stop", "width": 90, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "Qual", "field": "quality", "width": 75, "cellClassRules": {
|
|
||||||
"text-green": "value >= 0.5", "text-red": "value < 0.5"}},
|
|
||||||
{"headerName": "Pop", "field": "pop", "width": 65, "cellClassRules": {
|
|
||||||
"text-green": 'value === "✓"', "text-red": 'value === "✗"'}},
|
|
||||||
{"headerName": "VWAP", "field": "vwap", "width": 90, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "Catalyst", "field": "catalyst", "flex": 1},
|
|
||||||
],
|
|
||||||
"rowData": [], "domLayout": "autoHeight", "animateRows": True,
|
|
||||||
}).classes("w-full")
|
|
||||||
|
|
||||||
async def refresh_board():
|
|
||||||
data = await run.io_bound(fetch_board)
|
|
||||||
board_syms[:] = [r["symbol"] for r in data["rows"] if r.get("symbol")]
|
|
||||||
for r in data["rows"]:
|
|
||||||
lp = prices.get(r["symbol"])
|
|
||||||
if lp:
|
|
||||||
old = r.get("price")
|
|
||||||
r["_d"] = (lp - old) if isinstance(old, (int, float)) else 0
|
|
||||||
r["price"] = lp
|
|
||||||
live_grid.options["rowData"] = data["rows"]
|
|
||||||
live_grid.update()
|
|
||||||
snap_lbl.text = f"loop snapshot {data['ts']}"
|
|
||||||
armed_lbl.text = f"{data['armed']} armed · {len(data['rows'])} tracked"
|
|
||||||
|
|
||||||
async def refresh_prices():
|
|
||||||
if board_syms:
|
|
||||||
fresh = await run.io_bound(fetch_prices, list(board_syms))
|
|
||||||
if fresh:
|
|
||||||
prices.update(fresh)
|
|
||||||
rows = live_grid.options["rowData"]
|
|
||||||
for r in rows:
|
|
||||||
lp = prices.get(r["symbol"])
|
|
||||||
if lp and isinstance(r.get("price"), (int, float)):
|
|
||||||
r["_d"] = round(lp - r["price"], 4)
|
|
||||||
r["price"] = lp
|
|
||||||
live_grid.update()
|
|
||||||
|
|
||||||
ui.timer(3.0, refresh_board)
|
|
||||||
ui.timer(1.0, refresh_prices)
|
|
||||||
|
|
||||||
# ---- POSITIONS ----
|
|
||||||
with ui.tab_panel(t_pos):
|
|
||||||
pos_grid = ui.aggrid({
|
|
||||||
"columnDefs": [
|
|
||||||
{"headerName": "Sym", "field": "symbol", "width": 90},
|
|
||||||
{"headerName": "Qty", "field": "qty", "width": 80, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "Avg", "field": "avg", "width": 95, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "Price", "field": "price", "width": 95, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "uP&L $", "field": "pnl", "width": 110, "cellClass": "font-mono",
|
|
||||||
"cellClassRules": {"text-green": "value >= 0", "text-red": "value < 0"}},
|
|
||||||
{"headerName": "Stop", "field": "protected", "width": 110, "cellClassRules": {
|
|
||||||
"text-red": 'value !== "✓"'}},
|
|
||||||
], "rowData": [], "domLayout": "autoHeight",
|
|
||||||
}).classes("w-full")
|
|
||||||
|
|
||||||
async def refresh_pos():
|
|
||||||
pos_grid.options["rowData"] = await run.io_bound(fetch_positions)
|
|
||||||
pos_grid.update()
|
|
||||||
ui.timer(2.0, refresh_pos)
|
|
||||||
|
|
||||||
# ---- GATES + ATTRIBUTION ----
|
|
||||||
with ui.tab_panel(t_gates):
|
|
||||||
ui.label("Today's gate activity").classes("text-lg font-bold")
|
|
||||||
gates_grid = ui.aggrid({"columnDefs": [
|
|
||||||
{"headerName": "Gate", "field": "gate", "flex": 1},
|
|
||||||
{"headerName": "Cuts today", "field": "cuts", "width": 120}],
|
|
||||||
"rowData": [], "domLayout": "autoHeight"}).classes("w-1/2")
|
|
||||||
ui.label("Component attribution (7 days)").classes("text-lg font-bold mt-6")
|
|
||||||
att_grid = ui.aggrid({"columnDefs": [
|
|
||||||
{"headerName": "Day", "field": "day", "width": 110},
|
|
||||||
{"headerName": "Disc", "field": "disc", "width": 80},
|
|
||||||
{"headerName": "Ready", "field": "ready", "width": 80},
|
|
||||||
{"headerName": "Early%", "field": "early", "width": 90},
|
|
||||||
{"headerName": "Trades", "field": "trades", "width": 80},
|
|
||||||
{"headerName": "Win%", "field": "win", "width": 80},
|
|
||||||
{"headerName": "P&L", "field": "pnl", "width": 100, "cellClass": "font-mono",
|
|
||||||
"cellClassRules": {"text-green": "value >= 0", "text-red": "value < 0"}}],
|
|
||||||
"rowData": [], "domLayout": "autoHeight"}).classes("w-full")
|
|
||||||
|
|
||||||
async def refresh_gates():
|
|
||||||
gates_grid.options["rowData"] = await run.io_bound(fetch_gates)
|
|
||||||
gates_grid.update()
|
|
||||||
ui.timer(5.0, refresh_gates)
|
|
||||||
|
|
||||||
async def load_attr():
|
|
||||||
att_grid.options["rowData"] = await run.io_bound(fetch_attribution)
|
|
||||||
att_grid.update()
|
|
||||||
ui.button("Load / refresh attribution", on_click=load_attr).props("outline")
|
|
||||||
|
|
||||||
# ---- ACTIVITY: autonomous-ops live feed ----
|
|
||||||
with ui.tab_panel(t_act):
|
|
||||||
ui.label("Live activity (autonomous — no input needed)").classes("text-lg font-bold")
|
|
||||||
act_grid = ui.aggrid({"columnDefs": [
|
|
||||||
{"headerName": "Time", "field": "time", "width": 100, "cellClass": "font-mono"},
|
|
||||||
{"headerName": "Event", "field": "type", "width": 170, "cellClassRules": {
|
|
||||||
"text-green": 'value === "position_closed" || value === "order_filled"',
|
|
||||||
"text-red": 'value === "risk_rule_triggered"'}},
|
|
||||||
{"headerName": "Detail", "field": "message", "flex": 1}],
|
|
||||||
"rowData": [], "domLayout": "autoHeight"}).classes("w-full")
|
|
||||||
|
|
||||||
async def refresh_act():
|
|
||||||
act_grid.options["rowData"] = await run.io_bound(fetch_activity)
|
|
||||||
act_grid.update()
|
|
||||||
ui.timer(4.0, refresh_act)
|
|
||||||
|
|
||||||
# ---- global header timers ----
|
|
||||||
async def refresh_header():
|
|
||||||
acct = await run.io_bound(fetch_account)
|
|
||||||
if acct:
|
|
||||||
eq_lbl.text = f"equity ${acct['equity']:,.0f}"
|
|
||||||
phase, detail, _ = _market_clock()
|
|
||||||
phase_lbl.text = f"{phase} {detail}"
|
|
||||||
hb_lbl.text = f"♥ {datetime.now().strftime('%H:%M:%S')}"
|
|
||||||
ui.timer(2.0, refresh_header)
|
|
||||||
|
|
||||||
def tick_clock():
|
|
||||||
clock_lbl.text = datetime.now().strftime("%H:%M:%S")
|
|
||||||
ui.timer(1.0, tick_clock)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ in {"__main__", "__mp_main__"}:
|
|
||||||
ui.run(host="0.0.0.0", port=int(os.environ.get("CONTROLROOM_PORT", "8766")),
|
|
||||||
title="Momentum Control Room", dark=True, show=False, reload=False,
|
|
||||||
favicon="🎯")
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
"""Module contracts and interfaces for the momentum trading system.
|
|
||||||
|
|
||||||
Every module must implement the ModuleContract protocol, defining:
|
|
||||||
- input_schema: What the module expects
|
|
||||||
- output_schema: What the module produces
|
|
||||||
- run(): Execute the module
|
|
||||||
- validate_input(): Ensure data meets contracts
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, date
|
|
||||||
from typing import Any, Protocol, TypeVar
|
|
||||||
|
|
||||||
T_Input = TypeVar("T_Input")
|
|
||||||
T_Output = TypeVar("T_Output")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ModuleMetrics:
|
|
||||||
"""Standard metrics every module should track."""
|
|
||||||
|
|
||||||
module_name: str
|
|
||||||
timestamp: datetime
|
|
||||||
duration_ms: float
|
|
||||||
input_count: int = 0
|
|
||||||
output_count: int = 0
|
|
||||||
error_count: int = 0
|
|
||||||
warnings: list[str] = field(default_factory=list)
|
|
||||||
custom_metrics: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"module_name": self.module_name,
|
|
||||||
"timestamp": self.timestamp.isoformat(),
|
|
||||||
"duration_ms": self.duration_ms,
|
|
||||||
"input_count": self.input_count,
|
|
||||||
"output_count": self.output_count,
|
|
||||||
"error_count": self.error_count,
|
|
||||||
"warnings": self.warnings,
|
|
||||||
"custom_metrics": self.custom_metrics,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ValidationFailure:
|
|
||||||
"""Single validation failure with context."""
|
|
||||||
|
|
||||||
field: str
|
|
||||||
value: Any
|
|
||||||
reason: str
|
|
||||||
severity: str = "error" # "error" or "warning"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ModuleResult:
|
|
||||||
"""Base class for all module results."""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
timestamp: datetime
|
|
||||||
metrics: ModuleMetrics
|
|
||||||
validation_failures: list[ValidationFailure] = field(default_factory=list)
|
|
||||||
|
|
||||||
def has_errors(self) -> bool:
|
|
||||||
return bool(self.validation_failures or not self.success)
|
|
||||||
|
|
||||||
def error_summary(self) -> str:
|
|
||||||
"""Human-readable error summary."""
|
|
||||||
if not self.validation_failures:
|
|
||||||
return "No errors"
|
|
||||||
errors = [f for f in self.validation_failures if f.severity == "error"]
|
|
||||||
if not errors:
|
|
||||||
return f"{len(self.validation_failures)} warnings only"
|
|
||||||
return f"{len(errors)} error(s), {len([f for f in self.validation_failures if f.severity == 'warning'])} warning(s)"
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleContract(Protocol):
|
|
||||||
"""Contract every module must fulfill."""
|
|
||||||
|
|
||||||
def run(self, input: Any) -> ModuleResult:
|
|
||||||
"""Execute the module.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input: Module-specific input conforming to input_schema
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Module-specific result inheriting from ModuleResult
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def validate_input(self, input: Any) -> tuple[bool, list[ValidationFailure]]:
|
|
||||||
"""Validate input before processing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input: Data to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_valid, failures)
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_metrics(self) -> ModuleMetrics:
|
|
||||||
"""Get last execution metrics."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# INGESTION MODULE CONTRACTS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BarIngestRequest:
|
|
||||||
"""Input contract for ingestion module."""
|
|
||||||
|
|
||||||
symbols: list[str]
|
|
||||||
session_date: date
|
|
||||||
lookback_minutes: int = 120 # how many minutes of history to fetch
|
|
||||||
source: str = "alpaca" # "alpaca", "test", "file"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BarIngestResult(ModuleResult):
|
|
||||||
"""Output contract for ingestion module."""
|
|
||||||
|
|
||||||
minute_rows_inserted: int = 0
|
|
||||||
symbols_updated: list[str] = field(default_factory=list)
|
|
||||||
source_health: dict[str, str] = field(default_factory=dict) # {source: "healthy"|"error"}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# RESEARCH MODULE CONTRACTS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResearchRequest:
|
|
||||||
"""Input contract for research module."""
|
|
||||||
|
|
||||||
symbols: list[str]
|
|
||||||
session_date: date
|
|
||||||
lookback_days: int = 30 # for daily history
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResearchResult(ModuleResult):
|
|
||||||
"""Output contract for research module."""
|
|
||||||
|
|
||||||
symbols_researched: list[str] = field(default_factory=list)
|
|
||||||
symbols_with_data: list[str] = field(default_factory=list)
|
|
||||||
symbols_missing_data: list[str] = field(default_factory=list)
|
|
||||||
total_bars_available: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# EVALUATION MODULE CONTRACTS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CriterionDetail:
|
|
||||||
"""Result of evaluating one criterion."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
passed: bool
|
|
||||||
value: float # actual metric value
|
|
||||||
threshold: float | None = None # minimum to pass
|
|
||||||
reason: str = ""
|
|
||||||
confidence: float = 1.0 # 0-1, how confident in this result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EvaluationRequest:
|
|
||||||
"""Input contract for evaluation module."""
|
|
||||||
|
|
||||||
symbol: str
|
|
||||||
bars_df: Any # pd.DataFrame with {timestamp, open, high, low, close, volume}
|
|
||||||
previous_close: float
|
|
||||||
avg_daily_volume: float
|
|
||||||
session_date: date
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EvaluationResult(ModuleResult):
|
|
||||||
"""Output contract for evaluation module."""
|
|
||||||
|
|
||||||
symbol: str = ""
|
|
||||||
status: str = "late" # "ready", "blocked", "late", "error"
|
|
||||||
score: float = 0.0 # 0-100
|
|
||||||
criteria: list[CriterionDetail] = field(default_factory=list)
|
|
||||||
entry_price: float | None = None
|
|
||||||
stop_loss: float | None = None
|
|
||||||
target_price: float | None = None
|
|
||||||
quality_score: float = 0.0 # 0-1
|
|
||||||
blocking_reason: str | None = None
|
|
||||||
metrics_dict: dict[str, float] = field(default_factory=dict) # gap%, rvol, etc.
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# EXECUTION MODULE CONTRACTS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ExecutionRequest:
|
|
||||||
"""Input contract for execution module."""
|
|
||||||
|
|
||||||
symbol: str
|
|
||||||
entry_price: float
|
|
||||||
stop_price: float
|
|
||||||
target_price: float
|
|
||||||
score: float
|
|
||||||
criteria_count: int
|
|
||||||
account_equity: float
|
|
||||||
current_positions: int
|
|
||||||
today_realized_pnl: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ExecutionResult(ModuleResult):
|
|
||||||
"""Output contract for execution module."""
|
|
||||||
|
|
||||||
symbol: str = ""
|
|
||||||
status: str = "" # "approved", "rejected", "pending_approval", "error"
|
|
||||||
reason: str = "" # why it was approved/rejected
|
|
||||||
order_id: str | None = None
|
|
||||||
shares: int | None = None
|
|
||||||
risk_dollars: float | None = None
|
|
||||||
risk_checks_applied: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# BROKER MODULE CONTRACTS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BrokerRequest:
|
|
||||||
"""Input contract for broker module."""
|
|
||||||
|
|
||||||
symbol: str
|
|
||||||
order_id: str
|
|
||||||
entry_price: float
|
|
||||||
stop_price: float
|
|
||||||
target_price: float
|
|
||||||
shares: int
|
|
||||||
order_type: str = "limit" # "limit" or "market"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BrokerResult(ModuleResult):
|
|
||||||
"""Output contract for broker module."""
|
|
||||||
|
|
||||||
symbol: str = ""
|
|
||||||
status: str = "" # "submitted", "rejected", "error"
|
|
||||||
broker_order_id: str | None = None
|
|
||||||
submitted_at: datetime | None = None
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
"""Telemetry collection and event emission for module observability."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from core.contracts import ModuleMetrics
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TelemetryCollector:
|
|
||||||
"""Collect metrics during module execution."""
|
|
||||||
|
|
||||||
module_name: str
|
|
||||||
start_time: float = field(default_factory=time.time)
|
|
||||||
input_count: int = 0
|
|
||||||
output_count: int = 0
|
|
||||||
error_count: int = 0
|
|
||||||
warnings: list[str] = field(default_factory=list)
|
|
||||||
custom_metrics: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def record_input(self, count: int = 1) -> None:
|
|
||||||
"""Record input items processed."""
|
|
||||||
self.input_count += count
|
|
||||||
|
|
||||||
def record_output(self, count: int = 1) -> None:
|
|
||||||
"""Record output items produced."""
|
|
||||||
self.output_count += count
|
|
||||||
|
|
||||||
def record_error(self) -> None:
|
|
||||||
"""Record an error occurred."""
|
|
||||||
self.error_count += 1
|
|
||||||
|
|
||||||
def record_warning(self, message: str) -> None:
|
|
||||||
"""Record a warning."""
|
|
||||||
self.warnings.append(message)
|
|
||||||
|
|
||||||
def record_metric(self, name: str, value: float | int | str) -> None:
|
|
||||||
"""Record a custom metric."""
|
|
||||||
self.custom_metrics[name] = value
|
|
||||||
|
|
||||||
def finalize(self) -> ModuleMetrics:
|
|
||||||
"""Finalize and return metrics."""
|
|
||||||
duration_ms = (time.time() - self.start_time) * 1000
|
|
||||||
return ModuleMetrics(
|
|
||||||
module_name=self.module_name,
|
|
||||||
timestamp=datetime.now(),
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
input_count=self.input_count,
|
|
||||||
output_count=self.output_count,
|
|
||||||
error_count=self.error_count,
|
|
||||||
warnings=self.warnings,
|
|
||||||
custom_metrics=self.custom_metrics,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryEmitter:
|
|
||||||
"""Emit telemetry events to event store and observability systems."""
|
|
||||||
|
|
||||||
def __init__(self, event_store=None):
|
|
||||||
"""Initialize emitter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_store: Optional EventStore for persisting telemetry events
|
|
||||||
"""
|
|
||||||
self.event_store = event_store
|
|
||||||
self.callbacks: list[Callable[[dict], None]] = []
|
|
||||||
|
|
||||||
def add_callback(self, callback: Callable[[dict], None]) -> None:
|
|
||||||
"""Add a callback to receive telemetry events."""
|
|
||||||
self.callbacks.append(callback)
|
|
||||||
|
|
||||||
def emit_module_tick(
|
|
||||||
self,
|
|
||||||
module_name: str,
|
|
||||||
stage: str,
|
|
||||||
metrics: ModuleMetrics | None = None,
|
|
||||||
errors: list[dict] | None = None,
|
|
||||||
correlation_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Emit a module tick event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
module_name: Name of module (e.g., "ingestion", "evaluation")
|
|
||||||
stage: Stage name (e.g., "started", "processing", "completed")
|
|
||||||
metrics: Optional ModuleMetrics object
|
|
||||||
errors: Optional list of error details
|
|
||||||
correlation_id: Correlation ID linking to signal_ready event
|
|
||||||
"""
|
|
||||||
event = {
|
|
||||||
"type": "module_tick",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"module": module_name,
|
|
||||||
"stage": stage,
|
|
||||||
"metrics": metrics.to_dict() if metrics else None,
|
|
||||||
"errors": errors or [],
|
|
||||||
"correlation_id": correlation_id,
|
|
||||||
}
|
|
||||||
self._emit(event)
|
|
||||||
|
|
||||||
def emit_data_validation(
|
|
||||||
self,
|
|
||||||
module_name: str,
|
|
||||||
validation_type: str,
|
|
||||||
valid: bool,
|
|
||||||
details: dict,
|
|
||||||
correlation_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Emit a data validation event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
module_name: Name of module performing validation
|
|
||||||
validation_type: Type of validation ("schema", "checksum", "business_rules")
|
|
||||||
valid: Whether validation passed
|
|
||||||
details: Validation details (errors, warnings, statistics)
|
|
||||||
correlation_id: Optional correlation ID
|
|
||||||
"""
|
|
||||||
event = {
|
|
||||||
"type": "data_validation",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"module": module_name,
|
|
||||||
"validation_type": validation_type,
|
|
||||||
"valid": valid,
|
|
||||||
"details": details,
|
|
||||||
"correlation_id": correlation_id,
|
|
||||||
}
|
|
||||||
self._emit(event)
|
|
||||||
|
|
||||||
def emit_symbol_evaluation_detail(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
stage: str,
|
|
||||||
metrics: dict[str, Any],
|
|
||||||
passed: bool,
|
|
||||||
blocking_reason: str | None = None,
|
|
||||||
correlation_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Emit detailed evaluation event for a symbol.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol: Symbol being evaluated
|
|
||||||
stage: Evaluation stage (e.g., "bars_loaded", "gap_checked", "rvol_calculated")
|
|
||||||
metrics: Metrics at this stage
|
|
||||||
passed: Whether this stage passed
|
|
||||||
blocking_reason: If not passed, why
|
|
||||||
correlation_id: Optional correlation ID
|
|
||||||
"""
|
|
||||||
event = {
|
|
||||||
"type": "symbol_evaluation_detail",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"symbol": symbol,
|
|
||||||
"stage": stage,
|
|
||||||
"metrics": metrics,
|
|
||||||
"passed": passed,
|
|
||||||
"blocking_reason": blocking_reason,
|
|
||||||
"correlation_id": correlation_id,
|
|
||||||
}
|
|
||||||
self._emit(event)
|
|
||||||
|
|
||||||
def _emit(self, event: dict) -> None:
|
|
||||||
"""Emit event to all registered callbacks and event store."""
|
|
||||||
logger.debug(f"Telemetry: {event['type']} - {event.get('module', event.get('symbol', 'n/a'))}")
|
|
||||||
|
|
||||||
# Call all registered callbacks
|
|
||||||
for callback in self.callbacks:
|
|
||||||
try:
|
|
||||||
callback(event)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Telemetry callback failed: {e}")
|
|
||||||
|
|
||||||
# Emit to event store if available
|
|
||||||
if self.event_store:
|
|
||||||
try:
|
|
||||||
# Import here to avoid circular dependency
|
|
||||||
from storage.event_schema import ModuleTickEvent, DataValidationEvent
|
|
||||||
|
|
||||||
if event["type"] == "module_tick":
|
|
||||||
self.event_store.emit(
|
|
||||||
ModuleTickEvent(
|
|
||||||
timestamp=datetime.now(),
|
|
||||||
module=event["module"],
|
|
||||||
stage=event["stage"],
|
|
||||||
duration_ms=event["metrics"]["duration_ms"]
|
|
||||||
if event["metrics"]
|
|
||||||
else 0,
|
|
||||||
input_count=event["metrics"]["input_count"]
|
|
||||||
if event["metrics"]
|
|
||||||
else 0,
|
|
||||||
output_count=event["metrics"]["output_count"]
|
|
||||||
if event["metrics"]
|
|
||||||
else 0,
|
|
||||||
metrics=event["metrics"]["custom_metrics"]
|
|
||||||
if event["metrics"]
|
|
||||||
else {},
|
|
||||||
errors=event["errors"],
|
|
||||||
correlation_id=event.get("correlation_id"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif event["type"] == "data_validation":
|
|
||||||
self.event_store.emit(
|
|
||||||
DataValidationEvent(
|
|
||||||
timestamp=datetime.now(),
|
|
||||||
module=event["module"],
|
|
||||||
validation_type=event["validation_type"],
|
|
||||||
valid=event["valid"],
|
|
||||||
details=event["details"],
|
|
||||||
correlation_id=event.get("correlation_id"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to emit telemetry event to store: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance (can be overridden in tests)
|
|
||||||
_telemetry_emitter: TelemetryEmitter | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_telemetry_emitter(emitter: TelemetryEmitter) -> None:
|
|
||||||
"""Set the global telemetry emitter."""
|
|
||||||
global _telemetry_emitter
|
|
||||||
_telemetry_emitter = emitter
|
|
||||||
|
|
||||||
|
|
||||||
def get_telemetry_emitter() -> TelemetryEmitter:
|
|
||||||
"""Get the global telemetry emitter."""
|
|
||||||
global _telemetry_emitter
|
|
||||||
if _telemetry_emitter is None:
|
|
||||||
_telemetry_emitter = TelemetryEmitter()
|
|
||||||
return _telemetry_emitter
|
|
||||||
|
|
||||||
|
|
||||||
def emit_module_tick(
|
|
||||||
module_name: str,
|
|
||||||
stage: str,
|
|
||||||
metrics: ModuleMetrics | None = None,
|
|
||||||
errors: list[dict] | None = None,
|
|
||||||
correlation_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Convenience function to emit module tick."""
|
|
||||||
get_telemetry_emitter().emit_module_tick(module_name, stage, metrics, errors, correlation_id)
|
|
||||||
|
|
||||||
|
|
||||||
def emit_data_validation(
|
|
||||||
module_name: str,
|
|
||||||
validation_type: str,
|
|
||||||
valid: bool,
|
|
||||||
details: dict,
|
|
||||||
correlation_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Convenience function to emit data validation."""
|
|
||||||
get_telemetry_emitter().emit_data_validation(
|
|
||||||
module_name, validation_type, valid, details, correlation_id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def emit_symbol_evaluation_detail(
|
|
||||||
symbol: str,
|
|
||||||
stage: str,
|
|
||||||
metrics: dict[str, Any],
|
|
||||||
passed: bool,
|
|
||||||
blocking_reason: str | None = None,
|
|
||||||
correlation_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Convenience function to emit symbol evaluation detail."""
|
|
||||||
get_telemetry_emitter().emit_symbol_evaluation_detail(
|
|
||||||
symbol, stage, metrics, passed, blocking_reason, correlation_id
|
|
||||||
)
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
"""Data validation utilities for module boundaries."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from core.contracts import ValidationFailure
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SchemaValidator:
|
|
||||||
"""Validate data against a schema."""
|
|
||||||
|
|
||||||
required_fields: dict[str, type] # {field_name: expected_type}
|
|
||||||
optional_fields: dict[str, type] | None = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.optional_fields is None:
|
|
||||||
self.optional_fields = {}
|
|
||||||
|
|
||||||
def validate(self, data: dict) -> tuple[bool, list[dict]]:
|
|
||||||
"""Validate data against schema.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Dictionary to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_valid, list of failure dicts)
|
|
||||||
"""
|
|
||||||
failures = []
|
|
||||||
|
|
||||||
# Check required fields
|
|
||||||
for field_name, expected_type in self.required_fields.items():
|
|
||||||
if field_name not in data:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": field_name,
|
|
||||||
"value": None,
|
|
||||||
"reason": "Required field missing",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif not isinstance(data[field_name], expected_type):
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": field_name,
|
|
||||||
"value": data[field_name],
|
|
||||||
"reason": f"Expected {expected_type.__name__}, got {type(data[field_name]).__name__}",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check optional fields if present
|
|
||||||
if self.optional_fields:
|
|
||||||
for field_name, expected_type in self.optional_fields.items():
|
|
||||||
if field_name in data and not isinstance(data[field_name], expected_type):
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": field_name,
|
|
||||||
"value": data[field_name],
|
|
||||||
"reason": f"Expected {expected_type.__name__}, got {type(data[field_name]).__name__}",
|
|
||||||
"severity": "warning",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return len([f for f in failures if f.severity == "error"]) == 0, failures
|
|
||||||
|
|
||||||
|
|
||||||
class RangeValidator:
|
|
||||||
"""Validate numeric values are within expected ranges."""
|
|
||||||
|
|
||||||
def __init__(self, min_val: float | None = None, max_val: float | None = None):
|
|
||||||
self.min_val = min_val
|
|
||||||
self.max_val = max_val
|
|
||||||
|
|
||||||
def validate(self, value: float, field_name: str = "value") -> tuple[bool, dict | None]:
|
|
||||||
"""Validate value is in range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: Numeric value to validate
|
|
||||||
field_name: Name of field for error message
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_valid, failure dict or None)
|
|
||||||
"""
|
|
||||||
if self.min_val is not None and value < self.min_val:
|
|
||||||
return False, {
|
|
||||||
"field": field_name,
|
|
||||||
"value": value,
|
|
||||||
"reason": f"Value {value} below minimum {self.min_val}",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.max_val is not None and value > self.max_val:
|
|
||||||
return False, {
|
|
||||||
"field": field_name,
|
|
||||||
"value": value,
|
|
||||||
"reason": f"Value {value} above maximum {self.max_val}",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
class ChecksumValidator:
|
|
||||||
"""Validate data integrity using checksums."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def compute_checksum(data: dict | list) -> str:
|
|
||||||
"""Compute simple checksum for data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Dictionary or list to checksum
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Hex checksum string
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
|
|
||||||
data_str = json.dumps(data, sort_keys=True, default=str)
|
|
||||||
return hashlib.md5(data_str.encode()).hexdigest()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_checksum(
|
|
||||||
data: dict | list, expected_checksum: str
|
|
||||||
) -> tuple[bool, dict | None]:
|
|
||||||
"""Validate data matches checksum.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Data to validate
|
|
||||||
expected_checksum: Expected checksum value
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_valid, failure dict or None)
|
|
||||||
"""
|
|
||||||
actual_checksum = ChecksumValidator.compute_checksum(data)
|
|
||||||
if actual_checksum != expected_checksum:
|
|
||||||
return False, {
|
|
||||||
"field": "checksum",
|
|
||||||
"value": actual_checksum,
|
|
||||||
"reason": f"Checksum mismatch: expected {expected_checksum}, got {actual_checksum}",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
class BusinessRuleValidator:
|
|
||||||
"""Validate data against business logic rules."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.rules: dict[str, Callable[[Any], tuple[bool, str]]] = {}
|
|
||||||
|
|
||||||
def add_rule(self, rule_name: str, rule_fn: Callable[[Any], tuple[bool, str]]) -> None:
|
|
||||||
"""Add a business rule.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rule_name: Name of the rule
|
|
||||||
rule_fn: Function that takes data and returns (is_valid, reason)
|
|
||||||
"""
|
|
||||||
self.rules[rule_name] = rule_fn
|
|
||||||
|
|
||||||
def validate(self, data: Any) -> tuple[bool, list[dict]]:
|
|
||||||
"""Apply all business rules.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Data to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_valid, list of failure dicts)
|
|
||||||
"""
|
|
||||||
failures = []
|
|
||||||
for rule_name, rule_fn in self.rules.items():
|
|
||||||
try:
|
|
||||||
is_valid, reason = rule_fn(data)
|
|
||||||
if not is_valid:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": "business_rule",
|
|
||||||
"value": None,
|
|
||||||
"reason": f"{rule_name}: {reason}",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": "business_rule",
|
|
||||||
"value": None,
|
|
||||||
"reason": f"{rule_name} raised exception: {e}",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return len(failures) == 0, failures
|
|
||||||
|
|
||||||
|
|
||||||
# Concrete validators for common cases
|
|
||||||
|
|
||||||
class BarDataValidator:
|
|
||||||
"""Validate OHLCV bar data."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_bar(bar: dict) -> tuple[bool, list[dict]]:
|
|
||||||
"""Validate a single bar.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
bar: Bar dictionary with {timestamp, open, high, low, close, volume}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_valid, list of failure dicts)
|
|
||||||
"""
|
|
||||||
failures = []
|
|
||||||
|
|
||||||
# Check required fields
|
|
||||||
required = {"timestamp", "open", "high", "low", "close", "volume"}
|
|
||||||
for field in required:
|
|
||||||
if field not in bar:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": field,
|
|
||||||
"value": None,
|
|
||||||
"reason": "Missing required field",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if failures:
|
|
||||||
return False, failures
|
|
||||||
|
|
||||||
# Check value consistency
|
|
||||||
open_val = bar["open"]
|
|
||||||
high_val = bar["high"]
|
|
||||||
low_val = bar["low"]
|
|
||||||
close_val = bar["close"]
|
|
||||||
volume_val = bar["volume"]
|
|
||||||
|
|
||||||
# High must be >= all others
|
|
||||||
if high_val < open_val or high_val < close_val or high_val < low_val:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": "high",
|
|
||||||
"value": high_val,
|
|
||||||
"reason": f"High ({high_val}) must be >= open ({open_val}), close ({close_val}), low ({low_val})",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Low must be <= all others
|
|
||||||
if low_val > open_val or low_val > close_val or low_val > high_val:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": "low",
|
|
||||||
"value": low_val,
|
|
||||||
"reason": f"Low ({low_val}) must be <= open ({open_val}), close ({close_val}), high ({high_val})",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Volume should be non-negative
|
|
||||||
if volume_val < 0:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": "volume",
|
|
||||||
"value": volume_val,
|
|
||||||
"reason": "Volume cannot be negative",
|
|
||||||
"severity": "error",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Warn on zero volume
|
|
||||||
if volume_val == 0:
|
|
||||||
failures.append(
|
|
||||||
{
|
|
||||||
"field": "volume",
|
|
||||||
"value": volume_val,
|
|
||||||
"reason": "Zero volume bar",
|
|
||||||
"severity": "warning",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return len([f for f in failures if f.severity == "error"]) == 0, failures
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Momentum Trading Control Room (NiceGUI live-analysis dashboard, :8766)
|
|
||||||
StartLimitIntervalSec=0
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=/mnt/c/code/momentum
|
|
||||||
Environment=PYTHONPATH=/mnt/c/code/momentum
|
|
||||||
ExecStart=/home/philip/.venvs/momentum/bin/python -u controlroom/app.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=append:/mnt/c/code/momentum/data/controlroom.log
|
|
||||||
StandardError=append:/mnt/c/code/momentum/data/controlroom.log
|
|
||||||
SyslogIdentifier=momentum-controlroom
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
Whitespace-only changes.
@@ -1,26 +0,0 @@
|
|||||||
"""Models for Milestone 2.
|
|
||||||
|
|
||||||
Event and data models for the event-first architecture.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class EventMode(str):
|
|
||||||
"""Runtime modes for events."""
|
|
||||||
|
|
||||||
REPLAY = "replay"
|
|
||||||
PAPER = "paper"
|
|
||||||
LIVE = "live"
|
|
||||||
|
|
||||||
|
|
||||||
class BaseEvent(BaseModel):
|
|
||||||
"""Base event with common fields."""
|
|
||||||
|
|
||||||
timestamp: str = Field(description="Event timestamp (ISO format)")
|
|
||||||
mode: EventMode = Field(description="Runtime mode (replay/paper/live)")
|
|
||||||
event_type: str = Field(description="Event type identifier")
|
|
||||||
correlation_id: str | None = Field(
|
|
||||||
default=None, description="Correlation ID for causal chain"
|
|
||||||
)
|
|
||||||
message: str = Field(description="Human-readable message")
|
|
||||||
Generated
-18
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "momentum",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"claude": "^0.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/claude": {
|
|
||||||
"version": "0.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
|
|
||||||
"integrity": "sha512-j7oSibqQdIODNhkI1sEJzHMiPsF43L/GqNbcA+eDDyGM10+x2sH9NW/PK6vM3z0J2tLDKMBcc5ZjVaoRinhuCA==",
|
|
||||||
"license": "ISC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"claude": "^0.1.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-7
@@ -411,13 +411,21 @@ def _session_open(con, symbol: str, session_date) -> float | None:
|
|||||||
"AND is_regular_hours ORDER BY timestamp LIMIT 1", [symbol, session_date]
|
"AND is_regular_hours ORDER BY timestamp LIMIT 1", [symbol, session_date]
|
||||||
).fetchall()
|
).fetchall()
|
||||||
if r:
|
if r:
|
||||||
# trust it ONLY if it is really the opening bar (<= 09:33 ET = 13:33Z):
|
# trust it ONLY if it is really the 09:30 ET opening bar: mid-day-
|
||||||
# mid-day-discovered names briefly have bars starting mid-session, and
|
# discovered names briefly have bars starting mid-session, and treating
|
||||||
# treating that first bar as "the open" is how TVRD/FRTT armed as fake
|
# that first bar as "the open" is how TVRD/FRTT armed as fake 20%
|
||||||
# 20% gappers on 2026-07-08.
|
# gappers on 2026-07-08. minute_bars.timestamp is UTC-naive, so the
|
||||||
ts = str(r[0][1])
|
# open is 13:30Z in summer (EDT) but 14:30Z in winter (EST) — convert
|
||||||
hhmm = ts[11:16]
|
# to ET instead of a fixed UTC window, else the whole fast path arms
|
||||||
if "13:30" <= hhmm <= "13:33":
|
# nothing all winter.
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
raw = r[0][1]
|
||||||
|
ts = raw if isinstance(raw, datetime) else datetime.fromisoformat(str(raw))
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
et_hhmm = ts.astimezone(ZoneInfo("America/New_York")).strftime("%H:%M")
|
||||||
|
if "09:30" <= et_hhmm <= "09:33":
|
||||||
_SESSION_OPEN_CACHE[key] = float(r[0][0])
|
_SESSION_OPEN_CACHE[key] = float(r[0][0])
|
||||||
return _SESSION_OPEN_CACHE[key]
|
return _SESSION_OPEN_CACHE[key]
|
||||||
except Exception: # noqa: BLE001 — fail-open; gap falls back to window/day_open
|
except Exception: # noqa: BLE001 — fail-open; gap falls back to window/day_open
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"""Market-data provider registry."""
|
|
||||||
|
|
||||||
from runtime.providers.registry import (
|
|
||||||
ProviderKind,
|
|
||||||
ProviderConfig,
|
|
||||||
ProviderRegistry,
|
|
||||||
default_registry,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = ["ProviderKind", "ProviderConfig", "ProviderRegistry", "default_registry"]
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Provider registry for market-data sources.
|
|
||||||
|
|
||||||
(Repaired: ProviderConfig is a proper top-level dataclass and the registry
|
|
||||||
initializes its mapping correctly — the original nested the dataclass inside
|
|
||||||
the enum and used an invalid `self.providers: {}` annotation-as-assignment.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderKind(str, Enum):
|
|
||||||
"""Supported market-data providers."""
|
|
||||||
|
|
||||||
ALPACA = "alpaca"
|
|
||||||
SCHWAB = "schwab"
|
|
||||||
POLYGON = "polygon"
|
|
||||||
FINNHUB = "finnhub"
|
|
||||||
SYNTHETIC = "synthetic"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderConfig:
|
|
||||||
"""Configuration for one provider."""
|
|
||||||
|
|
||||||
kind: ProviderKind
|
|
||||||
enabled: bool = True
|
|
||||||
priority: int = 100 # lower = preferred
|
|
||||||
rate_limit_per_minute: int = 200
|
|
||||||
supports_minute_bars: bool = True
|
|
||||||
supports_quotes: bool = True
|
|
||||||
env_keys: tuple[str, ...] = field(default_factory=tuple)
|
|
||||||
|
|
||||||
def is_configured(self, env: dict[str, str] | None = None) -> bool:
|
|
||||||
"""A provider is configured when all of its env keys are present."""
|
|
||||||
values = dict(os.environ)
|
|
||||||
if env is not None:
|
|
||||||
values.update(env)
|
|
||||||
return all(values.get(key) for key in self.env_keys)
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderRegistry:
|
|
||||||
"""Registry of provider configs with priority-based selection."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.providers: dict[ProviderKind, ProviderConfig] = {}
|
|
||||||
|
|
||||||
def register(self, config: ProviderConfig) -> None:
|
|
||||||
self.providers[config.kind] = config
|
|
||||||
|
|
||||||
def get(self, kind: ProviderKind | str) -> ProviderConfig | None:
|
|
||||||
if isinstance(kind, str):
|
|
||||||
try:
|
|
||||||
kind = ProviderKind(kind)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return self.providers.get(kind)
|
|
||||||
|
|
||||||
def enabled_providers(self) -> list[ProviderConfig]:
|
|
||||||
return sorted(
|
|
||||||
(p for p in self.providers.values() if p.enabled),
|
|
||||||
key=lambda p: p.priority,
|
|
||||||
)
|
|
||||||
|
|
||||||
def best_configured(
|
|
||||||
self, env: dict[str, str] | None = None
|
|
||||||
) -> ProviderConfig | None:
|
|
||||||
for provider in self.enabled_providers():
|
|
||||||
if provider.is_configured(env):
|
|
||||||
return provider
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def default_registry() -> ProviderRegistry:
|
|
||||||
"""Registry with the standard provider stack."""
|
|
||||||
registry = ProviderRegistry()
|
|
||||||
registry.register(
|
|
||||||
ProviderConfig(
|
|
||||||
kind=ProviderKind.ALPACA,
|
|
||||||
priority=10,
|
|
||||||
env_keys=("ALPACA_API_KEY", "ALPACA_SECRET_KEY"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
ProviderConfig(
|
|
||||||
kind=ProviderKind.SCHWAB,
|
|
||||||
priority=20,
|
|
||||||
env_keys=("SCHWAB_MARKET_DATA_APP_KEY", "SCHWAB_MARKET_DATA_APP_SECRET"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
ProviderConfig(
|
|
||||||
kind=ProviderKind.POLYGON, priority=30, env_keys=("POLYGON_API_KEY",)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
ProviderConfig(
|
|
||||||
kind=ProviderKind.FINNHUB, priority=40, env_keys=("FINNHUB_API_KEY",)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
ProviderConfig(
|
|
||||||
kind=ProviderKind.SYNTHETIC,
|
|
||||||
priority=1000,
|
|
||||||
env_keys=(),
|
|
||||||
supports_quotes=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return registry
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
"""Replay/rebuild tooling for Milestone 2.
|
|
||||||
|
|
||||||
Reconstruct state from event store for analysis and testing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .event_store import EventStore
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class StateRebuilder:
|
|
||||||
"""Rebuild trading state from event store."""
|
|
||||||
|
|
||||||
def __init__(self, store: EventStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def rebuild_session(self, session_id: str) -> dict:
|
|
||||||
"""Rebuild complete state for a session from events."""
|
|
||||||
logger.info(f"Rebuilding session {session_id}")
|
|
||||||
|
|
||||||
events = self.store.query_events(session_id=session_id)
|
|
||||||
|
|
||||||
state = {
|
|
||||||
"session_id": session_id,
|
|
||||||
"symbols": {},
|
|
||||||
"orders": {},
|
|
||||||
"positions": {},
|
|
||||||
"events": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
for event in events:
|
|
||||||
event_type = event["event_type"]
|
|
||||||
payload = json.loads(event.get("payload_json", "{}"))
|
|
||||||
symbol = payload.get("symbol")
|
|
||||||
|
|
||||||
self._process_event(state, event_type, symbol, payload, event)
|
|
||||||
|
|
||||||
logger.info(f"Rebuilt state for {len(events)} events")
|
|
||||||
return state
|
|
||||||
|
|
||||||
def _process_event(
|
|
||||||
self,
|
|
||||||
state: dict,
|
|
||||||
event_type: str,
|
|
||||||
symbol: str | None,
|
|
||||||
payload: dict,
|
|
||||||
event: dict,
|
|
||||||
):
|
|
||||||
"""Process individual event and update state."""
|
|
||||||
if event_type == "symbol_discovered":
|
|
||||||
state["symbols"][symbol] = {
|
|
||||||
"state": "discovered",
|
|
||||||
"discovered_at": event["timestamp"],
|
|
||||||
}
|
|
||||||
|
|
||||||
elif event_type == "symbol_state_changed":
|
|
||||||
if symbol not in state["symbols"]:
|
|
||||||
state["symbols"][symbol] = {
|
|
||||||
"state": payload.get("previous_state") or "discovered",
|
|
||||||
"discovered_at": event["timestamp"],
|
|
||||||
}
|
|
||||||
state["symbols"][symbol]["state"] = payload["new_state"]
|
|
||||||
state["symbols"][symbol]["state_history"] = state["symbols"][symbol].get(
|
|
||||||
"state_history", []
|
|
||||||
)
|
|
||||||
state["symbols"][symbol]["state_history"].append(
|
|
||||||
{
|
|
||||||
"state": payload["new_state"],
|
|
||||||
"timestamp": event["timestamp"],
|
|
||||||
"reason": payload.get("state_reason"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event_type == "signal_ready":
|
|
||||||
if symbol in state["symbols"]:
|
|
||||||
state["symbols"][symbol]["signals"] = state["symbols"][symbol].get(
|
|
||||||
"signals", []
|
|
||||||
)
|
|
||||||
state["symbols"][symbol]["signals"].append(
|
|
||||||
{
|
|
||||||
"signal_type": payload["signal_type"],
|
|
||||||
"confidence": payload["confidence"],
|
|
||||||
"timestamp": event["timestamp"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event_type == "position_opened":
|
|
||||||
position_id = payload["position_id"]
|
|
||||||
state["positions"][position_id] = {
|
|
||||||
"symbol": symbol,
|
|
||||||
"status": "open",
|
|
||||||
"entry_price": payload["entry_price"],
|
|
||||||
"quantity": payload["quantity"],
|
|
||||||
"stop_loss_price": payload["stop_loss_price"],
|
|
||||||
"opened_at": event["timestamp"],
|
|
||||||
}
|
|
||||||
|
|
||||||
elif event_type == "position_closed":
|
|
||||||
position_id = payload["position_id"]
|
|
||||||
if position_id in state["positions"]:
|
|
||||||
state["positions"][position_id].update(
|
|
||||||
{
|
|
||||||
"status": "closed",
|
|
||||||
"exit_price": payload["exit_price"],
|
|
||||||
"exit_reason": payload["exit_reason"],
|
|
||||||
"realized_pnl": payload["realized_pnl"],
|
|
||||||
"closed_at": event["timestamp"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event_type == "order_filled":
|
|
||||||
state["events"].append(
|
|
||||||
{
|
|
||||||
"type": event_type,
|
|
||||||
"order_id": payload["order_id"],
|
|
||||||
"symbol": symbol,
|
|
||||||
"timestamp": event["timestamp"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event_type == "order_submitted":
|
|
||||||
order_id = payload["order_id"]
|
|
||||||
state["orders"][order_id] = {
|
|
||||||
"symbol": symbol,
|
|
||||||
"status": "submitted",
|
|
||||||
"side": payload.get("side"),
|
|
||||||
"quantity": payload.get("quantity"),
|
|
||||||
"price": payload.get("price"),
|
|
||||||
"updated_at": event["timestamp"],
|
|
||||||
}
|
|
||||||
|
|
||||||
elif event_type == "order_cancelled":
|
|
||||||
order_id = payload["order_id"]
|
|
||||||
state["orders"][order_id] = {
|
|
||||||
**state["orders"].get(order_id, {}),
|
|
||||||
"symbol": symbol,
|
|
||||||
"status": "cancelled",
|
|
||||||
"cancel_reason": payload.get("cancel_reason"),
|
|
||||||
"updated_at": event["timestamp"],
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Optional LLM (Ollama) enrichment hooks. Disabled unless configured."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_setup_with_llm(
|
|
||||||
setup: dict,
|
|
||||||
host: str = "http://localhost:11434",
|
|
||||||
model: str = "mistral",
|
|
||||||
timeout: int = 15,
|
|
||||||
) -> str | None:
|
|
||||||
"""Ask a local Ollama model to summarize a setup. Returns None on failure."""
|
|
||||||
prompt = (
|
|
||||||
"Summarize this momentum trading setup in one sentence:\n"
|
|
||||||
+ json.dumps(setup, default=str)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{host}/api/generate",
|
|
||||||
data=json.dumps(
|
|
||||||
{"model": model, "prompt": prompt, "stream": False}
|
|
||||||
).encode(),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
body = json.loads(resp.read().decode())
|
|
||||||
return body.get("response")
|
|
||||||
except Exception:
|
|
||||||
logger.debug("LLM enrichment unavailable", exc_info=True)
|
|
||||||
return None
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Persist setup evaluations into the research DB (setup_events table)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
def store_setup_event(con, symbol: str, session_date, result: dict) -> str:
|
|
||||||
"""Insert one evaluation result into setup_events. Returns setup_id."""
|
|
||||||
setup_id = str(uuid.uuid4())
|
|
||||||
setups = result.get("setups") or []
|
|
||||||
best = setups[0] if setups else {}
|
|
||||||
con.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO setup_events
|
|
||||||
(setup_id, symbol, setup_time, session_date, setup_name,
|
|
||||||
entry_reference_price, invalidation_price, gap_pct, relative_volume)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
setup_id,
|
|
||||||
symbol,
|
|
||||||
datetime.now(),
|
|
||||||
session_date,
|
|
||||||
best.get("setup_type") or "first_pullback",
|
|
||||||
best.get("entry_price"),
|
|
||||||
best.get("stop_loss_price"),
|
|
||||||
result.get("gap_pct"),
|
|
||||||
result.get("relative_volume"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return setup_id
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Lightweight in-memory tracking of evaluation history per symbol."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
|
|
||||||
class EvaluationTracker:
|
|
||||||
"""Tracks recent evaluation results per symbol for debounce/analysis."""
|
|
||||||
|
|
||||||
def __init__(self, max_history: int = 50):
|
|
||||||
self.max_history = max_history
|
|
||||||
self._history: dict[str, list[dict]] = defaultdict(list)
|
|
||||||
|
|
||||||
def record(self, symbol: str, result: dict) -> None:
|
|
||||||
history = self._history[symbol]
|
|
||||||
history.append(result)
|
|
||||||
if len(history) > self.max_history:
|
|
||||||
del history[: len(history) - self.max_history]
|
|
||||||
|
|
||||||
def last(self, symbol: str) -> dict | None:
|
|
||||||
history = self._history.get(symbol)
|
|
||||||
return history[-1] if history else None
|
|
||||||
|
|
||||||
def consecutive_status(self, symbol: str, status: str) -> int:
|
|
||||||
count = 0
|
|
||||||
for result in reversed(self._history.get(symbol, [])):
|
|
||||||
if result.get("status") == status:
|
|
||||||
count += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return count
|
|
||||||
Whitespace-only changes.
@@ -1,102 +0,0 @@
|
|||||||
"""Telemetry event emission and querying for runtime observability."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryEventType(str, Enum):
|
|
||||||
SOURCE_DEGRADED = "SOURCE_DEGRADED"
|
|
||||||
SOURCE_RECOVERED = "SOURCE_RECOVERED"
|
|
||||||
SOURCE_COOLDOWN = "SOURCE_COOLDOWN"
|
|
||||||
WATCHLIST_ADD = "WATCHLIST_ADD"
|
|
||||||
WATCHLIST_DROP = "WATCHLIST_DROP"
|
|
||||||
CANDIDATE_STATE_CHANGE = "CANDIDATE_STATE_CHANGE"
|
|
||||||
FETCH_ATTEMPT = "FETCH_ATTEMPT"
|
|
||||||
ENRICHMENT_QUEUED = "ENRICHMENT_QUEUED"
|
|
||||||
ENRICHMENT_DONE = "ENRICHMENT_DONE"
|
|
||||||
ENRICHMENT_FAILED = "ENRICHMENT_FAILED"
|
|
||||||
|
|
||||||
|
|
||||||
def emit_event(
|
|
||||||
con,
|
|
||||||
event_type: TelemetryEventType | str,
|
|
||||||
session_id: str | None = None,
|
|
||||||
symbol: str | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
from_state: str | None = None,
|
|
||||||
to_state: str | None = None,
|
|
||||||
reason: str | None = None,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
event_id = str(uuid.uuid4())
|
|
||||||
event_type_val = (
|
|
||||||
event_type.value
|
|
||||||
if isinstance(event_type, TelemetryEventType)
|
|
||||||
else str(event_type)
|
|
||||||
)
|
|
||||||
metadata_json = json.dumps(metadata) if metadata is not None else None
|
|
||||||
try:
|
|
||||||
con.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO telemetry_events
|
|
||||||
(id, event_type, session_id, symbol, source, from_state, to_state, reason, metadata_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
event_id,
|
|
||||||
event_type_val,
|
|
||||||
session_id,
|
|
||||||
symbol,
|
|
||||||
source,
|
|
||||||
from_state,
|
|
||||||
to_state,
|
|
||||||
reason,
|
|
||||||
metadata_json,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"telemetry emit_event failed — event_type=%s symbol=%s",
|
|
||||||
event_type_val,
|
|
||||||
symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def query_events(
|
|
||||||
con,
|
|
||||||
session_id: str | None = None,
|
|
||||||
symbol: str | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> list[dict]:
|
|
||||||
clauses: list[str] = []
|
|
||||||
params: list = []
|
|
||||||
|
|
||||||
if session_id is not None:
|
|
||||||
clauses.append("session_id = ?")
|
|
||||||
params.append(session_id)
|
|
||||||
if symbol is not None:
|
|
||||||
clauses.append("symbol = ?")
|
|
||||||
params.append(symbol)
|
|
||||||
if source is not None:
|
|
||||||
clauses.append("source = ?")
|
|
||||||
params.append(source)
|
|
||||||
|
|
||||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
||||||
sql = f"SELECT * FROM telemetry_events {where} ORDER BY created_at DESC LIMIT ?"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
rows = con.execute(sql, params).fetchall()
|
|
||||||
cols = [
|
|
||||||
desc[0]
|
|
||||||
for desc in con.execute(
|
|
||||||
f"SELECT * FROM telemetry_events {where} LIMIT 0", params[:-1]
|
|
||||||
).description
|
|
||||||
]
|
|
||||||
return [dict(zip(cols, row)) for row in rows]
|
|
||||||
@@ -231,3 +231,31 @@ def test_reconcile_seeds_from_persisted_snapshot_after_restart(store):
|
|||||||
rows = _closed(store)
|
rows = _closed(store)
|
||||||
assert len(rows) == 1 and rows[0]["symbol"] == "REST"
|
assert len(rows) == 1 and rows[0]["symbol"] == "REST"
|
||||||
assert rows[0]["realized_pnl"] == 5.0 # (2.5 - 2.0) * 10
|
assert rows[0]["realized_pnl"] == 5.0 # (2.5 - 2.0) * 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_not_retried_on_urlerror(monkeypatch):
|
||||||
|
"""Lost-response URLError on POST /orders must NOT re-send (duplicate live
|
||||||
|
order, 2026-07-06). GET/DELETE still retry."""
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from alpaca_paper.client import AlpacaPaperClient
|
||||||
|
from alpaca_paper.settings import AlpacaPaperSettings
|
||||||
|
cli = AlpacaPaperClient.__new__(AlpacaPaperClient)
|
||||||
|
cli.timeout = 1
|
||||||
|
cli.max_retries = 2
|
||||||
|
cli._headers = lambda: {}
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def boom(req, timeout=None):
|
||||||
|
calls["n"] += 1
|
||||||
|
raise urllib.error.URLError("connection reset")
|
||||||
|
|
||||||
|
monkeypatch.setattr(urllib.request, "urlopen", boom)
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(urllib.error.URLError):
|
||||||
|
cli._request("POST", "https://x/v2/orders", body={"symbol": "AAA"})
|
||||||
|
assert calls["n"] == 1, "POST must not retry a lost-response URLError"
|
||||||
|
calls["n"] = 0
|
||||||
|
with pytest.raises(urllib.error.URLError):
|
||||||
|
cli._request("GET", "https://x/v2/account")
|
||||||
|
assert calls["n"] > 1, "idempotent GET should still retry"
|
||||||
File renamed without changes.
@@ -875,3 +875,59 @@ def test_session_gate_bars_after_hours_entries():
|
|||||||
assert svc._outside_session(datetime(2026, 7, 9, 9, 30, tzinfo=ET)) is None # the open
|
assert svc._outside_session(datetime(2026, 7, 9, 9, 30, tzinfo=ET)) is None # the open
|
||||||
svc.settings = ExecutionSettings(session_only=False)
|
svc.settings = ExecutionSettings(session_only=False)
|
||||||
assert svc._outside_session(datetime(2026, 7, 9, 20, 9, tzinfo=ET)) is None # opt-out works
|
assert svc._outside_session(datetime(2026, 7, 9, 20, 9, tzinfo=ET)) is None # opt-out works
|
||||||
|
|
||||||
|
|
||||||
|
def test_flatten_all_closes_broker_position_missed_by_snapshot(store):
|
||||||
|
"""Naked-through-breaker fix: a position that filled in the sync gap isn't in
|
||||||
|
the event-store held set, so _flatten_all's step-2 skips it. Step 3 (moved out
|
||||||
|
of close_session so the daily-loss breaker gets it too) closes it from fresh
|
||||||
|
broker positions."""
|
||||||
|
from alpaca_paper.execution import AlpacaPaperExecutor
|
||||||
|
|
||||||
|
class _Cli:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = []
|
||||||
|
self.pos = [{"symbol": "GAP", "qty": "100"}] # filled, snapshot missed it
|
||||||
|
|
||||||
|
def get_positions(self, fresh=False):
|
||||||
|
return list(self.pos)
|
||||||
|
|
||||||
|
def close_position(self, symbol, qty=None, percentage=None):
|
||||||
|
self.closed.append(symbol)
|
||||||
|
self.pos = [p for p in self.pos if p["symbol"] != symbol]
|
||||||
|
return {"id": "c"}
|
||||||
|
|
||||||
|
def get_orders(self, **kw):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def cancel_order(self, oid):
|
||||||
|
pass
|
||||||
|
|
||||||
|
cli = _Cli()
|
||||||
|
svc = TradingExecutionService(
|
||||||
|
store, executor=AlpacaPaperExecutor(store, client=cli),
|
||||||
|
settings=ExecutionSettings(auto_approve=True), session_id="t")
|
||||||
|
result = svc._flatten_all("daily_loss_circuit_breaker")
|
||||||
|
assert "GAP" in cli.closed, "breaker flatten must close broker positions the snapshot missed"
|
||||||
|
assert "GAP" in result["closed_positions"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_state_rebuilds_from_events_on_restart(store):
|
||||||
|
"""Mid-session restart must not reset the entry cap to 0 or forget resting
|
||||||
|
orders (re-fire => duplicate order, 2026-07-06). A new service instance
|
||||||
|
rebuilds fresh-fill count, requested symbols, and losing benches from today."""
|
||||||
|
from storage.event_schema import OrderSubmittedEvent, OrderFilledEvent, PositionClosedEvent
|
||||||
|
now = datetime.now()
|
||||||
|
store.emit(OrderSubmittedEvent(timestamp=now, mode=EventMode.PAPER, correlation_id="t",
|
||||||
|
message="sub", order_id="o1", symbol="AAA", side="buy", quantity=10, price=5.0,
|
||||||
|
broker_order_id="b1"))
|
||||||
|
store.emit(OrderFilledEvent(timestamp=now, mode=EventMode.PAPER, correlation_id="t",
|
||||||
|
message="fill", order_id="o1", symbol="AAA", fill_price=5.0, fill_quantity=10,
|
||||||
|
broker_order_id="b1"))
|
||||||
|
store.emit(PositionClosedEvent(timestamp=now, mode=EventMode.PAPER, correlation_id="t",
|
||||||
|
message="closed", position_id="p1", symbol="AAA", side="buy", quantity=10,
|
||||||
|
entry_price=5.0, exit_price=4.5, realized_pnl=-5.0, exit_reason="stop"))
|
||||||
|
svc = make_service(store) # boot AFTER the events land
|
||||||
|
assert svc._fresh_entries["n"] == 1, "cap count must survive restart"
|
||||||
|
assert "AAA" in svc._requested_symbols, "resting-order dedup must survive restart"
|
||||||
|
assert "AAA" in svc._exited_today, "losing bench must survive restart"
|
||||||
+80
-17
@@ -403,6 +403,59 @@ class TradingExecutionService:
|
|||||||
)
|
)
|
||||||
# serializes state mutations across the main loop and the trigger thread
|
# serializes state mutations across the main loop and the trigger thread
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
# rebuild the memory-only daily state from today's events so a mid-session
|
||||||
|
# restart doesn't reset the entry cap to 0, un-bench stopped-out names, or
|
||||||
|
# forget which symbols already have a resting order (re-fire => duplicate
|
||||||
|
# order, the 2026-07-06 incident). Best-effort; a fresh DB is fine.
|
||||||
|
self._rebuild_daily_state()
|
||||||
|
|
||||||
|
def _rebuild_daily_state(self) -> None:
|
||||||
|
"""Restore per-day counters/benches from today's event stream on boot.
|
||||||
|
|
||||||
|
ponytail: rebuilds the SLOW-path dedup + cap + bench only. The fast
|
||||||
|
trigger book (run_live_paper) is separately memory-only; the standing
|
||||||
|
broker-open-orders diff after a restart is still the backstop for a
|
||||||
|
resting fast-path order (see momentum-live-loop-architecture memory).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
today = self._now().date()
|
||||||
|
since = today.isoformat()
|
||||||
|
|
||||||
|
def _payload(e):
|
||||||
|
raw = e.get("payload") or e.get("payload_json")
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
return _json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
return raw or {}
|
||||||
|
|
||||||
|
# order_filled carries no side; map fills back to their BUY submit so
|
||||||
|
# sell/exit fills don't inflate the fresh-entry cap
|
||||||
|
buy_order_ids = set()
|
||||||
|
for e in self.store.query_events(event_type="order_submitted", since=since, limit=500):
|
||||||
|
p = _payload(e)
|
||||||
|
sym, oid = p.get("symbol"), p.get("order_id")
|
||||||
|
if sym:
|
||||||
|
self._requested_symbols.add(str(sym))
|
||||||
|
if oid and str(p.get("side", "buy")).lower() == "buy":
|
||||||
|
buy_order_ids.add(str(oid))
|
||||||
|
n_fresh = sum(
|
||||||
|
1 for e in self.store.query_events(event_type="order_filled", since=since, limit=500)
|
||||||
|
if str(_payload(e).get("order_id")) in buy_order_ids)
|
||||||
|
self._fresh_entries = {"date": today, "n": n_fresh}
|
||||||
|
for e in self.store.query_events(event_type="position_closed", since=since, limit=500):
|
||||||
|
p = _payload(e)
|
||||||
|
sym = p.get("symbol")
|
||||||
|
# bench only names that closed at a LOSS (mirror the live bench rule)
|
||||||
|
if sym and float(p.get("realized_pnl") or 0) < 0:
|
||||||
|
self._exited_today.add(str(sym))
|
||||||
|
if n_fresh or self._requested_symbols or self._exited_today:
|
||||||
|
logger.info("restored daily state: %d fresh fills, %d requested, %d benched",
|
||||||
|
n_fresh, len(self._requested_symbols), len(self._exited_today))
|
||||||
|
except Exception as exc: # noqa: BLE001 — a rebuild failure must not block boot
|
||||||
|
logger.warning("daily-state rebuild skipped: %s", exc)
|
||||||
|
|
||||||
# -- pipeline -----------------------------------------------------------
|
# -- pipeline -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -499,10 +552,18 @@ class TradingExecutionService:
|
|||||||
block the rest. Resting bracket legs are cancelled before each close so
|
block the rest. Resting bracket legs are cancelled before each close so
|
||||||
the held quantity is freed (else close_position 403s)."""
|
the held quantity is freed (else close_position 403s)."""
|
||||||
result: dict = {"cancelled_entries": 0, "closed_positions": [], "errors": []}
|
result: dict = {"cancelled_entries": 0, "closed_positions": [], "errors": []}
|
||||||
|
client = getattr(self.executor, "client", None)
|
||||||
|
# broker truth up front: an entry that FILLED in the sync gap is still in
|
||||||
|
# _armed; cancelling its bracket parent would strip the stop/TP legs and
|
||||||
|
# leave the position naked. Never cancel a name the broker holds — flatten
|
||||||
|
# it in step 2 instead. None => positions unreadable, stay conservative.
|
||||||
|
broker_held = self._broker_held_symbols() or set()
|
||||||
# 1) cancel unfilled (armed) entries we are tracking
|
# 1) cancel unfilled (armed) entries we are tracking
|
||||||
for order_id in list(self._armed):
|
for order_id in list(self._armed):
|
||||||
armed = self._armed.pop(order_id)
|
armed = self._armed.pop(order_id)
|
||||||
symbol = armed.get("symbol", "")
|
symbol = armed.get("symbol", "")
|
||||||
|
if symbol in broker_held:
|
||||||
|
continue # filled — leave the bracket intact; step 2/3 closes it
|
||||||
try:
|
try:
|
||||||
self.executor.cancel_entry(
|
self.executor.cancel_entry(
|
||||||
order_id, armed.get("broker_order_id"), symbol, reason
|
order_id, armed.get("broker_order_id"), symbol, reason
|
||||||
@@ -511,8 +572,7 @@ class TradingExecutionService:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
result["errors"].append(f"cancel {symbol}: {exc}")
|
result["errors"].append(f"cancel {symbol}: {exc}")
|
||||||
self._requested_symbols.discard(symbol)
|
self._requested_symbols.discard(symbol)
|
||||||
# 2) market-close open positions
|
# 2) market-close open positions from the store snapshot
|
||||||
client = getattr(self.executor, "client", None)
|
|
||||||
for symbol in sorted(self._held_symbols()):
|
for symbol in sorted(self._held_symbols()):
|
||||||
if client is None or not hasattr(client, "close_position"):
|
if client is None or not hasattr(client, "close_position"):
|
||||||
result["errors"].append(f"close {symbol}: no broker client to flatten")
|
result["errors"].append(f"close {symbol}: no broker client to flatten")
|
||||||
@@ -522,6 +582,22 @@ class TradingExecutionService:
|
|||||||
result["closed_positions"].append(symbol) # only on a confirmed close
|
result["closed_positions"].append(symbol) # only on a confirmed close
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
result["errors"].append(f"close {symbol}: {exc}")
|
result["errors"].append(f"close {symbol}: {exc}")
|
||||||
|
# 3) belt-and-suspenders: close any BROKER position the store snapshot
|
||||||
|
# missed (filled within the ~60s sync lag). Was in close_session only, so
|
||||||
|
# the daily-loss breaker flatten skipped it — moved here so EVERY caller
|
||||||
|
# (breaker + EOD) gets it. This is the naked-through-breaker fix.
|
||||||
|
if client is not None and hasattr(client, "get_positions"):
|
||||||
|
try:
|
||||||
|
for p in client.get_positions(fresh=True) or []:
|
||||||
|
sym = p.get("symbol")
|
||||||
|
if sym and sym not in result["closed_positions"]:
|
||||||
|
try:
|
||||||
|
self._release_and_close(client, sym)
|
||||||
|
result["closed_positions"].append(sym)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
result["errors"].append(f"close {sym}: {exc}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
result["errors"].append(f"positions: {exc}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@_locked
|
@_locked
|
||||||
@@ -1396,21 +1472,7 @@ class TradingExecutionService:
|
|||||||
halt new entries for the rest of the session.
|
halt new entries for the rest of the session.
|
||||||
"""
|
"""
|
||||||
self._session_closed = True
|
self._session_closed = True
|
||||||
result = self._flatten_all(reason)
|
result = self._flatten_all(reason) # fresh-broker belt now lives inside
|
||||||
# belt-and-suspenders: close any broker position the store snapshot missed
|
|
||||||
client = getattr(self.executor, "client", None)
|
|
||||||
if client is not None and hasattr(client, "get_positions"):
|
|
||||||
try:
|
|
||||||
for p in client.get_positions():
|
|
||||||
sym = p.get("symbol")
|
|
||||||
if sym and sym not in result["closed_positions"]:
|
|
||||||
try:
|
|
||||||
self._release_and_close(client, sym)
|
|
||||||
result["closed_positions"].append(sym)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
result["errors"].append(f"close {sym}: {exc}")
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
result["errors"].append(f"positions: {exc}")
|
|
||||||
self.store.emit(
|
self.store.emit(
|
||||||
RiskRuleTriggeredEvent(
|
RiskRuleTriggeredEvent(
|
||||||
timestamp=datetime.now(), mode=self.mode, correlation_id=self.session_id,
|
timestamp=datetime.now(), mode=self.mode, correlation_id=self.session_id,
|
||||||
@@ -1576,6 +1638,7 @@ class TradingExecutionService:
|
|||||||
and self._fresh_entries["n"] > 0):
|
and self._fresh_entries["n"] > 0):
|
||||||
self._fresh_entries["n"] -= 1
|
self._fresh_entries["n"] -= 1
|
||||||
|
|
||||||
|
@_locked
|
||||||
def expire_stale_entries(self) -> list[dict]:
|
def expire_stale_entries(self) -> list[dict]:
|
||||||
"""Back out of unfilled entries that timed out or broke their trigger.
|
"""Back out of unfilled entries that timed out or broke their trigger.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user