Commit Graph
119 Commits
Author SHA1 Message Date
Claude 37f39299ad spread_pct P0: real decision-time bid/ask spread as an observe-only metric
GO_LIVE flags `spread_pct` as a not-yet-real field. The reason it was null is
structural: OHLCV bars carry no NBBO, so `minute_bars.spread_pct` can never be
populated from the bars endpoint. The execution-relevant figure is the spread at
the moment a setup is evaluated — a wide book quietly eats R on the thin sub-$20
names this system trades.

This makes that metric real, shadow-first (measure before gating), mirroring how
the catalyst score was introduced:

- `strategy/evaluation/liquidity.py`: pure `compute_spread_pct(bid, ask)` →
  (ask-bid)/mid, or None for missing/crossed/non-positive quotes (None never
  collapses to a real 0.0). Unit-tested.
- `AlpacaPaperClient.get_latest_quotes`: batched latest-NBBO fetch, mirrors
  `get_latest_trades`.
- `Watcher`: optional injected `spread_provider` (same shape as
  `catalyst_score_provider`); default None == prior behavior byte-for-byte.
  Computed spread is logged onto the criteria_evaluated and signal_ready events.
  Observe-only — it does NOT gate ready/blocked.
- `run_live_paper`: TTL-cached spread provider off the client; degrades to None
  on any fault, SPREAD_PCT_ENABLED=0 to silence.

The dead `scanner.spread_pct_max` knob is intentionally left for a later
promotion, once the logged data shows spread separates outcomes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019psgRHdtTXz6TCnnqPiqGw
2026-06-29 21:47:41 +00:00
momentum devandClaude Opus 4.8 31c35f01c1 VWAP-reclaim P3: placeability gate + pre-registered promotion validator (which FAILS in-sample)
Acts on the P2 edge-audit's two findings: (a) ~half of curl fires have a stop tighter
than the spread (median R 1.7% of price, $0.05 on a $2.62 name) so the backtested
+1R/-1R is unfillable; (b) the only subset that survived refutation was price>=$5 AND
mid-tercile impulse, scored as a +2R/runner filter.

- detect_vwap_reclaim: PLACEABILITY GATE (min_r_frac=0.015, min_r_abs=0.02, default ON
  for live safety) -- rejects a fire whose R=(entry-pullback_low) sits inside the spread.
  signal_values gains r_frac. Tests: a shallow-pullback curl now rejects "stop
  unplaceable" and fires only with the gate disabled.
- labeler: validate_vwap_reclaim + `validate-vr` CLI scores the PRE-REGISTERED candidate
  (placeable AND price>=$5 AND impulse in [0.15,0.26]) against a LOCKED bar: +2R Wilson-95
  lower bound > base 0.349 AND absolute max-upside-60m beats same-symbol-day ORB at
  +5%/+10%. Constants + _wilson_lower are committed so the bar can't drift. The shadow
  BASE track (vr1) deliberately keeps the gate OFF so report-vr base rates stay complete;
  placeability is a filter in validate-vr.

IN-SAMPLE RESULT (decisive): enforcing placeability collapses the audit's n=48 subset to
n=16 -- i.e. most of its apparent +2R lift rode on UNPLACEABLE stops. The remainder FAILS
the locked bar on both criteria: +2R 44% (Wilson LB 23.1% < 34.9% base) and, most
damningly on the artifact-proof axis, it produces a +5%/60m move only 31% of the time vs
ORB's 73% on the same days. Pre-registration did its job: it stopped us chasing an n=48
mirage of sub-spread stops. VERDICT: do not promote; the curl does not beat the bot's
existing ORB even in its best placeable subset. 15/15 unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:48:35 -04:00
momentum devandClaude Opus 4.8 2c2c457b0e Fix: vwap_reclaim shadow setup_id collides on sub-minute timestamps
Some sessions have irregular/sub-minute bar timestamps (no exact-duplicate timestamps,
but two bars in the same HH:MM), so keying setup_id on the wall-clock minute let two
fires a cooldown apart collide on the setup_events primary key and aborted the build.
Key on the RTH bar index (unique per session) instead. Tests still 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:28:17 -04:00
momentum devandClaude Opus 4.8 fa3f23cf6a VWAP-reclaim P2: shadow-score the detector through the labeler's forward-outcome engine
P2 = run detect_vwap_reclaim over history and score it with the SAME no-look-ahead
machinery that scores the live ORB setups, so its base rates are directly comparable
and there's no train/serve skew in the SCORING either (not just the features).

- Extracted research/labeler.py's forward-label logic into a shared _forward_labels()
  (max-upside/-drawdown 5/15/60m, reached_1R/2R-before-1R pessimistic, held-VWAP over
  the evolving cumulative VWAP, trend-day, failed-breakout, time-to-max). compute_setup
  now calls it -- the 3 existing labeler tests still pass, so the extraction is
  behavior-preserving.
- compute_vwap_reclaim_setups(): slides the detector over each session's RTH bars and
  emits a NON-TRADEABLE setup_event on each RISING EDGE (not-valid->valid, with a bar
  cooldown so one curl episode = one setup), carrying the detector's OWN entry/stop/R.
  STRICT time split: the detector at bar i sees only bars[:i+1]; labels use only bars
  after i. setup_version='vr1', setup_name='vwap_reclaim' -- isolated from the ORB 'v1'.
- build_vwap_reclaim / report_vwap_reclaim + CLI `build-vr` / `report-vr`. report-vr
  prints forward outcomes AND a head-to-head vs the ORB baseline on the same symbol-days.
- 2 new tests: a continuation session fires + labels a +1R winner; a one-way fade stays
  silent (anti falling-knife). 12/12 green.

PRELIMINARY read (n=35, 4 days -- far too small to trust; full build running): curl-
fires reached +1R 66% / +2R 49%, held-VWAP-15m 86%, avg +8% max-upside in 60m; the ORB
baseline on the same symbol-days reached +1R 67%. Real finding pending the full build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:26:19 -04:00
momentum devandClaude Opus 4.8 19b5378060 VWAP-reclaim P1: rolling local-swing anchor (re-arms per leg) + a finding that refutes the P0 premise
P0 anchored the impulse to the day's SINGLE HOD + session-absolute low, so a name
that spiked, topped, then faded read every later bar as "pullback broke the hold"
forever. P1 anchors the leg to a rolling `lookback` window (default 40 bars), so the
detector re-arms on each intraday leg. New regression test proves the property: when
an earlier, HIGHER leg deeply gives back (permanently blocking the whole-session
anchor), the local anchor still fires on a later leg curling off a higher-low base,
targeting that leg's local high rather than the stale day HOD. lookback=None keeps
the legacy anchor for the contrast test. 7/7 unit tests green.

FINDING (ground-truthing the raw bars refuted the P0 premise): MIMI and UPC do NOT
have an RTH VWAP-reclaim setup. Both spiked PRE-MARKET (MIMI 2.93->4.98, UPC
8.54->17.89) and their RTH was a one-way FADE below VWAP (MIMI 4.09->3.15 under VWAP
all day; UPC popped at the open, made lower highs, lost VWAP by 14:17). So 0 RTH
fires is CORRECT, not blindness -- there is no long curl to catch; if the trader
profited it was a pre-market play or a SHORT of the fade, neither of which a
long-only RTH detector should fire on. It fires precisely on the names that DO curl
in RTH (SDOT 6/26 10->22, FCUV 6/25). Same lesson as ignition/PLSM: when the move is
pre-market, the RTH detector should stay silent. (Deliberately did NOT lower
min_run_pct to chase MIMI's sub-10% local legs -- that would be fitting n=2 noise.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 12:36:50 -04:00
momentum devandClaude Opus 4.8 dd67878b4a VWAP-reclaim P0: pure first-pullback detector + tests + a decisive real-data finding
The trader's #1 taught setup is a CONTINUATION, not the bot's open-anchored ORB:
a leading gainer that spiked, pulled back (held >=50% of the run), and is curling
back up through VWAP, bought BELOW the HOD. Across 6/25-29 every name he monetized
(MIMI/FCUV/ZDAI/SDOT/UPC) was this one pattern; the bot blocked all on the ORB gate.

P0 = standalone pure detector (detect_vwap_reclaim) with 5 binary gates (impulse /
pullback-held / curl-reclaim / below-HOD / momentum-green), reusing calculate_vwap
& calculate_ema (no train/serve skew). SHADOW-ONLY: not wired into classify_setup.
6 unit tests green.

DECISIVE REAL-DATA FINDING (RTH minute bars): slid across 4 real trader names.
FIRED on the all-day trenders -- SDOT 6/26 (10.22->22.50) 22x, FCUV 6/25 2x -- but
ZERO on MIMI 6/25 and UPC 6/29, both rejected on "pullback broke past the hold
level" with a NEGATIVE held_frac. Root cause is STRUCTURAL: anchoring the impulse
to the day's SINGLE HOD + session-absolute low means a name that spikes->HOD->fades
reads every later bar as "broke the hold" forever, blind to the LOCAL spike->
pullback->curl legs that re-arm intraday. P1 fix = rolling local-swing anchor, not
the day's one HOD. (Deliberately NOT widening hold_frac to force n=4 anecdotes green.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 12:15:16 -04:00
momentum devandClaude Opus 4.8 1c8b4f4309 Ignition P0: pure detector + tests, and a decisive real-data finding
detect_momentum_ignition (strategy/evaluation/ignition.py): standalone, pure,
binary-rule detector for a vertical squeeze (blue-sky fresh-HOD + price velocity
+ ABSOLUTE volume burst, NOT rvol/float). Stop = last established higher low (per
operator). NOT wired into classify_setup (deliberately). 8 unit tests on
synthetic verticals: fires with the right trigger/stop, and each gate blocks
(not-blue-sky / not-new-HOD / velocity / volume / insufficient-bars / no-ATH).

REALITY CHECK (verify, don't assume): ran it over the REAL PLSM 2026-06-24 bars ->
0 fires, CORRECTLY. PLSM's high was at the 9:35 ET open (it ran +400% PRE-MARKET,
which we don't ingest), and in RTH it was HALTED 228 of ~390 minutes — a
halt-resumption GAP chop (+3.6/-12.5/-17.3/-14.4% jumps), not a smooth vertical.
A consecutive-bar detector structurally cannot see that. So the ignition
opportunity is a PRE-MARKET + halt-resumption phenomenon -> capturing it needs
pre-market bar ingestion + halt-aware gap detection + pre-market/halt trading
(into the FCUV halt-down risk), a far bigger lift than an RTH detector. Documented
front-and-center in docs/IGNITION_SETUP_SCOPE.md as a pre-P1 operator decision.
The P0 detector + tests are a correct foundation regardless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 06:15:12 -04:00
momentum devandClaude Opus 4.8 7ebb503790 docs: momentum-ignition setup scope (shadow-only)
Scope for a SECOND, shadow-only lane to catch PLSM-class vertical squeezes the
pullback-ORB scorer is structurally blind to (PLSM was evaluated 1895x and
blocked ~30% every time — a vertical gives no pullback). Produced by a read-only
4-agent mapping workflow; no code changed.

Key findings: detect on blue-sky fresh-HOD + price velocity + ABSOLUTE volume
burst (NOT RVOL, which zeros out on thin IPOs; NOT float, which is NULL). Emit a
new ShadowSignalEvent that the execution snapshot (query_ready_signals_snapshot,
filters event_type=signal_ready) can never read -> structurally cannot trade.
Score nightly by reusing the labeler + eod_replay forward-outcome machinery.
Pre-register the promotion bar (runner-vs-trap separation, distinct-runner n)
before shadowing. Phased P0 detector -> P1 dark emission -> P2 nightly scoring ->
P3 validation -> P4 tiny live lever only after separation is proven. Strictly
additive; never touches the ORB book or anti-chase guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:26:45 -04:00
momentum devandClaude Opus 4.8 813c5dce88 Exits: 0.25R step-trail from the buy price (ratcheting breakeven)
A small green pop that fades was round-tripping to -1R because the gain
protection only arms at +5% / +1R — SOFI peaked +2.6% (+$281), never reached a
threshold, and gave it all back (stop never moved off the original). Add a
step-trail that ratchets the stop up from entry in step-R increments: at +0.25R
the stop is at breakeven, +0.5R -> +0.25R, +0.75R -> +0.5R, ... It locks a small
pop AND keeps climbing under a runner.

Implemented in the shared _trail_stop (so backtest simulate_exit and live
manage_live agree); risk is recovered from reached_r so it needs no new args;
only ratchets up (max-of-all with the prior-low trail / profit-locks).
TRADING_EXIT_TRAIL_R_STEP (default 0, set 0.25 in .env). Supersedes the interim
standalone +2% breakeven, which reverts to its 0.05 default backstop.

Tests: the ratchet ladder (below-step=no move, +0.25R->BE, +0.5R->+0.25R,
ratchets-up-only) and an end-to-end pop-then-fade that now exits +0.25R instead
of round-tripping to a loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:58:03 -04:00
momentum devandClaude Opus 4.8 9ab12bb3d6 Fix: open catch-up flatten wrongly flattened partially-filled fresh positions
Root cause of SSPC getting flattened on a mid-session restart (2026-06-24):
flatten_overnight_carries built its today's-buy set from orders with
status=="filled" only. But a marketable buy that PARTIALLY fills (SSPC: 84 of
88) cancels its unfilled remainder, so the order reads status=="canceled" even
though it opened 84 real shares. The carry-detector saw "no filled buy today"
for SSPC, tagged it an overnight carry, and market-closed a position opened ~6
min earlier. (Harmless this time — closed +$20 — but it could cut a runner or
realize a loss.) Marketable orders partial-fill far more than resting limits, so
flipping to marketable exposed this latent bug.

Fix: extract buy_fills_from_orders() (runtime/flatten.py) that counts a buy as a
real today-entry by filled_qty>0 + filled_at, NOT status=="filled" (scans legs
too). flatten_overnight_carries uses it. Pure + unit-tested: partial-fill counts,
full fill counts, zero-fill backout does not, and the partial-fill position is
no longer flagged a carry.

Note: only manifests on a mid-session restart (a clean session's EOD flatten
leaves the book flat, so the open finds no carries). Deploys on next restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:05:05 -04:00
momentum devandClaude Opus 4.8 7e602b8b30 Entry-path unification (step 3): selectable fill model on the auto path
The last real divergence between the two entry paths was the fill model. Make it
a flag (TRADING_ENTRY_FILL_MODEL), default 'resting' (zero behaviour change):
- resting    — rest a buy-limit AT the breakout level (today's behaviour; fills
               only on a pullback, misses runners that gap up and never return).
- marketable — limit a hair above the trigger (entry * (1+trigger_slippage_pct)),
               fills as price breaks UP through the level (the fast path's
               runner-catching behaviour). The limit caps the fill, so a
               halt-resume gap-through can't be chased. R is still measured from
               the level, not the limit.

Operator chose ship-both-default-resting: flip to 'marketable' to A/B with eyes
on the first session. Boot log shows "fill=<model>".

With gates already unified (steps 1-2: anti-chase, liquidity) the two paths now
run the same gate set and the fill model is selectable — the substantive
unification is done. The remaining single-_place_entry structural merge is
cosmetic (gates are shared) and deliberately not forced through.

Tests: resting rests at the level, marketable lifts above the trigger. 69 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:08:07 -04:00
momentum devandClaude Opus 4.8 bb7674a708 Entry-path unification (step 2): liquidity sizing on the auto path
Adds the fast path's volume-based share cap to the live auto path: cap shares to
liquidity_max_volume_pct * cum_volume so a thin cheap name can't be oversized
into heavy slippage (the VTAK-style stop that filled far past its level on
2026-06-23). Threads cum_volume through the setup dict + ready-signal snapshot;
applies max_shares in request_approvals_for_ready_signals, gated by
TRADING_UNIFIED_ENTRY. Fail-open: no cum_volume -> no cap. Protective only
(can only shrink a position).

Tests: thin name capped to liq*cum_volume when on, risk-sized when off. 67 path
tests green. Remaining staged (supervised): the full single _place_entry
chokepoint + fill-model unification + halt-status plumbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:08:43 -04:00
momentum devandClaude Opus 4.8 045692c3a5 Telegram Q&A bot: real retrieval + direction (was answering blind)
The bot only knew P&L + positions, so "what's on the watchlist?" and "why
isn't it trading?" both fell back to "positions: none" — useless. Now it
retrieves the actual desk state and gives the user direction.

- gather_context: today P&L + recent closed trades (cross-day, robust to the
  midnight boundary) + open positions + the live WATCHLIST (states + scores) +
  READY SIGNALS (entry/stop/vwap) + a why-blocked tally (risk_rule_triggered
  counts) + all-time.
- symbol_context: when a question names a ticker the bot knows, append a focused
  dive — its last trade + the setup criteria it passed/failed.
- Help menu (help / /start) lists exactly what it can answer; sent on startup so
  the user has direction. Sharper system prompt (concrete, quote real numbers,
  say what data exists when it can't answer).

Verified live: "what's on the watchlist?" -> "148 symbols: 97 blocked, 48 late,
3 ready (LAES, NOWL, PBK)"; "why isn't it trading?" -> coherent; "what happened
with VTAK?" -> "stop loss -$659, entry $1.14". Tests: help menu, retrieval
includes watchlist/blocks; 10/10 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:04:51 -04:00
momentum devandClaude Opus 4.8 5de420fed9 Entry-path unification (step 1): shared anti-chase gates on the live auto path
Closes the safety gap the consolidation pass found: the anti-chase / halt
guards (the source-trader blow-up protections) ran ONLY on the fast trigger
path, leaving live auto entries — which is where 100% of fills actually
originate — unprotected.

- Extract _anti_chase_skip(entry_ref, trigger, day_open, halted) from
  submit_breakout_now (pure refactor; the fast path is behaviour-identical,
  60 trigger/exec tests still green).
- Thread day_open through the setup dict (setup_evaluator) +
  query_ready_signals_snapshot so the auto path has the gate input.
- request_approvals_for_ready_signals calls the shared gate behind
  TRADING_UNIFIED_ENTRY (env default on). The auto path rests a limit AT the
  trigger so it can't chase above it (over_extended is a no-op); the gate that
  bites is day-extension — don't enter a name whose breakout level is already
  >30% above the session open. Fail-open on missing day_open; halted=False (a
  resting limit won't fill mid-halt). Emits rule_type=over_extended_day on skip.
- Boot log shows "anti-chase ON (auto path)".

Tests: _anti_chase_skip logic, parabolic-day blocked when on, allowed when off,
fail-open on missing day_open, normal day allowed. Full path suites green (73).

Note: this is step 1 (the safety win). Full single-chokepoint + fill-model
unification + halt-status-on-auto-path remain staged per docs/CONSOLIDATION_PLAN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:23:23 -04:00
momentum devandClaude Opus 4.8 fb3f771935 docs: consolidation plan (scope-only, from read-only mapping workflow)
Prioritized P0->P2 plan to make the system cleaner + easier to troubleshoot,
produced by a 4-agent read-only mapping pass (no code changed). Key findings:

- Single-Postgres migration is FUNCTIONALLY COMPLETE at the connection layer
  (all live code -> storage/db.py -> db_pg -> one DATABASE_URL Postgres; no
  live duckdb.connect). Datastore work is cleanup, not architecture.
- The real architectural item is the TWO entry paths. The live (auto) path is
  missing five fast-path-only gates: extension, day-extension, halt-guard,
  liquidity-sizing, rank — a REAL current gap (47/47 of today's fills were
  approved_by="auto"). Root cause: the ready-signal snapshot carries no live
  price/day_open/volume/rank. Fix sequenced behind a flag, reversibly.
- Dead-code/cleanup: core/ + telemetry/ packages (zero callers), archive/ tree
  + 232MB zip (gitignored), dead schwab/ integration, an orphaned mem_ test
  schema left in prod Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:38:54 -04:00
momentum devandClaude Opus 4.8 59e296a736 Telegram bot: gated push alerts + standalone Ollama Q&A
C1 — runtime/notifier.py: gated, fire-and-forget push alerts that NEVER raise
into the loop. Decoupled by POLLING the event store (no notifier threaded
through breaker/exit/execution). Pushes on major events only: daily-loss
breaker, catastrophe-stop, naked-stop flatten, any |closed trade| > $300, and a
once-per-day EOD summary. Wired as a 45s scheduler step + EOD summary/failure
hooks in step_eod_flatten. Boot ping on start. Silent no-op when unconfigured.

C2 — telegram_bot.py: standalone (separate process) READ-ONLY Q&A listener,
locked to TELEGRAM_CHAT_ID. Long-polls getUpdates; gathers a Postgres data
snapshot (today P&L, open positions, recent trades, all-time) and asks the
local Ollama model. Falls back to the raw snapshot if Ollama is down/disabled;
refuses any action command (buy/sell/flatten) — questions only.

Tests: disabled no-op, major-event dedup, EOD-once, auth gate, action-refusal,
ollama-down + ollama-disabled fallback. 8/8 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:29:15 -04:00
momentum devandClaude Opus 4.8 e3ac89c2f8 VWAP entry gate on the live auto path + concentrate boot-guard
Wire the one validated selection signal (above-VWAP breakouts reach +1R
~1.5x as often, n=3,110 from the labeler lift report) into the LIVE auto
entry path (request_approvals_for_ready_signals) — NOT the dead fast
submit_breakout_now path where the cap and rank logic got stranded.

- setup_evaluator already computes above_vwap/levels.vwap; surface them in
  the setup dict (also an observability win) and pass through
  query_ready_signals_snapshot.
- New ExecutionSettings.require_above_vwap (TRADING_REQUIRE_ABOVE_VWAP, env
  default on). When a ready signal is below session VWAP: always emit a
  rule_type=vwap_below event (shadow measurement); only SKIP the entry when
  the flag is on. Fail-open: above_vwap=None (old signals) is never blocked.
- Boot log now prints the entry-gate state, and WARNS if
  TRADING_CONCENTRATE_TOP_N>0 (that rank switch only affects the dead fast
  path — see the rank-scaling audit).

Tests: gate skips below-VWAP when enforced, shadow-logs+allows when off,
allows above-VWAP, and fail-opens on the missing field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:21:58 -04:00
momentum devandClaude Opus 4.8 ac1ac50c9e Daily-entry cap: enforce on the auto-approval path (the one live entries use)
The per-day fresh-entry cap (TRADING_MAX_FRESH_ENTRIES_PER_DAY) was only
checked in the fast submit_breakout_now path. But live entries flow through
the SLOW auto-approval path (process_auto_approvals -> approve_order), which
had no cap — so the limit was silently dead. Observed 2026-06-23: 44 entries
taken against a configured cap of 5 (all approved_by="auto", zero via the
fast orb_trigger path).

Enforce the cap in approve_order for approved_by="auto", sharing the same
self._fresh_entries counter the fast path uses (so the cap is global across
both paths). Capped orders are REJECTED (reason=daily_entry_cap) so they
leave the pending queue instead of re-skipping every tick. Manual dashboard
approvals are a human override and are never capped.

Two regression tests: auto path binds at the cap + rejects the overflow;
manual approval ignores the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:29:01 -04:00
momentum devandClaude Opus 4.8 62540bab8c Labeler phase 2: per-signal lift report
`python -m research.labeler lift` measures, for each computable signal, the
win/runner rate in its most-favorable bucket vs the base rate (the LIFT), with n
shown so signal is distinguishable from small-sample noise.

First results (n=5,705 setups, full 19 days):
- vs +1R (base 24%): ABOVE-VWAP / distance-from-VWAP is the one real win signal —
  35-37% vs 24% = ~1.5x lift on huge n (2,851). gap%/price/data-quality are flat
  (~1.0x). Confirms the roadmap's #1 free win: make VWAP a trigger.
- vs +20% runner (base 2.4%): premarket-gap% high-tercile 10.6% = 4.46x (n=426)
  is the strongest runner tell; cheap-price low-tercile 6.8% = 2.87x; VWAP/gap
  1.4-1.8x. +100% (5-6 events) too sparse to measure -> not a mechanical filter.

Caveat surfaced in the output: autocorrelated days + sparse runners => lift is a
forward-shadow HYPOTHESIS, not a validated win-rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:43:46 -04:00
momentum devandClaude Opus 4.8 11e33bba91 Labeler phase 1: offline self-labeling / feature store
research/labeler.py turns each historical symbol-session into a (features ->
outcome) row so any forecasting signal's LIFT can be measured before it sizes
live. Reuses the LIVE feature fns (opening_range / compute_key_levels /
calculate_time_of_day_rvol / data-quality) so offline == live; STRICT time split
(features <= the ORB-completion decision point, labels strictly after);
idempotent + versioned; populates the previously-empty setup_events /
engineered_features / outcome_labels tables.

`build [--limit N] [--rebuild]` + `report` (base rates + runner rates via one
aggregate query). Core math is a pure compute_setup() — 3 unit tests cover the
time-split (a runner's feature VWAP can't see the post-setup run), the R-outcome,
failed-breakout, trend-day, and the <ORB-bars skip.

First real base rates (n=3,610 setups / 13 days): 24% reach +1R before -1R, 43%
fail outright, +20% runners 2.2%, +50% 0.4%, +100% (NXTS-class) just 0.1%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:37:30 -04:00
momentum devandClaude Opus 4.8 a86fa43aa7 Concentrate-by-rank sizing mechanism (goal #1) — built, gated OFF
The human's edge is concentration (4 trades on 1 name) vs the bot's 12-trade
spray. Added the mechanism: TRADING_CONCENTRATE_TOP_N (only the top-N armed names
by rank may enter; rank-1 full risk, rank-2+ half) + TRADING_MAX_FRESH_ENTRIES_
PER_DAY (hard daily cap). rank threaded from the armed trigger -> submit_breakout_
now -> scales both the % budget and the $500 cap by rank_risk_factor.

LEFT OFF by default (both 0): activating it meaningfully requires the rank key to
be QUALITY, but the armed rank is currently gap*rvol*catalyst. Verification also
found the roadmap's "quality_score" is calculate_data_quality_score = bar
coverage/integrity (a thin-name/liquidity proxy with a time-of-day confound),
correlated only weakly (q>=0.55 = 58% win / breakeven, NOT the 71% claimed; q<0.55
= 35% win / -$2,476). So concentration waits on a sound rank key (goal #2).

pure rank_risk_factor + tests. No behavior change until the env flags are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:55:20 -04:00
momentum devandClaude Opus 4.8 c3dc1b1677 Fixed-fractional sizing: hard per-trade dollar-risk cap
#3 from the loss diagnosis. Sizing was risk-based (1% of equity) but had no
absolute ceiling, so on the ~$90k paper account every full-budget trade risked
~$900 — the BTBT/AAL/WKSP/LNKS ~-$1k losers (verified: all sized to $900 risk).

Added max_risk_dollars to calculate_position_size: dollar risk used to size is
now min(equity*risk_per_trade_pct, max_risk_dollars), so no trade risks more than
the cap regardless of equity or stop width. ExecutionSettings.max_risk_dollars
(TRADING_MAX_RISK_DOLLARS, 0=off) wired into both entry paths; set to $500 in the
operator .env. PositionSizeResult.risk_amount now reports the ACTUAL sized risk
(after every cap), not the budget.

Verified vs history: the four ~-$1k losers were each sized to $900 risk; under the
$500 cap they'd risk $500 (~halved, ~-$1.8k saved across the four). Small-account
% sizing is unaffected (1% of $300 << $500). Tests: cap binds on big account,
no-op when budget smaller, risk_amount reports actual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:45:10 -04:00
momentum devandClaude Opus 4.8 8806f25aff Enforce protective stop after fill: flatten any naked position
#1 from the loss diagnosis (the preventive complement to the catastrophe stop).
The entry path already validates geometry and submits a real bracket, so the gap
is POST-FILL: NIVF's stop leg never stayed live, and the exit manager SILENTLY
SKIPPED any position with no usable stop (the `continue` at the old line 160) —
so a naked position got zero management and ran to -23%.

Now the exit manager computes the live stop leg once per position and, if it's
NAKED (no live protective sell-stop), flattens it after enforce_stop_grace_passes
consecutive naked passes (def 2 ~= 24s, a grace so a just-filled bracket's legs
can attach). A position WITH a stop (incl. breakeven-locked, stop>=entry) is
never touched. Long-only via the existing path. TRADING_ENFORCE_STOP_GRACE_PASSES
(0 = off). describe() shows "enforce-stop(Np)".

Net: every held position now either has a live stop or gets flattened within ~24s
— a naked NIVF-class trade can't sit unmanaged to -23% again (and the catastrophe
stop remains the -10% backstop). Tests: naked->flattened after grace; stopped
position never naked-flattened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:36:35 -04:00
momentum devandClaude Opus 4.8 db4272fe24 Catastrophe stop: hard backstop for a missing/non-firing protective stop
Diagnosis (why-strategy-loses-naked-stop): the net loss is ONE naked-stop blowup
— NIVF ran to -23% with no stop that fired (-$3,737 = ~111% of the loss); ex-NIVF
the book is +$380. And the exit manager SKIPS any position without a usable stop
(line ~160), so a naked position gets zero exit management and runs to EOD.

New catastrophe stop runs FIRST and unconditionally on every held position,
before the bars fetch and before that no-stop skip: market-exit (via the robust
cancel-legs-then-close path) when price is >= catastrophe_pct below entry (def
10%, TRADING_CATASTROPHE_STOP_PCT) OR >= catastrophe_risk_mult x the intended 1R
underwater when a stop is known (def 1.5x). Independent of the broker bracket, so
a missing/failed stop can't run to -23% again. Long-only; in-profit and bad-price
are no-ops; 0 disables.

Validated: would have CAUGHT NIVF at -10% (~-$1.6k vs -$3.7k), leaves normal -1R
stops (BTBT -4.2%) alone. Pure `catastrophe_triggered` + manage() wiring +
`_is_active`/describe updated. Tests cover pct arm, risk arm, in-profit, disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:23:11 -04:00
momentum devandClaude Opus 4.8 1be2dfd042 Open catch-up flatten: close stale overnight carries at the open
The backstop behind the EOD-flatten fix: even with the EOD flatten working, a
carry can survive (loop down at EOD, or the held-qty 403 before it was fixed) and
bleed for days — ATPC sat 4 days over the Juneteenth gap, -$651. This closes any
position carried from a PRIOR session, at the open, before it bleeds.

- runtime/flatten.find_overnight_carries (pure, tested): open positions with no
  BUY filled today (ET) = carried overnight. Today's fresh names have a buy
  today, so they're never touched.
- ExecutionService.flatten_overnight_carries: detects carries from the broker
  (positions + filled buys) and closes them via the robust cancel-legs-then-close
  path — WITHOUT halting the session (unlike close_session), so today's trading
  continues. Emits an open_catchup_flatten risk event.
- run_live_paper step_open_catchup_flatten: once per session, during RTH, gated by
  TRADING_OPEN_CATCHUP_FLATTEN_ENABLED (def 1). Loud log when it flattens a carry.

Dry-run against the live book: correctly targets ONLY ATPC (6/18 carry), leaves
today's DAMD/ECX. 38 runtime/safety tests pass. Needs a loop restart to activate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:08:09 -04:00
momentum devandClaude Opus 4.8 16715369c0 EOD flatten: close-relative + holiday-aware window (no naked carry over closures)
Diagnosed the ATPC -$651 drag: on 6/18 (last session before Juneteenth) the EOD
flatten FIRED at 15:55 but every close was rejected — Alpaca 403 "insufficient
qty... held_for_orders" because the bracket stop/TP legs reserved the shares.
That root cause was already fixed by 4520c69 (cancel legs before close; verified
the current self-fetch finds the active leg). What remained were two real
naked-carry holes this closes:

- Early-close half-days NEVER flattened: the window was a fixed [15:55, close),
  but half-days close at 13:00 (15:55 > 13:00) — and half-days (Jul 3, Black
  Friday, Dec 24) are exactly the pre-holiday sessions. The window is now
  computed RELATIVE TO THE ACTUAL CLOSE, so it fires on early closes too.
- No margin / silent failure before a multi-day gap. Before a weekend/holiday
  closure the flatten lead widens (TRADING_PRE_CLOSURE_FLATTEN_LEAD_MIN, def 20)
  so the book is flat with room to retry, and a flatten that can't fully close
  before a multi-day gap now SCREAMS ("‼ NAKED-CARRY RISK (N-day gap ahead)")
  instead of failing silently like 6/18 did.

New calendar helpers next_trading_day / days_to_next_session / eod_flatten_status
(pure, unit-tested: normal window, pre-holiday widening, early-close firing,
closed-day skip). Normal full days are unchanged (still 15:55-16:00). Dormant
until the next pre-closure session, so no effect on today's behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:45:19 -04:00
momentum devandClaude Opus 4.8 16c759e334 Daily Performance: per-day equity curve to compare days
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>
2026-06-22 08:39:14 -04:00
momentum devandClaude Opus 4.8 45e403e89f Per-day journal backfill + all-time score + Trades panel
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>
2026-06-22 08:28:40 -04:00
momentum devandClaude Opus 4.8 895bbb0a82 Emit position_closed events (trade journal) + make dashboard view-only
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>
2026-06-21 22:30:35 -04:00
momentum devandClaude Opus 4.8 59dc00fa25 Dashboard UI: catalyst on signals, feed filter, P&L bars, mobile
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>
2026-06-21 21:04:01 -04:00
momentum devandClaude Opus 4.8 efc5abfc73 Fix historical/overnight P&L: net out start-of-day carry
A past date (and any overnight hold) showed fabricated P&L: realized was
computed as broker_day - unrealized, where unrealized was a position's FULL
mark-vs-entry. A position opened days ago still carried its whole mark, so a $0
holiday read as "+$330 realized / -$330 unrealized" on EVERY day — the same
number leaking across the date picker.

Fix: the day's P&L decomposes as broker_day = realized + (U_eod - U_sod). Added
a start-of-day baseline (the latest positions snapshot strictly before the day
begins; today's midnight for live) and subtract it, so only the day's own
open-position move counts. total = realized + unrealized now holds by
construction. Live is unaffected on a normal day (EOD flatten => no overnight
positions => U_sod=0); it only changes genuine overnight holds / past days.
Also expose `open_unrealized_pnl` (the full current mark) for the positions UI.

Verified on live data: the +$330 leak is gone — 6/20 (Sat) reads 0/0/0, 6/18
reads a real -$2368 day with a sensible split. Tests: carry-netting regression
+ the broker-delta test scoped to its own day.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:55:47 -04:00
momentum devandClaude Opus 4.8 4153f027f0 Dashboard: first-paint loading overlay
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>
2026-06-21 20:38:54 -04:00
momentum devandClaude Opus 4.8 45ba264534 Fix P&L mislabelling + add a Catalyst Engine feed so the LLM is legible
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>
2026-06-21 20:21:30 -04:00
momentum devandClaude Opus 4.8 0dfd6a262b Phase-2 ON + dashboard catalyst visualization
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>
2026-06-21 19:56:05 -04:00
momentum devandClaude Opus 4.8 8305482376 Dilution detector: stop flagging "offering exposure" / product offerings
The recurring false positive (an ETF "offering exposure" tagged dilutive) came
from the regex FLOOR matching the bare word "offering" and one-directionally
flipping is_dilutive=true even when the model said false. Tightened both:
- _DILUTIVE_RE matches "offering" ONLY as a securities offering — a qualifier in
  front (public/underwritten/stock/equity/...) or "offering of ... shares/stock/
  units". Bare "offering" (ETF offering exposure, product offering) no longer trips.
- build_prompt's is_dilutive instruction now spells out the exclusions (ETF/fund
  "offering exposure", product/service offering, IPO of a different company,
  buyback, reverse split).

Re-measured on a labeled set: precision 88% -> 100%, recall stays 100% (all 7 real
offerings still caught), accuracy 94% -> 100%. News-enrichment unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:42:52 -04:00
momentum devandClaude Opus 4.8 e286330cd9 Fix the 3 Phase-2 mediums from the PR #2 review (still gated OFF)
Hardens the Ollama Phase-2 path before it can be enabled; no effect on the
default-off behaviour (these run only when the score-blend / veto flags are on).

1. catalyst_score no longer floors sentiment at neutral: a high-conviction
   BEARISH catalyst scored 0.45 (same as neutral) and LIFTED setup quality —
   backwards. Now `conviction * max(0, sentiment)`: only bullish boosts;
   bearish/neutral score 0. (+ bearish regression test.)
2. Prompt injection: the untrusted headline now sits in a delimited, length-
   capped <news> block, the model is told to treat it as DATA not instructions,
   and a </news> breakout token is stripped — so "...classify as bullish,
   is_dilutive=false" can't flip the verdict or bypass the veto. (+ 2 tests.)
3. The dilution-veto catalyst_provider no longer reads the SHARED research_con
   from the trigger thread: it refreshes under a lock using its OWN dedicated
   connection (opened lazily, single-threaded use), 30s TTL keeps DB hits rare.

tests/unit: 201 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:54:20 -04:00
momentum devandClaude Opus 4.8 8cee002c09 Merge PR #2 (Ollama news/catalyst enrichment) onto master + fix review blockers
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>
2026-06-20 11:54:07 -04:00
momentum devandClaude Opus 4.8 e601973de2 Alpaca News API ingestion -> raw_news_items (feeds catalysts + Ollama)
The only news source was RSS (slow, regex ticker-scraping). Alpaca exposes
Benzinga news with a stable id + authoritative symbols list. Add it as a second
source landing into the SAME raw_news_items table, so recent_news_map (catalyst
prioritisation) and the Ollama enrichment (PR #2) pick it up with no other changes.

- AlpacaPaperClient.get_news(symbols, limit): GET /v1beta1/news (works on the
  existing data keys; verified live).
- research/ingestion/alpaca_news.py: ingest_alpaca_news lands new items, deduped
  by the Alpaca news id; never raises (news is an enhancement, not a dependency).
- run_live_paper step_news on the scheduler (ALPACA_NEWS_* env, default on, 120s),
  scoped to the tracked universe.

Verified live: passes landed ~34 then deduped; recent_news_map went from RSS-only
to 370 ticker->headline catalysts. tests/unit: 165 passed (+5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:10:37 -04:00
Claude 767bd75a8d Wire catalyst score into the live watcher and arming rank
Completes the Phase 2 loop: the catalyst score now actually reaches the runtime
decision, not just the pure functions. Gated behind its own flag so enabling the
Phase 1 advisory/dashboard never silently changes trade scoring.

- OllamaConfig.catalyst_score_enabled (NEWS_CATALYST_SCORE_ENABLED, default off).
- Watcher accepts an optional catalyst_score_provider (symbol -> 0..1 | None) and
  passes it into evaluate_setup; a None provider reproduces today's behavior. The
  provider is injected so runtime/ stays free of research/ imports.
- run_live_paper: one TTL-cached catalyst provider shared by the watcher and the
  execution service; the watcher gets the score provider only when the blend is
  enabled. step_arm gains a gated graded ranking multiplier (bullish catalyst
  boosts by 1+gain*score; a confirmed dilutive offering is sunk below the armed
  cut), falling back to the existing binary news boost when off.
- Add the enrich task to the run_pass pipeline order so a single --once pass also
  runs enrichment (previously only the continuous loop did).
- .env.example documents the new flags; watcher tests cover the blend + the
  no-op None-provider case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
2026-06-19 18:45:18 +00:00
Claude e3cb2b8e5f Add local-LLM (Ollama) news/catalyst scoring
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
2026-06-19 17:29:24 +00:00
momentum devandClaude Opus 4.8 ea799f975c Dashboard: responsive layout for phones
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>
2026-06-19 11:22:13 -04:00
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