189 Commits
Author SHA1 Message Date
momentum devandClaude Fable 5 256481910f population: characterization tests pinning live _population_reject (pre-extraction)
11 table-driven cases pin the CURRENT behaviour of the live population gate
before plan 004 extracts it to strategy/population.py: in-band pass, below-gap,
dead band, squeeze pass/fail, both price-band sides, volume floor, missing-data
fail-open, the gap-is-a-FRACTION unit pin, and the gap-gate-disabled nesting.
These must stay green byte-for-byte across the extraction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:32:03 -04:00
momentum devandClaude Fable 5 23b3d01a00 money-path: nightly cage-count reconcile (broker vs event store) + schedule it
Plan 003 step 5 (completes plan 003). research/eod_reconcile.py diffs broker
truth (open positions, today's live orders) against event-store truth (today's
submits/fills/closes) and emits a loud eod_reconcile event + Telegram alert on
any drift — the duplicate-resting-order-after-restart class (2026-07-06) caught
automatically instead of by hand. Read-only (reports, never auto-fixes). Wired
into both scheduler blocks after premarket_eval. Pure diff core is unit-tested.

Suite: 373 passed (+22 plan-003 tests), 15 fail = WSL-loopback dashboard only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:29:00 -04:00
momentum devandClaude Fable 5 119e4c0ad1 money-path: reconcile a lost order-submit by client_order_id before journaling cancel
Plan 003 step 4. A transport error AFTER the POST landed leaves a live order
resting at the broker; journaling it 'cancelled' breeds the duplicate/naked
order (2026-07-06 incident). Add client.get_order_by_client_order_id + an
after= passthrough on get_orders; on the generic-exception path, look up the
order by the client_order_id we already send and, if live, emit the normal
submitted sequence (ok=True) instead of cancelled. AlpacaApiError (real
rejection) is NOT reconciled. Fills the empty test_alpaca_client.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:24:14 -04:00
momentum devandClaude Fable 5 d16eb85ee9 money-path: daily self-restart + SIGTERM + positive-evidence exit gate
Plan 003 steps 1-3.
- Loop exit(0) after a SUCCESSFUL EOD flatten (TRADING_EXIT_AFTER_EOD_FLATTEN,
  default on); compose restarts fresh. Restart-churn guard: _rebuild_daily_state
  detects today's eod_flatten event and seeds _session_closed/_eod_done so a
  post-flatten restart neither re-flattens nor re-exits. SIGTERM = clean drain.
- Exit manager: _FETCH_FAILED sentinel distinguishes a failed get_orders from a
  genuinely-empty snapshot. A failed fetch skips naked-stop enforcement (catastrophe
  still exits, re-fetching); an empty-but-successful snapshot needs TWO consecutive
  reads before 'naked' is trusted (Alpaca silently returns [] for symbols=). Costs
  ~one 12s pass on the all-naked case only; isolated naked positions unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:20:37 -04:00
momentum devandClaude Fable 5 99ff1dc51f delete: schwab/ (23 files, dormant) + dead strategy modules
Finishes what d54a2a9's message claimed: schwab/ was never actually removed.
Also: comprehensive_backtest.py (crashed on import — research/connection.py
does not exist), strategy/universe.py, strategy/backtest/outcomes.py,
detect_momentum_ignition (kept _last_higher_low — imported by runner/
premarket_setup/liquid_breakout), milestone3_verify.py, tests/unit/schwab/,
SCHWAB_* keys from .env.example. All grep-gated: zero live references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:02:46 -04:00
momentum devandClaude Fable 5 be35385ffc test nit: unused var in deadman ping test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:44:54 -04:00
momentum devandClaude Fable 5 5b95a9712c qabot: dead-man's switch on the loop's event heartbeat
The loop's Telegram notifier runs inside the loop process — loop-down means
alerts-down. The always-up qabot now reads max(events.timestamp) every ~120s
(ET-naive vs ET wall clock per RUNBOOK): stale > DEADMAN_STALE_MIN during
market hours (holiday-aware via runtime.market_calendar) alerts at most every
30 min; DEADMAN_DAILY_PING sends one OK-ping after 20:30 ET. Injected
clock/read/send; 6 unit tests, no network or DB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:37:01 -04:00
momentum devandClaude Opus 4.8 d54a2a930c Review sweep: money-path fixes + security + delete 805MB dead weight
Consolidated 4-tool review (2 correctness agents + Codex + over-eng audit).

Money-path (regression-tested):
- _flatten_all: moved the fresh-broker close from close_session INTO the
  shared function + added a broker-held guard on entry cancellation, so
  the daily-loss CIRCUIT BREAKER (which called _flatten_all raw) no
  longer strips a just-filled bracket's legs or leaves a sync-gap fill
  naked. One guard, all callers.
- restart state rebuild: _fresh_entries cap, _requested_symbols dedup,
  and losing benches now rebuild from today's events on boot — a
  mid-session restart no longer resets the cap to 0 or re-fires a
  resting order into a duplicate (the 2026-07-06 incident).
- POST /orders no longer retries a lost-response URLError (was
  re-sending duplicate live orders); GET/DELETE still retry.
- expire_stale_entries now @_locked (raced the trigger thread).
- _session_open: ET conversion instead of a hardcoded 13:30-13:33Z
  window that only matched EDT — the fast path armed nothing all winter.

Security:
- admin panel fail-CLOSED on empty ADMIN_TOKEN (was fail-open on a panel
  fronting the docker socket); header-only token, constant-time compare.
- admin API responses redact secret override VALUES (live keys would
  have echoed after a paper->live switch).
- dashboard: removed wildcard CORS (cross-origin drive-by could drive
  trade actions); optional DASHBOARD_TOKEN gate on POST.

Deletions (~805MB + dead code shipped in the prod image): momentum-
rebuilt.zip, archive venv/htmlcov, and schwab/ core/ telemetry/ models/
runtime/providers/ storage/replay.py controlroom/ + three dead
evaluation stubs + package.json — all verified zero-importer.

+4 regression tests. 99 money-path/trigger/exit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:00:10 -04:00
momentum devandClaude Opus 4.8 d4c695e213 Session-hours gate on fresh entries (the after-hours submission hole)
2026-07-09 evening: the loop submitted three real resting entries at
18:53-20:09 ET (TDTH x2, SUNE). Root cause: NO market-phase gate ever
existed on either entry path — SIP prints extended-hours trades, so
after-hours price crosses fire the trigger book and evening-fresh
signals sail through the approve path. The hole predates the container
move; Thursday was merely the first evening conditions aligned.

_outside_session() (ET wall clock via zoneinfo, weekend-aware) now bars
fresh entries outside TRADING_SESSION_START..END (default 09:30-15:55)
on both submit_breakout_now (skipped: outside_session) and the approve
path (quiet skip). Exits and management remain ungated. Dataclass
default OFF so directly-constructed test settings are unaffected;
from_env defaults ON so every deployed config gets the gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:50:35 -04:00
momentum devandClaude Opus 4.8 ff3a4b46fb Selection refinements from clean-universe replay of the trader's criteria
Tested the source trader's numeric selection playbook on the unbiased
(open-knowable) year universe, n=2,643 point-in-time ORB entries:
- Dead band RE-CONFIRMED on clean data (35-75% +0.006, 75-150% -0.066)
  — CLRO/VTAK were anecdotes; band exclusion + squeeze exception stand.
- His $5-10 price sweet spot inverts for our system: edge RISES with
  price ($10-20 best at +0.143); the $1-2 bucket is negative (-0.031,
  n=609). SHIPPED: price floor $1->$2 (population gate band now
  env-driven TRADING_PRICE_MIN/MAX; watcher floor raised to match).
- His rvol>=5 floor is backwards for us: rvol 2-5x at entry +0.346R vs
  >=10x -0.047 (n=818) — crowded spotlight names hurt our entries.
  NOT shipped (conflicts with the squeeze exception's rvol needs;
  logged for forward validation via the new per-signal relative_volume).
- SHIPPED: buyout/merger headlines never rank-boost (price is pegged —
  the CRNX class); keyword suppression in the catalyst multiplier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:09:54 -04:00
momentum devandClaude Opus 4.8 44ff36a669 Population gate audit: static overnight gap everywhere + full band
The 2026-07-08 audit (owner: "figure out if there is some kind of logic
or audit issue") found BOTH entry paths feeding the population gate the
WRONG gap. The fast path used the trigger book's dynamic gap (latest
price vs prev close), so intraday rallies masqueraded as gappers: TVRD
(6.0% true gap), SKIN (2.2%, filled at $0.98), JLHL (4.3%) all traded
outside the validated population. The slow path used max(gap, intraday)
with "open" taken from the first lookback-window bar, and had no upper
band at all — dead-band names SEER (38.6%) and IOTR (40.7%) entered.

Fixes: candidates carry gap_open_pct (session open vs prev close);
trigger eligibility + submit_breakout_now use it exclusively once the
session is open; _population_reject now implements the full validated
band (10-35% OR volume-verified >=150% squeeze, $1-20 price band) for
both paths; evaluator gap uses the session open; signals carry
relative_volume for squeeze verification. Plus momentum-testpg.service
so the test-isolation Postgres survives reboots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:34:46 -04:00
momentum devandClaude Opus 4.8 2e5346e035 Trigger book: mega-gapper squeeze exception (the LHSW/NXTS fix)
LHSW gapped +278% on 100M shares, was rank 2 in the trigger book at
4:49am, ran +346%, and was the source trader's entire day — the bot's
TRIGGER_GAP_MAX=35 capped it out, same as NXTS two weeks ago, while the
bot spent its entries on the shadow round-trips (SEER/ELAB/PLUG/YRD).

Year-scale replay (n=2,879 ORB entries, 14 months, reverse-split
artifacts excluded by requiring a real volume surge): the 35-150% band
is dead (+0.02R / +0.00R — the cap earns its keep there), but REAL
>150% squeezes (n=82) ran +0.63R with 8/14 months >= 0, 4x the core
band's edge. New gap_squeeze_min/gap_squeeze_rvol re-admit gaps >=150%
only when rvol >= 5 (split repricings show rvol ~0.01 and stay out).
Live: TRIGGER_GAP_SQUEEZE_MIN=150, TRIGGER_GAP_SQUEEZE_RVOL=5.0.
Existing guards (trigger/day extension, halt, BE@+5% ratchet, sizing)
still apply to these names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:13:04 -04:00
momentum devandClaude Opus 4.8 842b54c802 Exit manager: never manage a fill with zero post-entry bars
Live 2026-07-06: every fresh entry was market-exited seconds after
filling (ELAB round-tripped in 3s, SEER in 77s, PLUG in 85s) — all
logged as exit_trail. Root cause: seconds after a fill there is no
completed minute bar with timestamp >= the fill time, and _bars_since's
empty result fell back to the WHOLE DAY's bars, so manage_live's
high-water included the pre-entry HOD and the trail read as already
breached on any name trading off its high. The whole-day fallback is
removed: with no post-entry bars the manager now leaves the position
alone (the bracket's initial stop is the protection) until the first
post-entry bar lands. Regression test verified to fail on the old code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:25:06 -04:00
momentum devandClaude Opus 4.8 c72bb4e43b Population gate: trade only real gappers — the year-long +EV boundary
The gapper-history reconstruction (n=3,008 symbol-days / 14 months) REFUTED the 15-min
confirmation gate (late crosses beat early 12/14 months incl June 2026 — the 19-day
"+0.42R early" was a definition artifact) and found the first at-scale +EV configuration:
top-20 ranked gappers (gap >= 10%, $1-20, day volume >= 300k) are +0.05..+0.15R under the
live BE@+5% ratchet exits in EVERY rank x timing segment — including rank 6-20 ("D tier",
owner-approved: +0.153R early, the best cell). The live book bled by trading OUTSIDE that
population (low-gap, thin names), not by mistiming.

- ExecutionSettings.min_entry_gap_pct / min_day_volume (TRADING_MIN_ENTRY_GAP_PCT=0.10,
  TRADING_MIN_DAY_VOLUME=300000): POPULATION gate on BOTH entry paths — auto path skips +
  logs rule_type=population_below; fast path returns skipped=population_below. Fail-open
  on missing data (house rule: never block on unknowns).
- evaluate_setup now surfaces the true overnight gap_pct in signal_data; ready-signal
  snapshot passes it through.
- fast path: submit_breakout_now takes gap_pct (trigger book stores % units; call site
  normalizes to fraction); TRIGGER_GAP_MIN raised 3 -> 10 to align the arm floor.
- confirmation gate flipped OFF via env (code stays, 0=disabled).
- rank concentration stays OFF (owner call, data-consistent: rank 6-20 converts fine).

Tests: population skip+log, real gapper passes, fail-open on missing data. 52 pass.
Monday config: population gate + cap 10 fills + quality C+ + VWAP + BE@+5% ratchet + SIP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:04:38 -04:00
momentum devandClaude Opus 4.8 3b692586f2 SIP cutover guards: never double-scale a consolidated feed
The owner bought Alpaca Algo Trader Plus (real-time SIP; entitlement propagating on the
same keys — latest-trade already SIP-live, quotes/bars still 403). Before flipping
ALPACA_DATA_FEED=sip, two guards so the free-tier ratio estimator can never double-count:
- ingest_live_minute_bars resolves the EFFECTIVE feed and refuses volume_scale for
  anything non-IEX (provider marked alpaca_<feed>).
- step_vratio self-disables when client.settings.feed != iex (saves the API calls too).
The estimator stays as the dormant free-tier fallback (plan lapse -> flip env back).
Test: SIP client + x25 ratios -> volumes NOT re-scaled, provider alpaca_sip. 4 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:02:13 -04:00
momentum devandClaude Opus 4.8 1210ac8ef6 Ratio-corrected IEX volume: free fix for the 2-5% live undercount (estimator + audit)
With the nightly SIP backfill making all BASELINES consolidated-scale, the still-IEX live
ingest would make Monday's RVOL read iex_intraday/sip_baseline ~= 25-50x too LOW and choke
the scanner. Free-key SIP is only 15-min DELAYED, so each morning the true per-name capture
ratio is directly measurable:

- research/ingestion/volume_ratio.py: compute_ratios fetches BOTH feeds for the identical
  embargo-capped window (never vs our DB bars — those are scaled; that would double-correct);
  guardrails (min samples, clamp [1,300]); persist_ratios records per-day ratios.
- ingest_live_minute_bars(volume_scale=...): scales bar volumes per symbol at ingest, marks
  source_provider=alpaca_iex_scaled. Missing symbol -> 1.0 (conservative: reads LOW, blocks).
  Constant per-symbol scale leaves cumulative VWAP unchanged (cancels in the weighted mean).
- run_live_paper: "vratio" step every 300s (VOLUME_RATIO_ENABLED/INTERVAL) merges fresh
  ratios (a name gone thin keeps its last good ratio); step_ingest applies them.
- premarket_eval nightly audit: snapshots the day's scaled ESTIMATES before the SIP truth
  overwrites, then logs median/worst error to data/volume_estimator_audit.tsv — the
  estimator's accuracy is self-measured daily, which is exactly the evidence that decides
  whether the $99/mo real-time SIP plan is ever needed.

Live check (2026-07-02 full session): ratios are hugely name-dependent — DSY x300(clamped),
CETX x286, SOFI x45, SNDQ x42, NOK x17 — validating per-name daily measurement over any
global constant. 3 pure unit tests (ratio math + thin-sample skip; ingest scaling + provider
marking; no-ratio unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:58:01 -04:00
momentum devandClaude Opus 4.8 9bdb891738 Confirmation-speed entry gate — the first backtested POSITIVE-EV filter
Why the book is break-even (proven over 7 refuted levers): the ORB entry converts poorly and
the profitable trades are diluted by the negative majority. The 2026-07-02 quality-gate backtest
(6217 setups, adversarially verified across 3 seeds) isolated where the profit actually lives:
setups whose breakout CONFIRMS within ~10 bars of the open run +1R 71% / +0.42R expectancy —
even C-grade ones — while late-confirming setups are dead regardless of grade (bar-26+: 9% +1R,
-0.81R; time-of-day expectancy decays monotonically to -0.94R late-day). The edge is
confirmation TIMING, not the grade (the B-grade lift was mostly this confound).

ExecutionSettings.entry_confirm_by_minute (env TRADING_ENTRY_CONFIRM_BY_MINUTE, 0=off):
- auto path: request_approvals_for_ready_signals skips signals whose ready-timestamp landed
  more than N minutes after the 09:30 open, shadow-logged rule_type=late_confirmation (visible,
  measurable). Midday re-triggers are late by construction -> also cut (they were -0.75R).
- fast path: submit_breakout_now gates on now() (a live trigger fires in near-real-time, so
  now() IS the confirmation time) -> skipped: late_confirmation. This also closes the fast
  path's no-time-gate hole the all-day analysis flagged.
Deliberately KEEPS min_quality_score at 0.50 (C+): the gate retains the profitable early
C-band (+0.42R) that a B-grade gate would wrongly discard. Fail-open on unparseable timestamps.
4 new tests (late skip + logged, early passes, off-by-default, fast-path block); 50 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:36:57 -04:00
momentum devandClaude Opus 4.8 8f6ce2cc04 Daily entry cap: count FILLS not submissions (free slot on back-out)
The behavior half of the cap fix. Root-caused 2026-07-01 and re-confirmed live 2026-07-02
(now visible via the auto_rejected logging): the cap incremented on any broker-ACCEPTED
submission incl. resting limits that never fill, and never decremented on back-out -- so
unfilled/canceled entries permanently burned slots. Both days the bot capped after ~3 real
trades because 2 phantom unfilled limits ate the budget, with good setups still on the board.

Fix: mark each counted entry's armed record ("counted"); when expire_stale_entries backs it
out UNFILLED (timeout, price-break, or broker-cancel), _release_cap_slot decrements the counter.
Fills keep their slot (their armed record is popped without releasing). Net: the cap now means
"N real filled entries/day", not "N submission attempts". New test drives 2 resting entries to
the cap then times them out and asserts both slots free. 46 tests pass.

The cap VALUE (currently 5) and the all-day question are separate (data-driven) decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:08:42 -04:00
momentum devandClaude Opus 4.8 e5da67c928 YouTube extraction: normalize NULL/spaced tickers + asset-specific filter
Two data-quality fixes surfaced by inspecting the first 94 claims:
- _clean_ticker: coerce the literal 'NULL'/'NONE'/'$AAPL' strings the LLM sometimes returns to
  a real None, and collapse ASR space-mangling ('B VC' -> 'BVC').
- require_asset filter: drop claims with neither a ticker nor an asset_name -- generic trading
  commentary ('buy the dip') from tutorial content is not an asset claim. On the backlog this
  cut 94 -> 58 claims (36 generic), leaving clean per-asset signal.
Added the $2,000 Small Account Challenge recap playlist (the daily per-asset trade breakdowns) --
far higher claim density than the evergreen 'Anatomy of a Trade' tutorials. 7 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:16:05 -04:00
momentum devandClaude Opus 4.8 7e4925d778 Premarket setup detector + pm1 shadow track — the one unexplored lane
Every RTH selection/entry lever has now been refuted (6 of them separate a population but
don't convert through the entry). The one axis never touched is PREMARKET — the source
trader's actual edge (his words: news drops pre/after-hours, NO circuit-breaker halts pre-market
so moves run clean, and RTH after 9:30 is choppy/unpredictable — the exact window our bot trades).

strategy/evaluation/premarket_setup.py::detect_premarket_setup — pure, operates on PREMARKET bars:
gap vs prev_close + real premarket volume floor (the DXST-vs-CF tell: low pm volume = fail) +
price floor + fresh premarket-high breakout + premarket-VWAP hold. Calibrated to the thin pm tape
(cum pm volume p50 ~12k shares, so a 20k floor catches the genuine movers not 1%-gap noise).
Real-tape validated + SELECTIVE: 6/30 fires JEM(245%)/CELZ(143%); 7/1 fires LHAI(166%)/SOC/SNDQ —
2-3 names/day (~8% of pm names), vs the liquid detector's 50%. 6 unit tests green.

labeler pm1 track (build/report/validate-premarket): slides the detector over premarket bars,
scores forward on PREMARKET bars via the same _forward_labels (the trader enters+exits pre-9:30),
stratifies +1R by gap. Pre-registered bar locked 2026-07-01 (median pm upside >=+10%, adverse >-6%,
+1R >50%, big-gap subset clears). SHADOW-ONLY. Real caveats it sets aside: thin premarket fills, and
live premarket needs extended_hours execution the bot doesn't have. Full 26-day backtest building.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:03:53 -04:00
momentum devandClaude Opus 4.8 26013893d1 YouTube pipeline: harden claim quote-matching; add tests + systemd unit
Extraction fix: the anti-hallucination check (a claim's verbatim_quote must appear in the
source window) was too brittle against auto-captions — fragmented segments joined with
newlines meant a valid quote spanning a join was dropped (0 claims on a 623-segment captions
transcript). Now whitespace-normalized matching (_norm + _quote_supported, ~40-char prefix):
the SAME transcript yields 6 real claims (WETG +160% reverse-split+news; 4 AURC trade claims
with prices; a rejection concern), each quote verifiably present. Whisper output is cleaner
still; this just makes the captions fallback usable too.

- tests/unit/test_youtube_extraction.py: 5 pure-helper tests (norm, quote-support across joins,
  hallucination rejection, verbatim-quote-mandatory drop, chunk time-spans).
- youtube_claims/momentum-transcripts.service: reboot-surviving systemd user unit (Restart=always,
  loads secrets from .env; Whisper option A/CPU, env-flippable to B/GPU).

END-TO-END VERIFIED (captions path, no ffmpeg): detection -> claim -> transcribe -> extract ->
6 claim rows in Postgres. Full Whisper path needs `sudo apt install ffmpeg`, then
`systemctl --user enable --now momentum-transcripts` to process the 11-video backlog at
Whisper quality + poll for new uploads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:08:41 -04:00
momentum devandClaude Opus 4.8 a25e918240 Liquid intraday-RVOL breakout detector (prototype, shadow-only)
The 2026-07-01 forensic + the source trader's day-14 notes point at a thesis-scope gap:
the day's only clean, held, TRADEABLE +2R moves (MSTR, SSPC) were LIQUID names that ignited
intraday with ~zero premarket gap -- invisible to the premarket-gapper universe -- while the
cheap "held trenders" were untradeable dust and the liquid gappers the bot chased whipsawed.

strategy/evaluation/liquid_breakout.py::detect_liquid_intraday_breakout -- a pure detector that
makes LIQUIDITY (cumulative + per-bar dollar-volume) a first-class gate (the thing runner.py
lacked, which let it fire on dust), plus a price floor + fresh-session-high breakout + VWAP hold.
NO gap, NO blue-sky, NO float. Validated on the real tape: fires on MSTR (@bar15) and SSPC (@bar90),
correctly silent on the sub-$2 dust (DXF) and the <$1M single-print names.

Design finding baked in: RVOL is RECORDED, not a hard gate. MSTR/SSPC were NOT within-session
volume surges -- MSTR was liquid from the open (recent/early vol ratio ~0.5-1.0 the whole grind),
so a hard rvol>=3x "surge" gate structurally kills the steadily-liquid grinders this lane exists to
catch. rvol is stored as a stratification feature (the shadow track will measure whether high-rvol
fires convert better, like the runner GRADE); it only gates if rvol_min>0 is passed.

Selectivity is broad (~50% of the liquid universe fires at least once/day) -- this is a candidate
POPULATION definer, not a precise signal. The decisive question (does it convert to +1R better than
the ORB, and does any rvol/liquidity stratum beat it) is for the shadow track + a pre-registered
forward bar. SHADOW-ONLY: never routes to approve_order; 6th selection signal, must prove conversion
after cost before any live gating. See memory thesis-scope-liquid-intraday-momentum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:34:56 -04:00
momentum devandClaude Opus 4.8 4f40d512e3 Log visibility: stop counting entry rejections as executions
Root-caused (workflow wjy4nzpbw, adversarially verified) why the live loop's per-cycle
log printed `execute: ... auto_executed=1` ~951 times after 09:45 ET on 2026-07-01 with
ZERO matching broker orders: reject_order() returns a truthy {"ok": True} dict that
process_auto_approvals() appends unconditionally, and step_execute logged len() of that
list as auto_executed. So every daily-entry-cap rejection masqueraded as an execution,
hiding the fact that the bot was hard-capped at 5 fresh entries for the rest of the day.

This is the log-VISIBILITY half only (safe, zero trading-behavior change):
- reject_order() tags its return rejected=True + reason, so a rejection is distinguishable
  from a real execution (both carry ok=True).
- step_execute() counts auto_executed = results with a real broker_order_id, and appends
  `auto_rejected=N (reason x N, ...)` so a tripped cap is VISIBLE instead of silent, e.g.
  `auto_executed=0 auto_rejected=1 (daily_entry_capx1)`. ASCII-safe for the systemd log sink.

NOT included (deferred): the behavior fix (cap counts accepted submissions incl. unfilled
resting limits that get backed out, never decrements — so 4 phantom orders burned the whole
day's entry budget). That change lets the bot take more real shots, which given the live-
confirmed entry-conversion weakness likely means more churn — pair it with entry-quality
work, don't ship solo. See memory daily-entry-cap-counts-submissions.

Takes effect on the next loop boot; deliberately NOT restarting mid-session (the cap counter
is in-memory, so a restart would reset it to 0 and re-open entries into today's tape).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:52:06 -04:00
momentum devandClaude Opus 4.8 a33996f91c Runner-aware GRADE + leading-gainer runner DETECTOR, shadow-only
The default ORB grade (quality.py) pins catalyst RUNNERS at F/C: its two heaviest
components structurally break on a vertical spike -- STRUCTURE (30%) reads 0 (no
pullback to classify) and adv-RVOL (25%) reads 1.0x (NULL rolling-20d on fresh
reverse-split names). On the 2026-06-30 tape the source trader's winners (SVRE
+247%, JEM +348%, CELZ +472%) all graded C 0.565, indistinguishable from the bot's
F-grade chop. Two parallel, shadow-only artifacts fix this without touching the live
ORB path:

- strategy/evaluation/runner_quality.py::calculate_runner_quality -- a PARALLEL
  grade that drops the two broken components and scores the traits that actually
  separate runners from chop (gap saturating at +100% not +10%, price velocity,
  own-name volume burst, above-VWAP, catalyst). Validated on the real tape:
  CELZ/JEM/SVRE -> B/A, chop (SLS/RIVN/CCXI/BB/NVDX) -> F. quality.py untouched.

- strategy/evaluation/runner.py::detect_leading_gainer_runner -- catches the mover
  detect_momentum_ignition is blind to (it requires blue-sky > prior ATH, so it
  fires ZERO on beaten-down catalyst pops). Blue-sky deleted; gates = leading-gainer
  run + verticality + own-name burst + holding session-cumulative VWAP; PM-inclusive;
  tags (never gates) PM-exhaustion. Real-tape in-sample: 5/5 runners fire, 7/7 chop
  silent, zero false-fires. Cumulative VWAP is a prefix function, so a precomputed
  session_vwap makes a bar-by-bar slide O(n) not O(n^2) (identity verified exact).

- research/labeler.py -- build-runner/report-runner/validate-runner shadow track,
  mirroring vwap_reclaim: emit a non-tradeable setup on each fresh fire, score with
  the SAME _forward_labels. Pre-registered promotion bar LOCKED before forward data
  (median fwd-max >= +15%, median adverse > -8%, +1R > 50%, beat vwap_reclaim,
  PM-exhaustion tag must earn its keep). SHADOW-ONLY: never routes to approve_order,
  never gates live until the bar clears out-of-sample. The honest open question the
  track exists to answer: the detector confirms AT/AFTER the top for PM-driven names
  (SVRE fires at +242% already run) -- does that late entry actually convert to +1R?

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:10:19 -04:00
momentum devandClaude Opus 4.8 a114579cc1 Quality-grade entry gate: trade fewer, higher-quality setups (config, off by default)
Forensic on 2026-06-30: of the bot's 8 traded names, SEVEN graded F (<0.50) and one B —
it sprayed low-quality chop (SLS F0.12 -$412, RIVN F0.19, BB F0.21, CCXI F0.26 +$374,
NVDX F0.33, NOK F0.48, RDW F0.49; GVH B0.71). The trader made +$616 on SVRE — which the
bot SAW but its 35% gap cap excluded; and even graded, SVRE is F (0.35): low computed
RVOL + no clean pullback structure. So the grade cuts chop but does NOT recognise a
vertical catalyst RUNNER.

Adds ExecutionSettings.min_quality_score (env TRADING_MIN_QUALITY_SCORE, default 0.0 =
off) and a gate in request_approvals_for_ready_signals mirroring the VWAP gate: a signal
scoring below the threshold is skipped and logged rule_type=quality_below; fail-open on a
missing score. 3 tests (skip-low-when-enforced / allow-when-off / allow-at-threshold);
44/44 trading-execution tests green.

Honest scope: this is the "trade less chop" lever (evidence: the bot's own labeled data
has q>=0.55 win ~71% vs q<0.55 ~36%). It will NOT catch SVRE-class leading gainers — the
grade rewards pullback structure the runners lack, and the gap cap excludes them upstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:16:58 -04:00
momentum devandClaude Opus 4.8 0870bb780f Exit manager: re-anchor a breakeven carry's +0.25R trail to TODAY's opening range
Per the operator: a carried position sitting at breakeven shouldn't ladder off its stale
overnight entry. When a held position is at breakeven+ with no real R, the +0.25R trail
now RE-ANCHORS to today's opening range — reference = the session open, R = the first
5 RTH bars' high-low — so the stop tracks today's move from the open and locks in tighter
than the wide entry-based synthetic R. Until the opening range forms (first ~5 min) it
trails off the synthetic fallback R, then re-anchors once (one-shot, tracked in
_open_anchored; cleaned up when the position closes). The stop only ever ratchets UP and
never drops below the live breakeven. 1 test added (re-anchors + ladders tighter than the
synthetic); 11/11 exit-manager tests green.

For KEEL: at tomorrow's open its stop ladders up from the open's range instead of dangling
at the $5.56 breakeven.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:38:13 -04:00
momentum devandClaude Opus 4.8 3076d22375 Exit manager: trail a breakeven-protected position UP via a synthetic R (open-only)
KEEL was left at a frozen breakeven stop because its original R was lost — the bracket
stop leg was invalid (the limit filled below the ORB-low, so the stop sat ABOVE entry
and Alpaca cancelled it, which is what left it naked). The manager skipped any position
already at breakeven+, so it never trailed: a winner could only scratch or run
unprotected-above-breakeven.

Now a position first seen at breakeven+ with no real risk distance is tracked on a
SYNTHETIC R (ExitConfig.default_trail_r_pct, default 10% of entry, env
TRADING_EXIT_DEFAULT_TRAIL_R_PCT), so the SAME tested +0.25R step-trail / profit-lock
ratchet the broker stop UP as it rises (only ever up, never below the live stop).
default_trail_r_pct=0 keeps the old frozen behaviour; positions with a real sub-entry
stop are unchanged.

TRAILING is gated on the session being OPEN (one broker-clock check per pass): after
hours there are no new highs to trail under AND a resting GTC stop is often not
replaceable ("accepted" status -> Alpaca 422), so attempting it just churns. The stop
stays protective and ratchets up at the open. 4 tests added (synthetic-R trails up;
disabled=frozen; closed=no-attempt); 10/10 exit tests green.

Live now (after-hours): KEEL is protected at breakeven $5.56; the loop holds that stop
without churning and will trail it up once the session opens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:32:11 -04:00
momentum devandClaude Opus 4.8 b5909bc793 Naked-stop & catastrophe enforcement: rest a protective stop when the market is closed
KEEL was carried into after-hours as a naked long (its bracket stop leg never
attached — 0 stop orders in its whole history). The enforcement tried to FLATTEN it
with a market order every pass, but a market sell can't fill when the market is
closed, so it churned submit->cancel ~every 30s (76 market-sells / 79 cancels, the
"naked-stop enforced exit KEEL xN" spam) and left the position genuinely unprotected.

Fix (runtime/exit_manager.py): both the naked-stop enforcement AND the catastrophe
backstop now branch on the broker clock. Market OPEN -> flatten (a market exit fills,
unchanged). Market CLOSED -> REST a protective sell-stop instead (GTC) — at breakeven
when in profit, a catastrophe-distance stop below market when underwater — so the
position survives to the open PROTECTED and the next pass sees the stop and stops
enforcing. Catastrophe with an existing (loose) stop while closed just defers to it
(no market churn, no double stop). New client bits: AlpacaPaperClient.get_clock() and
a standalone stop_price on submit_order. 3 exit-manager tests added (open->flatten,
closed->breakeven-rest, closed-underwater->catastrophe-rest); 7/7 green.

Operationally: KEEL was protected at breakeven ($5.56) immediately and the loop now
leaves that stop in place instead of churning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:16:24 -04:00
momentum devandClaude Opus 4.8 f170305a41 No-chase entry shadow-validation: VWAP-pullback halves the ORB loss but FAILS the locked bar
The mechanics study found the no-chase entry (buy the VWAP reclaim, not the breakout high)
was the dominant lever. This codifies it as a permanent, pre-registered shadow-validation —
the disciplined close-out of the 4-lever investigation arc.

- strategy/evaluation/vwap_pullback_entry.py: pure find_vwap_pullback_entry — first forward
  bar that dips to the CUMULATIVE session VWAP (calculate_vwap, NOT the per-bar minute_bars.vwap
  midpoint, which would make 'reclaim' trivially true) and closes back above it; entry = its
  close, stop = its low (the tight no-chase structure stop). 4 tests.
- research/labeler.py validate_no_chase_entry + `validate-entry`: simulates it vs the ORB-high
  breakout via the live simulate_exit on real forward bars, judged in PERCENT net of a REALISTIC
  per-name cost proxy (4% sub-$3 / 2% else — no NBBO in bars). PRE-REGISTERED bar locked: mean
  net% must be STRICTLY POSITIVE, 'less negative than ORB' is NOT a pass.

IN-SAMPLE RESULT (v1 gappers, 19 autocorrelated days): the no-chase entry HALVES the loss
(ORB -7.94% -> no-chase -3.61% net, median -4.23%, offered on 75%), robustly (day-jackknife
[-3.83%,-3.48%]) -- confirming not-chasing the spike-top is the strongest mechanic -- but it
still does NOT cross zero under realistic cost: PRE-REGISTERED BAR = FAIL. Consistent with the
whole arc: no mechanizable entry/exit/selection lever beats cost on the full gapper book; the
path to positive is FEWER/cleaner setups (cut the ~70% fader population) COMBINED with this
no-chase entry. validate-entry re-runs forward; promote only if it clears the bar out-of-sample.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:08:56 -04:00
momentum devandClaude Opus 4.8 97e3bf91a6 Runner-propensity selection lever: cheap-price + premarket-gap, shadow-measured
The float audit and the labeler both point at the SAME free, available-today selection
signal the bot underuses: gap magnitude + cheap absolute price separate runners from
faders, while the bot's gap%×RVOL rank leans on RVOL (labeler-proven FLAT) and CAPS gap.

- strategy/evaluation/runner_propensity.py: pure, transparent, FROZEN-threshold primitive
  -> a coarse runner-propensity tier = gap_tier(0-3) + price_tier(0-2). Gap is weighted
  heavier BY CONSTRUCTION because the data says it dominates; premarket gap preferred over
  RTH gap; cheap price is a secondary tie-breaker (the two are correlated). 6 unit tests.
- research/labeler.py runner_rank_report + `runner-rank` CLI: measures the lever on the
  v1 ORB labeled set. SHADOW/measurement only, not wired into live selection.

MEASURED (v1 gappers, gap>=10%, n=187, 19 autocorrelated days -- small, caveated):
the combined tier ladders monotonically: tier1 2% -> tier3 35% -> tier5 (cheap+big-gap)
48% runner (1.63x base). Gap dominates (>=50% -> 48%, 1.64x); cheap<$2 a real secondary
(1.29x). OPERATIONAL KICKER: the live ~35% TRIGGER_GAP_MAX would EXCLUDE 73 gappers whose
runner rate is 44% vs 20% for the kept <=35% -- the cap discards the best runners (this is
how NXTS at +81% got excluded). Caveat: +1R/+2R separate far less than the +20% runner
label, and gap/price are correlated; a hypothesis to shadow-confirm + adversarially
validate before touching the live cap, not a validated rank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:08:56 -04:00
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 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 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 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 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