Commit Graph
44 Commits
Author SHA1 Message Date
momentum devandClaude Opus 5 45b0386347 feat: rank gappers by premarket range, the one measured predictor
Over 1,040 screened symbol-days (2026-08-01 on), asking which premarket
property predicts the session running >=50% open-to-high:

    premarket range   n     avg run   ran >=50%
    >= 30%           201     34.7%      15.9%
    15-30%           136     28.3%      14.7%
     5-15%           303     15.2%       5.9%
      < 5%           400     16.8%       4.3%

A name that already travelled >=15% premarket is 3.1x more likely to run
>=50% (15.4% vs 5.0%) -- the largest single lift in this dataset, and free:
it comes off minute bars the arm pass has already fetched.

Two findings constrain the shape:

  - It is a STEP at 15%, not a gradient (15-30% and >=30% are 14.7 vs 15.9;
    5-15% and <5% are 5.9 vs 4.3). So the multiplier is a threshold. A linear
    scale would invent precision the data does not support.

  - Premarket VOLUME carries NO signal (10.0/4.2/7.9/9.0% across participation
    buckets, non-monotonic) and raw premarket volume runs inversely to the
    outcome. scanner_snapshots.premarket_volume being NULL costs nothing;
    range is the field that mattered all along.

Do not expect 3x. Replaying armed-set selection over the same 29 sessions,
top-5 by mean/median run and >=50% runners captured out of 145 slots:

    gap x rvol (baseline)        39.77% / 10.97%   27
    x3.0 if PM range >= 15%      42.00% / 13.16%   32
    HARD FILTER >= 15%           37.27% / 12.67%   24   <- worse
    PM range alone               32.86% / 12.65%   20   <- much worse

gap x rvol already encodes much of the same information, so the marginal gain
is ~+2 points of mean run and 4-5 extra big runners per 145 slots. It is
BOOST-only for a reason: gating on this feature scored below baseline, and it
must never replace gap/rvol. Every threshold in 10-20% and gain in 1.5-5.0 beat
baseline -- a flat plateau, so not fitted. The shipped 15%/3.0x are the measured
step and the measured lift, not the sweep's peak cell.

Fail-open: an unknown range yields a neutral 1.0, never a penalty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-13 19:16:17 -05:00
momentum devandClaude Opus 5 9034839661 fix: a zero equity reading sized off the $100k default
Tradier returns total_equity 0.0 during its overnight maintenance window --
sixteen times in the last fourteen days, all around 01:06. Three defects
compounded so that a zero could size a position off default_equity:

  account_equity = equity or config.default_equity

`or` cannot tell "not supplied" from "read as zero", so a real 0.0 became
100000. Then the position-value cap, derived from the same equity, was
itself 0 -- and the guard

  if max_position_value is not None and max_position_value > 0:

SKIPPED the cap precisely when the cap said "no room". Finally the auto
path (~96% of approval requests) had no `pos_cap <= 0` early-out, unlike
the manual path at request_entry_approval. Net effect on a ~$250 account:
risk 5% of 100k = $5,000, capped only by liquidity. The broker would have
rejected it for buying power, which is luck rather than design.

Never fired in live hours -- every zero read landed with the market closed,
and _current_equity caches the last good value -- so this is latent, not a
realised loss. The reachable path is a boot that coincides with the
maintenance window, when no good read has been cached yet.

Distinguish None from zero, refuse to size on non-positive equity, treat a
zero cap as "no room" rather than "no cap", and mirror the manual path's
early-out. TRADING_DEFAULT_EQUITY is set to 300 on the live stack so even
the untested fallback cannot oversize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-13 13:34:53 -05:00
momentum devandClaude Opus 5 aae7269538 fix: stop the exit stack amputating every winner
The live exit ran FOUR independent stop ratchets at once: a +5% breakeven
inherited silently from the env default, a 0.25R step-trail, a prior-low
trail, and profit-lock tiers. Stacked, they pinned the stop at or just
above entry within the first minute of any real move, so winners were
capped near +1R while losers still ran to the full stop.

Measured over 62 live Tradier + 172 Alpaca paper round trips:

  - trades whose stock ran >=20% after entry (avg +63.7%) kept 9.3% of it
  - median hold 10 min for winners vs 19 min for losers -- inverted
  - PCLA 2026-09-11: entered 12.8233, stopped 12.82, a 0.03% stop

breakeven_at_pct was the only exit lever in ExitConfig that defaulted ON
from the env (0.05) while its dataclass default and every sibling lever
defaulted to off, so the live stack ran it without ever setting it. Turn
the env default off so "not configured" means "not applied".

Replayed through simulate_exit -- same entries, same initial stops, only
the management varying -- with each stack split 70/30 by date. A 10%
trail running from entry looked best in-sample and went negative out-of-
sample; what survives is a trail armed only after +1R. The 8-20% range
all beat the old config in all four train/test cells (mean R 0.20-0.25 vs
0.125), so the live value of 12% is a plateau pick rather than a fitted
one.

Live config_overrides updated separately (market closed, no open
positions); this commit is the code default and the regression guard.
Risk per trade is unchanged and both safety backstops -- the -10%
catastrophe exit and naked-stop enforcement -- are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-13 12:00:29 -05:00
momentum devandClaude Fable 5 5597250ff1 fix: marketable entries price from live last + time-of-day rvol arms fast path
Day-22 forensic (BIYA session): 6 entry submissions, 0 fills — IREX missed
+1.7R, IRE +1.65R by one cent. Two root causes fixed:

1. Slow-path limit rested AT the trigger while price ran 2% past it, and
   retries reused the same stale price. Marketable model now prices from
   max(trigger, live last) via price_provider; the anti-chase extension
   gate gets the live ref too (was an entry_ref==trigger no-op on this
   path). TRADING_ENTRY_FILL_MODEL flipped to marketable.

2. Fast trigger path armed nothing all day: legacy rvol (cum-so-far /
   full-day MEAN) is ~0.1-0.3 minutes after the open for a normal heavy
   name and its mean is poisoned by reverse-split/prior-squeeze days
   (BIYA legacy 1.27 vs real activity 481x median). New rvol_tod =
   cum / (MEDIAN(20d) * elapsed-session fraction) drives arm eligibility
   and the scanner keep-filter; legacy rvol untouched for validated
   boundaries (gap-squeeze rvol>=5).

Also: stagnation time-stop (TRADING_EXIT_STAGNATION_MIN/_R, default OFF)
in simulate_exit + manage_live with sweep_exits.py variants for
validation before any live enable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:40:15 -05:00
momentum devandClaude Fable 5 66e81a48f3 population: extract strategy/population.py; live gate delegates to it
The tradeable population is now defined ONCE. trading_execution._population_reject
becomes a thin wrapper building a PopulationConfig from its settings and calling
strategy.population.population_reject — logic moved verbatim (dead-band 0.35/1.50
and squeeze rvol 5.0 are module CONSTANTS, gap floor / price band / volume floor
from config). Behaviour byte-identical: the pre-extraction characterization suite
stays green through the new public seam.

from_env mirrors ExecutionSettings.from_env (same 4 keys + the [0,1] _qfloat clamp)
so a live-shaped shell reconstructs the identical gate. gap_pct FRACTION contract
and the as_of open-knowable contract are documented for offline consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:36:59 -04:00
momentum devandClaude Fable 5 af8137dfd7 delete: dead DDL (10 tables), dead projections, pinned dead code; README/pyproject prune
pg_schema.sql: drop CREATE TABLE for 10 zero-reference tables (operator DROP
list in plans/002-deletions.log — production tables untouched until run).
projections.py: remove 4 zero-consumer query functions.
Clear all 'plan 002' ruff pins from the CI gate (files now F-clean);
api/main.py:F821 pin remains for plan 007. README repo-map/test-count fixed;
pydantic-settings removed (zero imports). Full receipts: plans/002-deletions.log

Suite: 351 passed (16 schwab/dead-module tests removed), 15 failures = the
known WSL-loopback dashboard set only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:09:35 -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 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 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 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 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 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 ee6991d05a Runner track: match wgcbvb3ce grade signature + attach grade-stratified conversion
Fidelity pass against the leading-gainer-runner workflow (wgcbvb3ce) synthesis:

- runner_quality.py: reorder calculate_runner_quality signature to the spec's
  (gap_pct, vol_burst, velocity, above_vwap, ...) — behavior identical (all callers
  use keywords), now matches the documented signature so a positional caller reading
  the spec is correct.

- labeler.py: close build_plan step (3) "attach the runner grade". The grade is a pure
  function of components already persisted on each fire (gap/velocity/burst/vwap), so
  it is recomputed exactly at report/validate time (catalyst-OFF) — no 34-min rebuild.
  report-runner now stratifies forward +1R and median 60m upside BY GRADE; validate-runner
  adds the A/B-subset test (does concentrating on top grades salvage the locked bar?).

FINDING (385 fires, 25 session-days): the detector still FAILS all 5 pre-registered
bars (the workflow predicted this — SVRE/JEM put 100% of the move pre-market, RTH fires
confirm at/after the top). BUT the grade is MONOTONE with forward outcome:
  grade A: +1R 45%, median max-upside 60m 17.7%   (n=73)
  grade B: +1R 41%, median 9.2%                    (n=186)
  grade C: +1R 35%, median 7.8%                    (n=106)
  grade F: +1R 30%, median 3.5%                    (n=20)
Grade A ALONE clears the +15% upside bar (17.7%) but not +1R>50% (45%): A-runners have
real continuation, the naive last-close entry still whipsaws. Confirms the grade earns
its keep as a RANKER/sizer (concentrate on A) while the bottleneck stays ENTRY-conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:58:46 -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 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 f5dc7a8faa Cleanup: tidy the exit-manager / exits / db_pg changes (post-review)
Focused review pass on the session's safety-critical additions, applying the safe wins:
- exit_manager: drop the dead `_Tracked.last_stop` field (written, never read) and its
  assignment; guard `_rest_protective_stop` against a degenerate qty<1 (a 0-qty submit
  is rejected and would churn) + document the cur-falsy->breakeven fallback; reword the
  synthetic-R comment (the branch is also hit on a healthy trade once breakeven ratchets
  the stop to entry, not only the stripped-stop case).
- exits: surface default_trail_r_pct in ExitConfig.describe() (boot-log observability)
  and clamp it to [0, 0.95] in from_env (a >=1 / negative synthetic R would put the stop
  at/above entry).
- db_pg: comment the gate-vs-try-order distinction of _LAN_DB_HOSTS (superset incl.
  localhost) vs _LAN_DB_FALLBACKS so a future edit can't break the non-LAN-DSN guard.
- momentum_cli: drop an unused `import sys`.
Behavior-preserving; 29/29 exit/exits tests green. Deliberately did NOT extract a shared
flatten-vs-rest helper for the catastrophe/naked blocks — the explicit blocks read
clearer for this safety code and the tests guard against drift.

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

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

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

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

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

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

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

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

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

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

75/75 tests green.

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

75/75 tests green.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:15:19 -04:00
momentum devandClaude Opus 4.8 007cd95c79 Baseline before Postgres migration (WS0)
Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:39:29 -04:00