Commit Graph
78 Commits
Author SHA1 Message Date
momentum devandClaude Opus 4.8 506ff253ec Dashboard: make LIVE not depend on the SSE stream
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>
2026-06-19 09:56:19 -04:00
momentum devandClaude Opus 4.8 c14a89585a Dashboard: visual refresh — premium trading-terminal look
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>
2026-06-19 08:47:24 -04:00
momentum devandClaude Opus 4.8 559083b4ab Day-extension entry guard + percentage breakeven stop
Day-extension guard (the H1 gap from the prior review): the trigger-extension
ceiling only sees distance above the ORB high, so a name up huge intraday whose
break is itself CLEAN slips through — real example ATPC, entered +1.7% above the
trigger yet +32% above the session open, now a loser. New guard blocks when the
entry is > day_extension_max_pct above the SESSION OPEN. Default 0.30 calibrated
on 22 real fires: normal entries are <=14% above the open, the parabolic tail is
20-83% (ICCM +83/+41, BIRD +36, ATPC +34/+32, NEOV +29). day_open is computed in
step_arm (first RTH bar open), carried on ArmedTrigger, checked in
submit_breakout_now. Env TRADING_DAY_EXTENSION_MAX_PCT; 0 = off.

Percentage breakeven (the "ratchet to a better stop once it works" request): the
broker-side stop ratchet already exists (exit_manager replace_order), but its
earliest move was +1R — a big move for a wide opening-range stop. breakeven_at_pct
moves the stop to entry once price is +this fraction above entry, BEFORE 1R, so a
pop that reverses can't round-trip to a loss (the documented small-cap failure).
Lives in the shared strategy/exits._trail_stop (backtest + live agree), only ever
ratchets up. Default 0.05 live (TRADING_EXIT_BREAKEVEN_PCT); _is_active updated so
the manager runs when it's the only rule set.

tests/unit: 160 passed (+ day-ext block/allow/no-open, + pct-breakeven move/hold/
saves-reverser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:49:27 -04:00
momentum devandClaude Opus 4.8 87186be949 Entry guards: harden after adversarial review
- Reject a non-positive last_price ("bad_price") instead of `last_price or
  trigger` coercing 0.0 -> trigger, which silently skipped the extension guard
  and sized on a phantom price. (latent: fires() guarantees price>=trigger live,
  but submit_breakout_now is public.)
- Extract the halt-gap decision to runtime/halt_guard.is_halt_gap() — pure and
  unit-tested (the riskiest heuristic had no coverage). Encodes the fail-safe:
  None ages (query glitch / session-date mismatch) -> inert, not mass-block;
  global-health gate -> a feed-wide lag can't flag the whole book. 7 cases.
- Honest rationale on the extension ceiling: it measures distance above the ORB
  trigger (catches the intraday chase / gap-through / halt re-open); overnight
  +100% gappers are already excluded upstream by the trigger book's gap_max. It
  is not a day-extension metric (a fuller day-move guard is a possible follow-up).
- Fix stale test docstring (15% not 6%); add bad_price test.

tests/unit: 154 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:31:39 -04:00
momentum devandClaude Opus 4.8 297cb905d5 Entry guards: anti-chase extension ceiling + halt skip
Addresses the failure mode from the source trader's recap ("+100% move then 3
halts down on bad news") and our own data: parsing 22 real live-ORB fires, the
median entry was already +8% above the trigger, with a disaster tail at 26-64%
(NEOV x4 @27%, ICCM @64%/26%, BIRD @30%) — i.e. the system was chasing spike-tops
that reverse, and there was no guard against entering a halted name.

Both guards sit in submit_breakout_now (the single choke point every long entry
funnels through), as silent skips matching the existing gates:
- extension ceiling: block when last_price > trigger * (1 + extension_max_pct).
  A clean break crosses the ORB high smoothly (~0%); a violent gap-THROUGH (a
  parabolic spike, or a halt reopening past the level) lands far above it.
  Default 0.15 is calibrated to the data's dead zone: real entries cluster
  <=9.8%, nothing fires in 10-26%, then the disaster tail — so 15% blocks every
  chase and passes every normal break. Tunable via TRADING_EXTENSION_MAX_PCT;
  0 disables.
- halt guard: skip a name flagged halted (can't exit during a halt; it gaps
  through the stop on resume). Detection is a bar-gap heuristic in the loop
  (_recently_halted): during RTH the symbol's freshest bar is older than
  TRADING_HALT_MAX_BAR_GAP_MIN (default 3) WHILE ingestion is globally healthy,
  so a feed hiccup can't block the whole book. LULD halts run >=5 min so 3 min
  catches a real halt without tripping on normal IEX lag.

The fast-trigger loop passes halted=_recently_halted(sym) and logs TRIGGER
BLOCKED once per symbol. tests/unit/test_triggers.py: +6 (24 pass; 146 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:24:51 -04:00
momentum devandClaude Opus 4.8 5ed038feb4 Batch bulk DB writes (execute_batch): unfreeze the discovery step
The live loop froze ~40s every discovery pass. py-spy showed the main thread
stuck in upsert_daily_bars -> PgConnection.executemany, which used psycopg2's
cur.executemany — ONE INSERT round-trip per row. A discovery pass upserts
thousands of daily bars across the universe, so over the LAN Postgres that's
thousands of serial round-trips. While frozen the loop emits nothing and can't
service the trigger/exit cadence.

Switch executemany to psycopg2.extras.execute_batch (page_size=200): many
statements per round-trip, same SQL, same ON CONFLICT upsert semantics.
Benchmarked on the live DB: 771-row upsert 1.87s -> 0.06s (~31x); a real
discovery pass now finishes in one cycle (ingest 6803 rows, no stall).
Helps every bulk writer (daily/minute bars, scanner snapshots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:24:36 -04:00
momentum devandClaude Opus 4.8 1acf77adb1 Holiday-aware market calendar: don't strip stops on closed days
The live loop gated trading + EOD flatten on weekday()<5 only, so a market
HOLIDAY read as a normal session. On a full closure the EOD flatten (15:55-16:00
window) would cancel the protective bracket/stop legs and then fail to fill the
market sell (exchange shut) — leaving positions NAKED over the holiday weekend.
Tomorrow is Juneteenth (Fri 2026-06-19, NYSE closed), with 3 open positions.

- runtime/market_calendar.py: NYSE full closures + early closes (no external
  dep), is_trading_day / is_regular_hours / session_close_hm. 2026 confirmed.
- _is_rth() now uses is_regular_hours() (holiday + early-close aware).
- EOD flatten skips when session_close_hm() is None (weekend/holiday) and uses
  the real close (13:00 on early-close days) — so it only flattens into an OPEN
  market that can actually fill the sell, never orphaning the stops.

tests/unit/runtime/test_market_calendar.py: 6 passed. Verified Juneteenth ->
not a trading day -> EOD skipped -> the 3 GTC stops stay in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:10:07 -04:00
momentum devandClaude Opus 4.8 b44b707581 Fix projection bugs + test isolation surfaced while verifying
Running the suite against the cleanup exposed pre-existing breakage (these
tests had not been run since the Postgres migration / recent sync changes):

- _latest_event_payload ordered by `timestamp DESC` with no tie-break, so two
  account snapshots emitted in the same second returned the arbitrary/older one
  (test wanted 101.5k, got 100k). Tie-break on `created_at DESC`.
- query_watch_states_snapshot windowed BOTH event types to the live 45-min
  window; symbol_state_changed (~2k all-time, the watch list itself) must be
  unbounded so the list isn't empty off-hours, while criteria_evaluated (~120k,
  the 112s killer) stays windowed.
- Test isolation: the dashboard tests pass file-path db_paths expecting a fresh
  DB, but under Postgres the path is ignored — they read AND wrote the
  production `public` schema. get_connection now honours MOMENTUM_PG_SCHEMA
  (test-only) to pin all connections in a test to one throwaway schema; an
  autouse fixture sets/drops it. (Cleaned 125 leaked fixture events from prod.)
- FakeClient.get_orders missed the `nested=` kwarg added in ddf93d1.

tests/unit: 134 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:43:40 -04:00
momentum devandClaude Opus 4.8 9e730a3726 Remove DuckDB remnants (datastore is Postgres)
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>
2026-06-18 22:43:29 -04:00
momentum devandClaude Opus 4.8 eb380ee804 Dashboard: fix fatal 'rs' redeclaration that blanked the whole page
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>
2026-06-18 22:23:33 -04:00
momentum devandClaude Opus 4.8 66889bbb15 Dashboard: don't window the small day-scoped reads for live
The 45-min live window I added to fills_feed + session_pnl's closed-trades query
hid the day's earlier fills/closures (and broke two projection tests whose
fixtures use a fixed past date). Those event types (order_filled/submitted,
position_closed) are tiny, so live now reads them UNBOUNDED via _hist_window;
only a historical YYYY-MM-DD bounds to that day. watch_states keeps its 45-min
window (criteria_evaluated is the big one, ~99k/day, and it's live-only).

tests/unit/storage/test_interactive_projections.py: 6 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:03:49 -04:00
momentum devandClaude Opus 4.8 9392e0d4d3 Dashboard: fix snapshot timeout (O(1) reads) + date picker + .env/host
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>
2026-06-18 21:59:31 -04:00
momentum devandClaude Opus 4.8 80a2a0bde6 Dashboard: intraday equity sparkline + per-position R bars; sync stop_price
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>
2026-06-18 21:29:07 -04:00
momentum devandClaude Opus 4.8 f0d1c3f106 Web dashboard: live armed-trigger proximity radar
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>
2026-06-18 21:13:00 -04:00
momentum devandClaude Opus 4.8 fbe2eff3d4 Dashboard: fix freshness-chip timezone bug + sync-aware thresholds
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>
2026-06-18 20:21:54 -04:00
momentum devandClaude Opus 4.8 b79377f41e Web dashboard: portfolio risk strip + naked-position alarm
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>
2026-06-18 19:23:03 -04:00
momentum devandClaude Opus 4.8 977c6e1b25 Strategy: cap gap at 35% (blow-off gappers fade) — train/test validated
The ONLY entry change that survived rigorous validation on 14 SIP-backfilled
sessions (53 trades). Gap-size buckets: 15-30% gap = +0.65R/trade, but 30-60%
blow-offs = +0.10R. Train/test split (70/30 by date): lowering TRIGGER_GAP_MAX
60 -> 35 lifts HELD-OUT TEST expectancy 0.194 -> 0.462 R/trade AND total R
(18.7 -> 19.9), better win rate, on both splits. Principled (huge gaps
fade/buy-the-top) and corroborated by the entry-timing audit (losers were
wide-range blow-off gappers).

Rejected after validation (do NOT ship): open-delay (forgoes total R for
marginal expectancy), breakout-HOLD/close-confirmation (hurts on the larger
sample), rvol floor (no-op: all gappers already rvol>=6). Confirmed KEEP: the
$-vol floor is fine on SIP volume (the earlier "it hurts" was corrupted IEX
data); profit-lock ladder stays.

.env TRIGGER_GAP_MAX 60 -> 35. NB: 14-day sample — re-validate as data grows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:01:13 -04:00
momentum devandClaude Opus 4.8 ea414e2822 Dashboard: broker-authoritative session P&L (web strip read $0 on a losing day)
query_session_pnl reconstructed realized P&L solely from position_closed events
— of which the store has ZERO — so the web P&L strip showed $0 / no trades during
the real -$2,322 day while broker equity actually fell ~$11k peak-to-trough. The
operator flew blind.

Now prefer the broker equity delta: day P&L = latest account_summary equity -
prior-session close (last_equity); matched (realized) = day P&L - current
unrealized. Falls back to the closed-event sum when no equity snapshot exists.
Adds broker_day_pnl + pnl_source to the payload; existing frontend keys
unchanged. Verified live: total_pnl now reads the real -$1,840 (was $0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:46:02 -04:00
momentum devandClaude Opus 4.8 2600fcf7f7 docs(.env.example): document TRIGGER_PRICE_MAX_AGE
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:21:33 -04:00
momentum devandClaude Opus 4.8 ee1d5cf220 Triggers: price-staleness guard — don't fire on a frozen quote
ArmedTrigger stamps price_ts on each update; update_price(None) (a failed batch
price fetch) now CLEARS the price instead of leaving the last value live; and
fires() requires the price to be fresher than max_price_age_s (env
TRIGGER_PRICE_MAX_AGE=12). A stalled feed can no longer fire a breakout into a
name that has already reverted.

Tests: stale price blocks fire, fresh re-enables, None clears; 18 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:21:05 -04:00
momentum devandClaude Opus 4.8 e74539d449 Resiliency + risk + monitoring: SIP backfill, freshness watchdog, gross cap, naked alarm
From the multi-agent audit (ship-now, low-overfit-risk cluster):

INGESTION RESILIENCY
- client._request: 429/5xx now retry with Retry-After + exponential backoff
  (previously hard-failed, feeding silent-stale ingest).
- SIP backfill feed (settings.backfill_feed='sip'): live realtime stays IEX, but
  backtest/backfill ingestion uses the full consolidated tape — IEX sees only
  ~2-5% of true volume (verified: AAPL 22,797 vs 452,704), which was silently
  corrupting every rvol/$-volume decision and backtest. eod_replay wired to it.
- step_ingest freshness watchdog: emits a STALE event + board note when the
  newest bar is older than INGEST_STALE_SECONDS during RTH (45% of passes
  historically returned 0 rows with no error). Optional hard arm-block
  (INGEST_STALE_BLOCK, off by default).

RISK
- Portfolio gross-notional cap (TRADING_MAX_GROSS_NOTIONAL_PCT=0.60): both sizing
  paths now cap each entry to the remaining gross budget, so 6 x 16% can't pile
  the book into ~96% gross of correlated small-caps. Blocks/shrinks late entries.
- Fixed the duplicate TRADING_MAX_CONCURRENT_POSITIONS in .env.example.

MONITORING (UI)
- Naked/unprotected-position alarm: _unprotected_positions joins the latest
  position + order snapshots (order sync now flattens legs) and the TUI board
  shows a red UNPROTECTED banner the instant a held long lacks stop coverage —
  the exact blind spot when APWC/NOWL went naked. Verified it flags today's
  3 overnight positions.

Tests: gross-notional cap blocks when budget full; 50 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:18:36 -04:00
momentum devandClaude Opus 4.8 b706289a61 eod_replay: fix screener so historical dates work (unlocks backtesting)
_daily_gappers selected each symbol's LATEST daily bar (always the most recent
date in the table) and skipped the symbol unless that bar == the session date.
So replaying ANY past date saw rows[0] != sess and skipped every symbol —
silently reporting "no qualifying gappers" for all history. Multi-day
backtesting was impossible; the dataset looked empty when it was not.

Bound the query to bars ON OR BEFORE the session date so rows[0] is the session
day and rows[1] the prior trading day. Verified: June 17 now finds 11 gappers,
June 16 finds 8, etc. — and a 6-day / 24-trade replay shows the clean strategy
is +11.6R, 75% win, +0.48R/trade (the live losses were execution bugs, since
fixed; no entry-rule tweak robustly beats baseline on this sample).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:39:40 -04:00
momentum devandClaude Opus 4.8 78bdf26141 Perf: composite (event_type, timestamp) index + test-schema sweep + README
DB latency: every projection runs `WHERE event_type=? ORDER BY timestamp DESC
LIMIT n`. With only a plain timestamp index, the latest of a STALE type
backward-scans the whole events table — measured 373ms / ~52k buffers on 235k
rows. Add a composite index in event_store._ensure_schema:

    CREATE INDEX idx_events_type_ts ON events(event_type, timestamp DESC)

-> same query is now a 0.018ms index seek (3 buffers). Applied to the live DB
CONCURRENTLY; also dropped two redundant legacy indexes (idx_events_type, now
covered by the composite prefix; idx_events_correlation, a dup of
idx_events_session_id) to cut write overhead.

tests/conftest.py: sweep the throwaway `mem_*` schemas EventStore(":memory:")
creates — 170 had leaked into the shared Postgres, bloating the catalog.

README: rewritten around the current system — PostgreSQL event store, a full
command reference (run_live_paper / momentum CLI / eod_replay / sweeps / tuning /
cron), config knobs, and a database & performance section (the index above,
extension guidance, retention).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:09:55 -04:00
momentum devandClaude Opus 4.8 cf1ad17c2d CI: add Claude Code Review + PR Assistant workflows
Carried over from the GitHub App setup. Both reference secrets.CLAUDE_CODE_OAUTH_TOKEN
(a repo secret, no literal keys). To activate on this repo: install the Claude
GitHub App and set the CLAUDE_CODE_OAUTH_TOKEN secret.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:57:24 -04:00
momentum devandClaude Opus 4.8 129e8d4b15 Cleanup: consolidate flatten logic + refresh .env.example
- Extract the cancel-protective-legs-then-close routine (duplicated in the exit
  manager and the execution service after fixing the held-qty 403 in both) into
  one shared, heavily-documented helper: runtime/flatten.py. Both callers now
  delegate, so the fix can't drift out of sync. Behaviour unchanged; 121 tests
  pass.
- Refresh .env.example: document every key added this session (liquidity gate +
  sizing, backout cooldown, re-entry guard, managed-exit ladder, EOD flatten)
  with the why, and flag MAX_DAILY_LOSS_PCT to revert to 0.03 before live.
- Remove throwaway data/_watch_*.py monitoring scripts (gitignored clutter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:51:35 -04:00
momentum devandClaude Opus 4.8 88a127ac3f Re-entry guard: bench only after a LOSING exit, not any close
The blunt guard benched a name after ANY position close — so a quick scratch or
a win that re-set up was blocked. In the day-replay it killed the GRAB +513
re-entry while only saving a loss the liquidity floor already caught.

Refine: track each held name's last-seen unrealized return (unrealized_plpc) and
bench it on departure only if it exited DOWN more than reentry_min_loss_pct
(env TRADING_REENTRY_MIN_LOSS_PCT, default 1%). A scratch/win is left free to
re-enter. Replay with all changes: actual (flat 15:55) -3,285 -> -21, i.e. the
guard refinement alone recovers the +513 the blunt version forfeited.

Tests: losing exit benches; winning exit does NOT; 43 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:30:56 -04:00
momentum devandClaude Opus 4.8 6e99627da8 Journal: display trade times in ET (was UTC)
The journal/board reconstructs from broker timestamps (UTC) and printed the raw
UTC time, so a 10:12 ET entry showed as 14:12 on a US-market journal. Add _to_et
and convert both the today-filter and the displayed time to America/New_York.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:23:29 -04:00
momentum devandClaude Opus 4.8 4520c6935a EOD flatten: cancel bracket legs before close (was naked overnight)
The end-of-day flatten fired at 15:55 but FAILED on every position with the
held-qty 403: close_session/_flatten_all called close_position while the DAY
stop legs still reserved the shares (held_for_orders) — the same bug fixed in
the exit manager, but on the EOD path. The flatten then marked itself done and
never retried, leaving the whole book naked overnight (3 positions, no stops,
two underwater).

- Add _release_and_close(): cancel a symbol's resting bracket legs to free the
  qty, THEN close_position with retry. Route both _flatten_all and the
  close_session belt-and-suspenders loop through it.
- step_eod_flatten only marks _eod_done when the flatten had NO errors — so a
  failed flatten RETRIES within the [15:55,16:00) window instead of giving up.

Tests: EOD flatten cancels the leg before closing (no 403); 45 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:08:33 -04:00
momentum devandClaude Opus 4.8 0da1ba733b Entry liquidity gate + liquidity sizing + breakeven-at-1R
Today's losses were an ENTRY-quality problem: every loser reversed before +1R
(max favorable excursion 0.1-0.7R) — we filled at the top of one-bar spikes on
names too thin to hold the move (WKSP/LNKS/UWMC entered on 100-400 share bars).
rvol is RELATIVE, so 2x of near-nothing still qualified.

- Trigger $-volume floor: ArmedTriggerBook gains min_dollar_vol; step_arm
  computes recent 5-bar $-volume per candidate. Thin names go WEAK instead of
  arming (env TRIGGER_MIN_DOLLAR_VOL, .env=8000). Skips the untradeable
  (APWC $3.8k, WPRT $2.7k).
- Liquidity-aware sizing turned on (.env TRADING_LIQUIDITY_MAX_VOLUME_PCT=0.025):
  size caps to a fraction of volume, so thin names get sized down 90%+ while
  liquid names are untouched — exactly the asymmetry a small real account needs.
- Breakeven-at-1R enabled (.env): forward give-back safeguard. NB it changes 0
  of today's trades (no loser reached +1R) and was a prior OOS 'conservative
  backup' — kept as protection for bigger-winner days, not a today-fixer.

Back-comparison (one day, caveated): the two liquidity filters take the day from
~-1,215 to ~+202 excluding the APWC overlap already covered by the naked-stop
fix + re-entry guard. Needs forward validation, not just this back-fit.

Tests: $-vol gate (thin -> WEAK); 50 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:37:30 -04:00
momentum devandClaude Opus 4.8 03eb8bef7c Execution: block re-entry after a position closes (one round-trip/day)
A name that stopped out could be bought right back the same session — observed
live: APWC stopped out -904 in the morning, was re-entered, and lost another
-645. The backout cooldown only covered UNFILLED entries; a filled position that
exits had no re-entry guard.

Track names leaving the broker's held set (a closed round-trip) and bench them
from re-entry for the rest of the session, on both the slow approval path and
the fast trigger. Detection runs every guard pass (before the armed early-return)
off fresh broker truth; emits a reentry_block risk event. Toggle with
TRADING_BLOCK_REENTRY_AFTER_EXIT (default on).

Tests: re-entry blocked on both paths after a position closes; 40 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:23:53 -04:00
momentum devandClaude Opus 4.8 bace33edfd Board/journal: broker-authoritative realized in the P&L header
The header's "matched" subtotal used the FIFO round-trip sum, which doesn't
equal the broker's average-cost day P&L while positions are partially open — so
"matched + open" never tied to the headline day P&L (e.g. -1340 + open vs the
broker's -627).

Show broker-authoritative realized instead: matched = day P&L - open unrealized,
so matched + open == day P&L by construction. Per-trade rows stay FIFO-accurate
(each is a real fill pair); only the header subtotal is broker-truth. Factor the
open-unrealized sum into a shared helper used by both the live board and the
`journal` close capture. Falls back to the FIFO sum when no equity snapshot
exists.

Verified live: board + journal both reconcile (matched + open == day P&L).
Tests: 3 new (reconciliation invariant + FIFO fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:42:35 -04:00
momentum devandClaude Opus 4.8 ddf93d199c Journal: capture bracket-leg exits + paginate the order history
Today's-trades reconstruction showed normally-exited positions as falsely
"open" and understated realized P&L. Two causes:

1. sync_orders recorded only TOP-LEVEL orders. A position closed by its bracket
   STOP or TAKE-PROFIT leg has that exit fill as a child LEG, so every stop/TP
   exit was invisible — QTEX/BBD/UWMC/etc. looked open when they were flat, and
   their realized P&L was dropped. Now flatten parent + filled legs (legs inherit
   the parent symbol).

2. get_orders only fetched the most-recent page; Alpaca's `limit` truncates by
   recency (legs count toward the cap), so a busy day could drop the morning's
   fills entirely. Unscoped get_orders now pages backwards with `until` (dedup by
   id) to capture the full day; the symbol-scoped path stays single-page.

Verified live: journal open set now matches the broker exactly
(ATPC/GRAB/LNKS/NOWL/WPRT) and every reconstructed round-trip ties to a real
broker fill. NB: the realized total is FIFO-lot based, so while positions are
partially open it won't equal the broker's average-cost day P&L — the broker
figure remains the authoritative headline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:13:35 -04:00
momentum devandClaude Opus 4.8 e8533446b2 Exit manager: cancel protective orders before market-closing (held qty)
The trail-breach (and first-red) exit called close_position directly, but the
bracket stop/TP legs RESERVE the full quantity (held_for_orders). Alpaca then
rejects the market close 403 "insufficient qty available (available: 0)". The
position can never exit, so the manager retried every pass forever — observed
live: PLUG logged 191 identical 403s while a legitimate trail exit (+286) and
WPRT/ATPC stop-outs were all blocked.

Add _flatten(): cancel the symbol's active protective sell legs to release the
shares, THEN liquidate (retry the close so a not-yet-settled cancel doesn't
hang). Route the two full-close paths through it. Verified live: PLUG flattened
cleanly (0 dangling orders, profit locked), zero 403s after restart, remaining
book still fully stop-protected. Test: _flatten cancels protective then closes;
42 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:28:23 -04:00
momentum devandClaude Opus 4.8 34d22c9193 Execution: per-symbol backout cooldown (anti-thrash)
A breakout that won't fill on a marketable limit used to re-arm every pass: the
entry timed out (~2 min), backed out, freed the symbol, and re-armed next tick —
forever. Observed live: NEOV armed+backed-out 68x in one session, 106 of 130
buy orders cancelled. That order churn is also what truncated get_orders and
caused the false-naked scare (commit 11ab6c7).

Add a cooldown: after an entry backs out (timeout or price-break), bench the
symbol for TRADING_BACKOUT_COOLDOWN_SECONDS (180s); after
TRADING_MAX_BACKOUTS_PER_SYMBOL (3) backouts, bench it for the rest of the
session. Gated on both the slow path (request_approvals_for_ready_signals) and
the fast trigger (submit_breakout_now). Backout events now carry the cooldown
state.

Verified live: NEOV's first backout under the new code stamped
"[cooldown 180s (backout 1)]" and the symbol stayed ready every pass but
approvals_requested=[] for the full window — no re-arm. Tests: two new
(temporary cooldown + session bench), 39 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:21:20 -04:00
momentum devandClaude Opus 4.8 11ab6c7b56 Exit manager: scope get_orders to held symbols (stop-leg truncation)
Alpaca's orders `limit` keeps only the most-RECENT orders and counts child
legs toward the cap. On a busy day the order list runs to hundreds (every
backed-out entry is an order), so the exit manager's limit=100 snapshot
silently dropped the stop legs of older OPEN positions. A stop-leg lookup then
read "no stop" on a fully-protected position -> broken trailing/profit-lock for
that name (and false-naked alarms in monitoring).

Fix: get_orders gains a `symbols` filter; the exit manager scopes its snapshot
to the handful of held names (no truncation possible). Verified live: an
unscoped limit=100 query reported PLUG stop=None while the scoped query
correctly surfaced PLUG's stop @2.65.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:34:43 -04:00
momentum devandClaude Opus 4.8 1cd82de6a8 Fix naked-stop regression: speed cache made the guard strip live brackets
The get_positions TTL cache (added for the speed fix) silently broke the
naked-stop guard: _broker_held_symbols read 2.5s-stale positions, so a
just-filled entry wasn't seen as held, the guard thought it was unfilled, and
cancelled its bracket on a price dip -> NAKED position (APWC bled -12.8%
unprotected today). Fixes:
- client.get_positions(fresh=True) bypasses the cache; the guard uses it.
- entry_grace_seconds (default 5): never cancel an entry within N seconds of
  arming, so a marketable fill can confirm before the bracket could be stripped.
Regression test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:20:00 -04:00
momentum devandClaude Opus 4.8 27b0748b71 DB: auto-reconnect on dropped connection (board crashed on idle drop)
A long-lived `momentum watch` connection got closed by Postgres on idle/restart
overnight, and the next query raised OperationalError and crashed the board.
PgConnection now reconnects ONCE on OperationalError/InterfaceError (skipped for
isolated test schemas); the watch loop also catches transient errors and shows
'reconnecting…' instead of dying. Hardens the live loop's event store too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 07:04:49 -04:00
momentum devandClaude Opus 4.8 8a24226cfb Trade journal: momentum journal command + nightly close capture
momentum journal prints the day's trades (time/symbol/qty/entry/exit/P&L) +
broker-accurate day P&L + win summary. journal.sh appends it to
data/trade_journal.log; cron 16:02 weekdays keeps a durable daily record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:54:24 -04:00
momentum devandClaude Opus 4.8 c35224af94 Board: per-day trade history + accurate day P&L; manual symbol weights
- momentum watch: replace 'open positions' with TODAY'S trade history (FIFO
  round-trips from the broker orders snapshot: time/symbol/qty/entry/exit/P&L +
  open lots), resets each day. Headline day P&L is broker-accurate (equity -
  last_equity, now carried on AccountSummaryUpdatedEvent), shown in the header
  and the panel title. sync_orders limit 50->500 so the log captures the whole
  day across restarts.
- SYMBOL_WEIGHTS: optional manual per-symbol priority nudge applied to the
  armed-trigger rank score (small; doesn't override gap/volume logic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:33:22 -04:00
momentum devandClaude Opus 4.8 906237bfe3 News/catalyst ranking + nightly cron for the EOD replay
News (you were right it's relevant): recent_news_map() pulls fresh catalysts per
ticker from the ingested RSS feeds; step_arm tags each armed candidate and BOOSTS
catalyst-backed gappers up the rank (NEWS_RANK_BOOST) — a fresh PR/FDA/offering
is why a small-cap runs, so it surfaces the hotter name earlier and higher. The
board shows a 📰 marker + the headline. Feeds verified live (PR Newswire /
Business Wire / GlobeNewswire) — pulling real catalysts (FDA approvals, offerings).
Forward-tested only (news isn't OOS-backtestable).

eod_replay.sh + crontab: replay at 16:05 (grow dataset), tune at 17:30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:14:57 -04:00
momentum devandClaude Opus 4.8 6e59ba9c4b EOD whole-market replay: what today's logic would have done
eod_replay.py: after the close, pull the day's 100 most-active names (vs ~20
watched live), daily-screen the $1-5 gappers, minute-backfill only those, and
replay TODAY'S logic (entry filters + ORB + managed exits) over each — reporting
what it would have traded + est P&L + perfect-hindsight (biggest catchable
runs). Daily-first screening keeps it fast. Every run also grows the stored
dataset for the tuner/verification.

On 2026-06-17 it showed the new logic would have been +3.7R (~+$3.5k, 3 trades
100% win) vs the -$2.6k actually lost — the filters exclude exactly the losers
($7+ ICCM, blow-offs) that the old config traded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:05:18 -04:00
momentum devandClaude Opus 4.8 638843ca3a Account-aware sizing: size off REAL equity + buying-power & liquidity caps
For going live on a SMALL real account ($300). The sizer assumed a $100k
default -> at $300 it would size $1,000 positions and get rejected. Now:
- _current_equity() reads actual broker equity (cached get_account) and sizes
  off it, so a $300 account sizes for $300.
- max_position_value caps each position to ~1/3 of equity (TRADING_MAX_POSITION_PCT)
  so N positions fit buying power.
- max_shares liquidity cap (TRADING_LIQUIDITY_MAX_VOLUME_PCT * day volume) wired
  through the trigger book -> submit_breakout_now; off at small size, for scaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:52:22 -04:00
momentum devandClaude Opus 4.8 e4075557b3 Entry-quality analysis + gap cap (diagnostic-driven)
diagnose_entries.py buckets backtested trades by feature: cheap stocks ($1-5
+0.5R) crush $10-20 (-0.09R), rvol>=5 (+0.79R) beats <5x (+0.15R), and blow-off
gaps >150% fade. sweep_filters.py verifies price/volume/gap-cap combos
train-vs-test: px<=7 + gap<=60% ~doubles OOS expectancy (+0.76 vs +0.39R, win
86% vs 71%).

Add a gap cap to the armed-trigger eligibility (TRIGGER_GAP_MAX) so blow-off
gappers can be excluded; price cap (WATCHER_PRICE_MAX) and volume floor
(TRIGGER_RVOL_MIN) already exist. Final values pending OOS verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:26:26 -04:00
momentum devandClaude Opus 4.8 476d30e3d1 Board: show entry/last price + uPnL $ and % on open positions
So you can confirm the bought price next to the unrealized P&L.

Profit-lock tiers verified out-of-sample (lock_mid: 8:3,15:9,25:18,40:30 on the
prior-low trail) — test avgR 0.127 vs 0.123 baseline, win 55% vs 46%, not
overfit; already the .env default. The flashy lock_only_mid (+0.40R in-sample)
was rejected as overfit (collapsed to 0.187 test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:12:04 -04:00
momentum devandClaude Opus 4.8 9b3697be98 sweep_exits: add profit-lock tier variants (lock_off/tight/mid/wide/only)
For verifying the profit-lock checkpoints out-of-sample against the current
trail (lock_off = trailLow_a1). In-sample, lock_mid matches lock_off on
expectancy (+0.264 vs +0.262R) with a higher win rate (61% vs 57%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:07:46 -04:00
momentum devandClaude Opus 4.8 3e09f98c44 End-of-day auto-flatten (no overnight gap risk)
A day-trading book shouldn't carry overnight. New 'eod' loop step calls
TradingExecutionService.close_session at TRADING_EOD_FLATTEN_TIME (default
15:55 ET): flattens every position (cancelling their bracket legs), cancels
resting entries, and blocks new entries for the rest of the session. Only fires
inside [flatten_time, 16:00) so a post-close restart doesn't queue useless
after-hours orders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:04:40 -04:00
momentum devandClaude Opus 4.8 6dfc074c6d Profit-lock checkpoints + fix uPnL display
- strategy/exits.py: percentage profit-lock tiers (TRADING_EXIT_PROFIT_TIERS,
  e.g. 8:3,15:9,25:18,40:30). Once a trade has been up gain%, the stop is pinned
  to entry*(1+lock%) so a winner can only give back to a locked-in minimum gain
  and a fade sells you out IN PROFIT. Shared by backtest + live, active-trades
  only (ratchets the broker stop up via replace, never cancel).
- momentum_cli.py: open-positions uPnL was reading 'unrealized_pl' but the sync
  stores 'unrealized_pnl' -> always blank. Read the right key (+ green/red).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:57:27 -04:00
momentum devandClaude Opus 4.8 dd16a520f0 Process-wide DNS cache — the root fix for the slow loop
The 2.5s value-cache + fewer calls only halved the slowness; the real cause was
flaky WSL DNS (~5s on ~1 in 5 resolves) and urllib re-resolving on EVERY
request. Cache successful DNS lookups process-wide (5min TTL) so each Alpaca
host is resolved at most once per window. Measured: get_orders went from
intermittent 5s spikes to a steady ~200ms. Opt out via ALPACA_DNS_CACHE=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:27:23 -04:00
momentum devandClaude Opus 4.8 52b43245d5 Fix slow loop passes: cache account/positions + fewer broker calls
The single-threaded passes (and the trigger thread) ballooned to 60-120s.
Cause: flaky WSL DNS makes ~1 in 5 Alpaca calls take ~5s to resolve, and each
pass fired ~20 calls (breaker, sync, guard, exit manager all re-reading
account/positions/orders). Fixes:
- client: 2.5s TTL cache for get_account/get_positions (invalidated on any
  order mutation) — collapses the redundant per-pass reads to one.
- exit manager: fetch the (heavy) status=all orders snapshot ONCE per pass and
  reuse it for every position's stop-leg + entry-time lookup (was 2 calls/pos).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:21:31 -04:00
momentum devandClaude Opus 4.8 45d5484dd7 Breaker: recoverable data-halt + network retries (DNS blip ended the day)
A transient WSL DNS outage made 3 consecutive equity reads fail, and the
fail-CLOSED breaker then PERMANENTLY halted new entries for the whole session
even after DNS recovered seconds later. Fixes:
- split the breaker into a permanent _halted (real daily-loss breach) and a
  recoverable _data_halt (equity unreadable) that clears on the next good read;
  raise the pause threshold to 5 (TRADING_EQUITY_FAIL_LIMIT).
- AlpacaPaperClient._request retries transient URLError/gaierror (not HTTP
  4xx/5xx) a couple times, so brief blips don't even register as failures.
- sync logs network failures as one-line warnings, not full tracebacks (the
  log was spamming stacks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:03:35 -04:00
momentum devandClaude Opus 4.8 9cc02e01ac Exit manager: trail from actual broker fill time
Track entry_ts from the broker entry order's filled_at (not first-sight), so the
high-water mark and R-multiple reflect the move since the real entry — correct
trailing for positions that predate the manager (e.g. across a restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:36:49 -04:00
momentum devandClaude Opus 4.8 2a315d87d6 Exit manager: find held stop legs + trail-breach market exit
Two fixes found by running the manager against the live book:
- _stop_leg queried status="open", but a bracket's protective stop is a 'held'
  child of the FILLED entry, so it was never found -> stops never trailed.
  Query status="all" and match active leg statuses.
- when the trailed stop would sit ABOVE current price (price fell back through
  the prior swing low), a sell-stop there is rejected; that IS the trail firing,
  so exit at market instead.
Also drop the hardcoded "6" in the board title (armed set is now 10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:28:25 -04:00
momentum devandClaude Opus 4.8 ab5a92447f Managed exits (swept + OOS-verified) + bigger armed set
Replace the static -1R/+2R bracket with active, parameterised exit management
shared by the backtest and live (strategy/exits.py): move to breakeven, trail
under prior-bar lows or by %, scale out, or first-red exit.

Swept all variants over 187 sessions with a held-out train/test split
(sweep_exits.py), then an adversarial 3-judge workflow picked the robust winner
OUT-OF-SAMPLE: trailLow_a1 (trail under the prior bar's low after +1R) — test
avgR 0.128 vs the old static_2R 0.067 (~1.9x), win 49% vs 38%. The naive
in-sample winner (trailPct10) was correctly rejected as overfit
(train 0.684 -> test 0.085).

- strategy/exits.py: ExitConfig + simulate_exit (backtest) + manage_live (live)
- strategy/backtest/engine.py: simulate the managed exit on entry; Trade.entry_index
- runtime/exit_manager.py: live manager — ratchets the broker stop UP via
  REPLACE/PATCH (never cancel = never naked), scales out, first-red exit
- alpaca_paper/client.py: replace_order (PATCH) + partial close_position
- run_live_paper.py: 'exits' step (12s); TRIGGER_MAX_ARMED 6 -> 10
- tests: exits sim/live + manager ratchet-never-cancel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:16:47 -04:00
momentum devandClaude Opus 4.8 d5d79cccf7 Threaded fast trigger + incremental ingest + naked-stop guard
Three fixes from the first live session:

1) Naked-stop bug (critical): the entry-timeout guard cancelled an entry it
   thought was unfilled AFTER it had really filled (our order_filled events lag
   the 60s sync). Cancelling a bracket parent cancels its stop/TP legs, so the
   position went NAKED and bled 22% with no floor. Guard now checks the broker's
   LIVE positions (get_positions) before cancelling and never cancels a filled
   name. Regression test added.

2) Speed: ingest re-fetched a 240-min window for ~80 symbols every minute (~46s
   measured), and the single-threaded loop froze the 4s trigger that whole time
   -> breakouts caught once a minute, not in real time. Ingest is now
   incremental (heavy first pass covers the session so the opening range is
   present; then ~20-min refreshes, closed bars never change). Loop passes
   dropped from ~70s to ~2-5s.

3) Threaded trigger: the fast breakout detector/fire loop now runs on its own
   thread, fully decoupled from the slow scheduler. EventStore, the execution
   service, and the trigger book are made thread-safe (locks) so the thread and
   main loop can't race the shared psycopg2 connection or in-memory state.
   Verified live: fired EZGO/NEOV, all positions retain active stops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:39:50 -04:00
momentum devandClaude Opus 4.8 7b039ca288 Fix entry-invalidation churn: default 0.0 cancelled every breakout pre-fill
Live at the open, every fired entry was cancelled ~1s later by the
entry-invalidation guard: at the 0.0 default, any sub-cent dip below the
trigger invalidates, so a breakout oscillating around the level churns and
never holds a position (0 fills across a dozen orders). Default to 0.015 — give
the breakout room; the bracket stop at the opening-range low is the real
protection and risk stays capped at 1%/trade. After the fix the book held 4
positions (+$453 unrealized) including one via the new orb_trigger fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:44:04 -04:00
momentum devandClaude Opus 4.8 fb377ac321 Fast armed-trigger path + live, self-explanatory board
The "always late / miss the move" problem was structural: entries waited for a
1-min bar to CLOSE above the breakout then walked the ingest->watch->execute
cadence (~2 min), and rested a limit AT the trigger that a runner never fills.

Add a top-6 armed-trigger book (runtime/triggers.py): keep the most-promising
gappers ready with a pre-computed opening-range trigger/stop and FIRE the
instant LIVE price crosses, via TradingExecutionService.submit_breakout_now —
a marketable order (capped above the trigger) that actually fills — reusing the
same risk gates (daily-loss breaker, max-concurrent, 1%-risk sizing, dedup).

- structure.py: opening_range() helper (shared with the ORB detector)
- run_live_paper.py: step_arm (rank + arm, 20s) + step_trigger (price-cross
  fire + snapshot, 4s)
- momentum_cli.py: rewrite the watch board — market clock + countdown, loop
  heartbeat, the 6 armed triggers with live price / gap / rvol / trigger /
  distance / stop, and a plain-English "what it's hunting" line
- tests: opening range, trigger book state machine, submit_breakout_now gates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:28:53 -04:00
momentum devandClaude Opus 4.8 c7bfb3512a Self-tuning loop: per-setup report + nightly re-sweep + live learned-params
(a) learn_params.per_setup_report: permanent per-setup P&L/earliness breakdown
    in the harness (forces all-setups so the ORB-vs-rest comparison is visible).
(b) nightly_tune.py + nightly_tune.sh: backfill latest bars, sweep ORB params
    over accumulated gapper sessions, write the best PROFITABLE config to
    data/learned_params.json. run_live_paper reads it on boot and applies the
    tuned ready_score / min_bars (explicit env still wins); won't overwrite with
    a losing or thin-sample config. Cron-scheduled after the close.

Verified: 75/75 tests; boot logs the learned params and applies them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:45:03 -04:00
momentum devandClaude Opus 4.8 92985938e1 Strategy: ORB-first + STRATEGY_SETUPS config; harness gapper filter + per-setup attribution
Sweep over 172 real gapper sessions (16 days) with a per-setup breakdown found
the opening-range breakout is the ONLY profitable setup (+0.25R); every slow
pattern loses (bull_flag -$78k @ ~42min is the worst). Running ORB-ONLY flips
the book from -$95k to +$7.4k AND enters ~11 min earlier (23 vs 34 min avg).

- classify_setup prefers the ORB when valid and honors STRATEGY_SETUPS env
  (comma-sep setup values) to restrict active setups; .env defaults to
  STRATEGY_SETUPS=opening_range_break (ORB-only).
- BacktestEngine Trade.setup_type captured for per-setup attribution.
- learn_params load_sessions filters to gapper sessions (mimics live scanner) so
  the backtest trades the same universe the system would.

75/75 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:44:03 -04:00
momentum devandClaude Opus 4.8 14cccc5d30 Add opening-range-breakout setup (the EARLY trigger) + history backfill
- structure.detect_opening_range_breakout: break of the first ~5 RTH bars' high
  on >= OR-average volume; entry=OR high, stop=OR low. Placed first in
  classify_setup so it fires minutes after the open instead of waiting for a
  pullback to form. Verified: RGNT triggers at bar 6 (~9:36 ET) q=1.0.
- evaluate_setup counts OPENING_RANGE_BREAK toward the structure criterion.
- backfill_history.py: pull ~3 weeks of minute + 60d daily bars for the sub-$20
  universe so learn_params optimizes over real sessions.

75/75 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:22:43 -04:00
momentum devandClaude Opus 4.8 9677c79db4 Learning harness + tunable timing knobs (diagnose 'always late')
- learn_params.py: grid-search the strategy timing/threshold knobs over stored
  sessions, ranking by total P&L AND 'entry_min' (minutes after the open the
  entry fires) — a learning method to find params that enter earlier while
  staying green.
- evaluate_setup(min_bars=...) + BacktestEngine(ready_score_pct/criteria/min_bars)
  now tunable; watcher passes WATCHER_MIN_BARS through (was a no-op: evaluator
  hard-coded 10).

Sweep finding: even the earliest params enter ~28-30 min after the open
(baseline ~33) because the first-pullback setup waits for a pullback to FORM.
Cadence/min_bars are not the bottleneck — the setup design is. Genuine earliness
needs a fast opening-breakout trigger + more historical sessions to learn from.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:07:08 -04:00
momentum devandClaude Opus 4.8 660cd494d5 Harden order/risk path (execution+broker audit): 3 HIGH + 2 MED + 1 LOW
HIGH:
- Circuit breaker fails CLOSED: a repeated unreadable equity (API 401/429/5xx or
  missing field) now halts new entries after 3 consecutive failures instead of
  silently reporting 'no breach' and continuing to trade through a drawdown.
- execute(): map order statuses. Terminal-fail (rejected/expired/...) -> ok=False
  (never armed as a resting entry); record ANY fill (partial too), not only an
  exact 'filled'; require a real broker order id. approve_order only arms
  genuinely OPEN statuses.
- sync: account/positions/orders failures now emit a BrokerHealthChangedEvent
  (degraded) and step_sync surfaces 'DEGRADED', instead of a success-shaped None
  that left risk gates reading stale snapshots as current.
MEDIUM:
- _flatten_all records a position closed only on a CONFIRMED close; the breaker
  event reports 'halted_flatten_incomplete' + the unclosed symbols when a close
  fails, instead of claiming the book is flat.
- submit_exit_order prefers close_position (handles short sign + fractional qty;
  a plain sell of abs(qty) would double a short).
LOW:
- get_orders(nested=true) so bracket child legs aren't dropped from the snapshot.

+ tests/unit/test_execution_safety.py (4 regressions). 75/75 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:13:28 -04:00
momentum devandClaude Opus 4.8 c146341bb4 Harden screener auth-vs-empty + boot fallback + news DB-error (audit HIGH#1/MEDIUM)
- discover_active_symbols: distinguish AUTH/entitlement failures (HTTP 401/403,
  logged as ERROR, 'not no-movers') from transient/empty in the screener except.
- run_live_paper: the boot watchlist event records an explicit error when it
  falls back (screener empty at boot) instead of looking like 'market quiet'.
- discovery.news_candidates: log DB faults instead of swallowing to [].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:03:48 -04:00
momentum devandClaude Opus 4.8 91a0215da9 discover_active_symbols: price the full screener list (was [:50]); batching makes it safe
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:44:19 -04:00
momentum devandClaude Opus 4.8 e134818aea Fix unbounded universe -> over-cap ingest 400 (audit HIGH #2)
The live 'symbols' list only ever grew (discover appends every 5 min, dashboard
injects add more) and was comma-joined into ONE bars request; once >~100 symbols
Alpaca 400s the whole batch -> ALL minute-bar ingestion silently stops mid-session.

- client.get_minute_bars / get_daily_bars / get_latest_trades batch symbols in
  chunks of 100 (cap-safe) and merge; get_daily_bars also paginates now.
- run_live_paper bounds the watchlist (WATCHER_MAX_UNIVERSE, default 80) so it
  rotates toward current movers instead of growing all day.

Found by the silent-failure audit workflow. Verified _chunked(250)->[100,100,50],
live calls intact. 71/71 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:43:35 -04:00
momentum devandClaude Opus 4.8 3e2b4f562e Harden discovery: surface screener failures + regression test
- discover_active_symbols logs the screener error instead of swallowing silently.
- run_discovery records an explicit error when the universe is empty (surfaces in
  the discovery event + Grafana).
- momentum doctor now FAILs when the latest discovery universe is 0 (the morning's
  dead screener would have shown red within a minute).
- tests/unit/research/test_discovery.py: pins get_most_actives top<=100 and the
  ETF/price-band filtering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:23:21 -04:00
momentum devandClaude Opus 4.8 9113d8ea4c Fix screener returning empty all session (top>100) + add momentum launcher
- discover_active_symbols requested get_most_actives(top*3); once screen_universe
  used top*2 headroom for the ETF filter (top=20 -> 40 -> get_most_actives(120)),
  this exceeded Alpaca's hard cap of 100 -> HTTP 400, swallowed by the bare
  except -> discovery returned EMPTY every cycle. The live loop therefore traded
  the static fallback list (non-gappers) instead of real movers all morning.
  Fix: cap at min(top*3, 100). Verified: screen_universe now returns 20 real
  sub-$20 names, ETF-filtered.
- Add ./momentum launcher (project venv) so 'momentum watch' works without the
  full venv path ('python' on the box is the Windows pyenv shim, no deps).

67/67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:58:07 -04:00
momentum devandClaude Opus 4.8 020eec7f96 WS5 Grafana dashboards + RSS news-catalyst discovery
- grafana/: docker-compose + provisioned Postgres datasource + 'Momentum — Live'
  dashboard (evaluation board with status colours, ready signals, risk events,
  bars + equity timeseries) reading the event store via JSONB extraction.
  Run: cd grafana && docker compose up -d -> http://localhost:3000.
- RSS news-catalyst discovery wired: extract_tickers also matches $CASHTAGS;
  run_discovery pulls tickers from recent news into the universe when NEWS_FEEDS
  is set (no-op / safe otherwise).
- docs: .env.example NEWS_FEEDS; GO_LIVE monitoring adds 'momentum watch' + Grafana.

67/67 tests green. Live board (momentum watch) verified rendering the evaluation
board with real determinations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:00:22 -04:00
momentum devandClaude Opus 4.8 11f1dd8b15 Live monitoring board: momentum watch
Full-screen auto-refreshing board (rich.Live) of what the system is evaluating
in real time: per-symbol status (ready/late/blocked), score, gap%, RVOL and the
blocking reason, ranked by score — plus ready signals, open positions, and risk
events. 'momentum watch' (refresh) or '--once' (single frame). Reads the
Postgres event store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:54:09 -04:00
momentum devandClaude Opus 4.8 6c339121f0 Circuit breaker: auto-flatten on breach
When the daily-loss breaker trips it now also flattens the book: cancels
unfilled (armed) entries and market-closes every open position via
client.close_position (which cancels resting bracket legs). Configurable via
TRADING_FLATTEN_ON_BREACH (default on). Best-effort per symbol; emits a
'halted_and_flattened' risk_rule_triggered event with the flatten summary.

Verified: trip at -4% closes 2 seeded positions + cancels 1 armed entry. 67/67 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:20:57 -04:00
momentum devandClaude Opus 4.8 37c6131b8e GO_LIVE.md: trading enabled (auto-approve + circuit breaker + ETF filter)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:03:56 -04:00
momentum devandClaude Opus 4.8 a0276958ca Trading enablement: circuit breaker, ETF filter, robust discovery
- trading_execution: implement the daily-loss circuit breaker (account equity vs
  prior close; latches once tripped; fail-open on a data hiccup) — was
  advertised in the docstring/settings but never implemented.
- discovery: filter leveraged ETFs (SOXS/TZA/TSLL/NVD/BITO/...) out of the
  universe; retry the screener on a transient empty (rate-limit) result.
- run_live_paper: fallback watchlist is now liquid sub-$20 names, not mega-caps
  (mega-caps just get filtered by the watcher band -> nothing to trade).

Verified live: forced ready signal -> auto-approve -> real Alpaca bracket order
(pending_new, then cancelled); breaker trips at -3% (simulated). 67/67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:02:46 -04:00
momentum devandClaude Opus 4.8 d5d50a5e4f GO_LIVE.md runbook + verified continuous loop for market open
Market-open runbook: start command (observe mode), the 9:30-10:35 ET trading
window (10:30 entry cutoff), monitoring commands (doctor / inspect signals /
criteria / bars), how to enable paper execution, env tuning, honest known limits.

Verified this session: continuous loop stable with graceful Ctrl-C; full
pipeline (discover -> ingest -> evaluate) produces ready signals on real
2026-06-15 morning data; 67/67 tests green throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:28:17 -04:00
momentum devandClaude Opus 4.8 a16a8cd29d WS3: surface evaluation internals (gap/rvol/per-criterion reasons)
- SetupEvaluationResult.criteria_detail: per-criterion {name,passed,reason}.
- watcher CriteriaEvaluatedEvent now carries gap_pct, relative_volume, status,
  reason, and the per-criterion detail (was only passed/failed names).
- CLI: 'inspect criteria SYMBOL' shows status + gap/rvol + each criterion reason;
  new 'inspect signals' = ready board + per-symbol gap/rvol/status.

Verified on real 2026-06-15 bars: RGNT shows 9/9 with reasons (rvol 26.2x),
'late' explained by the 10:30 ET cutoff. 67/67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:22:55 -04:00
momentum devandClaude Opus 4.8 9e79c50751 WS2: momentum inspect/doctor CLI (per-section verification)
momentum_cli.py (typer + rich):
- doctor: PASS/WARN/FAIL across db / schema / research / ingestion / discovery /
  event_store, with honest known-gap WARNs (circuit breaker not implemented,
  quality_score hardcoded, ETF universe).
- inspect events / bars / discovery / criteria: read the Postgres event store +
  bar tables. 'inspect bars' shows per-symbol counts + freshness and flags
  0-bar (market-closed/thin) vs broken.

Verified against live Postgres: 6 pass / 4 warn / 0 fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:38:17 -04:00
momentum devandClaude Opus 4.8 1443e7691a Discovery-by-default + WS1 bars/discovery telemetry
- research/ingestion/discovery.py: screen sub-$20 most-actives, backfill daily,
  rank gappers, persist scanner snapshot, and EMIT a discovery event.
- run_live_paper: watchlist defaults to the screener universe (was a hardcoded
  mega-cap list); add a periodic 'discover' task; emit bars-collected
  (per-symbol counts) and discovery events every pass.
- IngestionResult.per_symbol: per-symbol row counts (incl 0) so 'are bars
  collected?' is answerable per symbol, distinguishing market-closed from broken.

Verified live vs Postgres: watches SOXS/RGNT/GRAB/SOFI/ACHR/... (sub-$20),
820 daily bars backfilled, discovery+ingestion events land in the event store.
67/67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:35:52 -04:00
momentum devandClaude Opus 4.8 b1ef0930ca WS0 cleanup: DuckDB-free + wire DATABASE_URL
- Drop duckdb dependency (pyproject, requirements) -> psycopg2-binary.
- run_live_paper: boot log shows the real Postgres target (masked); add
  _db_target_desc() helper.
- .env.example: document DATABASE_URL / PG* vars; mark WATCHER_EVENT_DB_PATH legacy.
- evaluation/storage.py: INSERT OR REPLACE -> INSERT (setup_id is a fresh uuid).

Verified: 67/67 tests green on Postgres with duckdb UNINSTALLED; app boots
against the live endpoint (127.0.0.1:5432 proxy) and wrote 205 daily bars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:15:19 -04:00
momentum devandClaude Opus 4.8 c4669fe0ce WS0: migrate datastore DuckDB -> PostgreSQL (psycopg2)
- storage/db_pg.py: thin psycopg2 connection shim (?->%s translation,
  autocommit, .df(), and per-test isolated schema for ':memory:').
- storage/db.py: delegate get_connection/get_memory_connection to db_pg.
- storage/pg_schema.sql: Postgres schema, applied on connect.
- research/query.py runs UNMODIFIED on Postgres via the shim.
- ingestion upserts: INSERT OR REPLACE -> INSERT ... ON CONFLICT DO UPDATE.
- event_store: drop invalid btree-on-JSON-text index.

Full logic suite: 67/67 green against Postgres (was DuckDB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:48:35 -04:00
momentum devandClaude Opus 4.8 007cd95c79 Baseline before Postgres migration (WS0)
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>
2026-06-15 23:39:29 -04:00