75 Commits
Author SHA1 Message Date
momentum devandClaude Opus 5.5 0c8a27e23f feat: switch to turn off the >=150% gap squeeze exception (default on)
TRADING_GAP_SQUEEZE_ENABLED=0 rejects >=150% gappers in the population gate
(watcher pre-filter and entry gate) and zeroes the trigger book's squeeze
arm. One switch covers all three paths.

Why: while testing the trader's "trade the leading gainers" idea on 1,093
live-band v1 ORB breaks (Jun-Sep 2026, current exits), top-3 gainers at the
break did WORSE (diff -0.48R Jun-Jul, -0.40R Aug-Sep, 90% CIs excluding 0).
That turned out to be almost entirely the >=150% squeeze-exception names:

  gap 10-35%   Jun-Jul +0.27R (n=342)   Aug-Sep +0.13R (n=327)
  gap >=150%   Jun-Jul -0.21R (n=65)    Aug-Sep -0.30R (n=53)

Our own squeeze fills: -0.31R / -0.33R realized (n=16). By the time a squeeze
name breaks its opening range the move has happened; the trader catches these
names premarket, which the bot does not. This finding was post-hoc (not the
pre-registered direction), but it held in both halves and in the real fills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-28 09:45:51 -05:00
momentum devandClaude Opus 5.5 dff21bae29 Merge: whole-market premarket squeeze scanner (snapshot scan)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:13:11 -05:00
momentum devandClaude Opus 5.5 1cead3f29b feat: whole-market premarket squeeze scan via Alpaca snapshots
Discovery finds premarket squeezes far too late. Across 53 trades by a
professional small-cap momentum trader, our scanner had found only 4 of his
names before his entry, and 27 of his 28 entries were premarket (07:00-09:30
ET). KITT on 2026-09-25 ran $3.50 -> $8.39 at 09:00-09:01 ET (prior close
$0.49); the loop first "Discovered KITT via manual" at 09:35. The existing
sources (Alpaca movers / most-actives screeners) only reflect the regular
session, so a premarket squeeze is invisible until the open.

research/ingestion/snapshot_scan.py sweeps every active, tradable, listed US
equity (~11.9k names; OTC, warrants/units/rights and DISCOVER_EXCLUDE dropped;
asset list cached per ET day) through GET /v2/stocks/snapshots in 2,000-symbol
batches. The endpoint is bound by URL length rather than symbol count (8,000
worked; ~12.7k returned 414). A name qualifies when:
  - the LATEST TRADE price is inside WATCHER_PRICE_MIN/MAX (never the prior
    close, which is routinely < $1 on these names),
  - % change vs the prior close >= DISCOVER_SNAPSHOT_MIN_PCT (20),
  - it is trading now: last trade <= 180s old, latest minute bar <= 300s old
    with >= 5,000 shares, and >= 100,000 shares since 04:00 ET (summed from
    minute bars for the short list, one extra request).
Qualifiers are ranked by % change, capped per sweep, and announced once per
day. dailyBar OHLC ignores extended-hours prints (KITT's dailyBar high was
$3.24 against a premarket high of $8.39), so in premarket it can still be
yesterday's bar. The prior close is prevDailyBar.c only when dailyBar is dated
today, otherwise dailyBar.c.

run_live_paper.py runs it on its own daemon thread (04:00-16:00 ET weekdays,
every 45s), using the plan-019 pattern: its own DB connection, the thread-safe
Watchlist and the generic interval driver. Each new name emits
symbol_discovered with source="snapshot_scan" and price/prev_close/pct/minute
and cumulative volume in symbol_data, so first-sighting latency can be
measured. It gets a daily-history backfill and joins the watchlist, so the
normal new-symbol ingest path gives it its first full-session pull. This is
discovery only; no trading gate changes.

Gated by DISCOVER_SNAPSHOT_SCAN_ENABLED (default 1); knobs in .env.example.
Live read-only sweep at 13:10 ET: 11,871 names in 8 requests / 6.3s cold
(7 requests / 3.5s warm); top hits KITT +376%, MSGY +262%, INLF +60%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:11:45 -05:00
momentum devandClaude Opus 5.5 81e505b845 fix: verify squeezes on a median volume baseline and ingest from the premarket
KITT on 2026-09-25 (+557%, 9.3M shares, the day's biggest mover) was benched
as "gap 557% squeeze unverified (rvol 1.54)". Two separate faults:

1. The rvol baseline was the 20-day MEAN, and two 11-14M-share days on
   09-01/09-02 put it at 1.66M against a 333k median. Squeeze verification
   (gap >= 150% needs rvol >= 5) now takes the stronger of the mean-baseline
   reading and the median-baseline one. Both are cumulative volume over a
   full day's baseline, not prorated, so they share the units the rvol >= 5
   boundary was calibrated in. A reverse-split fake still reads ~0.01 on
   both. This applies in the watcher pre-filter, the trigger book's squeeze
   exception and the fast-path population gate. The ranking and the tod-rvol
   arm signal are unchanged.

2. A name's first ingest reached back only to ~09:15 (09:30 + 15 min, or
   the 20-minute refresh floor). KITT was found at 09:35, so its stored day
   began at 09:13 and missed the 09:00 squeeze and ~6M premarket shares.
   The first pass now covers the session from 04:00 ET.

With both fixed, KITT's volume at 09:35 is ~20x its median day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:08:13 -05:00
momentum devandClaude Opus 5.5 7f1ebe9cab fix: read the data feed through the Tradier wrapper — live volume was 80-160x
With BROKER=tradier the loop's `client` is a TradierClient that delegates
market data to the Alpaca client in `data_client`. Two places read the feed
as getattr(client.settings, "feed", "iex"). TradierSettings has no feed, so
both read "iex" even though the container runs ALPACA_DATA_FEED=sip (verified
in the running loop and in config_overrides):

  - step_vratio kept computing IEX->SIP ratios as if on the free feed
  - ingest_live_minute_bars multiplied the real SIP volume by those ratios

Checked against the paper stack's SIP bars for the same minutes: prices are
identical and unscaled volume matches 1.00x, but scaled rows run x83 (GLND),
x117-126 (GRML) and x163 (SKYQ). The 2026-09-25 GRML signal logged a
cum_volume of 1.39 billion shares and an rvol of 64.6. For any name with a
ratio, every volume gate — rvol, the 300k day-volume floor, min dollar
volume, the 2.5%-of-volume liquidity cap, and the gap x rvol ranking — has
been reading inflated numbers since the stack went live on 2026-07-31.

data_feed() reads through data_client when present. The regression test
wraps a SIP client in a TradierClient and fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:14:11 -05:00
momentum devandClaude Opus 5 11b0fcd7b7 fix: read the session open from the first regular-hours bar, not bars[0]
_population_reject computed the static open gap from bars["open"].iloc[0].
Premarket that is a PREMARKET bar, so the gap is fabricated. Live on
2026-09-14: FTFT opened 3.30 against a 2.88 previous close (+14.6%, well
inside the tradeable 10-35% band) but its premarket bars started near 2.52,
so the filter read -12.5% and benched it at 09:12 -- before the session had
even opened.

It never self-corrected. The frame keeps its premarket bars all session, so
every later pass recomputed the same wrong number, and _transition only emits
on change, so the symbol stayed out_of_population for the day. Several of the
day's rejections show negative gaps (-12.5%, -17.4%, -1.9%) that look like the
same artifact.

Trading was unaffected -- the trigger path does not consult watcher state, and
FTFT was still evaluated and rejected on its own merits (over_extended_day, it
was already +59% off the open). The damage is diagnostic: valid candidates lose
their criteria trail, which is the blank-reasoning-panel problem a previous
commit set out to fix.

Read the open off the first is_regular_hours bar; return None (fail open) when
the session has not opened, since the static gap is not yet knowable. A frame
with no session flag is treated as already regular-hours-only, which is what
tests and replay hand it.

Also drop the pm-range=N counter from step_arm's return string. The arm step
never emits a module_tick -- verified against both 2026-09-11 and 2026-09-14,
where only discovery, ingestion, enrichment and triggers do -- so it reached
container stdout and nothing else. Adding a tick purely to carry it would put
~4k events/day back after the population filter had just removed 88% of them.
pm_range_pct is already on the armed candidate rows for the board; persist it
on the approval payload if it needs to be measurable per trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-14 15:27:01 -05:00
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 97a3a1b29d perf: stop the watcher scoring names that can never trade
criteria_evaluated + signal_blocked held 3.1 GB of a 7.6 GB events table.
On 2026-09-10 alone the watcher wrote 191,458 criteria receipts across 57
symbols -- ~3,359 per symbol per day -- and 154,260 of them (80.6%) were
for names whose static open gap was outside the tradeable band. NOK, NIO,
PATH, PLUG, SOFI, AAL and F all sit inside the $2-20 price filter, so
WATCHER_PRICE_MIN/MAX admits them; only the gap floor excludes them, and
that was checked per-tick instead of once up front.

Apply the SHARED population gate (strategy/population.population_reject --
the same function, same config the entry path already enforces) before the
evaluation and its receipts. This is behaviour-preserving for trading: those
names were being scored, emitted, and then rejected downstream anyway --
429 "outside tradeable population" risk events across 221 symbols, led by
exactly the names that top the receipt counts. The decision just moves
upstream of the write.

Details that matter:

  - The verdict is recomputed each tick, never cached. A >150% gapper reads
    "squeeze unverified" on thin volume and is admitted once rvol confirms;
    a cached rejection would bench it for the session.
  - _transition only emits on CHANGE, so a rejected symbol costs one
    state event per session and is then silent.
  - Fail-open throughout: an unreadable open, a missing previous close or
    absent volume returns None, so a data gap can never bench a symbol.
  - Gap is the STATIC OPEN gap (session open vs previous close), fixed for
    the day, not the live price -- the verdict must not wander intraday.

Off by default (population=None) so every existing caller and test keeps
its current behaviour; the live loop opts in, and WATCHER_POPULATION_GATE=0
restores the old path.

Note for diagnostics: momentum_cli's criteria views now show only names
inside the population. That is the intended trade -- the excluded rows
described symbols the bot could never have taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-13 14:27:06 -05:00
momentum devandClaude Opus 5 9b4c8e5da5 fix: news ranking silently switched itself off for 40 days
step_arm ranks gappers by catalyst, but the enrichment feeding it stopped
on 2026-08-03 -- two days before the first real-money fill. raw_news_items
kept ingesting ~45k items/day throughout; news_events and
news_catalyst_cache have no rows after 2026-08-03. So every live trade to
date was ranked with the news multiplier pinned at 1.0.

Three faults, the third of which hid the other two:

1. Ollama was configured at a host/port with nothing listening, running a
   model that was never pulled.

2. _cat_mult returned a flat 1.0 for a symbol with no LLM advisory instead
   of falling back to the binary headline boost. With the scorer enabled
   and the cache empty that branch is reached for EVERY symbol, so turning
   on the graded ranker silently turned off the simple one it was meant to
   improve. Fall back properly, so news ranking degrades instead of
   disappearing.

3. enrich_recent_news never raises -- it reports failures in a returned
   counts dict, and enrich_loop discarded that return value. Every 120s
   for 40 days it received {enriched: 0, errors: 50} and threw it away: no
   log line, no event, nothing to distinguish a dead pipeline from an idle
   one. Consume the counts and emit a module_tick naming host and model,
   on the first failed pass and hourly thereafter.

Fault 3 is the one worth fixing; 1 and 2 are its first casualties.

No claim that news ranking pays: a binary "has a headline" flag tests
mildly NEGATIVE across 1,030 screened symbol-days (7.8% vs 10.4% ran
>=50%), and the graded classifier only overlaps bar data for five days,
too few to separate anything. Restoring the pipeline makes it measurable;
it does not make it trusted. Re-measure catalyst type and sentiment
against realised runs before giving the multiplier more authority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-13 12:00:40 -05:00
momentum devandClaude Fable 5 37757d3d47 fix: armed trigger names now get watcher scoring (blank reasoning panels)
Four of ten armed triggers had NO criteria events ever — the trigger book
arms from the live gapper scan while the watcher scores only the
discovery watchlist, so scanner-only names (TCX/ZTG class) showed an
empty reasoning panel with nothing behind it.

- arm pass injects every complete armed candidate into the watchlist
  provider's extra_symbols (its standing manual-add hook)
- provider returns injected symbols FIRST: the watcher truncates at
  max_symbols, so extras appended after a full research list were
  silently dropped — 'always include manually injected symbols' was
  untrue exactly when the list was full

60 watcher/watchlist/trigger tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:41:11 -05:00
momentum devandClaude Fable 5 12f7fefec0 feat: nightly event retention + external telemetry push
- runtime/retention.py: prune 8 high-volume debug event types older than
  RETENTION_DAYS (default 14) once/day at RETENTION_HOUR (default 03:00).
  Audit events (orders, fills, closes, risk rules) are never pruned. The
  events table hit 6 GB in 3 weeks at ~340k rows/day; this caps it.
- runtime/telemetry_push.py: POST a JSON snapshot (equity, day realized
  P&L, trades/wins, open positions, last-event age) to TELEMETRY_PUSH_URL
  every TELEMETRY_PUSH_INTERVAL_SECONDS (default 300). Unset URL = step
  disabled. Push failures are reported in the step result, never raised —
  an external outage can't touch the loop. Works as healthchecks.io
  dead-man ping + payload log in one.
- wired both as scheduler steps in run_live_paper (retention gated by
  RETENTION_ENABLED, telemetry by TELEMETRY_PUSH_URL presence)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:25:09 -05:00
momentum devandClaude Fable 5 1cefb51c63 feat: Tradier broker mirror — config-switch redundancy for Alpaca execution
tradier_broker/ mirrors every trading call the live loop makes on
AlpacaPaperClient (account, clock, positions, orders, submit incl.
bracket->OTOCO, cancel, replace, close) with identical signatures and
Alpaca-shaped returns, so trading_execution / exit_manager / flatten /
execution / sync run unchanged. Market data always delegates to Alpaca.

- BROKER=tradier (+ TRADIER_* env) swaps execution client in run_live_paper;
  default path untouched
- client_order_id -> tag preserves the lost-submit reconcile
- TradierApiError subclasses AlpacaApiError so the executor's
  rejection-vs-transport fork works as-is
- sandbox-verified: balances/clock/positions/orders, OTOCO preview
  (result:true), place -> tag lookup -> cancel round trip
- 18 unit tests (translation + client paths); executor integration checked
- docs/TRADIER-FALLBACK.md switch runbook

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:01:50 -05:00
momentum devandClaude Fable 5 625abee66f fix: tuner heartbeat also recorded in DB — file-only heartbeat was unverifiable
The 07-22 'tuner NOT running' alert could not be diagnosed remotely: the
heartbeat is a file on the scheduler container's volume, invisible to
everything else, and indistinguishable from a stale scheduler image or a
DATA_DIR mismatch. _write_heartbeat now also inserts a telemetry_events
row (best-effort); the boot staleness check falls back to the DB row when
the file is absent, so 'tuner ran, no profitable config' stops looking
like 'tuner dead'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:35:50 -05:00
momentum devandClaude Fable 5 3fdf268227 fix: trigger fires require a fresh cross — stale breaks after a blind window are chases
SNXX 2026-07-21: 18s after a mid-morning restart the fast path bought a
15-minute-old break +1.6% above the trigger — fires() only checked
price >= trigger, so any blind window (boot, halt, feed outage) fired
instantly on already-extended moves. A trigger now fires only if the live
price was seen BELOW the trigger since arming (a real cross), with a
small at-the-break tolerance (TRIGGER_FRESH_CROSS_TOL, default 1.5%)
so a fast break between ticks still fires; beyond it the name must pull
back below the level and re-cross. Gap-through runners stay covered by
the slow marketable path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 08:50:41 -05:00
momentum devandClaude Fable 5 256bdc31dc fix: clean-exit at date rollover — post-EOD boot ran overnight, day-2 took zero entries
A process booted AFTER the day's EOD flatten never gets exit-after-flatten,
survives midnight, and every memory-only daily flag (_session_closed, entry
caps, benches) stays stale — the documented day-2 limitation, hit live at the
2026-07-21 open (booted 17:11 the day before; signals READY + book armed but
zero gate evaluations, both entry paths silently blocked by _session_closed).
Main loop now exits cleanly when the session date rolls over; compose restarts
a fresh process that rebuilds the new day's state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 08:42:04 -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 b3a47d775d feat(019): discovery on its own thread + tuner heartbeat (honest stale alert)
Telemetry: discovery took 6-30s ON the single scheduler, freezing ingest/arm
so the trigger read stale bars — the real 'late edge detection'. Now discovery
runs on its own daemon thread with a dedicated DB connection; the watchlist it
mutates is a lock-guarded Watchlist (list-like drop-in, no torn reads); the
scheduler discover step auto-disables (single-writer). Loop is now 3 fast
threads (entries/exits/discovery) + slow scheduler for tolerant work.

Also: nightly_tune writes a heartbeat every run, so the boot alert distinguishes
'tuner not running' (real) from 'ran, no profitable ORB config, kept params'
(expected — the strategy has no edge). Kills the misleading Telegram alarm.
543 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:44:10 -05:00
momentum devandClaude Fable 5 5896d291d8 feat(018): sub-second exits on a dedicated thread + fast decision pipeline
Exits move off the single-threaded scheduler onto their own daemon thread at
1s (mirrors the fast trigger thread). Dedicated research connection (shared
facade isn't thread-safe); scheduler exits step auto-disabled when the thread
runs (single-caller invariant — no racing instance state, no double-submit);
manage() already idempotent (stop ratchets >1c up only). 3 driver tests + 539
suite green. On fast small-caps this is the 'get out fast' the owner asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:32:46 -05:00
momentum devandClaude Fable 5 e7c34f298f operator-plane: guarded command bus (flatten / pause kill switch)
Adds OperatorCommandEvent + a closed command vocabulary (flatten_all,
flatten_symbol, pause_entries, resume_entries). POST /api/command emits it
(token-required: 503 when DASHBOARD_TOKEN unset — commands never run
unauthenticated); the live loop's new 5s "command" step consumes them via
OperatorCommandConsumer with three replay-safety layers (in-session nonce set,
persisted operator_command_done reload, 10-min staleness window).

flatten_all routes through close_session DIRECTLY (never step_eod_flatten) so it
cannot trip plan 003's daily-restart exit. Pause is checked at the top of BOTH
request_approvals_for_ready_signals AND submit_breakout_now (missing either
leaks pause on that path). qabot gains an authorized-chat-only /flatten slash
command (prose "flatten" still refuses via _ACTION_WORDS). Snapshot exposes
broker truth + command_bus + a defensive experiment ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:15:00 -04:00
momentum devandClaude Fable 5 07ec5a3eb8 entry-gate: drop the now-false rank-concentration boot warning (plan 005 step 4)
Rank concentration now runs on BOTH paths via the shared gate, so the warning
that it was 'enforced ONLY on the dead fast-trigger path' is false. Removed it
and folded rank top-N state into the [boot] entry-gates summary line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:20:56 -04:00
momentum devandClaude Fable 5 d10b5cc921 entry-gate: extract shared _evaluate_entry_gates for both paths (plan 005 step 2)
One ordered gate sequence (session, confirmation-speed, population, VWAP,
quality, concentrate-by-rank, daily cap [fast-only], dedup/cooldown/reentry,
dilution veto, anti-chase) that BOTH request_approvals_for_ready_signals and
submit_breakout_now call. Gate bodies lifted verbatim from whichever path
owned them; slow-path risk events preserved source-guarded so the fast path
gains no new events. The two documented asymmetries now close:
  - fast path routes through the VWAP gate (was ungated),
  - slow path honours concentrate-by-rank via a rank_map threaded from the
    trigger book into tick()/request_approvals (no-op at concentrate_top_n=0,
    which is the production config — STOP #3 does not apply).

Price-sanity/entry_ref coercion stays path-native (different data sources);
slow-path dedup pre-filter kept first so per-tick re-scans don't re-emit.
16 characterization tests pin both paths; the only decision diffs vs
pre-extraction are the two closures above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:17:56 -04:00
momentum devandClaude Fable 5 b737acfc2c flywheel: atomic learned_params write + boot-time staleness warning
- nightly_tune: write learned_params.json via tmp -> flush -> fsync ->
  os.replace so a crash mid-dump can't leave the live loop parsing half a JSON
  on its next boot (it reads this file on every restart).
- run_live_paper._load_learned: warns (boot log + one gated Telegram note) when
  the file is older than LEARNED_PARAMS_MAX_AGE_DAYS (default 3) — the missing
  signal that let the params sit 26 days stale. Loads anyway; never blocks boot.
  Staleness math factored into _learned_params_age_days (naive ts = UTC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:45:04 -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 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 f0fb91a069 Admin panel (:8767): config, profiles, service control, paper/live switch
New admin service — LAN-only, token-authed (ADMIN_TOKEN), card-based UI
matching the owner's preferred dashboard style. Capabilities:
- health: docker containers via /var/run/docker.sock (stdlib unix-socket
  HTTP client), DB heartbeat, Alpaca account/clock, Ollama, feed
- config: whitelisted strategy knobs (TRADING_/TRIGGER_/WATCHER_/...)
  edited via data/overrides.env, which every entrypoint now loads with
  override=True at boot (runtime/env_overrides.py) — so changes apply on
  container restart without touching the deployment YAML; secrets are
  presence-only and rejected server-side if edited
- profiles: named knob presets (seeded: validated-core, conservative,
  squeeze-hunter) applied in one click
- restart buttons per container; paper/live switch guarded behind
  ALPACA_LIVE_* keys existing
Verified live in docker: auth 401s, status/config/profiles/write paths,
secret-edit rejection, page serves. Added to both compose files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:37:18 -04:00
momentum devandClaude Opus 4.8 8864041066 Docker stack modernized: full service set, PG 18, validated end-to-end
Brings the pre-migration compose file up to the current architecture:
python 3.13 image with ffmpeg + faster-whisper/yt-dlp (transcripts),
postgres 16 -> 18 (prod parity), and the real service set — loop
(--no-dashboard, heartbeat healthcheck), dashboard_api.py on :8765,
telegram qabot, youtube transcripts, and a 20:00-ET weekday scheduler
replacing the premarket-eval systemd timer. Jobs (journal/eod-replay/
nightly-tune) and Grafana keep their profiles. Self-contained by
default (in-stack DATABASE_URL); deploy/docker/README.md documents the
pg_dump migration, the transition mode against the LAN DB, and the
cutover checklist. Validated: image builds, compose config passes, and
a smoke container served the dashboard (HTTP 200) against the live
LAN Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:06:27 -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 722add6815 Ingest: per-symbol session backfill; never arm without the true open
Evening audit of 2026-07-08's post-fix entries (TVRD +4% true gap,
FRTT — both fired as "20% gappers"): the morning's static-gap fix was
defeated by a data hole. ingest_live_minute_bars' full-session first
pass was a GLOBAL one-shot, so symbols discovered mid-session only ever
received the 20-minute refresh window — their earliest known bar was a
mid-day bar, which became both their "session open" (fake overnight
gap) and the base of their "opening range" (fake ORB trigger). Exactly
the failure class the population gate exists to prevent.

Fixes: (1) step_ingest tracks per-symbol first sight — new names get
the full-session lookback, known names the incremental refresh;
(2) _session_open only trusts a first-RTH bar stamped <= 09:33 ET and
is the sole source for the static gap; (3) if the session is underway
and a candidate's true open is not yet ingested, it cannot arm (trigger
withheld until the backfill lands, typically the next pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:49:09 -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 857c64ea48 Local-data cleanup: archive dead DuckDB relics, rotate logs, fix labels
Everything hot already lives in Postgres — local data/ was 25 MB of
pre-migration relics and unbounded logs. Moved the two dead .duckdb
files (untouched since 2026-06-16) to archive/, added nightly log
rotation (copy-truncate >20 MB, safe with systemd append mode), and
updated the last vestigial "market.duckdb" docstrings to say Postgres,
closing the CONSOLIDATION_PLAN cleanup items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:26:40 -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 97b318dd6e Quote-size snapshot recorder — free order-book-imbalance proxy (recording only)
Owner's idea from the CETX day: unusually large resting order volume just above/below price
(the trader's "big seller at $6" read) may predict pop-vs-fade. Full L2 depth needs a paid
feed, but Alpaca NBBO quotes carry bid/ask SIZES — a workable free proxy. We stored no quotes,
so the idea was untestable; this makes it accumulate.

- quote_snapshots table (market schema): symbol, ts, bid/ask price + SIZE; (symbol, ts) index.
- step_quotes in the live loop (QUOTE_SNAPSHOT_INTERVAL_SECONDS=15, QUOTE_SNAPSHOT_ENABLED=1):
  records tracked (trigger-book) + held names each pass, premarket included (where the edge
  data lives). Recording ONLY — zero trading impact; fetch failures degrade to a log line.
- Caveat carried in the docstring: free IEX-feed NBBO is thinner than SIP (thin names often
  print 100-share round lots), so persistent imbalance is the signal, not single snapshots.

Verified live: 10 names/pass recording every 15s with real sizes (e.g. RIVN 1300 bid / 3200 ask).
A shadow study tests ask/bid-size imbalance as a pop-vs-fade predictor once data accumulates.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

tests/unit: 201 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:54:20 -04:00
momentum devandClaude Opus 4.8 8cee002c09 Merge PR #2 (Ollama news/catalyst enrichment) onto master + fix review blockers
Integrates the local-LLM enrichment ADDITIVELY on top of all current work, with
every Ollama feature OFF by default — with the flags off the system behaves
exactly as before (199 unit tests pass; a full --once pass runs clean with no
Ollama). Fixes the three issues the adversarial review flagged:

- BLOCKER: keep the Alpaca News ingestion. The PR predated it; the run_live_paper
  conflict is resolved to keep BOTH step_news (fetch -> raw_news_items) AND the
  enrichment. They're complementary: ingestion feeds enrichment authoritative-
  ticker headlines instead of thin RSS.
- HIGH-1: enrichment runs on its OWN daemon thread with its OWN DB connection,
  not the scheduler thread — a slow/hung Ollama can no longer starve exits /
  guard / eod-flatten, and it no longer shares the non-thread-safe research_con.
- HIGH-2: per-headline writes are all-or-nothing — on any ticker store failure
  the partial rows are deleted so the headline fully retries (no stranded
  tickers) and enriched is counted only on full success (no false count).

Phase-2 automation (catalyst score blend, dilution veto, arming-rank multiplier)
remains gated OFF pending the medium fixes (bearish-catalyst boost, prompt-
injection hardening, veto cross-thread read) noted in the review.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
2026-06-19 18:45:18 +00:00
Claude e3cb2b8e5f Add local-LLM (Ollama) news/catalyst scoring
Leverage a local Ollama model to interpret news catalysts for the small-cap
momentum strategy — distinguishing bullish catalysts (FDA approval, M&A) from
dilutive offerings the technicals alone can't see. Advisory-first, then automate.

Phase 1 (advisory; no change to trade behavior):
- research/ingestion/news_enrichment.py: stdlib urllib call to Ollama with
  structured JSON output, classifying each headline into a catalyst type,
  sentiment, conviction and dilution flag. Graceful degradation (None on any
  failure) so trading is never blocked.
- news_catalyst_cache table + populate the previously-dead news_events.sentiment.
- Runs on the existing interval Scheduler (off the hot path), gated by
  OllamaConfig; deduped by the raw_news_items payload hash.
- Surface the advisory on the dashboard approval queue (projection + api + UI).

Phase 2 (automation; ships gated OFF):
- Optional catalyst_score blended into calculate_setup_quality (None reproduces
  the exact legacy weights); pure pass-through via evaluate_setup.
- Dilution veto in TradingExecutionService (slow + fast paths) emitting a
  catalyst_dilution_veto risk event, behind NEWS_DILUTION_VETO_ENABLED.

Config (OllamaConfig.from_env), .env.example docs, and tests for parsing,
persistence/dedupe, the projection, the score blend, the veto, and an
end-to-end enrich->advisory flow (Ollama mocked). Default model moves to
qwen2.5:7b-instruct (best small-model JSON, fits a 12GB GPU).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
2026-06-19 17:29:24 +00:00