Root-caused (workflow wjy4nzpbw, adversarially verified) why the live loop's per-cycle
log printed `execute: ... auto_executed=1` ~951 times after 09:45 ET on 2026-07-01 with
ZERO matching broker orders: reject_order() returns a truthy {"ok": True} dict that
process_auto_approvals() appends unconditionally, and step_execute logged len() of that
list as auto_executed. So every daily-entry-cap rejection masqueraded as an execution,
hiding the fact that the bot was hard-capped at 5 fresh entries for the rest of the day.
This is the log-VISIBILITY half only (safe, zero trading-behavior change):
- reject_order() tags its return rejected=True + reason, so a rejection is distinguishable
from a real execution (both carry ok=True).
- step_execute() counts auto_executed = results with a real broker_order_id, and appends
`auto_rejected=N (reason x N, ...)` so a tripped cap is VISIBLE instead of silent, e.g.
`auto_executed=0 auto_rejected=1 (daily_entry_capx1)`. ASCII-safe for the systemd log sink.
NOT included (deferred): the behavior fix (cap counts accepted submissions incl. unfilled
resting limits that get backed out, never decrements — so 4 phantom orders burned the whole
day's entry budget). That change lets the bot take more real shots, which given the live-
confirmed entry-conversion weakness likely means more churn — pair it with entry-quality
work, don't ship solo. See memory daily-entry-cap-counts-submissions.
Takes effect on the next loop boot; deliberately NOT restarting mid-session (the cap counter
is in-memory, so a restart would reset it to 0 and re-open entries into today's tape).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fidelity pass against the leading-gainer-runner workflow (wgcbvb3ce) synthesis:
- runner_quality.py: reorder calculate_runner_quality signature to the spec's
(gap_pct, vol_burst, velocity, above_vwap, ...) — behavior identical (all callers
use keywords), now matches the documented signature so a positional caller reading
the spec is correct.
- labeler.py: close build_plan step (3) "attach the runner grade". The grade is a pure
function of components already persisted on each fire (gap/velocity/burst/vwap), so
it is recomputed exactly at report/validate time (catalyst-OFF) — no 34-min rebuild.
report-runner now stratifies forward +1R and median 60m upside BY GRADE; validate-runner
adds the A/B-subset test (does concentrating on top grades salvage the locked bar?).
FINDING (385 fires, 25 session-days): the detector still FAILS all 5 pre-registered
bars (the workflow predicted this — SVRE/JEM put 100% of the move pre-market, RTH fires
confirm at/after the top). BUT the grade is MONOTONE with forward outcome:
grade A: +1R 45%, median max-upside 60m 17.7% (n=73)
grade B: +1R 41%, median 9.2% (n=186)
grade C: +1R 35%, median 7.8% (n=106)
grade F: +1R 30%, median 3.5% (n=20)
Grade A ALONE clears the +15% upside bar (17.7%) but not +1R>50% (45%): A-runners have
real continuation, the naive last-close entry still whipsaws. Confirms the grade earns
its keep as a RANKER/sizer (concentrate on A) while the bottleneck stays ENTRY-conversion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default ORB grade (quality.py) pins catalyst RUNNERS at F/C: its two heaviest
components structurally break on a vertical spike -- STRUCTURE (30%) reads 0 (no
pullback to classify) and adv-RVOL (25%) reads 1.0x (NULL rolling-20d on fresh
reverse-split names). On the 2026-06-30 tape the source trader's winners (SVRE
+247%, JEM +348%, CELZ +472%) all graded C 0.565, indistinguishable from the bot's
F-grade chop. Two parallel, shadow-only artifacts fix this without touching the live
ORB path:
- strategy/evaluation/runner_quality.py::calculate_runner_quality -- a PARALLEL
grade that drops the two broken components and scores the traits that actually
separate runners from chop (gap saturating at +100% not +10%, price velocity,
own-name volume burst, above-VWAP, catalyst). Validated on the real tape:
CELZ/JEM/SVRE -> B/A, chop (SLS/RIVN/CCXI/BB/NVDX) -> F. quality.py untouched.
- strategy/evaluation/runner.py::detect_leading_gainer_runner -- catches the mover
detect_momentum_ignition is blind to (it requires blue-sky > prior ATH, so it
fires ZERO on beaten-down catalyst pops). Blue-sky deleted; gates = leading-gainer
run + verticality + own-name burst + holding session-cumulative VWAP; PM-inclusive;
tags (never gates) PM-exhaustion. Real-tape in-sample: 5/5 runners fire, 7/7 chop
silent, zero false-fires. Cumulative VWAP is a prefix function, so a precomputed
session_vwap makes a bar-by-bar slide O(n) not O(n^2) (identity verified exact).
- research/labeler.py -- build-runner/report-runner/validate-runner shadow track,
mirroring vwap_reclaim: emit a non-tradeable setup on each fresh fire, score with
the SAME _forward_labels. Pre-registered promotion bar LOCKED before forward data
(median fwd-max >= +15%, median adverse > -8%, +1R > 50%, beat vwap_reclaim,
PM-exhaustion tag must earn its keep). SHADOW-ONLY: never routes to approve_order,
never gates live until the bar clears out-of-sample. The honest open question the
track exists to answer: the detector confirms AT/AFTER the top for PM-driven names
(SVRE fires at +242% already run) -- does that late entry actually convert to +1R?
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ollama was unreachable — the enrichment/qabot pointed at the 127.0.0.1:30069 netsh tunnel,
which the WSL network no longer routes (same reboot-flip landmine as the DB), while the
direct 192.168.1.5:30068 answers. config.OllamaConfig.from_env now resolves the host: probe
the configured endpoint, else the first reachable known candidate (tunnel 30069 <-> direct
30068 — note the DIFFERENT ports). So enrichment self-heals on boot instead of silently
failing. .env OLLAMA_HOST also repointed at the direct host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forensic on 2026-06-30: of the bot's 8 traded names, SEVEN graded F (<0.50) and one B —
it sprayed low-quality chop (SLS F0.12 -$412, RIVN F0.19, BB F0.21, CCXI F0.26 +$374,
NVDX F0.33, NOK F0.48, RDW F0.49; GVH B0.71). The trader made +$616 on SVRE — which the
bot SAW but its 35% gap cap excluded; and even graded, SVRE is F (0.35): low computed
RVOL + no clean pullback structure. So the grade cuts chop but does NOT recognise a
vertical catalyst RUNNER.
Adds ExecutionSettings.min_quality_score (env TRADING_MIN_QUALITY_SCORE, default 0.0 =
off) and a gate in request_approvals_for_ready_signals mirroring the VWAP gate: a signal
scoring below the threshold is skipped and logged rule_type=quality_below; fail-open on a
missing score. 3 tests (skip-low-when-enforced / allow-when-off / allow-at-threshold);
44/44 trading-execution tests green.
Honest scope: this is the "trade less chop" lever (evidence: the bot's own labeled data
has q>=0.55 win ~71% vs q<0.55 ~36%). It will NOT catch SVRE-class leading gainers — the
grade rewards pullback structure the runners lack, and the gap cap excludes them upstream.
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>
From a design pass (CLI spec + dashboard spec + an adversarial glance-safety review)
that read the real output of both surfaces. Principle: each safety/state number appears
ONCE on the first screen, every list has a bounded height capped by relevance (active/open
first), and load-bearing signals (the UNPROTECTED banner, heartbeat, day P&L, circuit
breaker) are never trimmed or hidden.
CLI board (momentum_cli.py _render_board): drop the static 2-line strategy subtitle (never
changed, redraws every 3s); trim the heartbeat to "Ns"; armed table shows actionable
(armed/fired/long) rows first then the top-4 scanning by rvol, dropping corrupt >1000%-gap /
0-volume rows with a "+N more scanning" footer; remove the redundant ready-signals panel
(restated the armed table) and the day-P&L echo in the trades title; trades float open lots
to the top (never capped off) and cap closed at 6; risk collapses consecutive identical
fires into one "×N" row (the 12 duplicate KEEL lines -> one) and is promoted ABOVE trades.
Dashboard (api/static/dashboard.html): cap the long "runs" — ready-signals to top 15 by
quality (also kills ~80 per-refresh chart fetches), activity feed 20, catalyst feed 20, order
lifecycle shows only actionable orders (raw 1000s incl rejected/cancelled read as an alarm),
watch-states hide the score-gate 'blocked' rows; drop redundant strip cells (Positions dup of
pnl Open/Closed, Gross) and the Account panel's Buying-Power/Synced; demote Account to a
collapsed half-width panel; drop the ~constant ready-signals "Setup" column. JS syntax
validated, no dangling refs, serves 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DB self-heal: the LAN Postgres is reached from WSL via EITHER the 127.0.0.1 netsh
portproxy OR a direct route to 192.168.1.5, and which one works FLIPS across WSL/host
reboots and network-mode changes — the recurring crash-loop landmine. storage/db_pg.py
_connect now tries the configured LAN host and then the alternate, so the loop boots
regardless of the current tunnel state ("connection refused" is instant, so it's cheap).
Dashboard UI: api/static/dashboard.html panels are now collapsible <details> (native,
no JS for the toggle; inner ids preserved so the live rendering JS is untouched). The
live/actionable panels (Armed Triggers, Open Positions, Approval Queue, Ready Signals,
Account) start OPEN and lead the layout; the big/historical ones (Catalyst Engine,
Daily Performance, Trades, Watch States, Activity Feed, Order Lifecycle, All-Time Score)
start COLLAPSED so the page is compact and scannable. Open/closed state persists per
panel in localStorage; an EXPAND/COLLAPSE-ALL header button toggles everything.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mechanics study found the no-chase entry (buy the VWAP reclaim, not the breakout high)
was the dominant lever. This codifies it as a permanent, pre-registered shadow-validation —
the disciplined close-out of the 4-lever investigation arc.
- strategy/evaluation/vwap_pullback_entry.py: pure find_vwap_pullback_entry — first forward
bar that dips to the CUMULATIVE session VWAP (calculate_vwap, NOT the per-bar minute_bars.vwap
midpoint, which would make 'reclaim' trivially true) and closes back above it; entry = its
close, stop = its low (the tight no-chase structure stop). 4 tests.
- research/labeler.py validate_no_chase_entry + `validate-entry`: simulates it vs the ORB-high
breakout via the live simulate_exit on real forward bars, judged in PERCENT net of a REALISTIC
per-name cost proxy (4% sub-$3 / 2% else — no NBBO in bars). PRE-REGISTERED bar locked: mean
net% must be STRICTLY POSITIVE, 'less negative than ORB' is NOT a pass.
IN-SAMPLE RESULT (v1 gappers, 19 autocorrelated days): the no-chase entry HALVES the loss
(ORB -7.94% -> no-chase -3.61% net, median -4.23%, offered on 75%), robustly (day-jackknife
[-3.83%,-3.48%]) -- confirming not-chasing the spike-top is the strongest mechanic -- but it
still does NOT cross zero under realistic cost: PRE-REGISTERED BAR = FAIL. Consistent with the
whole arc: no mechanizable entry/exit/selection lever beats cost on the full gapper book; the
path to positive is FEWER/cleaner setups (cut the ~70% fader population) COMBINED with this
no-chase entry. validate-entry re-runs forward; promote only if it clears the bar out-of-sample.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The float audit and the labeler both point at the SAME free, available-today selection
signal the bot underuses: gap magnitude + cheap absolute price separate runners from
faders, while the bot's gap%×RVOL rank leans on RVOL (labeler-proven FLAT) and CAPS gap.
- strategy/evaluation/runner_propensity.py: pure, transparent, FROZEN-threshold primitive
-> a coarse runner-propensity tier = gap_tier(0-3) + price_tier(0-2). Gap is weighted
heavier BY CONSTRUCTION because the data says it dominates; premarket gap preferred over
RTH gap; cheap price is a secondary tie-breaker (the two are correlated). 6 unit tests.
- research/labeler.py runner_rank_report + `runner-rank` CLI: measures the lever on the
v1 ORB labeled set. SHADOW/measurement only, not wired into live selection.
MEASURED (v1 gappers, gap>=10%, n=187, 19 autocorrelated days -- small, caveated):
the combined tier ladders monotonically: tier1 2% -> tier3 35% -> tier5 (cheap+big-gap)
48% runner (1.63x base). Gap dominates (>=50% -> 48%, 1.64x); cheap<$2 a real secondary
(1.29x). OPERATIONAL KICKER: the live ~35% TRIGGER_GAP_MAX would EXCLUDE 73 gappers whose
runner rate is 44% vs 20% for the kept <=35% -- the cap discards the best runners (this is
how NXTS at +81% got excluded). Caveat: +1R/+2R separate far less than the +20% runner
label, and gap/price are correlated; a hypothesis to shadow-confirm + adversarially
validate before touching the live cap, not a validated rank.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The risk-strip code declared `const rs = $("risk-status")` in render(), colliding
with the existing `const rs = d.ready_signals` later in the same scope. A
duplicate lexical declaration is a fatal parse error — the ENTIRE <script> never
executed, so render/populateDates/setView were undefined, SSE never connected,
and every field showed '—' (this, not only the API timeout, was why it "didn't
look like it's working"). Renamed the risk element to `rsEl`.
Verified in a headless browser: no JS errors; the date <select> populates with
LIVE + the last sessions, switches to a historical day and back, both views
render (armed radar, watch list, positions, fills, order lifecycle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 45-min live window I added to fills_feed + session_pnl's closed-trades query
hid the day's earlier fills/closures (and broke two projection tests whose
fixtures use a fixed past date). Those event types (order_filled/submitted,
position_closed) are tiny, so live now reads them UNBOUNDED via _hist_window;
only a historical YYYY-MM-DD bounds to that day. watch_states keeps its 45-min
window (criteria_evaluated is the big one, ~99k/day, and it's live-only).
tests/unit/storage/test_interactive_projections.py: 6 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /api/snapshots endpoint was timing out (the screen showed all '—'): several
projections did query_events(type, limit=None), loading an event type's ENTIRE
history just to take the latest. Timed: watch_states 112s, account_orders 78s,
risk 80s -> ~280s total.
- Latest-state reads (account orders/positions/summary, session_pnl, risk_state)
now use a DESC-LIMIT-1 index seek (_latest_event_payload) — O(1). Live snapshot
4.7s -> 1.5s; the heavy account_orders payload no longer loads all history.
- Date picker: /api/snapshots?date=YYYY-MM-DD reviews a past session (windowed
queries via _evt_window). Default LIVE; header <select> populated from
available_dates; live-only panels (triggers/watch/approval/ready) skip for a
historical view (watch_states was the 112s killer + a live concept). Frontend
setView()/loadHistorical() switch between live stream and a one-shot date load.
- dashboard_api.py now loads .env (DATABASE_URL resolves to the configured
Postgres) and binds 0.0.0.0 / port 8765 so it's reachable from outside WSL.
Verified: LIVE 1.5s, HISTORICAL(2026-06-18) 0.75s with full P&L/equity/fills.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more visualizations + the bug building them exposed:
- Equity-curve sparkline in the P&L strip: query_equity_curve returns the day's
total_equity series + prior-close baseline (dashed); rendered as an inline SVG
(green/red vs baseline) so the day's P&L shape reads at a glance.
- Per-position R bar in Open Positions: shows current open profit/loss in R units
(-1R = the stop, 0R = breakeven), green right / red left, with a per-row ⚠ when
a position has no resting protective stop. Falls back to % when no stop known.
Also fixed the P/L column to compute from entry/last (was reading a None field).
- BUG: sync_orders dropped stop_price (kept only limit_price), so the synced
snapshot showed stop=None on real stop orders — breaking the R bar and spark
stop-line. Now captured. (The naked alarm was unaffected — it counts qty
coverage, not the level.)
Verified live: equity curve renders the -1,886 day (94,124 -> 92,238); R bars
render (fall back to % until the loop re-syncs stop_price). Tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The web Watcher ignored the armed-trigger book entirely — the single most
actionable view for an ORB strategy (which gapper is about to break) lived only
in the TUI, despite 25k module_tick(triggers) events in the store. Add
query_armed_triggers (bounded 15-min scan, cheap) -> /api/snapshots, and a top
"Armed Triggers" panel rendering each loaded gapper as a proximity bar: the fill
shows where the LIVE price sits between the stop (left) and the breakout trigger
(the marker line); crossing the marker = fired. Rows sort closest-to-fire on top,
color-coded by state (armed/fired/filled/weak/waiting), with gap%/rvol, the
distance-to-trigger %, and any news catalyst. Armed names within 1% of firing get
a highlighted track.
Verified live: 13 triggers render (NEOV fired +27%, SPRO armed -8%, CRVO -15.6%),
projections suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live verification caught it: the data-freshness chip read ~14,477s (RED) on data
that was actually 1s fresh — exactly the EDT offset. Event timestamps are stored
NAIVE LOCAL (datetime.now()), but query_risk_state compared them against UTC now.
Compare naive-local to naive-local instead (tz-aware timestamps still use UTC).
Verified live: chip now reads ~78s. Also align the amber/red thresholds to the
~60s broker-sync cadence (amber >90s, red >180s) so a healthy feed reads green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the audit's remaining web-UI gaps. New query_risk_state projection bundles
the conditions that silently cost money this session: concentration vs the caps
(open/max, gross %), day P&L vs the daily-loss limit, broker-sync data freshness,
and any UNPROTECTED (naked) long. Wired into /api/snapshots.
dashboard.html: a pulsing red UNPROTECTED banner the instant a held long lacks
stop coverage (would have shown the moment APWC/NOWL went naked), plus a risk
strip — ok/warn/critical status dot, positions n/max, gross%/cap, day%/limit, and
a freshness chip (amber >30s, red >90s). Verified end-to-end: the API returns the
risk block (status=critical on today's 3 unprotected overnight positions) and the
page renders it.
Tests: projections suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ONLY entry change that survived rigorous validation on 14 SIP-backfilled
sessions (53 trades). Gap-size buckets: 15-30% gap = +0.65R/trade, but 30-60%
blow-offs = +0.10R. Train/test split (70/30 by date): lowering TRIGGER_GAP_MAX
60 -> 35 lifts HELD-OUT TEST expectancy 0.194 -> 0.462 R/trade AND total R
(18.7 -> 19.9), better win rate, on both splits. Principled (huge gaps
fade/buy-the-top) and corroborated by the entry-timing audit (losers were
wide-range blow-off gappers).
Rejected after validation (do NOT ship): open-delay (forgoes total R for
marginal expectancy), breakout-HOLD/close-confirmation (hurts on the larger
sample), rvol floor (no-op: all gappers already rvol>=6). Confirmed KEEP: the
$-vol floor is fine on SIP volume (the earlier "it hurts" was corrupted IEX
data); profit-lock ladder stays.
.env TRIGGER_GAP_MAX 60 -> 35. NB: 14-day sample — re-validate as data grows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
query_session_pnl reconstructed realized P&L solely from position_closed events
— of which the store has ZERO — so the web P&L strip showed $0 / no trades during
the real -$2,322 day while broker equity actually fell ~$11k peak-to-trough. The
operator flew blind.
Now prefer the broker equity delta: day P&L = latest account_summary equity -
prior-session close (last_equity); matched (realized) = day P&L - current
unrealized. Falls back to the closed-event sum when no equity snapshot exists.
Adds broker_day_pnl + pnl_source to the payload; existing frontend keys
unchanged. Verified live: total_pnl now reads the real -$1,840 (was $0).
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>
From the multi-agent audit (ship-now, low-overfit-risk cluster):
INGESTION RESILIENCY
- client._request: 429/5xx now retry with Retry-After + exponential backoff
(previously hard-failed, feeding silent-stale ingest).
- SIP backfill feed (settings.backfill_feed='sip'): live realtime stays IEX, but
backtest/backfill ingestion uses the full consolidated tape — IEX sees only
~2-5% of true volume (verified: AAPL 22,797 vs 452,704), which was silently
corrupting every rvol/$-volume decision and backtest. eod_replay wired to it.
- step_ingest freshness watchdog: emits a STALE event + board note when the
newest bar is older than INGEST_STALE_SECONDS during RTH (45% of passes
historically returned 0 rows with no error). Optional hard arm-block
(INGEST_STALE_BLOCK, off by default).
RISK
- Portfolio gross-notional cap (TRADING_MAX_GROSS_NOTIONAL_PCT=0.60): both sizing
paths now cap each entry to the remaining gross budget, so 6 x 16% can't pile
the book into ~96% gross of correlated small-caps. Blocks/shrinks late entries.
- Fixed the duplicate TRADING_MAX_CONCURRENT_POSITIONS in .env.example.
MONITORING (UI)
- Naked/unprotected-position alarm: _unprotected_positions joins the latest
position + order snapshots (order sync now flattens legs) and the TUI board
shows a red UNPROTECTED banner the instant a held long lacks stop coverage —
the exact blind spot when APWC/NOWL went naked. Verified it flags today's
3 overnight positions.
Tests: gross-notional cap blocks when budget full; 50 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_daily_gappers selected each symbol's LATEST daily bar (always the most recent
date in the table) and skipped the symbol unless that bar == the session date.
So replaying ANY past date saw rows[0] != sess and skipped every symbol —
silently reporting "no qualifying gappers" for all history. Multi-day
backtesting was impossible; the dataset looked empty when it was not.
Bound the query to bars ON OR BEFORE the session date so rows[0] is the session
day and rows[1] the prior trading day. Verified: June 17 now finds 11 gappers,
June 16 finds 8, etc. — and a 6-day / 24-trade replay shows the clean strategy
is +11.6R, 75% win, +0.48R/trade (the live losses were execution bugs, since
fixed; no entry-rule tweak robustly beats baseline on this sample).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DB latency: every projection runs `WHERE event_type=? ORDER BY timestamp DESC
LIMIT n`. With only a plain timestamp index, the latest of a STALE type
backward-scans the whole events table — measured 373ms / ~52k buffers on 235k
rows. Add a composite index in event_store._ensure_schema:
CREATE INDEX idx_events_type_ts ON events(event_type, timestamp DESC)
-> same query is now a 0.018ms index seek (3 buffers). Applied to the live DB
CONCURRENTLY; also dropped two redundant legacy indexes (idx_events_type, now
covered by the composite prefix; idx_events_correlation, a dup of
idx_events_session_id) to cut write overhead.
tests/conftest.py: sweep the throwaway `mem_*` schemas EventStore(":memory:")
creates — 170 had leaked into the shared Postgres, bloating the catalog.
README: rewritten around the current system — PostgreSQL event store, a full
command reference (run_live_paper / momentum CLI / eod_replay / sweeps / tuning /
cron), config knobs, and a database & performance section (the index above,
extension guidance, retention).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Carried over from the GitHub App setup. Both reference secrets.CLAUDE_CODE_OAUTH_TOKEN
(a repo secret, no literal keys). To activate on this repo: install the Claude
GitHub App and set the CLAUDE_CODE_OAUTH_TOKEN secret.
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>
The blunt guard benched a name after ANY position close — so a quick scratch or
a win that re-set up was blocked. In the day-replay it killed the GRAB +513
re-entry while only saving a loss the liquidity floor already caught.
Refine: track each held name's last-seen unrealized return (unrealized_plpc) and
bench it on departure only if it exited DOWN more than reentry_min_loss_pct
(env TRADING_REENTRY_MIN_LOSS_PCT, default 1%). A scratch/win is left free to
re-enter. Replay with all changes: actual (flat 15:55) -3,285 -> -21, i.e. the
guard refinement alone recovers the +513 the blunt version forfeited.
Tests: losing exit benches; winning exit does NOT; 43 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The journal/board reconstructs from broker timestamps (UTC) and printed the raw
UTC time, so a 10:12 ET entry showed as 14:12 on a US-market journal. Add _to_et
and convert both the today-filter and the displayed time to America/New_York.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The end-of-day flatten fired at 15:55 but FAILED on every position with the
held-qty 403: close_session/_flatten_all called close_position while the DAY
stop legs still reserved the shares (held_for_orders) — the same bug fixed in
the exit manager, but on the EOD path. The flatten then marked itself done and
never retried, leaving the whole book naked overnight (3 positions, no stops,
two underwater).
- Add _release_and_close(): cancel a symbol's resting bracket legs to free the
qty, THEN close_position with retry. Route both _flatten_all and the
close_session belt-and-suspenders loop through it.
- step_eod_flatten only marks _eod_done when the flatten had NO errors — so a
failed flatten RETRIES within the [15:55,16:00) window instead of giving up.
Tests: EOD flatten cancels the leg before closing (no 403); 45 pass.
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>
A name that stopped out could be bought right back the same session — observed
live: APWC stopped out -904 in the morning, was re-entered, and lost another
-645. The backout cooldown only covered UNFILLED entries; a filled position that
exits had no re-entry guard.
Track names leaving the broker's held set (a closed round-trip) and bench them
from re-entry for the rest of the session, on both the slow approval path and
the fast trigger. Detection runs every guard pass (before the armed early-return)
off fresh broker truth; emits a reentry_block risk event. Toggle with
TRADING_BLOCK_REENTRY_AFTER_EXIT (default on).
Tests: re-entry blocked on both paths after a position closes; 40 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header's "matched" subtotal used the FIFO round-trip sum, which doesn't
equal the broker's average-cost day P&L while positions are partially open — so
"matched + open" never tied to the headline day P&L (e.g. -1340 + open vs the
broker's -627).
Show broker-authoritative realized instead: matched = day P&L - open unrealized,
so matched + open == day P&L by construction. Per-trade rows stay FIFO-accurate
(each is a real fill pair); only the header subtotal is broker-truth. Factor the
open-unrealized sum into a shared helper used by both the live board and the
`journal` close capture. Falls back to the FIFO sum when no equity snapshot
exists.
Verified live: board + journal both reconcile (matched + open == day P&L).
Tests: 3 new (reconciliation invariant + FIFO fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Today's-trades reconstruction showed normally-exited positions as falsely
"open" and understated realized P&L. Two causes:
1. sync_orders recorded only TOP-LEVEL orders. A position closed by its bracket
STOP or TAKE-PROFIT leg has that exit fill as a child LEG, so every stop/TP
exit was invisible — QTEX/BBD/UWMC/etc. looked open when they were flat, and
their realized P&L was dropped. Now flatten parent + filled legs (legs inherit
the parent symbol).
2. get_orders only fetched the most-recent page; Alpaca's `limit` truncates by
recency (legs count toward the cap), so a busy day could drop the morning's
fills entirely. Unscoped get_orders now pages backwards with `until` (dedup by
id) to capture the full day; the symbol-scoped path stays single-page.
Verified live: journal open set now matches the broker exactly
(ATPC/GRAB/LNKS/NOWL/WPRT) and every reconstructed round-trip ties to a real
broker fill. NB: the realized total is FIFO-lot based, so while positions are
partially open it won't equal the broker's average-cost day P&L — the broker
figure remains the authoritative headline.
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>
A breakout that won't fill on a marketable limit used to re-arm every pass: the
entry timed out (~2 min), backed out, freed the symbol, and re-armed next tick —
forever. Observed live: NEOV armed+backed-out 68x in one session, 106 of 130
buy orders cancelled. That order churn is also what truncated get_orders and
caused the false-naked scare (commit 11ab6c7).
Add a cooldown: after an entry backs out (timeout or price-break), bench the
symbol for TRADING_BACKOUT_COOLDOWN_SECONDS (180s); after
TRADING_MAX_BACKOUTS_PER_SYMBOL (3) backouts, bench it for the rest of the
session. Gated on both the slow path (request_approvals_for_ready_signals) and
the fast trigger (submit_breakout_now). Backout events now carry the cooldown
state.
Verified live: NEOV's first backout under the new code stamped
"[cooldown 180s (backout 1)]" and the symbol stayed ready every pass but
approvals_requested=[] for the full window — no re-arm. Tests: two new
(temporary cooldown + session bench), 39 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>
The get_positions TTL cache (added for the speed fix) silently broke the
naked-stop guard: _broker_held_symbols read 2.5s-stale positions, so a
just-filled entry wasn't seen as held, the guard thought it was unfilled, and
cancelled its bracket on a price dip -> NAKED position (APWC bled -12.8%
unprotected today). Fixes:
- client.get_positions(fresh=True) bypasses the cache; the guard uses it.
- entry_grace_seconds (default 5): never cancel an entry within N seconds of
arming, so a marketable fill can confirm before the bracket could be stripped.
Regression test added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A long-lived `momentum watch` connection got closed by Postgres on idle/restart
overnight, and the next query raised OperationalError and crashed the board.
PgConnection now reconnects ONCE on OperationalError/InterfaceError (skipped for
isolated test schemas); the watch loop also catches transient errors and shows
'reconnecting…' instead of dying. Hardens the live loop's event store too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
momentum journal prints the day's trades (time/symbol/qty/entry/exit/P&L) +
broker-accurate day P&L + win summary. journal.sh appends it to
data/trade_journal.log; cron 16:02 weekdays keeps a durable daily record.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- momentum watch: replace 'open positions' with TODAY'S trade history (FIFO
round-trips from the broker orders snapshot: time/symbol/qty/entry/exit/P&L +
open lots), resets each day. Headline day P&L is broker-accurate (equity -
last_equity, now carried on AccountSummaryUpdatedEvent), shown in the header
and the panel title. sync_orders limit 50->500 so the log captures the whole
day across restarts.
- SYMBOL_WEIGHTS: optional manual per-symbol priority nudge applied to the
armed-trigger rank score (small; doesn't override gap/volume logic).
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>
eod_replay.py: after the close, pull the day's 100 most-active names (vs ~20
watched live), daily-screen the $1-5 gappers, minute-backfill only those, and
replay TODAY'S logic (entry filters + ORB + managed exits) over each — reporting
what it would have traded + est P&L + perfect-hindsight (biggest catchable
runs). Daily-first screening keeps it fast. Every run also grows the stored
dataset for the tuner/verification.
On 2026-06-17 it showed the new logic would have been +3.7R (~+$3.5k, 3 trades
100% win) vs the -$2.6k actually lost — the filters exclude exactly the losers
($7+ ICCM, blow-offs) that the old config traded.
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>
So you can confirm the bought price next to the unrealized P&L.
Profit-lock tiers verified out-of-sample (lock_mid: 8:3,15:9,25:18,40:30 on the
prior-low trail) — test avgR 0.127 vs 0.123 baseline, win 55% vs 46%, not
overfit; already the .env default. The flashy lock_only_mid (+0.40R in-sample)
was rejected as overfit (collapsed to 0.187 test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
For verifying the profit-lock checkpoints out-of-sample against the current
trail (lock_off = trailLow_a1). In-sample, lock_mid matches lock_off on
expectancy (+0.264 vs +0.262R) with a higher win rate (61% vs 57%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A day-trading book shouldn't carry overnight. New 'eod' loop step calls
TradingExecutionService.close_session at TRADING_EOD_FLATTEN_TIME (default
15:55 ET): flattens every position (cancelling their bracket legs), cancels
resting entries, and blocks new entries for the rest of the session. Only fires
inside [flatten_time, 16:00) so a post-close restart doesn't queue useless
after-hours orders.
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 2.5s value-cache + fewer calls only halved the slowness; the real cause was
flaky WSL DNS (~5s on ~1 in 5 resolves) and urllib re-resolving on EVERY
request. Cache successful DNS lookups process-wide (5min TTL) so each Alpaca
host is resolved at most once per window. Measured: get_orders went from
intermittent 5s spikes to a steady ~200ms. Opt out via ALPACA_DNS_CACHE=0.
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>