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>
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>
Live 2026-07-06: every fresh entry was market-exited seconds after
filling (ELAB round-tripped in 3s, SEER in 77s, PLUG in 85s) — all
logged as exit_trail. Root cause: seconds after a fill there is no
completed minute bar with timestamp >= the fill time, and _bars_since's
empty result fell back to the WHOLE DAY's bars, so manage_live's
high-water included the pre-entry HOD and the trail read as already
breached on any name trading off its high. The whole-day fallback is
removed: with no post-entry bars the manager now leaves the position
alone (the bracket's initial stop is the protection) until the first
post-entry bar lands. Regression test verified to fail on the old code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
KEEL was carried into after-hours as a naked long (its bracket stop leg never
attached — 0 stop orders in its whole history). The enforcement tried to FLATTEN it
with a market order every pass, but a market sell can't fill when the market is
closed, so it churned submit->cancel ~every 30s (76 market-sells / 79 cancels, the
"naked-stop enforced exit KEEL xN" spam) and left the position genuinely unprotected.
Fix (runtime/exit_manager.py): both the naked-stop enforcement AND the catastrophe
backstop now branch on the broker clock. Market OPEN -> flatten (a market exit fills,
unchanged). Market CLOSED -> REST a protective sell-stop instead (GTC) — at breakeven
when in profit, a catastrophe-distance stop below market when underwater — so the
position survives to the open PROTECTED and the next pass sees the stop and stops
enforcing. Catastrophe with an existing (loose) stop while closed just defers to it
(no market churn, no double stop). New client bits: AlpacaPaperClient.get_clock() and
a standalone stop_price on submit_order. 3 exit-manager tests added (open->flatten,
closed->breakeven-rest, closed-underwater->catastrophe-rest); 7/7 green.
Operationally: KEEL was protected at breakeven ($5.56) immediately and the loop now
leaves that stop in place instead of churning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
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>
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>
#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>
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>
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>
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>
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
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>
- 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>
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>
ArmedTrigger stamps price_ts on each update; update_price(None) (a failed batch
price fetch) now CLEARS the price instead of leaving the last value live; and
fires() requires the price to be fresher than max_price_age_s (env
TRIGGER_PRICE_MAX_AGE=12). A stalled feed can no longer fire a breakout into a
name that has already reverted.
Tests: stale price blocks fire, fresh re-enables, None clears; 18 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Extract the cancel-protective-legs-then-close routine (duplicated in the exit
manager and the execution service after fixing the held-qty 403 in both) into
one shared, heavily-documented helper: runtime/flatten.py. Both callers now
delegate, so the fix can't drift out of sync. Behaviour unchanged; 121 tests
pass.
- Refresh .env.example: document every key added this session (liquidity gate +
sizing, backout cooldown, re-entry guard, managed-exit ladder, EOD flatten)
with the why, and flag MAX_DAILY_LOSS_PCT to revert to 0.03 before live.
- Remove throwaway data/_watch_*.py monitoring scripts (gitignored clutter).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Today's losses were an ENTRY-quality problem: every loser reversed before +1R
(max favorable excursion 0.1-0.7R) — we filled at the top of one-bar spikes on
names too thin to hold the move (WKSP/LNKS/UWMC entered on 100-400 share bars).
rvol is RELATIVE, so 2x of near-nothing still qualified.
- Trigger $-volume floor: ArmedTriggerBook gains min_dollar_vol; step_arm
computes recent 5-bar $-volume per candidate. Thin names go WEAK instead of
arming (env TRIGGER_MIN_DOLLAR_VOL, .env=8000). Skips the untradeable
(APWC $3.8k, WPRT $2.7k).
- Liquidity-aware sizing turned on (.env TRADING_LIQUIDITY_MAX_VOLUME_PCT=0.025):
size caps to a fraction of volume, so thin names get sized down 90%+ while
liquid names are untouched — exactly the asymmetry a small real account needs.
- Breakeven-at-1R enabled (.env): forward give-back safeguard. NB it changes 0
of today's trades (no loser reached +1R) and was a prior OOS 'conservative
backup' — kept as protection for bigger-winner days, not a today-fixer.
Back-comparison (one day, caveated): the two liquidity filters take the day from
~-1,215 to ~+202 excluding the APWC overlap already covered by the naked-stop
fix + re-entry guard. Needs forward validation, not just this back-fit.
Tests: $-vol gate (thin -> WEAK); 50 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trail-breach (and first-red) exit called close_position directly, but the
bracket stop/TP legs RESERVE the full quantity (held_for_orders). Alpaca then
rejects the market close 403 "insufficient qty available (available: 0)". The
position can never exit, so the manager retried every pass forever — observed
live: PLUG logged 191 identical 403s while a legitimate trail exit (+286) and
WPRT/ATPC stop-outs were all blocked.
Add _flatten(): cancel the symbol's active protective sell legs to release the
shares, THEN liquidate (retry the close so a not-yet-settled cancel doesn't
hang). Route the two full-close paths through it. Verified live: PLUG flattened
cleanly (0 dangling orders, profit locked), zero 403s after restart, remaining
book still fully stop-protected. Test: _flatten cancels protective then closes;
42 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alpaca's orders `limit` keeps only the most-RECENT orders and counts child
legs toward the cap. On a busy day the order list runs to hundreds (every
backed-out entry is an order), so the exit manager's limit=100 snapshot
silently dropped the stop legs of older OPEN positions. A stop-leg lookup then
read "no stop" on a fully-protected position -> broken trailing/profit-lock for
that name (and false-naked alarms in monitoring).
Fix: get_orders gains a `symbols` filter; the exit manager scopes its snapshot
to the handful of held names (no truncation possible). Verified live: an
unscoped limit=100 query reported PLUG stop=None while the scoped query
correctly surfaced PLUG's stop @2.65.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
News (you were right it's relevant): recent_news_map() pulls fresh catalysts per
ticker from the ingested RSS feeds; step_arm tags each armed candidate and BOOSTS
catalyst-backed gappers up the rank (NEWS_RANK_BOOST) — a fresh PR/FDA/offering
is why a small-cap runs, so it surfaces the hotter name earlier and higher. The
board shows a 📰 marker + the headline. Feeds verified live (PR Newswire /
Business Wire / GlobeNewswire) — pulling real catalysts (FDA approvals, offerings).
Forward-tested only (news isn't OOS-backtestable).
eod_replay.sh + crontab: replay at 16:05 (grow dataset), tune at 17:30.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
diagnose_entries.py buckets backtested trades by feature: cheap stocks ($1-5
+0.5R) crush $10-20 (-0.09R), rvol>=5 (+0.79R) beats <5x (+0.15R), and blow-off
gaps >150% fade. sweep_filters.py verifies price/volume/gap-cap combos
train-vs-test: px<=7 + gap<=60% ~doubles OOS expectancy (+0.76 vs +0.39R, win
86% vs 71%).
Add a gap cap to the armed-trigger eligibility (TRIGGER_GAP_MAX) so blow-off
gappers can be excluded; price cap (WATCHER_PRICE_MAX) and volume floor
(TRIGGER_RVOL_MIN) already exist. Final values pending OOS verification.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
The single-threaded passes (and the trigger thread) ballooned to 60-120s.
Cause: flaky WSL DNS makes ~1 in 5 Alpaca calls take ~5s to resolve, and each
pass fired ~20 calls (breaker, sync, guard, exit manager all re-reading
account/positions/orders). Fixes:
- client: 2.5s TTL cache for get_account/get_positions (invalidated on any
order mutation) — collapses the redundant per-pass reads to one.
- exit manager: fetch the (heavy) status=all orders snapshot ONCE per pass and
reuse it for every position's stop-leg + entry-time lookup (was 2 calls/pos).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Track entry_ts from the broker entry order's filled_at (not first-sight), so the
high-water mark and R-multiple reflect the move since the real entry — correct
trailing for positions that predate the manager (e.g. across a restart).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes found by running the manager against the live book:
- _stop_leg queried status="open", but a bracket's protective stop is a 'held'
child of the FILLED entry, so it was never found -> stops never trailed.
Query status="all" and match active leg statuses.
- when the trailed stop would sit ABOVE current price (price fell back through
the prior swing low), a sell-stop there is rejected; that IS the trail firing,
so exit at market instead.
Also drop the hardcoded "6" in the board title (armed set is now 10).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Three fixes from the first live session:
1) Naked-stop bug (critical): the entry-timeout guard cancelled an entry it
thought was unfilled AFTER it had really filled (our order_filled events lag
the 60s sync). Cancelling a bracket parent cancels its stop/TP legs, so the
position went NAKED and bled 22% with no floor. Guard now checks the broker's
LIVE positions (get_positions) before cancelling and never cancels a filled
name. Regression test added.
2) Speed: ingest re-fetched a 240-min window for ~80 symbols every minute (~46s
measured), and the single-threaded loop froze the 4s trigger that whole time
-> breakouts caught once a minute, not in real time. Ingest is now
incremental (heavy first pass covers the session so the opening range is
present; then ~20-min refreshes, closed bars never change). Loop passes
dropped from ~70s to ~2-5s.
3) Threaded trigger: the fast breakout detector/fire loop now runs on its own
thread, fully decoupled from the slow scheduler. EventStore, the execution
service, and the trigger book are made thread-safe (locks) so the thread and
main loop can't race the shared psycopg2 connection or in-memory state.
Verified live: fired EZGO/NEOV, all positions retain active stops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- 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>
- 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>
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>