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
Acts on the P2 edge-audit's two findings: (a) ~half of curl fires have a stop tighter
than the spread (median R 1.7% of price, $0.05 on a $2.62 name) so the backtested
+1R/-1R is unfillable; (b) the only subset that survived refutation was price>=$5 AND
mid-tercile impulse, scored as a +2R/runner filter.
- detect_vwap_reclaim: PLACEABILITY GATE (min_r_frac=0.015, min_r_abs=0.02, default ON
for live safety) -- rejects a fire whose R=(entry-pullback_low) sits inside the spread.
signal_values gains r_frac. Tests: a shallow-pullback curl now rejects "stop
unplaceable" and fires only with the gate disabled.
- labeler: validate_vwap_reclaim + `validate-vr` CLI scores the PRE-REGISTERED candidate
(placeable AND price>=$5 AND impulse in [0.15,0.26]) against a LOCKED bar: +2R Wilson-95
lower bound > base 0.349 AND absolute max-upside-60m beats same-symbol-day ORB at
+5%/+10%. Constants + _wilson_lower are committed so the bar can't drift. The shadow
BASE track (vr1) deliberately keeps the gate OFF so report-vr base rates stay complete;
placeability is a filter in validate-vr.
IN-SAMPLE RESULT (decisive): enforcing placeability collapses the audit's n=48 subset to
n=16 -- i.e. most of its apparent +2R lift rode on UNPLACEABLE stops. The remainder FAILS
the locked bar on both criteria: +2R 44% (Wilson LB 23.1% < 34.9% base) and, most
damningly on the artifact-proof axis, it produces a +5%/60m move only 31% of the time vs
ORB's 73% on the same days. Pre-registration did its job: it stopped us chasing an n=48
mirage of sub-spread stops. VERDICT: do not promote; the curl does not beat the bot's
existing ORB even in its best placeable subset. 15/15 unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some sessions have irregular/sub-minute bar timestamps (no exact-duplicate timestamps,
but two bars in the same HH:MM), so keying setup_id on the wall-clock minute let two
fires a cooldown apart collide on the setup_events primary key and aborted the build.
Key on the RTH bar index (unique per session) instead. Tests still 5/5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
P2 = run detect_vwap_reclaim over history and score it with the SAME no-look-ahead
machinery that scores the live ORB setups, so its base rates are directly comparable
and there's no train/serve skew in the SCORING either (not just the features).
- Extracted research/labeler.py's forward-label logic into a shared _forward_labels()
(max-upside/-drawdown 5/15/60m, reached_1R/2R-before-1R pessimistic, held-VWAP over
the evolving cumulative VWAP, trend-day, failed-breakout, time-to-max). compute_setup
now calls it -- the 3 existing labeler tests still pass, so the extraction is
behavior-preserving.
- compute_vwap_reclaim_setups(): slides the detector over each session's RTH bars and
emits a NON-TRADEABLE setup_event on each RISING EDGE (not-valid->valid, with a bar
cooldown so one curl episode = one setup), carrying the detector's OWN entry/stop/R.
STRICT time split: the detector at bar i sees only bars[:i+1]; labels use only bars
after i. setup_version='vr1', setup_name='vwap_reclaim' -- isolated from the ORB 'v1'.
- build_vwap_reclaim / report_vwap_reclaim + CLI `build-vr` / `report-vr`. report-vr
prints forward outcomes AND a head-to-head vs the ORB baseline on the same symbol-days.
- 2 new tests: a continuation session fires + labels a +1R winner; a one-way fade stays
silent (anti falling-knife). 12/12 green.
PRELIMINARY read (n=35, 4 days -- far too small to trust; full build running): curl-
fires reached +1R 66% / +2R 49%, held-VWAP-15m 86%, avg +8% max-upside in 60m; the ORB
baseline on the same symbol-days reached +1R 67%. Real finding pending the full build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
P0 anchored the impulse to the day's SINGLE HOD + session-absolute low, so a name
that spiked, topped, then faded read every later bar as "pullback broke the hold"
forever. P1 anchors the leg to a rolling `lookback` window (default 40 bars), so the
detector re-arms on each intraday leg. New regression test proves the property: when
an earlier, HIGHER leg deeply gives back (permanently blocking the whole-session
anchor), the local anchor still fires on a later leg curling off a higher-low base,
targeting that leg's local high rather than the stale day HOD. lookback=None keeps
the legacy anchor for the contrast test. 7/7 unit tests green.
FINDING (ground-truthing the raw bars refuted the P0 premise): MIMI and UPC do NOT
have an RTH VWAP-reclaim setup. Both spiked PRE-MARKET (MIMI 2.93->4.98, UPC
8.54->17.89) and their RTH was a one-way FADE below VWAP (MIMI 4.09->3.15 under VWAP
all day; UPC popped at the open, made lower highs, lost VWAP by 14:17). So 0 RTH
fires is CORRECT, not blindness -- there is no long curl to catch; if the trader
profited it was a pre-market play or a SHORT of the fade, neither of which a
long-only RTH detector should fire on. It fires precisely on the names that DO curl
in RTH (SDOT 6/26 10->22, FCUV 6/25). Same lesson as ignition/PLSM: when the move is
pre-market, the RTH detector should stay silent. (Deliberately did NOT lower
min_run_pct to chase MIMI's sub-10% local legs -- that would be fitting n=2 noise.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trader's #1 taught setup is a CONTINUATION, not the bot's open-anchored ORB:
a leading gainer that spiked, pulled back (held >=50% of the run), and is curling
back up through VWAP, bought BELOW the HOD. Across 6/25-29 every name he monetized
(MIMI/FCUV/ZDAI/SDOT/UPC) was this one pattern; the bot blocked all on the ORB gate.
P0 = standalone pure detector (detect_vwap_reclaim) with 5 binary gates (impulse /
pullback-held / curl-reclaim / below-HOD / momentum-green), reusing calculate_vwap
& calculate_ema (no train/serve skew). SHADOW-ONLY: not wired into classify_setup.
6 unit tests green.
DECISIVE REAL-DATA FINDING (RTH minute bars): slid across 4 real trader names.
FIRED on the all-day trenders -- SDOT 6/26 (10.22->22.50) 22x, FCUV 6/25 2x -- but
ZERO on MIMI 6/25 and UPC 6/29, both rejected on "pullback broke past the hold
level" with a NEGATIVE held_frac. Root cause is STRUCTURAL: anchoring the impulse
to the day's SINGLE HOD + session-absolute low means a name that spikes->HOD->fades
reads every later bar as "broke the hold" forever, blind to the LOCAL spike->
pullback->curl legs that re-arm intraday. P1 fix = rolling local-swing anchor, not
the day's one HOD. (Deliberately NOT widening hold_frac to force n=4 anecdotes green.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
detect_momentum_ignition (strategy/evaluation/ignition.py): standalone, pure,
binary-rule detector for a vertical squeeze (blue-sky fresh-HOD + price velocity
+ ABSOLUTE volume burst, NOT rvol/float). Stop = last established higher low (per
operator). NOT wired into classify_setup (deliberately). 8 unit tests on
synthetic verticals: fires with the right trigger/stop, and each gate blocks
(not-blue-sky / not-new-HOD / velocity / volume / insufficient-bars / no-ATH).
REALITY CHECK (verify, don't assume): ran it over the REAL PLSM 2026-06-24 bars ->
0 fires, CORRECTLY. PLSM's high was at the 9:35 ET open (it ran +400% PRE-MARKET,
which we don't ingest), and in RTH it was HALTED 228 of ~390 minutes — a
halt-resumption GAP chop (+3.6/-12.5/-17.3/-14.4% jumps), not a smooth vertical.
A consecutive-bar detector structurally cannot see that. So the ignition
opportunity is a PRE-MARKET + halt-resumption phenomenon -> capturing it needs
pre-market bar ingestion + halt-aware gap detection + pre-market/halt trading
(into the FCUV halt-down risk), a far bigger lift than an RTH detector. Documented
front-and-center in docs/IGNITION_SETUP_SCOPE.md as a pre-P1 operator decision.
The P0 detector + tests are a correct foundation regardless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scope for a SECOND, shadow-only lane to catch PLSM-class vertical squeezes the
pullback-ORB scorer is structurally blind to (PLSM was evaluated 1895x and
blocked ~30% every time — a vertical gives no pullback). Produced by a read-only
4-agent mapping workflow; no code changed.
Key findings: detect on blue-sky fresh-HOD + price velocity + ABSOLUTE volume
burst (NOT RVOL, which zeros out on thin IPOs; NOT float, which is NULL). Emit a
new ShadowSignalEvent that the execution snapshot (query_ready_signals_snapshot,
filters event_type=signal_ready) can never read -> structurally cannot trade.
Score nightly by reusing the labeler + eod_replay forward-outcome machinery.
Pre-register the promotion bar (runner-vs-trap separation, distinct-runner n)
before shadowing. Phased P0 detector -> P1 dark emission -> P2 nightly scoring ->
P3 validation -> P4 tiny live lever only after separation is proven. Strictly
additive; never touches the ORB book or anti-chase guards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A small green pop that fades was round-tripping to -1R because the gain
protection only arms at +5% / +1R — SOFI peaked +2.6% (+$281), never reached a
threshold, and gave it all back (stop never moved off the original). Add a
step-trail that ratchets the stop up from entry in step-R increments: at +0.25R
the stop is at breakeven, +0.5R -> +0.25R, +0.75R -> +0.5R, ... It locks a small
pop AND keeps climbing under a runner.
Implemented in the shared _trail_stop (so backtest simulate_exit and live
manage_live agree); risk is recovered from reached_r so it needs no new args;
only ratchets up (max-of-all with the prior-low trail / profit-locks).
TRADING_EXIT_TRAIL_R_STEP (default 0, set 0.25 in .env). Supersedes the interim
standalone +2% breakeven, which reverts to its 0.05 default backstop.
Tests: the ratchet ladder (below-step=no move, +0.25R->BE, +0.5R->+0.25R,
ratchets-up-only) and an end-to-end pop-then-fade that now exits +0.25R instead
of round-tripping to a loss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Adds the fast path's volume-based share cap to the live auto path: cap shares to
liquidity_max_volume_pct * cum_volume so a thin cheap name can't be oversized
into heavy slippage (the VTAK-style stop that filled far past its level on
2026-06-23). Threads cum_volume through the setup dict + ready-signal snapshot;
applies max_shares in request_approvals_for_ready_signals, gated by
TRADING_UNIFIED_ENTRY. Fail-open: no cum_volume -> no cap. Protective only
(can only shrink a position).
Tests: thin name capped to liq*cum_volume when on, risk-sized when off. 67 path
tests green. Remaining staged (supervised): the full single _place_entry
chokepoint + fill-model unification + halt-status plumbing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bot only knew P&L + positions, so "what's on the watchlist?" and "why
isn't it trading?" both fell back to "positions: none" — useless. Now it
retrieves the actual desk state and gives the user direction.
- gather_context: today P&L + recent closed trades (cross-day, robust to the
midnight boundary) + open positions + the live WATCHLIST (states + scores) +
READY SIGNALS (entry/stop/vwap) + a why-blocked tally (risk_rule_triggered
counts) + all-time.
- symbol_context: when a question names a ticker the bot knows, append a focused
dive — its last trade + the setup criteria it passed/failed.
- Help menu (help / /start) lists exactly what it can answer; sent on startup so
the user has direction. Sharper system prompt (concrete, quote real numbers,
say what data exists when it can't answer).
Verified live: "what's on the watchlist?" -> "148 symbols: 97 blocked, 48 late,
3 ready (LAES, NOWL, PBK)"; "why isn't it trading?" -> coherent; "what happened
with VTAK?" -> "stop loss -$659, entry $1.14". Tests: help menu, retrieval
includes watchlist/blocks; 10/10 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
The per-day fresh-entry cap (TRADING_MAX_FRESH_ENTRIES_PER_DAY) was only
checked in the fast submit_breakout_now path. But live entries flow through
the SLOW auto-approval path (process_auto_approvals -> approve_order), which
had no cap — so the limit was silently dead. Observed 2026-06-23: 44 entries
taken against a configured cap of 5 (all approved_by="auto", zero via the
fast orb_trigger path).
Enforce the cap in approve_order for approved_by="auto", sharing the same
self._fresh_entries counter the fast path uses (so the cap is global across
both paths). Capped orders are REJECTED (reason=daily_entry_cap) so they
leave the pending queue instead of re-skipping every tick. Manual dashboard
approvals are a human override and are never capped.
Two regression tests: auto path binds at the cap + rejects the overflow;
manual approval ignores the cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`python -m research.labeler lift` measures, for each computable signal, the
win/runner rate in its most-favorable bucket vs the base rate (the LIFT), with n
shown so signal is distinguishable from small-sample noise.
First results (n=5,705 setups, full 19 days):
- vs +1R (base 24%): ABOVE-VWAP / distance-from-VWAP is the one real win signal —
35-37% vs 24% = ~1.5x lift on huge n (2,851). gap%/price/data-quality are flat
(~1.0x). Confirms the roadmap's #1 free win: make VWAP a trigger.
- vs +20% runner (base 2.4%): premarket-gap% high-tercile 10.6% = 4.46x (n=426)
is the strongest runner tell; cheap-price low-tercile 6.8% = 2.87x; VWAP/gap
1.4-1.8x. +100% (5-6 events) too sparse to measure -> not a mechanical filter.
Caveat surfaced in the output: autocorrelated days + sparse runners => lift is a
forward-shadow HYPOTHESIS, not a validated win-rate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
research/labeler.py turns each historical symbol-session into a (features ->
outcome) row so any forecasting signal's LIFT can be measured before it sizes
live. Reuses the LIVE feature fns (opening_range / compute_key_levels /
calculate_time_of_day_rvol / data-quality) so offline == live; STRICT time split
(features <= the ORB-completion decision point, labels strictly after);
idempotent + versioned; populates the previously-empty setup_events /
engineered_features / outcome_labels tables.
`build [--limit N] [--rebuild]` + `report` (base rates + runner rates via one
aggregate query). Core math is a pure compute_setup() — 3 unit tests cover the
time-split (a runner's feature VWAP can't see the post-setup run), the R-outcome,
failed-breakout, trend-day, and the <ORB-bars skip.
First real base rates (n=3,610 setups / 13 days): 24% reach +1R before -1R, 43%
fail outright, +20% runners 2.2%, +50% 0.4%, +100% (NXTS-class) just 0.1%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
#3 from the loss diagnosis. Sizing was risk-based (1% of equity) but had no
absolute ceiling, so on the ~$90k paper account every full-budget trade risked
~$900 — the BTBT/AAL/WKSP/LNKS ~-$1k losers (verified: all sized to $900 risk).
Added max_risk_dollars to calculate_position_size: dollar risk used to size is
now min(equity*risk_per_trade_pct, max_risk_dollars), so no trade risks more than
the cap regardless of equity or stop width. ExecutionSettings.max_risk_dollars
(TRADING_MAX_RISK_DOLLARS, 0=off) wired into both entry paths; set to $500 in the
operator .env. PositionSizeResult.risk_amount now reports the ACTUAL sized risk
(after every cap), not the budget.
Verified vs history: the four ~-$1k losers were each sized to $900 risk; under the
$500 cap they'd risk $500 (~halved, ~-$1.8k saved across the four). Small-account
% sizing is unaffected (1% of $300 << $500). Tests: cap binds on big account,
no-op when budget smaller, risk_amount reports actual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#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>
Adds a day-over-day view so the bot's per-day record is comparable at a glance.
query_daily_performance returns one row per NYSE trading day {date, day_pnl
(broker equity delta), cum_pnl, realized, trades, win_rate, equity} — account
summaries (EOD equity) joined to the closed-trade journal; weekend/holiday
mark-drift on held positions is filtered out (trading days only). Wired into the
snapshot as `daily`.
Dashboard panel renders it as a waterfall: the blue line is the cumulative P&L
(the equity curve), each bar is that day's step (green up / red down) on one
shared scale. Hover shows date/day-P&L/trades/win%; click a day to open its full
view; the currently-viewed day is highlighted.
Tests: trading-days-only filter + cumulative math. Headless-verified (4 days
render, click-to-open switches the view); no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
You wanted to compare the bot's success rate per market day, plus an all-time
total. Three pieces:
- Backfill (backfill_journal.py + storage/journal_backfill.py): reconstruct every
completed round-trip from Alpaca's full filled-order history (pair entries with
exits per symbol into episodes, read exit reason + bracket stop from the
orders), and emit position_closed events timestamped (ET) at the exit fill so
each lands on its own market day. Idempotent (re-run replaces prior backfill);
live-reconciled closes deduped by symbol+minute. Run once: it journaled 22
round-trips across 6/16-6/18, so each day's win-rate/avg-R/trades now populate
retroactively (6/17: 57% win, 6/18: 43%).
- All-Time score (query_alltime_score + dashboard panel): cumulative across every
closed round-trip — total realized, win rate, trades, avg R, trading days,
best/worst day. Always shown, not date-scoped.
- Per-day Trades panel: the individual round-trips for the viewed day (symbol,
color-coded exit reason, R, P/L, time) with a net/win-rate header — so you can
see exactly what happened each day. LIVE now scopes the journal to TODAY (was
unbounded, which would have shown the all-time list once the journal filled).
Tests: round-trip reconstruction (stop/target/scale-out/short/open/legs),
all-time aggregation, live=today scoping. Headless-verified both panels live and
on 6/18. No JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trade journal was blind: exits emit no event, so closed_trades / win-rate /
avg-R / the trade list all read 0 even on days with booked round-trips.
Now AlpacaPaperSync.sync_all reconciles closes by diffing the positions
snapshot: a symbol that was open and is now gone/flat has closed. This one
signal catches every exit path — bracket STOP/TARGET legs fill broker-side, EOD
flatten closes via market orders — and reads the exit fill (price + reason) and
the bracket stop level from the orders snapshot, emitting a PositionClosedEvent
with entry/exit/stop/side/realized so query_session_pnl can compute the
R-multiple. Headline realized $ stays broker-authoritative (the broker-delta
branch is unchanged); these events feed the wins/losses/avg-R/trade-list journal.
Robustness: the close baseline seeds from the last PERSISTED snapshot so a close
during downtime is still journaled once; reconcile is wrapped so it can never
break sync; PositionClosedEvent gains optional entry_price/stop_loss_price/side/
quantity fields (older emitters/tests unaffected).
Dashboard is view-only: removed the manual Exit button + exitPos handler from
Open Positions (kept the approval-queue approve/reject — that's the intended
human-in-the-loop control).
Tests: stop->loss, target->win, still-open->no-emit, no-fill->last-mark
fallback, restart-seed-from-persisted. 49 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four operator-requested enhancements (all dashboard.html, headless-verified, no
JS errors):
- Ready Signals: new Catalyst column — the LLM read (dot + type) per observed
symbol, not just armed triggers; "—" when there's no recent read.
- Catalyst Engine chip is now clickable to filter the feed to dilutive/veto
reads only (count shows "N / total"); click again to clear. Verified 40 -> 15
with every filtered row dilutive.
- Open Positions P/L cell now shows $ + % + a colored magnitude bar (maxes at
±10%) so underwater/up names read at a glance — pairs with the P&L fixes.
- Mobile: catalyst-feed rows wrap (headline + rationale) instead of truncating,
the conviction column drops, the engine chip goes full-width, and buttons get
38px tap targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A past date (and any overnight hold) showed fabricated P&L: realized was
computed as broker_day - unrealized, where unrealized was a position's FULL
mark-vs-entry. A position opened days ago still carried its whole mark, so a $0
holiday read as "+$330 realized / -$330 unrealized" on EVERY day — the same
number leaking across the date picker.
Fix: the day's P&L decomposes as broker_day = realized + (U_eod - U_sod). Added
a start-of-day baseline (the latest positions snapshot strictly before the day
begins; today's midnight for live) and subtract it, so only the day's own
open-position move counts. total = realized + unrealized now holds by
construction. Live is unaffected on a normal day (EOD flatten => no overnight
positions => U_sod=0); it only changes genuine overnight holds / past days.
Also expose `open_unrealized_pnl` (the full current mark) for the positions UI.
Verified on live data: the +$330 leak is gone — 6/20 (Sat) reads 0/0/0, 6/18
reads a real -$2368 day with a sensible split. Tests: carry-netting regression
+ the broker-delta test scoped to its own day.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The snapshot does real Postgres work, so the first load left a blank board that
read as broken. Added a full-screen overlay (on-brand spinner + "connecting to
the control room…" + what it's fetching) that:
- shows on first paint, dismissed the instant real data renders (SSE, poll, or
historical fetch);
- after 5s of waiting, softens the sub-text to "taking a moment — still
fetching…" so a slow snapshot reads as working, not frozen;
- on a failed first fetch, flips to a red "couldn't reach the control room —
retrying…" state (stops the spinner) instead of hanging silently.
Verified headless: overlay visible at first paint, display:none once pnl/feed
populate (no JS errors); 500 path shows the error state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things the operator couldn't see were wrong/opaque:
1) P&L attribution bug. query_session_pnl read each position's unrealized as
`unrealized_pl`, but the live snapshot stores it as `unrealized_pnl` (see
alpaca_paper/sync.py). So unrealized always read $0 and the ENTIRE broker
equity-delta was dumped into "Realized" — a real +$330 realized / -$67
unrealized day showed as "$263 realized, $0 unrealized, 0 closed". Fixed to
prefer the stored key, accept the alpaca-native alias, else derive from
qty*(price-cost). The existing test used the same wrong key (which is why it
never caught this) — corrected, plus a regression test reproducing the exact
broker-delta day and a derive-from-qty test.
(Still surfaced separately: closed round-trips emit no position_closed event,
so realized P&L is broker-derived and the trade list / win-rate read 0. That
journaling gap is noted, not fixed here.)
2) Catalyst Engine feed. The Ollama portion only showed totals; you couldn't see
WHAT it read or WHY it ruled. Added query_catalyst_feed (the raw stream from
news_catalyst_cache, newest first, with a `vetoed` flag = dilutive AND
conviction >= the 0.60 floor) -> snapshot `catalyst_feed` -> a new dashboard
panel under Armed Triggers: time (ET), symbol, colored read (🟢/🔻/🔴 +
type), conviction, headline, and the model's own rationale, with a red ⚠ VETO
badge on confirmed dilution. Makes the engine legible at the open — and
immediately exposed the 7B over-tagging one "SpaceX bear ETF" headline as
dilutive across a dozen unrelated large-caps (harmless: none are targets).
Verified headless: strip reads total/realized/unrealized correctly; feed renders
40 reads with rationale; no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn on the catalyst score blend + dilution veto, and make the engine VISIBLE so
it can be watched live at the open:
- api/main.py: snapshot now carries catalyst_engine {enabled, model, score_blend,
dilution_veto, advisories, dilutive, vetoes_today} (config read from .env +
live counts incl. how many entries the veto blocked today).
- dashboard: a catalyst-engine status chip in the Armed Triggers header
(model · N read · dilutive · veto · score · vetoes-today), and each armed
gapper now shows the LLM's READ instead of just the raw headline — colored
sentiment dot, catalyst type, and a red "⚠ VETO" badge on a confirmed dilution
at/above the conviction floor (or "dilut?" below it).
Verified headless: engine chip renders live (230 advisories, veto+score on); a
seeded bullish gapper shows "fda approval", a dilutive one shows the VETO badge;
no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The recurring false positive (an ETF "offering exposure" tagged dilutive) came
from the regex FLOOR matching the bare word "offering" and one-directionally
flipping is_dilutive=true even when the model said false. Tightened both:
- _DILUTIVE_RE matches "offering" ONLY as a securities offering — a qualifier in
front (public/underwritten/stock/equity/...) or "offering of ... shares/stock/
units". Bare "offering" (ETF offering exposure, product offering) no longer trips.
- build_prompt's is_dilutive instruction now spells out the exclusions (ETF/fund
"offering exposure", product/service offering, IPO of a different company,
buyback, reverse split).
Re-measured on a labeled set: precision 88% -> 100%, recall stays 100% (all 7 real
offerings still caught), accuracy 94% -> 100%. News-enrichment unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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
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
Panels already stacked at <=1040px, but phones still broke: dense tables
overflowed the page, the risk/P&L flex strips didn't wrap, and the armed-trigger
rows had fixed pixel widths. Added a <=640px breakpoint:
- header wraps; desktop-only Sound/Keys buttons hidden; connection chip right-aligned
- risk strip wraps; P&L becomes a 2-up grid with the hero Total P&L, day-curve full row
- tighter main padding; panel hint text hidden to save width
- wide tables get overflow-x:auto + nowrap cells -> swipe horizontally inside the
panel instead of stretching the page
- armed-trigger radar drops the meta/catalyst columns, keeps symbol + bar + dist
Verified headless at 390px: zero horizontal page overflow (scrollWidth==clientWidth),
tables scroll internally (665px in a 368px panel), all panels render, no JS errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LIVE rendered ONLY from the EventSource snapshot handler — and that handler
swallowed render/parse errors (catch {}). If EventSource didn't establish in the
user's browser (proxy, extension, connection cap), LIVE stayed blank while the
date selector — a plain fetch — worked fine. Exactly the reported symptom.
- startStream() now does an immediate pollOnce() (the same fetch path the date
selector uses) so LIVE shows data even if SSE never connects; SSE then layers
on for low-latency pushes, with onerror -> interval polling as before.
- Surface render errors (console + conn status) instead of hiding them.
- Serve the dashboard HTML with no-store/no-cache so pushed fixes actually reach
the browser (a stale cached shell was part of why "I see nothing" persisted).
Verified headless: with EventSource forcibly disabled, LIVE still populates
(10 armed rows, pnl, risk) via the polling fallback — stamp shows "polling ·".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure CSS (every id/class the JS renders is preserved; verified headless, no JS
errors, all panels intact):
- Depth: radial-gradient background, lifted panels (soft shadow + subtle top
highlight + hover), glassy blurred sticky header.
- Hierarchy: hero Total P&L (27px) with green/red glow + tabular numerals;
accent gradient rule before every panel title; thin live shimmer line at the
top edge; status dots get a soft glow.
- Polish: refined palette, softer/ bordered pills, gradient action buttons with
press feedback, custom scrollbars, panel load-in animation, hover affordances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Addresses the failure mode from the source trader's recap ("+100% move then 3
halts down on bad news") and our own data: parsing 22 real live-ORB fires, the
median entry was already +8% above the trigger, with a disaster tail at 26-64%
(NEOV x4 @27%, ICCM @64%/26%, BIRD @30%) — i.e. the system was chasing spike-tops
that reverse, and there was no guard against entering a halted name.
Both guards sit in submit_breakout_now (the single choke point every long entry
funnels through), as silent skips matching the existing gates:
- extension ceiling: block when last_price > trigger * (1 + extension_max_pct).
A clean break crosses the ORB high smoothly (~0%); a violent gap-THROUGH (a
parabolic spike, or a halt reopening past the level) lands far above it.
Default 0.15 is calibrated to the data's dead zone: real entries cluster
<=9.8%, nothing fires in 10-26%, then the disaster tail — so 15% blocks every
chase and passes every normal break. Tunable via TRADING_EXTENSION_MAX_PCT;
0 disables.
- halt guard: skip a name flagged halted (can't exit during a halt; it gaps
through the stop on resume). Detection is a bar-gap heuristic in the loop
(_recently_halted): during RTH the symbol's freshest bar is older than
TRADING_HALT_MAX_BAR_GAP_MIN (default 3) WHILE ingestion is globally healthy,
so a feed hiccup can't block the whole book. LULD halts run >=5 min so 3 min
catches a real halt without tripping on normal IEX lag.
The fast-trigger loop passes halted=_recently_halted(sym) and logs TRIGGER
BLOCKED once per symbol. tests/unit/test_triggers.py: +6 (24 pass; 146 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live loop froze ~40s every discovery pass. py-spy showed the main thread
stuck in upsert_daily_bars -> PgConnection.executemany, which used psycopg2's
cur.executemany — ONE INSERT round-trip per row. A discovery pass upserts
thousands of daily bars across the universe, so over the LAN Postgres that's
thousands of serial round-trips. While frozen the loop emits nothing and can't
service the trigger/exit cadence.
Switch executemany to psycopg2.extras.execute_batch (page_size=200): many
statements per round-trip, same SQL, same ON CONFLICT upsert semantics.
Benchmarked on the live DB: 771-row upsert 1.87s -> 0.06s (~31x); a real
discovery pass now finishes in one cycle (ingest 6803 rows, no stall).
Helps every bulk writer (daily/minute bars, scanner snapshots).
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>
Running the suite against the cleanup exposed pre-existing breakage (these
tests had not been run since the Postgres migration / recent sync changes):
- _latest_event_payload ordered by `timestamp DESC` with no tie-break, so two
account snapshots emitted in the same second returned the arbitrary/older one
(test wanted 101.5k, got 100k). Tie-break on `created_at DESC`.
- query_watch_states_snapshot windowed BOTH event types to the live 45-min
window; symbol_state_changed (~2k all-time, the watch list itself) must be
unbounded so the list isn't empty off-hours, while criteria_evaluated (~120k,
the 112s killer) stays windowed.
- Test isolation: the dashboard tests pass file-path db_paths expecting a fresh
DB, but under Postgres the path is ignored — they read AND wrote the
production `public` schema. get_connection now honours MOMENTUM_PG_SCHEMA
(test-only) to pin all connections in a test to one throwaway schema; an
autouse fixture sets/drops it. (Cleaned 125 leaked fixture events from prod.)
- FakeClient.get_orders missed the `nested=` kwarg added in ddf93d1.
tests/unit: 134 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The duckdb library is already gone (not a dependency, not importable, no
`import duckdb` anywhere) — these were leftover text references that misled.
- Delete storage/schema.py: 418 lines of DuckDB DDL with zero importers. The
Postgres schema is created by db_pg._apply_schema + event_store._ensure_schema.
- Neutralise stale default db_path strings (./data/momentum*.duckdb) in
event_store, config, run_live_paper, dashboard_api — the connection layer
ignores the path (DATABASE_URL is the datastore), so these phantom file paths
only created confusion. Dropped the now-pointless os.makedirs for them.
- research/multi_schema: the "three DuckDB files" are one Postgres DB now (all
resolve to the same connection); doc + constants updated to match reality.
- Fix stale "DuckDB handle" comments in api/main.
Kept the DuckDB mentions in storage/db_pg.py + storage/db.py — they document
why the Postgres facade mimics DuckDB's ? placeholders and :memory: semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>