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

tests/unit: 154 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:31:39 -04:00
momentum devandClaude Opus 4.8 297cb905d5 Entry guards: anti-chase extension ceiling + halt skip
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>
2026-06-19 07:24:51 -04:00
momentum devandClaude Opus 4.8 e74539d449 Resiliency + risk + monitoring: SIP backfill, freshness watchdog, gross cap, naked alarm
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>
2026-06-18 18:18:36 -04:00
momentum devandClaude Opus 4.8 129e8d4b15 Cleanup: consolidate flatten logic + refresh .env.example
- 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>
2026-06-18 16:51:35 -04:00
momentum devandClaude Opus 4.8 88a127ac3f Re-entry guard: bench only after a LOSING exit, not any close
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>
2026-06-18 16:30:56 -04:00
momentum devandClaude Opus 4.8 4520c6935a EOD flatten: cancel bracket legs before close (was naked overnight)
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>
2026-06-18 16:08:33 -04:00
momentum devandClaude Opus 4.8 03eb8bef7c Execution: block re-entry after a position closes (one round-trip/day)
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>
2026-06-18 15:23:53 -04:00
momentum devandClaude Opus 4.8 34d22c9193 Execution: per-symbol backout cooldown (anti-thrash)
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>
2026-06-18 13:21:20 -04:00
momentum devandClaude Opus 4.8 1cd82de6a8 Fix naked-stop regression: speed cache made the guard strip live brackets
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>
2026-06-18 10:20:00 -04:00
momentum devandClaude Opus 4.8 638843ca3a Account-aware sizing: size off REAL equity + buying-power & liquidity caps
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>
2026-06-17 16:52:22 -04:00
momentum devandClaude Opus 4.8 3e09f98c44 End-of-day auto-flatten (no overnight gap risk)
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>
2026-06-17 16:04:40 -04:00
momentum devandClaude Opus 4.8 45d5484dd7 Breaker: recoverable data-halt + network retries (DNS blip ended the day)
A transient WSL DNS outage made 3 consecutive equity reads fail, and the
fail-CLOSED breaker then PERMANENTLY halted new entries for the whole session
even after DNS recovered seconds later. Fixes:
- split the breaker into a permanent _halted (real daily-loss breach) and a
  recoverable _data_halt (equity unreadable) that clears on the next good read;
  raise the pause threshold to 5 (TRADING_EQUITY_FAIL_LIMIT).
- AlpacaPaperClient._request retries transient URLError/gaierror (not HTTP
  4xx/5xx) a couple times, so brief blips don't even register as failures.
- sync logs network failures as one-line warnings, not full tracebacks (the
  log was spamming stacks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:03:35 -04:00
momentum devandClaude Opus 4.8 d5d79cccf7 Threaded fast trigger + incremental ingest + naked-stop guard
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>
2026-06-17 12:39:50 -04:00
momentum devandClaude Opus 4.8 7b039ca288 Fix entry-invalidation churn: default 0.0 cancelled every breakout pre-fill
Live at the open, every fired entry was cancelled ~1s later by the
entry-invalidation guard: at the 0.0 default, any sub-cent dip below the
trigger invalidates, so a breakout oscillating around the level churns and
never holds a position (0 fills across a dozen orders). Default to 0.015 — give
the breakout room; the bracket stop at the opening-range low is the real
protection and risk stays capped at 1%/trade. After the fix the book held 4
positions (+$453 unrealized) including one via the new orb_trigger fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:44:04 -04:00
momentum devandClaude Opus 4.8 fb377ac321 Fast armed-trigger path + live, self-explanatory board
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>
2026-06-17 07:28:53 -04:00
momentum devandClaude Opus 4.8 660cd494d5 Harden order/risk path (execution+broker audit): 3 HIGH + 2 MED + 1 LOW
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>
2026-06-16 16:13:28 -04:00
momentum devandClaude Opus 4.8 6c339121f0 Circuit breaker: auto-flatten on breach
When the daily-loss breaker trips it now also flattens the book: cancels
unfilled (armed) entries and market-closes every open position via
client.close_position (which cancels resting bracket legs). Configurable via
TRADING_FLATTEN_ON_BREACH (default on). Best-effort per symbol; emits a
'halted_and_flattened' risk_rule_triggered event with the flatten summary.

Verified: trip at -4% closes 2 seeded positions + cancels 1 armed entry. 67/67 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:20:57 -04:00
momentum devandClaude Opus 4.8 a0276958ca Trading enablement: circuit breaker, ETF filter, robust discovery
- 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>
2026-06-16 08:02:46 -04:00
momentum devandClaude Opus 4.8 007cd95c79 Baseline before Postgres migration (WS0)
Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:39:29 -04:00