GO_LIVE flags `spread_pct` as a not-yet-real field. The reason it was null is
structural: OHLCV bars carry no NBBO, so `minute_bars.spread_pct` can never be
populated from the bars endpoint. The execution-relevant figure is the spread at
the moment a setup is evaluated — a wide book quietly eats R on the thin sub-$20
names this system trades.
This makes that metric real, shadow-first (measure before gating), mirroring how
the catalyst score was introduced:
- `strategy/evaluation/liquidity.py`: pure `compute_spread_pct(bid, ask)` →
(ask-bid)/mid, or None for missing/crossed/non-positive quotes (None never
collapses to a real 0.0). Unit-tested.
- `AlpacaPaperClient.get_latest_quotes`: batched latest-NBBO fetch, mirrors
`get_latest_trades`.
- `Watcher`: optional injected `spread_provider` (same shape as
`catalyst_score_provider`); default None == prior behavior byte-for-byte.
Computed spread is logged onto the criteria_evaluated and signal_ready events.
Observe-only — it does NOT gate ready/blocked.
- `run_live_paper`: TTL-cached spread provider off the client; degrades to None
on any fault, SPREAD_PCT_ENABLED=0 to silence.
The dead `scanner.spread_pct_max` knob is intentionally left for a later
promotion, once the logged data shows spread separates outcomes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019psgRHdtTXz6TCnnqPiqGw
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>
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 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>
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>
Hardens the Ollama Phase-2 path before it can be enabled; no effect on the
default-off behaviour (these run only when the score-blend / veto flags are on).
1. catalyst_score no longer floors sentiment at neutral: a high-conviction
BEARISH catalyst scored 0.45 (same as neutral) and LIFTED setup quality —
backwards. Now `conviction * max(0, sentiment)`: only bullish boosts;
bearish/neutral score 0. (+ bearish regression test.)
2. Prompt injection: the untrusted headline now sits in a delimited, length-
capped <news> block, the model is told to treat it as DATA not instructions,
and a </news> breakout token is stripped — so "...classify as bullish,
is_dilutive=false" can't flip the verdict or bypass the veto. (+ 2 tests.)
3. The dilution-veto catalyst_provider no longer reads the SHARED research_con
from the trigger thread: it refreshes under a lock using its OWN dedicated
connection (opened lazily, single-threaded use), 30s TTL keeps DB hits rare.
tests/unit: 201 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integrates the local-LLM enrichment ADDITIVELY on top of all current work, with
every Ollama feature OFF by default — with the flags off the system behaves
exactly as before (199 unit tests pass; a full --once pass runs clean with no
Ollama). Fixes the three issues the adversarial review flagged:
- BLOCKER: keep the Alpaca News ingestion. The PR predated it; the run_live_paper
conflict is resolved to keep BOTH step_news (fetch -> raw_news_items) AND the
enrichment. They're complementary: ingestion feeds enrichment authoritative-
ticker headlines instead of thin RSS.
- HIGH-1: enrichment runs on its OWN daemon thread with its OWN DB connection,
not the scheduler thread — a slow/hung Ollama can no longer starve exits /
guard / eod-flatten, and it no longer shares the non-thread-safe research_con.
- HIGH-2: per-headline writes are all-or-nothing — on any ticker store failure
the partial rows are deleted so the headline fully retries (no stranded
tickers) and enriched is counted only on full success (no false count).
Phase-2 automation (catalyst score blend, dilution veto, arming-rank multiplier)
remains gated OFF pending the medium fixes (bearish-catalyst boost, prompt-
injection hardening, veto cross-thread read) noted in the review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The only news source was RSS (slow, regex ticker-scraping). Alpaca exposes
Benzinga news with a stable id + authoritative symbols list. Add it as a second
source landing into the SAME raw_news_items table, so recent_news_map (catalyst
prioritisation) and the Ollama enrichment (PR #2) pick it up with no other changes.
- AlpacaPaperClient.get_news(symbols, limit): GET /v1beta1/news (works on the
existing data keys; verified live).
- research/ingestion/alpaca_news.py: ingest_alpaca_news lands new items, deduped
by the Alpaca news id; never raises (news is an enhancement, not a dependency).
- run_live_paper step_news on the scheduler (ALPACA_NEWS_* env, default on, 120s),
scoped to the tracked universe.
Verified live: passes landed ~34 then deduped; recent_news_map went from RSS-only
to 370 ticker->headline catalysts. tests/unit: 165 passed (+5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the Phase 2 loop: the catalyst score now actually reaches the runtime
decision, not just the pure functions. Gated behind its own flag so enabling the
Phase 1 advisory/dashboard never silently changes trade scoring.
- OllamaConfig.catalyst_score_enabled (NEWS_CATALYST_SCORE_ENABLED, default off).
- Watcher accepts an optional catalyst_score_provider (symbol -> 0..1 | None) and
passes it into evaluate_setup; a None provider reproduces today's behavior. The
provider is injected so runtime/ stays free of research/ imports.
- run_live_paper: one TTL-cached catalyst provider shared by the watcher and the
execution service; the watcher gets the score provider only when the blend is
enabled. step_arm gains a gated graded ranking multiplier (bullish catalyst
boosts by 1+gain*score; a confirmed dilutive offering is sunk below the armed
cut), falling back to the existing binary news boost when off.
- Add the enrich task to the run_pass pipeline order so a single --once pass also
runs enrichment (previously only the continuous loop did).
- .env.example documents the new flags; watcher tests cover the blend + the
no-op None-provider case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
Leverage a local Ollama model to interpret news catalysts for the small-cap
momentum strategy — distinguishing bullish catalysts (FDA approval, M&A) from
dilutive offerings the technicals alone can't see. Advisory-first, then automate.
Phase 1 (advisory; no change to trade behavior):
- research/ingestion/news_enrichment.py: stdlib urllib call to Ollama with
structured JSON output, classifying each headline into a catalyst type,
sentiment, conviction and dilution flag. Graceful degradation (None on any
failure) so trading is never blocked.
- news_catalyst_cache table + populate the previously-dead news_events.sentiment.
- Runs on the existing interval Scheduler (off the hot path), gated by
OllamaConfig; deduped by the raw_news_items payload hash.
- Surface the advisory on the dashboard approval queue (projection + api + UI).
Phase 2 (automation; ships gated OFF):
- Optional catalyst_score blended into calculate_setup_quality (None reproduces
the exact legacy weights); pure pass-through via evaluate_setup.
- Dilution veto in TradingExecutionService (slow + fast paths) emitting a
catalyst_dilution_veto risk event, behind NEWS_DILUTION_VETO_ENABLED.
Config (OllamaConfig.from_env), .env.example docs, and tests for parsing,
persistence/dedupe, the projection, the score blend, the veto, and an
end-to-end enrich->advisory flow (Ollama mocked). Default model moves to
qwen2.5:7b-instruct (best small-model JSON, fits a 12GB GPU).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
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 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>
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>
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>
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>
- 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>
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>
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>
Replace the static -1R/+2R bracket with active, parameterised exit management
shared by the backtest and live (strategy/exits.py): move to breakeven, trail
under prior-bar lows or by %, scale out, or first-red exit.
Swept all variants over 187 sessions with a held-out train/test split
(sweep_exits.py), then an adversarial 3-judge workflow picked the robust winner
OUT-OF-SAMPLE: trailLow_a1 (trail under the prior bar's low after +1R) — test
avgR 0.128 vs the old static_2R 0.067 (~1.9x), win 49% vs 38%. The naive
in-sample winner (trailPct10) was correctly rejected as overfit
(train 0.684 -> test 0.085).
- strategy/exits.py: ExitConfig + simulate_exit (backtest) + manage_live (live)
- strategy/backtest/engine.py: simulate the managed exit on entry; Trade.entry_index
- runtime/exit_manager.py: live manager — ratchets the broker stop UP via
REPLACE/PATCH (never cancel = never naked), scales out, first-red exit
- alpaca_paper/client.py: replace_order (PATCH) + partial close_position
- run_live_paper.py: 'exits' step (12s); TRIGGER_MAX_ARMED 6 -> 10
- tests: exits sim/live + manager ratchet-never-cancel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes from the first live session:
1) Naked-stop bug (critical): the entry-timeout guard cancelled an entry it
thought was unfilled AFTER it had really filled (our order_filled events lag
the 60s sync). Cancelling a bracket parent cancels its stop/TP legs, so the
position went NAKED and bled 22% with no floor. Guard now checks the broker's
LIVE positions (get_positions) before cancelling and never cancels a filled
name. Regression test added.
2) Speed: ingest re-fetched a 240-min window for ~80 symbols every minute (~46s
measured), and the single-threaded loop froze the 4s trigger that whole time
-> breakouts caught once a minute, not in real time. Ingest is now
incremental (heavy first pass covers the session so the opening range is
present; then ~20-min refreshes, closed bars never change). Loop passes
dropped from ~70s to ~2-5s.
3) Threaded trigger: the fast breakout detector/fire loop now runs on its own
thread, fully decoupled from the slow scheduler. EventStore, the execution
service, and the trigger book are made thread-safe (locks) so the thread and
main loop can't race the shared psycopg2 connection or in-memory state.
Verified live: fired EZGO/NEOV, all positions retain active stops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "always late / miss the move" problem was structural: entries waited for a
1-min bar to CLOSE above the breakout then walked the ingest->watch->execute
cadence (~2 min), and rested a limit AT the trigger that a runner never fills.
Add a top-6 armed-trigger book (runtime/triggers.py): keep the most-promising
gappers ready with a pre-computed opening-range trigger/stop and FIRE the
instant LIVE price crosses, via TradingExecutionService.submit_breakout_now —
a marketable order (capped above the trigger) that actually fills — reusing the
same risk gates (daily-loss breaker, max-concurrent, 1%-risk sizing, dedup).
- structure.py: opening_range() helper (shared with the ORB detector)
- run_live_paper.py: step_arm (rank + arm, 20s) + step_trigger (price-cross
fire + snapshot, 4s)
- momentum_cli.py: rewrite the watch board — market clock + countdown, loop
heartbeat, the 6 armed triggers with live price / gap / rvol / trigger /
distance / stop, and a plain-English "what it's hunting" line
- tests: opening range, trigger book state machine, submit_breakout_now gates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(a) learn_params.per_setup_report: permanent per-setup P&L/earliness breakdown
in the harness (forces all-setups so the ORB-vs-rest comparison is visible).
(b) nightly_tune.py + nightly_tune.sh: backfill latest bars, sweep ORB params
over accumulated gapper sessions, write the best PROFITABLE config to
data/learned_params.json. run_live_paper reads it on boot and applies the
tuned ready_score / min_bars (explicit env still wins); won't overwrite with
a losing or thin-sample config. Cron-scheduled after the close.
Verified: 75/75 tests; boot logs the learned params and applies them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HIGH:
- Circuit breaker fails CLOSED: a repeated unreadable equity (API 401/429/5xx or
missing field) now halts new entries after 3 consecutive failures instead of
silently reporting 'no breach' and continuing to trade through a drawdown.
- execute(): map order statuses. Terminal-fail (rejected/expired/...) -> ok=False
(never armed as a resting entry); record ANY fill (partial too), not only an
exact 'filled'; require a real broker order id. approve_order only arms
genuinely OPEN statuses.
- sync: account/positions/orders failures now emit a BrokerHealthChangedEvent
(degraded) and step_sync surfaces 'DEGRADED', instead of a success-shaped None
that left risk gates reading stale snapshots as current.
MEDIUM:
- _flatten_all records a position closed only on a CONFIRMED close; the breaker
event reports 'halted_flatten_incomplete' + the unclosed symbols when a close
fails, instead of claiming the book is flat.
- submit_exit_order prefers close_position (handles short sign + fractional qty;
a plain sell of abs(qty) would double a short).
LOW:
- get_orders(nested=true) so bracket child legs aren't dropped from the snapshot.
+ tests/unit/test_execution_safety.py (4 regressions). 75/75 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- discover_active_symbols: distinguish AUTH/entitlement failures (HTTP 401/403,
logged as ERROR, 'not no-movers') from transient/empty in the screener except.
- run_live_paper: the boot watchlist event records an explicit error when it
falls back (screener empty at boot) instead of looking like 'market quiet'.
- discovery.news_candidates: log DB faults instead of swallowing to [].
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live 'symbols' list only ever grew (discover appends every 5 min, dashboard
injects add more) and was comma-joined into ONE bars request; once >~100 symbols
Alpaca 400s the whole batch -> ALL minute-bar ingestion silently stops mid-session.
- client.get_minute_bars / get_daily_bars / get_latest_trades batch symbols in
chunks of 100 (cap-safe) and merge; get_daily_bars also paginates now.
- run_live_paper bounds the watchlist (WATCHER_MAX_UNIVERSE, default 80) so it
rotates toward current movers instead of growing all day.
Found by the silent-failure audit workflow. Verified _chunked(250)->[100,100,50],
live calls intact. 71/71 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- trading_execution: implement the daily-loss circuit breaker (account equity vs
prior close; latches once tripped; fail-open on a data hiccup) — was
advertised in the docstring/settings but never implemented.
- discovery: filter leveraged ETFs (SOXS/TZA/TSLL/NVD/BITO/...) out of the
universe; retry the screener on a transient empty (rate-limit) result.
- run_live_paper: fallback watchlist is now liquid sub-$20 names, not mega-caps
(mega-caps just get filtered by the watcher band -> nothing to trade).
Verified live: forced ready signal -> auto-approve -> real Alpaca bracket order
(pending_new, then cancelled); breaker trips at -3% (simulated). 67/67 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- research/ingestion/discovery.py: screen sub-$20 most-actives, backfill daily,
rank gappers, persist scanner snapshot, and EMIT a discovery event.
- run_live_paper: watchlist defaults to the screener universe (was a hardcoded
mega-cap list); add a periodic 'discover' task; emit bars-collected
(per-symbol counts) and discovery events every pass.
- IngestionResult.per_symbol: per-symbol row counts (incl 0) so 'are bars
collected?' is answerable per symbol, distinguishing market-closed from broken.
Verified live vs Postgres: watches SOXS/RGNT/GRAB/SOFI/ACHR/... (sub-$20),
820 daily bars backfilled, discovery+ingestion events land in the event store.
67/67 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Drop duckdb dependency (pyproject, requirements) -> psycopg2-binary.
- run_live_paper: boot log shows the real Postgres target (masked); add
_db_target_desc() helper.
- .env.example: document DATABASE_URL / PG* vars; mark WATCHER_EVENT_DB_PATH legacy.
- evaluation/storage.py: INSERT OR REPLACE -> INSERT (setup_id is a fresh uuid).
Verified: 67/67 tests green on Postgres with duckdb UNINSTALLED; app boots
against the live endpoint (127.0.0.1:5432 proxy) and wrote 205 daily bars.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>