Commit Graph
14 Commits
Author SHA1 Message Date
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 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 Fable 5 390d6660ea fix: halt-aware flatten — let a working market sell rest instead of churning
A market sell that sits unfilled means the symbol is halted (or session
closed); cancelling and resubmitting it every exit-manager pass fills no
faster and spams the broker — YXT 2026-08-05: 87 market sells across a
10-minute LULD halt, 86 cancelled, plus one exit_naked_stop journal event
every ~4s. On a halt-down the resting sell is also the position's only
protection.

cancel_protective_and_close now returns False (nothing submitted) when a
market sell for the symbol is already working, and only cancels
non-market protective sells before closing. Exit-manager paths
(catastrophe / naked-stop / first-red / trail) journal the exit only when
a close was actually submitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:01:18 -05:00
momentum devandClaude Fable 5 dd13c41fc8 test(019): de-flake watchlist concurrency test — assert single-snapshot consistency
The test compared len(w) and list(w) as two SEPARATE locked reads, so the
churn thread could mutate between them (30 vs 31) — a bug in the test, not the
Watchlist (each read is individually consistent; there is no torn-read
guarantee ACROSS two calls). Now snapshots once and asserts that snapshot never
tears + concurrent contains/slice never raise. 5/5 green.

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

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

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

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

tests/unit: 154 passed.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:10:07 -04:00
momentum devandClaude Opus 4.8 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