Owner asks on the candlesticks:
- VOLUME: histogram series (bottom 20% overlay), coloured by candle direction.
- BOLLINGER BANDS: 20-period 2-sigma, computed client-side from closes; price
riding the upper band = strong, break back inside = fading (momentum context).
- EXIT markers were NEVER wired — the fills feed carries only entries
(order_submitted), and the loop fills brackets via position-diffing so no
order_filled fires. Exits live in position_closed; now surfaced as square
markers with reason + P&L (green win / red loss). Entry 'expected' (unfilled
submit) markers labelled distinctly. Fixed a self-inflicted bug: queried the
event store AFTER closing it — moved the position_closed fetch inside the
open-store block. 11 chart tests green incl. new exit-marker test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The self-heal commit (a00f967) inserted the _research method INSIDE __init__ at
method-body indent, truncating it so _change_signal/_backtest_lock/_backtest_
cache never got set -> AttributeError on backtest. Moved _research to a sibling
method; __init__ now sets all attrs. Two chart/sparkline tests updated: a None
research_con now REOPENS lazily, so 'no research db attached' is reached only
when the reopen also fails (they monkeypatch open to fail). 22 api tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The charts weren't broken — the data pipe was empty. dashboard_api opened the
research connection ONCE at boot; if the DB was unreachable then (container
started before Postgres, or a blip) it stayed None forever, so /api/chart and
/api/bars returned 'no research db attached' and every candlestick + sparkline
rendered blank. Live-verified: GET /api/chart returned bars:[] reason:'no
research db attached' on the deployed dashboard. Now DashboardState._research()
opens the connection LAZILY and reopens if None — same self-heal as the
event-store emit fix. 2 tests; chart/backtest endpoint tests still green.
No library change: Lightweight Charts v5.2 is fine, it just had nothing to draw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner saw a blank /backtest — it was CORRECT (the regime gate sat out every
recent session) but unexplained. Now: ?regime=0|1 override on /api/backtest,
an 'apply regime gate (as deployed)' checkbox, and an empty-state message
saying WHY there are no trades and what to click. Gated view = what the bot
would do; ungated = the raw simple criteria.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a read-only /backtest page showing how the bot WOULD have traded past
sessions under the CURRENTLY configured live settings: theoretical ORB fires
from setup_events, replayed through the shared exit engine (simulate_exit +
ExitConfig.from_env(), same adverse-fill accounting the exit-experiment
harness uses), screened through the shared population gate and (optionally)
the live regime gate. No new simulation math — replay.replay_window() is pure
assembly of existing pieces (reconstruct_fires_from_setups, _fire_in_population,
_load_regime_favorable, simulate_exit); api/main.py adds the /backtest and
/api/backtest routes plus a dedicated-lock 10-min cache so a slow replay never
blocks the live dashboard's SSE stream. Also extends /api/chart with an
optional date= anchor so the trade drill-in chart modal can reach sessions
older than the existing 72h-from-now window, and adds a one-line BACKTEST nav
link to the live dashboard header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaces the entry_gate_evaluated decision receipts (plan 005) that were
write-only until now: a Decision Trace panel (per-gate value->threshold
chips, symbol filter, lazy-load + SSE refresh) and collapsed reject markers
merged into the plan-013 symbol chart's existing markers primitive.
- storage/projections.py: query_gate_receipts (case-insensitive symbol
filter, newest-first, same _hist_window idiom as query_fills_feed)
- api/main.py: GET /api/decisions (clamped limit<=500, date-scoped);
DashboardState.decisions() adds a server-computed epoch per row; factored
chart()'s inline to_epoch into a shared _to_epoch_et helper
- api/static/dashboard.html: Decision Trace panel + refreshChart now also
fetches /api/decisions and merges collapsed reject markers (same-reason
bursts within 5min -> one marker) into the single setMarkers call
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createSeriesMarkers(series, markers) attaches a NEW primitive to the series
on every call — refreshChart() called it on every 5s SSE-driven refresh, so
a modal left open accumulated one primitive per refresh (identical markers
overdraw so it looked fine, but it's an unbounded render leak on long-open
modals). v5 pattern: create the markers primitive once in openChart, mutate
it via markersApi.setMarkers(markers) on refresh. chart.remove() in
closeChart already tears the primitive down with the chart.
Click any symbol cell to open a 24h candlestick chart (vendored TradingView
Lightweight Charts v5.2, Apache-2.0) with buy/sell arrows at the bot's fills,
muted arrows for submitted-unfilled orders, and entry/stop/target price
lines. New read-only /api/chart endpoint (DashboardState.chart) converts
naive-ET minute_bars/event timestamps to UTC epoch seconds; query_fills_feed
gained an optional symbol filter. Chart refreshes off the existing SSE
snapshot signal (5s throttle, full replace) and tears down on close. Also
backfills order_filled markers' missing `side` (OrderFilledEvent's schema
has no side field) via an order_id join to the sibling order_submitted row.
Merged plan 012 (advisor/012-dashboard-order-limits) first — this plan
depends on its stopWorkingBy/targetBy/entryLimitBy maps.
sync.py fetches status="all" so the legacy stopBy map (kept for the
R-bar/spark stop line, unchanged) can pick up a cancelled stop leg.
Add stopWorkingBy, filtered to the same WORKING statuses as
targetBy/entryLimitBy, and point the new Positions Stop column at it
so a cancelled leg never renders as live protection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Positions table gains Stop/Target columns from resting bracket legs
(targetBy/entryLimitBy maps, filtered to working order status). Order
Lifecycle splits the generic Price column into Type/Limit/Stop/Fill via
a client-side join to the broker order snapshot (client_order_id ==
the internal order_id we send Alpaca) since the lifecycle events
themselves never captured limit/stop/type; the truncated order-id
column is dropped in favor of a title attribute on the Symbol cell.
Approval queue drops the always-"auto" Mode column and shows explicit
entry/stop prices in the risk chip instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The session-level metalabel P(our ORB profits today) — the one lever that
survives out-of-sample. Backtested on 266 prod sessions (walk-forward):
trade-always (trained subset): -0.111R/session, 36% hit
meta_v1 GATED (trades best ~32% of days): -0.004R/session, 48% hit
delta +0.107R in-sample, +0.128R OOS (recent 80 sessions), improves across folds
It does NOT make the bot profitable — it takes a bleeding strategy to roughly
flat by sitting out the worst ~2/3 of sessions. threshold_v1 rule gates too
little to matter; meta_v1 (walk-forward GBM) is the signal. Leaky full-fit
(+0.61R) >> walk-forward confirms the discipline was load-bearing; the OOS
holding is the evidence the +0.078R is real, not look-ahead.
Changes:
- research/regime.py: batch minute-bar load per session (64k per-fire round-trips
-> ~266 queries; fixed a >5min build to ~3min) + incremental label pass (skip
already-labeled sessions; --rebuild forces full). Nightly cost now ~one new day.
- docker-compose.yml + truenas-compose.yml: scheduler runs build-features + score
(both models) before `experiments run`, nightly. ALL-SHADOW — does NOT gate live
trading (that stays owner-gated Step 5).
- admin_api.py + admin.html: POST /api/regime/run (detached) + GET /api/regime/status
+ a Run/Status button — the panel had no way to trigger this.
- plans/011 + docs/research/regime_notes.md (6-book distillation) + plans 008/009 docs.
Prod state (shadow, rollback = DROP regime_features/regime_scores + DELETE
experiments WHERE id LIKE 'regime%'): 266 feature rows, 532 scores, 2 experiments
registered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 6: tests/smoke_dashboard_headless.py renders the real page in a headless
browser and fails on any JS console/pageerror (standalone script, not
pytest-collected; CI non-blocking first). admin_api gains GET /api/config/effective
(per-key masked value + source: overrides.env vs compose, missing .env.example
keys flagged as drift) + an admin.html table (values XSS-escaped). dashboard.html
gains the Experiment Ledger panel reading the plan-006 verdicts from the snapshot
(empty-state until the tables exist). Removes the last CI ruff per-file pin
(api/main.py:F821) now that os/hmac are imported; scoped ruff is green unpinned.
Marks plan 007 DONE in plans/README.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Badge now derives PAPER/LIVE from broker truth (base-URL host) instead of the
schwab_live literal that read PAPER while go-live swapped only ALPACA_BASE_URL;
live flips body.live-mode (top shimmer -> solid red) with a tooltip of host +
account. Adds the header FLATTEN ALL (two-step arm/confirm, 5s auto-disarm) and
PAUSE ENTRIES toggle (+ amber banner), per-position EXIT buttons (two-step), and
an operator-token field sent as X-Dashboard-Token on every POST (also fixes
approve/reject when a token is set). Action controls gate on
has_execution || command_bus; kill-switch controls on command_bus. No keyboard
shortcut for any destructive action. New styling is :root-token-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds OperatorCommandEvent + a closed command vocabulary (flatten_all,
flatten_symbol, pause_entries, resume_entries). POST /api/command emits it
(token-required: 503 when DASHBOARD_TOKEN unset — commands never run
unauthenticated); the live loop's new 5s "command" step consumes them via
OperatorCommandConsumer with three replay-safety layers (in-session nonce set,
persisted operator_command_done reload, 10-min staleness window).
flatten_all routes through close_session DIRECTLY (never step_eod_flatten) so it
cannot trip plan 003's daily-restart exit. Pause is checked at the top of BOTH
request_approvals_for_ready_signals AND submit_breakout_now (missing either
leaks pause on that path). qabot gains an authorized-chat-only /flatten slash
command (prose "flatten" still refuses via _ACTION_WORDS). Snapshot exposes
broker truth + command_bus + a defensive experiment ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every SSE client polled COUNT(*) on a fresh EventStore connection each ~1s
(re-applying the 473-line schema unless PG_SKIP_SCHEMA=1). Replaced with one
process-wide _ChangeSignal: a single long-lived connection reads max(timestamp)
(idx_events_timestamp tail seek) at most once per second GLOBALLY behind a lock,
so N clients cost one query/sec. EventStore.max_timestamp() backs it; reader is
injectable for the query-budget test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every POST endpoint (approve/reject/exit/watch) crashed with NameError:
do_POST references os.environ + hmac.compare_digest but neither was imported.
Adds the imports, a non-loopback regression test for the token gate (no token
-> routes; token set -> 401 without/with-wrong header, 200 with correct), and
plumbs DASHBOARD_TOKEN + PG_SKIP_SCHEMA=1 into both compose dashboard services
(+ .env.example docs for DASHBOARD_TOKEN / COMMAND_BUS_ENABLED).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The loop healthcheck opened a fresh connection every 60s with schema
auto-apply enabled; the CREATE-IF-NOT-EXISTS lock bursts cancelled
minute_bars autovacuum every single cycle (visible as 'canceling
autovacuum task' each minute post-migration), so the 19M-row table
never got analyzed, snapshot queries crawled, browsers timed out, and
the dashboard log filled with double BrokenPipeError tracebacks.
- healthchecks now set PG_SKIP_SCHEMA=1 (both compose files)
- api/main.py _send_json swallows BrokenPipe/ConnectionReset (client
hung up; nothing to tell it)
- live DB: ALTER SYSTEM max_wal_size=4GB, shared_buffers=1GB,
effective_cache_size=3GB, checkpoint_completion_target=0.9 (the
checkpoint-every-24s warning under migration load on NAS storage)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New admin service — LAN-only, token-authed (ADMIN_TOKEN), card-based UI
matching the owner's preferred dashboard style. Capabilities:
- health: docker containers via /var/run/docker.sock (stdlib unix-socket
HTTP client), DB heartbeat, Alpaca account/clock, Ollama, feed
- config: whitelisted strategy knobs (TRADING_/TRIGGER_/WATCHER_/...)
edited via data/overrides.env, which every entrypoint now loads with
override=True at boot (runtime/env_overrides.py) — so changes apply on
container restart without touching the deployment YAML; secrets are
presence-only and rejected server-side if edited
- profiles: named knob presets (seeded: validated-core, conservative,
squeeze-hunter) applied in one click
- restart buttons per container; paper/live switch guarded behind
ALPACA_LIVE_* keys existing
Verified live in docker: auth 401s, status/config/profiles/write paths,
secret-edit rejection, page serves. Added to both compose files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The owner's modularity/monitoring ask: rate each pipeline component so what's earning (and
what's cutting) is visible instead of inferred. Everything derives from the existing event
stream — no pipeline changes.
- research/attribution.py: compute_attribution(days) -> per-day per-stage metrics:
DISCOVERY (symbols found + precision = fraction that ever go ready), SIGNALS (ready split
EARLY-vs-LATE against the confirmation window — the only +EV ones), GATES (risk_rule
histogram: what each gate cut), EXECUTION (submissions vs cancels = fill efficiency),
OUTCOMES (realized P&L, win rate, per-close detail). CLI: python -m research.attribution.
All queries filter the native timestamp column first (payload casts on the 676k-row events
table time out).
- api/main.py: GET /api/attribution?days=N.
- dashboard.html: "Component Attribution" panel, lazy-loads on open, per-day table with
gate-histogram hover.
- momentum_cli board header: live pulse line — today's early/late signal split + top gate
cuts (two tiny date-scoped queries).
Verified live: CLI scorecard over 7 days, endpoint returns real ratings (e.g. 2026-07-02:
discovery 45%, early 38%, fill 39%, win 50%, P&L +$29), board renders the pulse line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From a design pass (CLI spec + dashboard spec + an adversarial glance-safety review)
that read the real output of both surfaces. Principle: each safety/state number appears
ONCE on the first screen, every list has a bounded height capped by relevance (active/open
first), and load-bearing signals (the UNPROTECTED banner, heartbeat, day P&L, circuit
breaker) are never trimmed or hidden.
CLI board (momentum_cli.py _render_board): drop the static 2-line strategy subtitle (never
changed, redraws every 3s); trim the heartbeat to "Ns"; armed table shows actionable
(armed/fired/long) rows first then the top-4 scanning by rvol, dropping corrupt >1000%-gap /
0-volume rows with a "+N more scanning" footer; remove the redundant ready-signals panel
(restated the armed table) and the day-P&L echo in the trades title; trades float open lots
to the top (never capped off) and cap closed at 6; risk collapses consecutive identical
fires into one "×N" row (the 12 duplicate KEEL lines -> one) and is promoted ABOVE trades.
Dashboard (api/static/dashboard.html): cap the long "runs" — ready-signals to top 15 by
quality (also kills ~80 per-refresh chart fetches), activity feed 20, catalyst feed 20, order
lifecycle shows only actionable orders (raw 1000s incl rejected/cancelled read as an alarm),
watch-states hide the score-gate 'blocked' rows; drop redundant strip cells (Positions dup of
pnl Open/Closed, Gross) and the Account panel's Buying-Power/Synced; demote Account to a
collapsed half-width panel; drop the ~constant ready-signals "Setup" column. JS syntax
validated, no dangling refs, serves 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DB self-heal: the LAN Postgres is reached from WSL via EITHER the 127.0.0.1 netsh
portproxy OR a direct route to 192.168.1.5, and which one works FLIPS across WSL/host
reboots and network-mode changes — the recurring crash-loop landmine. storage/db_pg.py
_connect now tries the configured LAN host and then the alternate, so the loop boots
regardless of the current tunnel state ("connection refused" is instant, so it's cheap).
Dashboard UI: api/static/dashboard.html panels are now collapsible <details> (native,
no JS for the toggle; inner ids preserved so the live rendering JS is untouched). The
live/actionable panels (Armed Triggers, Open Positions, Approval Queue, Ready Signals,
Account) start OPEN and lead the layout; the big/historical ones (Catalyst Engine,
Daily Performance, Trades, Watch States, Activity Feed, Order Lifecycle, All-Time Score)
start COLLAPSED so the page is compact and scannable. Open/closed state persists per
panel in localStorage; an EXPAND/COLLAPSE-ALL header button toggles everything.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a day-over-day view so the bot's per-day record is comparable at a glance.
query_daily_performance returns one row per NYSE trading day {date, day_pnl
(broker equity delta), cum_pnl, realized, trades, win_rate, equity} — account
summaries (EOD equity) joined to the closed-trade journal; weekend/holiday
mark-drift on held positions is filtered out (trading days only). Wired into the
snapshot as `daily`.
Dashboard panel renders it as a waterfall: the blue line is the cumulative P&L
(the equity curve), each bar is that day's step (green up / red down) on one
shared scale. Hover shows date/day-P&L/trades/win%; click a day to open its full
view; the currently-viewed day is highlighted.
Tests: trading-days-only filter + cumulative math. Headless-verified (4 days
render, click-to-open switches the view); no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
You wanted to compare the bot's success rate per market day, plus an all-time
total. Three pieces:
- Backfill (backfill_journal.py + storage/journal_backfill.py): reconstruct every
completed round-trip from Alpaca's full filled-order history (pair entries with
exits per symbol into episodes, read exit reason + bracket stop from the
orders), and emit position_closed events timestamped (ET) at the exit fill so
each lands on its own market day. Idempotent (re-run replaces prior backfill);
live-reconciled closes deduped by symbol+minute. Run once: it journaled 22
round-trips across 6/16-6/18, so each day's win-rate/avg-R/trades now populate
retroactively (6/17: 57% win, 6/18: 43%).
- All-Time score (query_alltime_score + dashboard panel): cumulative across every
closed round-trip — total realized, win rate, trades, avg R, trading days,
best/worst day. Always shown, not date-scoped.
- Per-day Trades panel: the individual round-trips for the viewed day (symbol,
color-coded exit reason, R, P/L, time) with a net/win-rate header — so you can
see exactly what happened each day. LIVE now scopes the journal to TODAY (was
unbounded, which would have shown the all-time list once the journal filled).
Tests: round-trip reconstruction (stop/target/scale-out/short/open/legs),
all-time aggregation, live=today scoping. Headless-verified both panels live and
on 6/18. No JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trade journal was blind: exits emit no event, so closed_trades / win-rate /
avg-R / the trade list all read 0 even on days with booked round-trips.
Now AlpacaPaperSync.sync_all reconciles closes by diffing the positions
snapshot: a symbol that was open and is now gone/flat has closed. This one
signal catches every exit path — bracket STOP/TARGET legs fill broker-side, EOD
flatten closes via market orders — and reads the exit fill (price + reason) and
the bracket stop level from the orders snapshot, emitting a PositionClosedEvent
with entry/exit/stop/side/realized so query_session_pnl can compute the
R-multiple. Headline realized $ stays broker-authoritative (the broker-delta
branch is unchanged); these events feed the wins/losses/avg-R/trade-list journal.
Robustness: the close baseline seeds from the last PERSISTED snapshot so a close
during downtime is still journaled once; reconcile is wrapped so it can never
break sync; PositionClosedEvent gains optional entry_price/stop_loss_price/side/
quantity fields (older emitters/tests unaffected).
Dashboard is view-only: removed the manual Exit button + exitPos handler from
Open Positions (kept the approval-queue approve/reject — that's the intended
human-in-the-loop control).
Tests: stop->loss, target->win, still-open->no-emit, no-fill->last-mark
fallback, restart-seed-from-persisted. 49 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four operator-requested enhancements (all dashboard.html, headless-verified, no
JS errors):
- Ready Signals: new Catalyst column — the LLM read (dot + type) per observed
symbol, not just armed triggers; "—" when there's no recent read.
- Catalyst Engine chip is now clickable to filter the feed to dilutive/veto
reads only (count shows "N / total"); click again to clear. Verified 40 -> 15
with every filtered row dilutive.
- Open Positions P/L cell now shows $ + % + a colored magnitude bar (maxes at
±10%) so underwater/up names read at a glance — pairs with the P&L fixes.
- Mobile: catalyst-feed rows wrap (headline + rationale) instead of truncating,
the conviction column drops, the engine chip goes full-width, and buttons get
38px tap targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The snapshot does real Postgres work, so the first load left a blank board that
read as broken. Added a full-screen overlay (on-brand spinner + "connecting to
the control room…" + what it's fetching) that:
- shows on first paint, dismissed the instant real data renders (SSE, poll, or
historical fetch);
- after 5s of waiting, softens the sub-text to "taking a moment — still
fetching…" so a slow snapshot reads as working, not frozen;
- on a failed first fetch, flips to a red "couldn't reach the control room —
retrying…" state (stops the spinner) instead of hanging silently.
Verified headless: overlay visible at first paint, display:none once pnl/feed
populate (no JS errors); 500 path shows the error state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things the operator couldn't see were wrong/opaque:
1) P&L attribution bug. query_session_pnl read each position's unrealized as
`unrealized_pl`, but the live snapshot stores it as `unrealized_pnl` (see
alpaca_paper/sync.py). So unrealized always read $0 and the ENTIRE broker
equity-delta was dumped into "Realized" — a real +$330 realized / -$67
unrealized day showed as "$263 realized, $0 unrealized, 0 closed". Fixed to
prefer the stored key, accept the alpaca-native alias, else derive from
qty*(price-cost). The existing test used the same wrong key (which is why it
never caught this) — corrected, plus a regression test reproducing the exact
broker-delta day and a derive-from-qty test.
(Still surfaced separately: closed round-trips emit no position_closed event,
so realized P&L is broker-derived and the trade list / win-rate read 0. That
journaling gap is noted, not fixed here.)
2) Catalyst Engine feed. The Ollama portion only showed totals; you couldn't see
WHAT it read or WHY it ruled. Added query_catalyst_feed (the raw stream from
news_catalyst_cache, newest first, with a `vetoed` flag = dilutive AND
conviction >= the 0.60 floor) -> snapshot `catalyst_feed` -> a new dashboard
panel under Armed Triggers: time (ET), symbol, colored read (🟢/🔻/🔴 +
type), conviction, headline, and the model's own rationale, with a red ⚠ VETO
badge on confirmed dilution. Makes the engine legible at the open — and
immediately exposed the 7B over-tagging one "SpaceX bear ETF" headline as
dilutive across a dozen unrelated large-caps (harmless: none are targets).
Verified headless: strip reads total/realized/unrealized correctly; feed renders
40 reads with rationale; no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn on the catalyst score blend + dilution veto, and make the engine VISIBLE so
it can be watched live at the open:
- api/main.py: snapshot now carries catalyst_engine {enabled, model, score_blend,
dilution_veto, advisories, dilutive, vetoes_today} (config read from .env +
live counts incl. how many entries the veto blocked today).
- dashboard: a catalyst-engine status chip in the Armed Triggers header
(model · N read · dilutive · veto · score · vetoes-today), and each armed
gapper now shows the LLM's READ instead of just the raw headline — colored
sentiment dot, catalyst type, and a red "⚠ VETO" badge on a confirmed dilution
at/above the conviction floor (or "dilut?" below it).
Verified headless: engine chip renders live (230 advisories, veto+score on); a
seeded bullish gapper shows "fda approval", a dilutive one shows the VETO badge;
no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integrates the local-LLM enrichment ADDITIVELY on top of all current work, with
every Ollama feature OFF by default — with the flags off the system behaves
exactly as before (199 unit tests pass; a full --once pass runs clean with no
Ollama). Fixes the three issues the adversarial review flagged:
- BLOCKER: keep the Alpaca News ingestion. The PR predated it; the run_live_paper
conflict is resolved to keep BOTH step_news (fetch -> raw_news_items) AND the
enrichment. They're complementary: ingestion feeds enrichment authoritative-
ticker headlines instead of thin RSS.
- HIGH-1: enrichment runs on its OWN daemon thread with its OWN DB connection,
not the scheduler thread — a slow/hung Ollama can no longer starve exits /
guard / eod-flatten, and it no longer shares the non-thread-safe research_con.
- HIGH-2: per-headline writes are all-or-nothing — on any ticker store failure
the partial rows are deleted so the headline fully retries (no stranded
tickers) and enriched is counted only on full success (no false count).
Phase-2 automation (catalyst score blend, dilution veto, arming-rank multiplier)
remains gated OFF pending the medium fixes (bearish-catalyst boost, prompt-
injection hardening, veto cross-thread read) noted in the review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Leverage a local Ollama model to interpret news catalysts for the small-cap
momentum strategy — distinguishing bullish catalysts (FDA approval, M&A) from
dilutive offerings the technicals alone can't see. Advisory-first, then automate.
Phase 1 (advisory; no change to trade behavior):
- research/ingestion/news_enrichment.py: stdlib urllib call to Ollama with
structured JSON output, classifying each headline into a catalyst type,
sentiment, conviction and dilution flag. Graceful degradation (None on any
failure) so trading is never blocked.
- news_catalyst_cache table + populate the previously-dead news_events.sentiment.
- Runs on the existing interval Scheduler (off the hot path), gated by
OllamaConfig; deduped by the raw_news_items payload hash.
- Surface the advisory on the dashboard approval queue (projection + api + UI).
Phase 2 (automation; ships gated OFF):
- Optional catalyst_score blended into calculate_setup_quality (None reproduces
the exact legacy weights); pure pass-through via evaluate_setup.
- Dilution veto in TradingExecutionService (slow + fast paths) emitting a
catalyst_dilution_veto risk event, behind NEWS_DILUTION_VETO_ENABLED.
Config (OllamaConfig.from_env), .env.example docs, and tests for parsing,
persistence/dedupe, the projection, the score blend, the veto, and an
end-to-end enrich->advisory flow (Ollama mocked). Default model moves to
qwen2.5:7b-instruct (best small-model JSON, fits a 12GB GPU).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
Panels already stacked at <=1040px, but phones still broke: dense tables
overflowed the page, the risk/P&L flex strips didn't wrap, and the armed-trigger
rows had fixed pixel widths. Added a <=640px breakpoint:
- header wraps; desktop-only Sound/Keys buttons hidden; connection chip right-aligned
- risk strip wraps; P&L becomes a 2-up grid with the hero Total P&L, day-curve full row
- tighter main padding; panel hint text hidden to save width
- wide tables get overflow-x:auto + nowrap cells -> swipe horizontally inside the
panel instead of stretching the page
- armed-trigger radar drops the meta/catalyst columns, keeps symbol + bar + dist
Verified headless at 390px: zero horizontal page overflow (scrollWidth==clientWidth),
tables scroll internally (665px in a 368px panel), all panels render, no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LIVE rendered ONLY from the EventSource snapshot handler — and that handler
swallowed render/parse errors (catch {}). If EventSource didn't establish in the
user's browser (proxy, extension, connection cap), LIVE stayed blank while the
date selector — a plain fetch — worked fine. Exactly the reported symptom.
- startStream() now does an immediate pollOnce() (the same fetch path the date
selector uses) so LIVE shows data even if SSE never connects; SSE then layers
on for low-latency pushes, with onerror -> interval polling as before.
- Surface render errors (console + conn status) instead of hiding them.
- Serve the dashboard HTML with no-store/no-cache so pushed fixes actually reach
the browser (a stale cached shell was part of why "I see nothing" persisted).
Verified headless: with EventSource forcibly disabled, LIVE still populates
(10 armed rows, pnl, risk) via the polling fallback — stamp shows "polling ·".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure CSS (every id/class the JS renders is preserved; verified headless, no JS
errors, all panels intact):
- Depth: radial-gradient background, lifted panels (soft shadow + subtle top
highlight + hover), glassy blurred sticky header.
- Hierarchy: hero Total P&L (27px) with green/red glow + tabular numerals;
accent gradient rule before every panel title; thin live shimmer line at the
top edge; status dots get a soft glow.
- Polish: refined palette, softer/ bordered pills, gradient action buttons with
press feedback, custom scrollbars, panel load-in animation, hover affordances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The duckdb library is already gone (not a dependency, not importable, no
`import duckdb` anywhere) — these were leftover text references that misled.
- Delete storage/schema.py: 418 lines of DuckDB DDL with zero importers. The
Postgres schema is created by db_pg._apply_schema + event_store._ensure_schema.
- Neutralise stale default db_path strings (./data/momentum*.duckdb) in
event_store, config, run_live_paper, dashboard_api — the connection layer
ignores the path (DATABASE_URL is the datastore), so these phantom file paths
only created confusion. Dropped the now-pointless os.makedirs for them.
- research/multi_schema: the "three DuckDB files" are one Postgres DB now (all
resolve to the same connection); doc + constants updated to match reality.
- Fix stale "DuckDB handle" comments in api/main.
Kept the DuckDB mentions in storage/db_pg.py + storage/db.py — they document
why the Postgres facade mimics DuckDB's ? placeholders and :memory: semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The risk-strip code declared `const rs = $("risk-status")` in render(), colliding
with the existing `const rs = d.ready_signals` later in the same scope. A
duplicate lexical declaration is a fatal parse error — the ENTIRE <script> never
executed, so render/populateDates/setView were undefined, SSE never connected,
and every field showed '—' (this, not only the API timeout, was why it "didn't
look like it's working"). Renamed the risk element to `rsEl`.
Verified in a headless browser: no JS errors; the date <select> populates with
LIVE + the last sessions, switches to a historical day and back, both views
render (armed radar, watch list, positions, fills, order lifecycle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /api/snapshots endpoint was timing out (the screen showed all '—'): several
projections did query_events(type, limit=None), loading an event type's ENTIRE
history just to take the latest. Timed: watch_states 112s, account_orders 78s,
risk 80s -> ~280s total.
- Latest-state reads (account orders/positions/summary, session_pnl, risk_state)
now use a DESC-LIMIT-1 index seek (_latest_event_payload) — O(1). Live snapshot
4.7s -> 1.5s; the heavy account_orders payload no longer loads all history.
- Date picker: /api/snapshots?date=YYYY-MM-DD reviews a past session (windowed
queries via _evt_window). Default LIVE; header <select> populated from
available_dates; live-only panels (triggers/watch/approval/ready) skip for a
historical view (watch_states was the 112s killer + a live concept). Frontend
setView()/loadHistorical() switch between live stream and a one-shot date load.
- dashboard_api.py now loads .env (DATABASE_URL resolves to the configured
Postgres) and binds 0.0.0.0 / port 8765 so it's reachable from outside WSL.
Verified: LIVE 1.5s, HISTORICAL(2026-06-18) 0.75s with full P&L/equity/fills.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more visualizations + the bug building them exposed:
- Equity-curve sparkline in the P&L strip: query_equity_curve returns the day's
total_equity series + prior-close baseline (dashed); rendered as an inline SVG
(green/red vs baseline) so the day's P&L shape reads at a glance.
- Per-position R bar in Open Positions: shows current open profit/loss in R units
(-1R = the stop, 0R = breakeven), green right / red left, with a per-row ⚠ when
a position has no resting protective stop. Falls back to % when no stop known.
Also fixed the P/L column to compute from entry/last (was reading a None field).
- BUG: sync_orders dropped stop_price (kept only limit_price), so the synced
snapshot showed stop=None on real stop orders — breaking the R bar and spark
stop-line. Now captured. (The naked alarm was unaffected — it counts qty
coverage, not the level.)
Verified live: equity curve renders the -1,886 day (94,124 -> 92,238); R bars
render (fall back to % until the loop re-syncs stop_price). Tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The web Watcher ignored the armed-trigger book entirely — the single most
actionable view for an ORB strategy (which gapper is about to break) lived only
in the TUI, despite 25k module_tick(triggers) events in the store. Add
query_armed_triggers (bounded 15-min scan, cheap) -> /api/snapshots, and a top
"Armed Triggers" panel rendering each loaded gapper as a proximity bar: the fill
shows where the LIVE price sits between the stop (left) and the breakout trigger
(the marker line); crossing the marker = fired. Rows sort closest-to-fire on top,
color-coded by state (armed/fired/filled/weak/waiting), with gap%/rvol, the
distance-to-trigger %, and any news catalyst. Armed names within 1% of firing get
a highlighted track.
Verified live: 13 triggers render (NEOV fired +27%, SPRO armed -8%, CRVO -15.6%),
projections suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live verification caught it: the data-freshness chip read ~14,477s (RED) on data
that was actually 1s fresh — exactly the EDT offset. Event timestamps are stored
NAIVE LOCAL (datetime.now()), but query_risk_state compared them against UTC now.
Compare naive-local to naive-local instead (tz-aware timestamps still use UTC).
Verified live: chip now reads ~78s. Also align the amber/red thresholds to the
~60s broker-sync cadence (amber >90s, red >180s) so a healthy feed reads green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the audit's remaining web-UI gaps. New query_risk_state projection bundles
the conditions that silently cost money this session: concentration vs the caps
(open/max, gross %), day P&L vs the daily-loss limit, broker-sync data freshness,
and any UNPROTECTED (naked) long. Wired into /api/snapshots.
dashboard.html: a pulsing red UNPROTECTED banner the instant a held long lacks
stop coverage (would have shown the moment APWC/NOWL went naked), plus a risk
strip — ok/warn/critical status dot, positions n/max, gross%/cap, day%/limit, and
a freshness chip (amber >30s, red >90s). Verified end-to-end: the API returns the
risk block (status=critical on today's 3 unprotected overnight positions) and the
page renders it.
Tests: projections suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>