Author SHA1 Message Date
momentum devandClaude Fable 5.1 731e5a5818 chore: close the news-scorer deviation in the live test freeze as adopted baseline
The reactive-coverage cap (3e523e1) has run unchanged since it went live at
10:10 ET on 2026-10-02, so it is part of the strategy under test rather than an
open deviation; only TNON that morning traded without it. Dated closed with a
resolution note so the daily report stops listing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-10 10:04:04 -05:00
momentum devandClaude Fable 5.1 5f0625fb6f fix: watcher spends its 25-slot evaluation budget on in-population names first
The watcher evaluates candidates[:max_symbols] each tick. The live provider puts
dashboard/scan-injected symbols first (they never expire) and ranks the rest by
bar count, so on 2026-10-08 fifteen injected intraday movers plus liquid names
filled all 25 slots and every in-population gapper stopped being scored at 09:43
while 50 already-rejected names were re-rejected every 8 s. Entries were not
affected (the trigger book arms independently and injects its armed names), but
the state board and the judge's reasoning trail went stale for the session.

Names already in out_of_population now go to the back of the list and rotate, so
they are still re-checked when budget remains (a squeeze can be re-admitted) but
can never crowd out a name that can still trade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-10 10:04:04 -05:00
momentum devandClaude Fable 5.1 b0fb0582d7 fix: journal round trips that open and close between two broker polls (TNON)
The trade journal learns about exits only by diffing consecutive position
snapshots. On 2026-10-02 TNON was bought at 09:38:37 and its bracket stop filled
at 09:39:26, so no snapshot ever held it and no position_closed was written: the
80-trade scorecard dropped a -1R loser and the re-entry bench fired on a trade the
journal did not have.

AlpacaPaperSync.reconcile_unseen_round_trips runs after the snapshot diff on every
sync. For a symbol open in neither snapshot that has a filled buy and a filled sell
submitted this session (ET), it emits one position_closed per unjournaled exit fill:
entry is the VWAP of the buy fills, exit the sell's fill, stop the session's stop
order, position_id the exit's broker order id. Exit ids already journaled (by the
diff path or an earlier run, seeded from the store) are skipped, so nothing is
double-counted across polls or restarts. Long round trips only; the bot is long-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-10 10:04:04 -05:00
momentum devandClaude Fable 5.1 b5b38ab798 fix: scorecard counts round trips the broker filled but the journal never closed (TNON)
On 2026-10-02 the live bot bought 7 TNON at 4.62 and the Tradier sell-stop filled at
4.30 forty-nine seconds later, before a position record existed, so no position_closed
event was written and the 80-trade scorecard silently dropped a -1R loser. The daily
equity-vs-P&L gap ($2.31 that day, cents everywhere else) is what exposed it.

load_trades now merges the journal with a cross-check of the broker's filled orders
(account_orders_updated snapshots, one row per broker order id). For any session day and
symbol with a filled buy and sell but no journaled close, reconstruct_missing synthesises
the trade from the fills: fill VWAPs for entry/exit, the sell-stop's stop price for R.
Such trades are labelled "[broker fill; journal missed]" and the daily report alerts on a
same-day miss. The paper side of the exit A/B skips the check: that store's order
snapshots run to 2.2 GB over the window and its Alpaca journal is reconciled by
alpaca_paper/sync.

Corrected test state: 16/80, mean R -0.08, realized +$0.91 (was 15/80, -0.02, +$3.15).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-08 12:58:53 -05:00
momentum devandClaude Fable 5.1 3ea83d568b fix: run pg_dump/pg_restore windowless so closing a console cannot kill the nightly backup
The LiveDbBackup task runs under pythonw, but pg_dump.exe is a console program, so
Windows opened a visible console for it. On 2026-10-06 that window was closed and the
dump died with 0xC000013A before writing a byte (the Ross recap died the same way ten
minutes earlier). Pass CREATE_NO_WINDOW to both subprocess calls, the same fix the
judge paper trader got in 7e6f881.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-08 12:58:53 -05:00
momentum devandClaude Opus 5.5 b3f023f6e2 feat: nightly off-box backup of the live DB, and phone alerts from the daily report
The live (Tradier) stack has no backup service. Only the paper stack backs up its
own DB, so the 80-trade test's whole record lived only on the TrueNAS pool.
- deploy/backup_live_db.py, run by Task Scheduler "Momentum\LiveDbBackup" at 20:40 ET
  nightly: pg_dump -Fc (PostgreSQL 18.6 client) of the live DB to
  C:\Users\Admin\momentum-backups. It is verified with pg_restore --list (the events
  and config_overrides data must be present), keeps the newest 7, and writes
  status.json. A broken dump is deleted rather than kept.
- research/daily_report.py alerts when the last backup failed or is more than 36 h
  old. When there are alerts it sends them to Telegram, using the stack's own sender
  and the credentials in .env. --no-telegram suppresses that for dry runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-02 21:46:05 -05:00
momentum devandClaude Opus 5.5 7e6f881b17 fix: judge paper trader runs windowless and re-watches earlier symbols after a restart
On 2026-10-02 the trader died 5 times (13:15-15:05 ET). It left no traceback, and
each death came within the task's 5-min re-fire window, which fits its console
window being closed (as on 2026-09-29) while the PC was in use. Every relaunch popped
a new window.
- Task Scheduler now launches it with pythonw: no console window to close. Under
  pythonw, stdout/stderr go to data/judge/logs/live_<date>.log, so a real crash
  still leaves its traceback.
- A relaunched trader re-loads every symbol today's earlier runs were watching (from
  the journal's "add" rows). It used to know only the names still passing the
  scanner, so it stopped deciding on the others and never saved their ticks; the
  review then had nothing to compare CABO and APLX against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-02 16:08:55 -05:00
momentum devandClaude Opus 5.5 ac92280b9a fix: daily report no longer flags a stack as silent when run mid-session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-02 11:11:27 -05:00
momentum devandClaude Opus 5.5 1101e0e3fe chore: record the news-scorer reactive-coverage cap in the live test deviations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-02 09:17:35 -05:00
momentum devandClaude Opus 5.5 3e523e132a fix: reactive coverage (halt notices, "why is X up") no longer scores as a catalyst
qwen3:14b rated "halted on circuit breaker to the upside, stock now up 172%" at
conviction 0.8 / sentiment +1.0, a bigger rank boost than most real news. So
names that had already run were ranked up.
- Prompt: halt notices, movers roundups and articles that only report a price move
  are not a new catalyst (conviction <= 0.1). If one names a new announcement,
  classify it with conviction <= 0.3.
- A deterministic cap that does not depend on the model: halt type or halt wording
  caps conviction at 0.1; after-the-fact wording ("why is X stock up", "X shares
  surge", "stocks moving") caps it at 0.3. Dilution flags are never touched.
On the 4,046-headline news history the pattern catches 100% of halt notices and 82%
of roundups, 0% of analyst items and 0.8% of earnings items. Against qwen3:14b on a
fixed 20-headline set: reactive mean boost 0.318 -> 0.055 (max 0.80 -> 0.21); real
catalysts 0.353 -> 0.362; offerings 3/3 still flagged dilutive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 21:53:42 -05:00
momentum devandClaude Opus 5.5 082d01e8bd chore: close the news-scoring outage in the live test record (qwen3:14b from 2026-10-01 22:37 ET)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 21:40:30 -05:00
momentum devandClaude Opus 5.5 6189885aed chore: record the qwen3:14b-on-mason restore plan for the live news scorer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 21:06:56 -05:00
momentum devandClaude Opus 5.5 4361f80e1b fix: drop unused import that failed the CI ruff gate (blocked image builds)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 21:00:44 -05:00
momentum devandClaude Opus 5.5 eed4ec0b62 fix: catalyst classifier works with qwen3 (OLLAMA_THINK) and no longer hides an empty answer
qwen3 thinks by default. With format=json, Ollama then returns "{}", which the parser
silently read as a confident "other" (sentiment 0, conviction 0), so every headline
scored zero with no error. Two changes:
- OLLAMA_THINK (true/false). It is sent as Ollama's "think" flag only when set, so other
  models are untouched. qwen3:14b needs OLLAMA_THINK=false.
- A JSON object with none of the expected keys is now a failure (None, retried
  next pass), not "other".
Checked through the live code path against qwen3:14b on mason (think=false): 1.4 s per
headline. 5/5 offerings (including an ATM sales agreement and a PIPE) flagged
dilutive. 'Stocks moving' roundups scored no catalyst. Earnings, guidance, analyst,
FDA and contract items were labelled correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 20:59:32 -05:00
momentum devandClaude Opus 5.5 b38edd4ca9 feat: daily after-close report and the judge paper-trial criteria
research/daily_report.py, run by Task Scheduler "Momentum\DailyReport" at 16:25 ET
on weekdays, writes data/reports/daily_<date>.md with ALERTS on top:
- the live opening-range bot's frozen 80-trade test: verdict, mean R with CI,
  P&L, equity headroom over the floor, today's trades, open deviations, settings
  drift;
- the judge v2 paper trial: judge.review for every counted day, cumulative
  paper-vs-sim execution parity, today's flagged trades, trader errors and
  restarts;
- the old-vs-new exit A/B (alerts at 50 pairs);
- stack health: down at the open, silent gaps of 10+ min, the press-release
  recorder.
Read-only everywhere (PG_SKIP_SCHEMA=1, SELECTs only). Replayed on 2026-09-30 it
flags that day's real problems: the judge error loop, the paper stack down until
13:51, and a 25-min gap.

judge/PAPER_TRIAL.md, written before any counted trade: trades count from
2026-10-02. At 60 trades the paper trial must show execution parity with the
simulator (paper minus sim >= -0.15R) before a 150-trade performance read. Real
money is only considered after that, and only with the owner's go-ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 14:37:18 -05:00
momentum devandClaude Opus 5.5 98805279d1 feat: real-time press-release and SEC filing recorder (prwire)
Premarket news timing could not be tested on history. Alpaca's Benzinga feed has
almost no PR-wire items for microcaps, and wire RSS feeds only keep the latest few
dozen items. So from now on, every item is recorded with our first-seen time.

- Polls PR Newswire, GlobeNewswire, Business Wire and SEC EDGAR's current 8-K, 6-K
  and 424B feeds every 20 s, in parallel. A failing feed backs off up to 10 min.
- Each new item goes to data/prwire/<date ET>.jsonl with seen_utc, feed, title,
  link, tickers (exchange-tagged, e.g. "(Nasdaq CM: ABCD)"; SEC filings via CIK)
  and form. Restarts do not re-log items already recorded.
- Runs on the workstation via Task Scheduler "Momentum\PrWireRecorder" (pythonw,
  no console window; re-fired every 10 min with IgnoreNew so it self-heals).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 08:12:10 -05:00
momentum devandClaude Opus 5.5 8dc97a53f8 feat: judge paper trader runs a software stop alongside the broker stop
It sells as soon as any trade prints at or through the stop, or below the 6% trail
once +1R, walking the new prints in order exactly as the validated simulator does.
Whichever acts first wins: the broker stop or this one.
Why: Alpaca's paper engine filled triggered stops 6-60 s late on 2026-09-30 (CMCT,
PWCM; judge.review), which makes the trial look worse than the simulation for reasons
that are not the strategy's. Brokers also run no stop orders outside regular hours,
so any premarket trading needs this.
JUDGE_SOFT_STOP=0 turns it off. judge.review treats soft_stop/soft_trail as
stop/trail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 07:40:24 -05:00
momentum devandClaude Opus 5.5 5315e99978 feat: judge.review compares each paper trade with the simulator
For a paper day, every order is lined up with what the validated simulator does on
the same recorded ticks: the sim exit from the real fill (stop, 6% trail after +1R,
20-min time stop, sell at the bid 300 ms after the trigger) vs the paper exit,
plus the sim R of orders that did not fill. Trades are flagged when R differs by
more than 0.3R, the exit reason differs, or the entry slipped. The report goes to
data/judge/reviews/<date>.md.

On 2026-09-30: paper -8.25R vs sim -3.97R on the same six fills. The gap is two
trades where Alpaca's paper engine filled a triggered stop 6-60 s late (CMCT,
PWCM; PWCM's own exit was also blocked by the stop-id bug fixed in f3c8be1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 07:11:54 -05:00
momentum devandClaude Opus 5.5 358b859c59 fix: give Postgres a 5-minute start_period in both TrueNAS composes
After an unclean shutdown, WAL replay outlasted the 5 x 10 s healthcheck window,
so compose marked Postgres unhealthy and refused to start any dependent service.
The paper app sat in 'Deploying' on 2026-09-30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-09-30 19:06:02 -05:00
momentum devandClaude Opus 5.5 3e3388c7fd chore: note the TrueNAS Ollama outage as a known deviation in the live test
The host ran out of memory (843 MiB free; Ollama held 7.8 GB at a 131k context)
and the owner turned ollama2 off on 2026-09-30 evening. Strategy settings are
unchanged, but the LLM catalyst score is inert while it is off, and the
dilution veto falls back to its regex floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-09-30 19:06:02 -05:00
momentum devandClaude Opus 5.5 f3c8be11b6 fix: judge paper trader tracks replaced stop orders
On 2026-09-30 PWCM's stop was moved, and Alpaca's replace returns a NEW order id.
The trader kept the dead id, so reconcile treated the stop as gone and tried to
arm another one (refused with 403, shares held) about 20 times in 80 s, and the
trail exit could not sell because it cancelled the stale id.

- Adopt the id returned by replace. Reconcile follows replaced_by, and before
  arming it adopts any working sell stop already at the broker.
- Exits cancel every open order on the symbol before close_position.
- If the price is already through the stop, close instead of arming a stop the
  broker would reject.
- tests/unit/test_judge_live_exits.py covers these cases and the 2026-09-29 ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-09-30 15:58:52 -05:00
momentum devandClaude Opus 5.5 347031e2bb feat: judge paper trader survives a crash mid-session
On 2026-09-29 the trader's console was killed at 10:21 ET (exit 0xC000013A) and
it sat dead for the rest of the day. The scheduled task now re-fires every 5 min
from 08:10 to 15:00 local, and IgnoreNew makes that a no-op while the trader is
running. It also catches up if the machine was off at 08:10.

A relaunched trader now picks up where the dead one left off:
- positions still held at the broker are adopted, with the entry, original
  stop, fill time and high-water taken from today's journal and the backfilled
  tape, so the trail, time stop and 15:55 flat still apply. The working stop leg
  is reused; if none is working a new one is armed. A position with no known
  stop is closed rather than guessed at.
- the per-symbol order count is rebuilt from the journal, so the cap holds
  across restarts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-09-29 21:21:42 -05:00
momentum devandClaude Opus 5.5 c518747387 fix: judge paper trader reconciles with the broker before exiting
Day 1 of the paper trial (2026-09-29) exposed two exit bugs:
- When the broker stop had already filled, a trail/time exit tried to cancel the
  filled leg, got a 422, returned without clearing the position, and retried
  every second forever (BKYI). The leg-filled check sat after a `continue` and
  never ran.
- A partial fill whose parent was cancelled took its OTO stop leg with it,
  leaving the shares unprotected (MSGY, 82 of 133 filled).

Now every 5 s, and after any failed exit or stop move, the trader checks the stop
leg and the broker position: a filled stop or a flat position is recorded as the
exit, and held shares with no working stop get a standalone stop. Partial
fills get their stop re-armed straight away. Failed exits retry every 5 s instead
of every second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:18:41 -05:00
momentum devandClaude Opus 5.5 f2c7e52c79 feat: judgement-model (v2) paper trader on the Alpaca "Momentum V2" account
A gradient-boosted model scores burst / micro-pullback / whole-dollar signals on
squeeze stocks from tick data, and only its top-scored regular-hours signals
are traded. Validated in data/ticksim before any live use:
  - v1 on a fresh 600-day sample: real ranking (AUC 0.63), thin edge. FAIL.
  - v2 (trained on Jun-Jul of samples 1+2), pre-registered, on 500 NEW Aug-Sep
    symbol-days: +1.11%/trade after the spread (day-block 90% lo +0.54%),
    n=332 over 38 days. PASS. It held at 2 s latency and +0.2% extra cost.

- judge/engine.py: the validated signals, execution gates and features, as one
  shared source.
- judge/model.py: trains and freezes v2 (cutoff = the training top 20%).
- judge/decide.py: pure decision logic.
- judge/replay.py: a live-style replay of recorded days. Parity: 40 of 41 research
  trades matched with identical model scores.
- judge/live.py: the paper trader. Watchlist from the snapshot scan with
  backfill, SIP trade/quote websocket, decisions each second, a limit at the ask
  with a broker OTO stop (cancelled if unfilled in 3 s), trail 6% after +1R,
  20-min time stop, flat by 15:55. $20 risk and $1,000 max notional per trade.
  Everything is journalled and the day's ticks are kept for paper-vs-simulation
  review. Weekends and holidays exit.
- Runs from the workstation (Task Scheduler, weekdays 09:10 ET) with the
  research venv. Paper only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-28 18:25:26 -05:00
momentum devandClaude Opus 5.5 1cc15f5ea7 chore: restart the live test on 2026-09-29 with the squeeze exception off
TRADING_GAP_SQUEEZE_ENABLED=0 from the next session. The >=150% gap names lost
in both halves of the Jun-Sep history (-0.21R / -0.30R) and in our real fills,
while the 10-35% band made money. 2026-09-28's four trades ran with the
exception on, so they are recorded as a previous period and not counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-28 16:33:16 -05:00
momentum devandClaude Opus 5.5 0c8a27e23f feat: switch to turn off the >=150% gap squeeze exception (default on)
TRADING_GAP_SQUEEZE_ENABLED=0 rejects >=150% gappers in the population gate
(watcher pre-filter and entry gate) and zeroes the trigger book's squeeze
arm. One switch covers all three paths.

Why: while testing the trader's "trade the leading gainers" idea on 1,093
live-band v1 ORB breaks (Jun-Sep 2026, current exits), top-3 gainers at the
break did WORSE (diff -0.48R Jun-Jul, -0.40R Aug-Sep, 90% CIs excluding 0).
That turned out to be almost entirely the >=150% squeeze-exception names:

  gap 10-35%   Jun-Jul +0.27R (n=342)   Aug-Sep +0.13R (n=327)
  gap >=150%   Jun-Jul -0.21R (n=65)    Aug-Sep -0.30R (n=53)

Our own squeeze fills: -0.31R / -0.33R realized (n=16). By the time a squeeze
name breaks its opening range the move has happened; the trader catches these
names premarket, which the bot does not. This finding was post-hoc (not the
pre-registered direction), but it held in both halves and in the real fills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-28 09:45:51 -05:00
momentum devandClaude Opus 5.5 efde7c721d fix: one invalid symbol can no longer stop all live bar ingestion
At 09:34 ET on 2026-09-28 Alpaca's movers screener returned 'TSX:STC', a
Toronto listing. The discovery filter only dropped symbols containing a dot,
so it joined the universe. The live ingest fetches bars for every symbol in
one batched request, and Alpaca rejects the WHOLE batch with 400 "invalid
symbol". From 09:35 every ingest pass stored 0 rows and the loop ran on
stale bars ("INGEST STALE: freshest bar 458s old").

- Discovery keeps only plain 1-5 letter US tickers from both screeners.
- The ingest never sends a non-US or invalid symbol. If Alpaca still rejects
  one ("invalid symbol: X"), it drops X and retries the rest (up to 3 times),
  so one bad name costs one symbol, not the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-28 08:48:51 -05:00
momentum devandClaude Opus 5.5 990ee89b19 chore: restart the live test on 2026-09-28 with the deployed fixes
The 2026-09-24 period ran on settings the 5372585 deploy changed: live volume
was inflated 80-160x, squeezes were verified on a mean volume baseline, and
the first ingest began at ~09:15. It also had no premarket scan and no
fail-fast exit (now 60s/0.3R). Trades from before the restart would mix two
systems, so the 80-trade count starts again on the first session under the
new build. The four earlier trades are kept in the freeze file for the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-27 19:46:50 -05:00
momentum devandClaude Opus 5.5 53725854f4 feat: fail-fast exit for breakouts that fail inside their first minute (off by default)
At the first exit-thread pass >= TRADING_EXIT_FAIL_FAST_SECONDS after the fill,
flatten if price has already traded TRADING_EXIT_FAIL_FAST_R x R below entry.
It is checked once per position. A position first seen after its window (a
restart or a carry) is never judged late. 0 seconds = off (the default).

Evidence, from tick data on 1,093 live-band v1 ORB breaks (Jun-Sep 2026) with
outcomes under the current exit rules:
  - 60 s / 0.3R: +0.060R per trade Jun-Jul (90% CI +0.015..+0.102) and +0.062R
    Aug-Sep (+0.029..+0.094). It fired on 29% of trades and cut 22 runners.
  - Every threshold from -0.15R to -0.4R at 60 s was positive in both halves,
    a plateau rather than one fitted cell.
  - On our own 194 real fills (10-s bars): +0.015R. Same sign, not yet
    significant.

It is deliberately narrow. The same study found that later "bail out below
+1R" rules kill runners: the LLM trade-manager pilot, the hybrid bailout, and
orderflow H1 (Jul 17). The first minute is the one window where failure tells
you something. Tape-flow features (aggressor buy share, print rate, big
prints, quote imbalance, spread) separated nothing (AUC 0.44-0.59, not stable
across halves).

Live-only: it needs sub-minute prices, so simulate_exit (minute bars) does not
model it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-26 19:20:17 -05:00
momentum devandClaude Opus 5.5 3d0ee52214 Merge: nightly Ross-recap learning loop on local Ollama
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:24:02 -05:00
momentum devandClaude Opus 5.5 91af4c2e93 feat: nightly Ross-recap learning loop on the owner's local Ollama
Why: the pilot in data/ross showed that Ross Cameron's daily recaps, joined to
the SIP tape and to what our bot saw, answer "which of his trades could we have
taken and why didn't we". It was a set of one-off scripts run by hand; the
server-side youtube_claims pipeline has been dead since ~08-24 (YouTube blocks
the TrueNAS IP) and its captions fallback hid that. This makes the pilot a
nightly, local, cumulative loop without a paid LLM API.

youtube_claims/recap/ (python -m youtube_claims.recap.nightly):
- channel: yt-dlp flat listing of @DaytradeWarrior, m4a audio, faster-whisper
  small.en on CPU, pilot-format bundles (meta + our scanner list + transcript).
- extract: Ollama structured output (JSON schema) per EXTRACT_SPEC, chunked
  with overlap for long transcripts and merged. The verbatim rule is enforced in
  code: every quote must be found (token-level, one-word tolerance) and is
  snapped to the transcript's words; unquoted items, trades with no surviving
  quote, prices never spoken, and passed-on names never said are dropped.
- llm: endpoint pool over RECAP_OLLAMA_HOSTS (round-robin + failover, per-host
  model/ctx/think pins) since the owner runs several Ollama boxes.
- tape/join/ourside: ported join.py/ourside.py -- tickers are price-verified on
  the Alpaca SIP tape (plus an order-free check for his own named ticker), then
  paper (:5433) and live (:5434) event views are attached. Bars cached on disk.
- store + schema.sql: idempotent recap_* tables in the transcripts schema,
  upserted per video_id. report: the pilot's sections + "what changed since last
  run" against the previous day's metrics JSON.
- evaluate: scores any model/prompt against the 36 gold extractions.

Model choice (36 gold videos, final code path, P/R):
  gpt-oss:20b on the workstation GPU, two focused calls, think=low:
    trades P .84 R .70; tickers P .82 R .77; entry +-2% P .54 R .50;
    passed-on P .66 R .76; temperature 16/31; kind 30/36; ~49 s/video.
  tools:latest (4B, server) one call: trades P .81 R .72, tickers P .83 R .85,
    passed-on P .80 R .75, temperature 20/31, but it invents prices
    (entry P .42) and occasionally runs away (5-9 min). Prices drive the tape
    verification, so the more precise local model is the default.
  Iterations: prompt v2 + spoken-price rescale (699 -> 6.99) + split calls took
  gpt-oss trades F1 .74 -> .76-.78 and tickers .78 -> .79-.80; think=medium
  blew the token budget. qwen3-30b-a3b on mason only finished 5 videos (host
  saturated) -- inconclusive.

Also: youtube_claims/transcription.py::_captions_fallback now picks the
youtube-transcript-api shape by feature detection instead of retrying the
removed get_transcript on any error, and transcribe() reports both the whisper
and captions causes -- the IpBlocked error was being masked as AttributeError.

No trading code touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:23:18 -05:00
momentum devandClaude Opus 5.5 dff21bae29 Merge: whole-market premarket squeeze scanner (snapshot scan)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:13:11 -05:00
momentum devandClaude Opus 5.5 1cead3f29b feat: whole-market premarket squeeze scan via Alpaca snapshots
Discovery finds premarket squeezes far too late. Across 53 trades by a
professional small-cap momentum trader, our scanner had found only 4 of his
names before his entry, and 27 of his 28 entries were premarket (07:00-09:30
ET). KITT on 2026-09-25 ran $3.50 -> $8.39 at 09:00-09:01 ET (prior close
$0.49); the loop first "Discovered KITT via manual" at 09:35. The existing
sources (Alpaca movers / most-actives screeners) only reflect the regular
session, so a premarket squeeze is invisible until the open.

research/ingestion/snapshot_scan.py sweeps every active, tradable, listed US
equity (~11.9k names; OTC, warrants/units/rights and DISCOVER_EXCLUDE dropped;
asset list cached per ET day) through GET /v2/stocks/snapshots in 2,000-symbol
batches. The endpoint is bound by URL length rather than symbol count (8,000
worked; ~12.7k returned 414). A name qualifies when:
  - the LATEST TRADE price is inside WATCHER_PRICE_MIN/MAX (never the prior
    close, which is routinely < $1 on these names),
  - % change vs the prior close >= DISCOVER_SNAPSHOT_MIN_PCT (20),
  - it is trading now: last trade <= 180s old, latest minute bar <= 300s old
    with >= 5,000 shares, and >= 100,000 shares since 04:00 ET (summed from
    minute bars for the short list, one extra request).
Qualifiers are ranked by % change, capped per sweep, and announced once per
day. dailyBar OHLC ignores extended-hours prints (KITT's dailyBar high was
$3.24 against a premarket high of $8.39), so in premarket it can still be
yesterday's bar. The prior close is prevDailyBar.c only when dailyBar is dated
today, otherwise dailyBar.c.

run_live_paper.py runs it on its own daemon thread (04:00-16:00 ET weekdays,
every 45s), using the plan-019 pattern: its own DB connection, the thread-safe
Watchlist and the generic interval driver. Each new name emits
symbol_discovered with source="snapshot_scan" and price/prev_close/pct/minute
and cumulative volume in symbol_data, so first-sighting latency can be
measured. It gets a daily-history backfill and joins the watchlist, so the
normal new-symbol ingest path gives it its first full-session pull. This is
discovery only; no trading gate changes.

Gated by DISCOVER_SNAPSHOT_SCAN_ENABLED (default 1); knobs in .env.example.
Live read-only sweep at 13:10 ET: 11,871 names in 8 requests / 6.3s cold
(7 requests / 3.5s warm); top hits KITT +376%, MSGY +262%, INLF +60%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:11:45 -05:00
momentum devandClaude Opus 5.5 81e505b845 fix: verify squeezes on a median volume baseline and ingest from the premarket
KITT on 2026-09-25 (+557%, 9.3M shares, the day's biggest mover) was benched
as "gap 557% squeeze unverified (rvol 1.54)". Two separate faults:

1. The rvol baseline was the 20-day MEAN, and two 11-14M-share days on
   09-01/09-02 put it at 1.66M against a 333k median. Squeeze verification
   (gap >= 150% needs rvol >= 5) now takes the stronger of the mean-baseline
   reading and the median-baseline one. Both are cumulative volume over a
   full day's baseline, not prorated, so they share the units the rvol >= 5
   boundary was calibrated in. A reverse-split fake still reads ~0.01 on
   both. This applies in the watcher pre-filter, the trigger book's squeeze
   exception and the fast-path population gate. The ranking and the tod-rvol
   arm signal are unchanged.

2. A name's first ingest reached back only to ~09:15 (09:30 + 15 min, or
   the 20-minute refresh floor). KITT was found at 09:35, so its stored day
   began at 09:13 and missed the 09:00 squeeze and ~6M premarket shares.
   The first pass now covers the session from 04:00 ET.

With both fixed, KITT's volume at 09:35 is ~20x its median day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:08:13 -05:00
momentum devandClaude Opus 5.5 7f1ebe9cab fix: read the data feed through the Tradier wrapper — live volume was 80-160x
With BROKER=tradier the loop's `client` is a TradierClient that delegates
market data to the Alpaca client in `data_client`. Two places read the feed
as getattr(client.settings, "feed", "iex"). TradierSettings has no feed, so
both read "iex" even though the container runs ALPACA_DATA_FEED=sip (verified
in the running loop and in config_overrides):

  - step_vratio kept computing IEX->SIP ratios as if on the free feed
  - ingest_live_minute_bars multiplied the real SIP volume by those ratios

Checked against the paper stack's SIP bars for the same minutes: prices are
identical and unscaled volume matches 1.00x, but scaled rows run x83 (GLND),
x117-126 (GRML) and x163 (SKYQ). The 2026-09-25 GRML signal logged a
cum_volume of 1.39 billion shares and an rvol of 64.6. For any name with a
ratio, every volume gate — rvol, the 300k day-volume floor, min dollar
volume, the 2.5%-of-volume liquidity cap, and the gap x rvol ranking — has
been reading inflated numbers since the stack went live on 2026-07-31.

data_feed() reads through data_client when present. The regression test
wraps a SIP client in a TradierClient and fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:14:11 -05:00
momentum devandClaude Opus 5.5 e2e9687c45 feat: live-test scorecard and frozen strategy settings
Pre-registers the real-money test that starts 2026-09-24, the first session
without commission drag. PASS needs at least 80 round trips with a positive
mean R and positive P&L. FAIL is any session the equity floor halts. Changing
any strategy setting in live_test_freeze.json restarts the count, and the
scorecard reports that drift.

The settings were frozen, not tuned, because a study of 239 real fills
re-simulated under the current exit found nothing worth changing:
  - Capping how far above the trigger an entry may fill lost R at every cap
    from 0.25% to 10%, with either skip or resting-limit handling. Fills more
    than 5% above the trigger were the best bucket (+0.66R).
  - No break-time factor (spread, price, VWAP distance, stop width, L1
    bid/ask size, premarket range) kept its sign across both date halves and
    both accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:53:39 -05:00
momentum devandClaude Opus 5 42ca523053 fix: stop gating entries on minute-bar completeness
TRADING_MIN_QUALITY_SCORE 0.60 -> 0.20.

setup_evaluator.py:164 emits "quality_score": quality.score, where quality comes
from calculate_data_quality_score(bars). That module's own docstring reads "Data
quality scoring for minute-bar coverage and integrity" -- it measures whether the
bars are complete, not whether the setup is good. The name collision meant a
coverage metric has been gating live entries.

Tested against realised outcomes over 62 trades: r = 0.0845. Grade A averaged
+0.13%, grade B -0.71%, grade C -0.17% with an 80% win rate -- grade C beat grade
A. There is no signal in it. On 2026-09-14 it blocked SNDQ ("ready below quality
gate (score 0.56 < 0.60)") purely for gappy bars.

0.20 keeps it as a guard against genuinely broken data while removing it as a
setup filter. Renaming the field so the collision cannot recur is the proper fix
and is left for a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-16 20:01:57 -05:00
momentum devandClaude Opus 5 f7bec45c32 chore: arm the equity-floor circuit breaker at $215
The 2026-09-13 exit retune is $7.50 behind the config it replaced across its
first 8 live trades. Its thesis -- surrender small frequent gains to keep a rare
large tail -- predicts exactly this early shape, and the backtest behind it was
234 trades across two stacks with train/test splits. But 8 losing live trades are
also what a wrong call looks like, and nothing in the data separates the two yet.

So pre-commit the exit rather than deciding it mid-drawdown. TRADING_EQUITY_FLOOR
halts the session and flattens the book at $215, about -11% from the 241.75 the
change started at. The check reads broker equity directly and returns early on a
0.0 reading, so it cannot trip on the overnight-maintenance nulls.

Owner decision 2026-09-16: keep the retune, on the view that the same day's
risk-equalisation fix (0.05 -> 0.015 per trade) removes most of what was actually
doing the damage -- the four widest-stop entries produced -$8.57 of the -$8.84
week, and those are the positions the sizing change shrinks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-16 14:58:19 -05:00
momentum devandClaude Opus 5 60e009f0c6 fix: equalise dollar risk per trade — the position cap was doing the sizing
TRADING_RISK_PER_TRADE_PCT 0.05 -> 0.015.

On a ~$240 account the 5% risk budget ($12) is unreachable: a 4.5% stop would
need $267 of notional against a $38 position cap (max_position_pct=0.16). So
calculate_position_size never bound -- the POSITION CAP did, on every trade.
The consequence is that every position was the same NOTIONAL (~$34 average) and
the dollar risk floated with stop width instead of being held constant:

    measured over 64 live trades: risk ranged $0.52 to $8.79 -- a 17x spread

That is the opposite of what risk-based sizing is for, and it concentrated the
damage exactly where the stops were widest. On 2026-09-15/16 the four widest-stop
entries (CANG 13.5%, FTFT 21.6%, ZTG 13.2%, VNCE 19.8%) produced -$8.57 of an
-$8.84 week. It also interacts badly with the -10% catastrophe floor: any stop
wider than 10% is effectively a 10% stop, so a wide-stop trade cannot be saved by
its own stop but still realises a full 10%+ loss on the same notional.

At 1.5% the risk calc binds for wide stops while the position cap still binds for
narrow ones, compressing the spread to 6.8x. Replaying the same 64 trades:
-$4.09 -> -$0.32.

Checked that this is risk EQUALISATION and not merely trading smaller: against a
plain proportional shrink to the same average notional, equalising is worth
+$3.27 at 1.25%, +$2.30 at 1.0%, +$2.45 at 0.75%. The benefit holds at every
matched size.

Below 1% trades start being skipped outright as shares round to zero (1 of 64
lost at 1.0%, 13 of 64 at 0.5%) -- a hidden cost that looks like improved P&L.
1.5% is the tightest setting that drops no trades. Expressed as a percentage
rather than TRADING_MAX_RISK_DOLLARS so it scales with the account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-16 14:36:13 -05:00
momentum devandClaude Opus 5 11b0fcd7b7 fix: read the session open from the first regular-hours bar, not bars[0]
_population_reject computed the static open gap from bars["open"].iloc[0].
Premarket that is a PREMARKET bar, so the gap is fabricated. Live on
2026-09-14: FTFT opened 3.30 against a 2.88 previous close (+14.6%, well
inside the tradeable 10-35% band) but its premarket bars started near 2.52,
so the filter read -12.5% and benched it at 09:12 -- before the session had
even opened.

It never self-corrected. The frame keeps its premarket bars all session, so
every later pass recomputed the same wrong number, and _transition only emits
on change, so the symbol stayed out_of_population for the day. Several of the
day's rejections show negative gaps (-12.5%, -17.4%, -1.9%) that look like the
same artifact.

Trading was unaffected -- the trigger path does not consult watcher state, and
FTFT was still evaluated and rejected on its own merits (over_extended_day, it
was already +59% off the open). The damage is diagnostic: valid candidates lose
their criteria trail, which is the blank-reasoning-panel problem a previous
commit set out to fix.

Read the open off the first is_regular_hours bar; return None (fail open) when
the session has not opened, since the static gap is not yet knowable. A frame
with no session flag is treated as already regular-hours-only, which is what
tests and replay hand it.

Also drop the pm-range=N counter from step_arm's return string. The arm step
never emits a module_tick -- verified against both 2026-09-11 and 2026-09-14,
where only discovery, ingestion, enrichment and triggers do -- so it reached
container stdout and nothing else. Adding a tick purely to carry it would put
~4k events/day back after the population filter had just removed 88% of them.
pm_range_pct is already on the armed candidate rows for the board; persist it
on the approval payload if it needs to be measurable per trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-14 15:27:01 -05:00
momentum devandClaude Opus 5 4e0559501a fix: stop boosting names because a headline already reported the move
Three independent tests over 2026-08-01+, all negative:

  premarket news, any window      7.9% vs 8.6%  ran >=50%
  ...controlling for PM range    11.3% vs 21.0% ran >=50%
  intraday arrival, hour-matched 16.8% vs 24.6% ran >=10% forward

The feed explains it. Of 1,621 headlines delivered for screened names during
sessions: 61.7% reactive roundups ("Why X Shares Are Trading Higher", "20
Stocks Moving In..."), 2.7% law-firm spam, and 0.9% genuine catalyst PRs. The
wire reports the move after it happens, so a boost on "has a headline" buys the
late stage of a run. 11 of the 14 biggest runners (up to +912%) had no coverage
at all.

Set NEWS_RANK_BOOST to 1.0 (neutral). This also neutralises the binary fallback
restored in 9b4c8e5 -- correct in intent (a dead enrichment must not silently
disable news ranking) but it handed a flat 2x to any unclassified headline, and
62% of them are lagging roundups.

Deliberately unchanged:
  - the LLM path. catalyst_score = conviction * max(0, sentiment), so a roundup
    (type none, sentiment <= 0) scores 0 and earns nothing, while a real bullish
    catalyst still lifts. It targets the 0.9% correctly.
  - the dilution veto, which sinks confirmed offerings and is a separate,
    sound mechanism.
  - the enrichment-down alarm from 9b4c8e5.

Also evaluated and NOT shipped: halt-resumption as a catalyst feature. It is by
far the best signal in this universe -- a >=3min bar gap resuming >=2% up ran
>=25% afterwards 40-47% of the time vs ~11-20% baseline, holding in every hour
of the session. But a time-aware A/B of the armed set moved mean forward run
only 27.09% -> 27.84% and 165 -> 173 runners per 725 slots, because gap x rvol
already encodes it (a halting stock has high relative volume by definition).
Same collapse premarket range showed: strong univariate, near-zero marginal.
The ranking is saturated; selection is not where the remaining edge is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-14 07:31:37 -05:00
momentum devandClaude Opus 5 45b0386347 feat: rank gappers by premarket range, the one measured predictor
Over 1,040 screened symbol-days (2026-08-01 on), asking which premarket
property predicts the session running >=50% open-to-high:

    premarket range   n     avg run   ran >=50%
    >= 30%           201     34.7%      15.9%
    15-30%           136     28.3%      14.7%
     5-15%           303     15.2%       5.9%
      < 5%           400     16.8%       4.3%

A name that already travelled >=15% premarket is 3.1x more likely to run
>=50% (15.4% vs 5.0%) -- the largest single lift in this dataset, and free:
it comes off minute bars the arm pass has already fetched.

Two findings constrain the shape:

  - It is a STEP at 15%, not a gradient (15-30% and >=30% are 14.7 vs 15.9;
    5-15% and <5% are 5.9 vs 4.3). So the multiplier is a threshold. A linear
    scale would invent precision the data does not support.

  - Premarket VOLUME carries NO signal (10.0/4.2/7.9/9.0% across participation
    buckets, non-monotonic) and raw premarket volume runs inversely to the
    outcome. scanner_snapshots.premarket_volume being NULL costs nothing;
    range is the field that mattered all along.

Do not expect 3x. Replaying armed-set selection over the same 29 sessions,
top-5 by mean/median run and >=50% runners captured out of 145 slots:

    gap x rvol (baseline)        39.77% / 10.97%   27
    x3.0 if PM range >= 15%      42.00% / 13.16%   32
    HARD FILTER >= 15%           37.27% / 12.67%   24   <- worse
    PM range alone               32.86% / 12.65%   20   <- much worse

gap x rvol already encodes much of the same information, so the marginal gain
is ~+2 points of mean run and 4-5 extra big runners per 145 slots. It is
BOOST-only for a reason: gating on this feature scored below baseline, and it
must never replace gap/rvol. Every threshold in 10-20% and gain in 1.5-5.0 beat
baseline -- a flat plateau, so not fitted. The shipped 15%/3.0x are the measured
step and the measured lift, not the sweep's peak cell.

Fail-open: an unknown range yields a neutral 1.0, never a penalty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-13 19:16:17 -05:00
momentum devandClaude Opus 5 ff63b19e91 Merge: exit retune, news pipeline restore, sizing and watcher fixes
Five fixes from the 2026-09-13 live-account investigation:

  aae7269  exits stopped amputating winners (9.3% of the move captured on
           trades that ran >=20%; winners held 10 min vs 19 for losers)
  9b4c8e5  news ranking had been off since 2026-08-03, two days before the
           first real-money fill -- and could not be seen to be off
  0910e29  track the live overrides seed that LIVE-CUTOVER.md references
  9034839  a zero equity reading sized off the $100k default (latent)
  97a3a1b  watcher stops scoring names the entry path can never accept
           (3.1 GB of a 7.6 GB events table)

Config changes are already applied to config_overrides on the live stack.
This merge is what ships the CODE half to the image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
2026-09-13 15:37:12 -05:00
71 changed files with 6977 additions and 66 deletions

No files matched your search

+16
View File
@@ -84,6 +84,22 @@ LIVE_BARS_INTERVAL_SECONDS=20
LIVE_BARS_REFRESH_MINUTES=20
WATCHER_INTERVAL_SECONDS=15
DISCOVER_INTERVAL_SECONDS=120
# --- whole-market premarket squeeze scan (research/ingestion/snapshot_scan.py) ---
# The movers/most-actives screeners only see the regular session, so premarket
# squeezes were invisible until 09:30 (KITT 2026-09-25 ran at 09:00, found 09:35).
# This sweeps every listed, tradable US equity through Alpaca snapshots
# 04:00-16:00 ET on its own thread and adds qualifying names to the watchlist
# (symbol_discovered source=snapshot_scan). Discovery only; all trading gates apply.
# Price band = WATCHER_PRICE_MIN/MAX on the LATEST trade (not the prior close).
DISCOVER_SNAPSHOT_SCAN_ENABLED=1
DISCOVER_SNAPSHOT_INTERVAL_SECONDS=45 # one full sweep (~7 requests)
DISCOVER_SNAPSHOT_MIN_PCT=20 # % change vs prior close to qualify
DISCOVER_SNAPSHOT_MAX_PER_SWEEP=5 # new names injected per sweep (top by %)
DISCOVER_SNAPSHOT_MIN_BAR_VOLUME=5000 # shares in the latest minute bar
DISCOVER_SNAPSHOT_MIN_CUM_VOLUME=100000 # shares traded today since 04:00 ET
DISCOVER_SNAPSHOT_MAX_TRADE_AGE_SECONDS=180
DISCOVER_SNAPSHOT_MAX_BAR_AGE_SECONDS=300
DISCOVER_SNAPSHOT_BATCH=2000 # symbols per request (URL-length bound; 414 at ~12.7k)
# Active setups. The backtest sweep found the opening-range breakout is the only
# profitable one (others lose and fire later); empty = all setups. See learn_params.py.
STRATEGY_SETUPS=opening_range_break
+100
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
import json
@@ -31,6 +32,7 @@ _EXIT_REASON = {
logger = logging.getLogger(__name__)
BROKER_NAME = "alpaca_paper"
_ET = ZoneInfo("America/New_York")
class AlpacaPaperSync:
@@ -56,6 +58,10 @@ class AlpacaPaperSync:
# until the first sync, when it's seeded from the last PERSISTED snapshot
# so a close that happened while the loop was down is still journaled.
self._prev_positions: list[dict] | None = None
# exit order ids already journaled as position_closed (position_id), so the
# orders-based pass below never double-counts an exit the snapshot diff
# (or an earlier run) already recorded. None until seeded from the store.
self._journaled_exits: set[str] | None = None
def _emit_health(self, reason: str) -> None:
"""Make a broker sync failure VISIBLE (else stale snapshots look current)."""
@@ -230,6 +236,92 @@ class AlpacaPaperSync:
reason = _EXIT_REASON.get(str(f.get("type") or ""), "closed")
return px, reason, stop_level, f.get("broker_order_id")
def _load_journaled_exit_ids(self) -> set[str]:
"""position_ids of the closes already in the store (an exit's broker order
id, see reconcile_closed) so a restart does not re-journal them."""
try:
rows = self.store.con.execute(
"SELECT payload_json FROM events WHERE event_type = 'position_closed' "
"ORDER BY timestamp DESC LIMIT 2000").fetchall()
return {str(json.loads(r[0] or "{}").get("position_id") or "") for r in rows} - {""}
except Exception: # noqa: BLE001
return set()
@staticmethod
def _same_session(submitted_at, today) -> bool:
"""True if an order's submitted_at (ISO, UTC) falls on ``today`` (ET), or is
unparseable (Tradier's list is already scoped to the current session)."""
if not submitted_at:
return True
try:
ts = datetime.fromisoformat(str(submitted_at).replace("Z", "+00:00"))
return ts.astimezone(_ET).date() == today
except ValueError:
return True
def reconcile_unseen_round_trips(self, prev_positions, new_positions, orders,
today=None) -> int:
"""Journal round trips that opened AND closed between two position polls.
2026-10-02 TNON: bought at 09:38:37, the bracket stop filled at 09:39:26,
and no positions snapshot ever held it, so the snapshot diff in
reconcile_closed had nothing to diff and the trade vanished from the
journal (the 80-trade scorecard dropped a -1R loser). The broker's order
list still shows both fills, so for any symbol that is open in NEITHER
snapshot but has a filled buy and a filled sell submitted this session,
emit a position_closed per unjournaled exit fill: entry = VWAP of the buy
fills, exit = that sell's fill price, stop = the session's stop order.
Long round trips only (the bot is long-only). Returns the number emitted."""
if self._journaled_exits is None:
self._journaled_exits = self._load_journaled_exit_ids()
today = today or datetime.now(_ET).date()
open_syms = {p.get("symbol") for p in (prev_positions or []) + (new_positions or [])
if abs(float(p.get("quantity") or 0)) > 0}
by_sym: dict[str, list[dict]] = {}
for o in orders or []:
sym = o.get("symbol")
if (not sym or sym in open_syms or str(o.get("status") or "").lower() != "filled"
or not o.get("type") or o.get("filled_avg_price") in (None, "")
or float(o.get("filled_quantity") or 0) <= 0
or not self._same_session(o.get("submitted_at"), today)):
continue
by_sym.setdefault(sym, []).append(o)
emitted = 0
for sym, fills in by_sym.items():
buys = [o for o in fills if str(o.get("side") or "").lower() == "buy"]
sells = [o for o in fills if str(o.get("side") or "").lower() == "sell"
and str(o.get("broker_order_id") or "") not in self._journaled_exits]
if not buys or not sells:
continue
bought = sum(float(o["filled_quantity"]) for o in buys)
entry = sum(float(o["filled_quantity"]) * float(o["filled_avg_price"]) for o in buys) / bought
stop_level = None
for o in fills:
if str(o.get("type") or "") in ("stop", "stop_limit") and o.get("stop_price") not in (None, ""):
try:
stop_level = float(o["stop_price"])
except (TypeError, ValueError):
pass
for f in sorted(sells, key=lambda o: str(o.get("submitted_at") or "")):
qty = min(float(f["filled_quantity"]), bought)
exit_price = float(f["filled_avg_price"])
reason = _EXIT_REASON.get(str(f.get("type") or ""), "closed")
realized = (exit_price - entry) * qty
oid = str(f.get("broker_order_id") or f"{sym}-{datetime.now().isoformat()}")
self.store.emit(PositionClosedEvent(
timestamp=datetime.now(), mode=self.mode, correlation_id=self.session_id,
message=(f"{sym} closed @ {exit_price:.4f} ({reason}) pnl={realized:+.2f} "
f"[round trip between polls]"),
position_id=oid, symbol=sym, exit_price=round(exit_price, 4),
exit_reason=reason, realized_pnl=round(realized, 2),
entry_price=round(entry, 4),
stop_loss_price=round(stop_level, 4) if stop_level else None,
side="buy", quantity=qty,
))
self._journaled_exits.add(oid)
emitted += 1
return emitted
def reconcile_closed(self, prev_positions, new_positions, orders) -> int:
"""Emit a position_closed event for every symbol that was open in the
previous snapshot and is gone (or flat) in the new one. This is the ONLY
@@ -264,6 +356,10 @@ class AlpacaPaperSync:
stop_loss_price=round(stop_level, 4) if stop_level else None,
side=("sell" if is_short else "buy"), quantity=qty,
))
if oid:
if self._journaled_exits is None:
self._journaled_exits = self._load_journaled_exit_ids()
self._journaled_exits.add(str(oid))
emitted += 1
return emitted
@@ -281,5 +377,9 @@ class AlpacaPaperSync:
self.reconcile_closed(self._prev_positions, positions, orders or [])
except Exception as exc: # noqa: BLE001 — never let journaling break sync
logger.warning("position-close reconcile failed: %s", exc)
try:
self.reconcile_unseen_round_trips(self._prev_positions, positions, orders or [])
except Exception as exc: # noqa: BLE001
logger.warning("round-trip reconcile failed: %s", exc)
self._prev_positions = positions
return {"account": account, "positions": positions, "orders": orders}
+5
View File
@@ -183,6 +183,9 @@ class OllamaConfig(BaseModel):
timeout_seconds: int = 30
max_tokens: int = 256
temperature: float = 0.3
# Ollama "think" flag. None = not sent (models without a thinking mode). qwen3 thinks by default, and with
# format=json Ollama then returns an empty "{}", so set OLLAMA_THINK=false for qwen3.
think: bool | None = None
# News/catalyst enrichment (advisory layer).
enrichment_enabled: bool = False
enrichment_interval_seconds: int = 120
@@ -227,6 +230,8 @@ class OllamaConfig(BaseModel):
timeout_seconds=i("OLLAMA_TIMEOUT_SECONDS", 30),
max_tokens=i("OLLAMA_MAX_TOKENS", 256),
temperature=f("OLLAMA_TEMPERATURE", 0.3),
think=(None if not (v.get("OLLAMA_THINK") or "").strip()
else v["OLLAMA_THINK"].strip().lower() in {"1", "true", "yes", "on"}),
enrichment_enabled=b("NEWS_ENRICH_ENABLED", False),
enrichment_interval_seconds=i("NEWS_ENRICH_INTERVAL_SECONDS", 120),
enrichment_lookback_hours=i("NEWS_ENRICH_LOOKBACK_HOURS", 12),
+77
View File
@@ -0,0 +1,77 @@
"""Nightly off-box backup of the LIVE (Tradier) Postgres, from the owner's workstation.
The live stack on TrueNAS has no db-backup service; the paper stack backs up only its own database. This keeps the
live test's full record (events, trades, settings) somewhere other than the TrueNAS pool.
- pg_dump -Fc (compressed, restorable with pg_restore) of the live database (DATABASE_URL host, port 5434)
- verified: pg_restore --list must read it and it must contain the events and config_overrides tables
- keeps the newest KEEP dumps (default 7) in LIVE_BACKUP_DIR (default C:\\Users\\Admin\\momentum-backups)
- writes <dir>/status.json for research.daily_report to alert on a missed or failed backup
usage: python deploy/backup_live_db.py (Task Scheduler "Momentum\\LiveDbBackup", nightly)
restore: pg_restore -d <empty database> --no-owner <dump>
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
# pg_dump/pg_restore are console programs: launched from pythonw they would pop a console window,
# and closing that window kills the dump (exit 0xC000013A, seen 2026-10-06). Run them windowless.
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
ROOT = Path(__file__).resolve().parent.parent
PG_BIN = Path(os.environ.get("PG_BIN", r"C:\Users\Admin\pgsql18\bin"))
OUT = Path(os.environ.get("LIVE_BACKUP_DIR", r"C:\Users\Admin\momentum-backups"))
KEEP = int(os.environ.get("LIVE_BACKUP_KEEP", "7"))
PORT = int(os.environ.get("LIVE_DB_PORT", "5434"))
ET = ZoneInfo("America/New_York")
MUST_HAVE = ("TABLE DATA public events", "TABLE DATA public config_overrides")
def main() -> int:
from dotenv import dotenv_values
u = urlparse(dotenv_values(ROOT / ".env")["DATABASE_URL"])
OUT.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(ET).strftime("%Y-%m-%d_%H%M")
dump = OUT / f"live_{stamp}.dump"
env = dict(os.environ, PGPASSWORD=u.password or "")
status = dict(at=datetime.now(ET).isoformat(timespec="seconds"), file=str(dump), ok=False)
t0 = time.time()
try:
subprocess.run([str(PG_BIN / "pg_dump.exe"), "-h", u.hostname, "-p", str(PORT), "-U", u.username,
"-d", u.path[1:], "-Fc", "-Z", "6", "-f", str(dump)],
env=env, check=True, capture_output=True, text=True, timeout=3600,
creationflags=NO_WINDOW)
listing = subprocess.run([str(PG_BIN / "pg_restore.exe"), "--list", str(dump)],
check=True, capture_output=True, text=True, timeout=600,
creationflags=NO_WINDOW).stdout
missing = [m for m in MUST_HAVE if m not in listing]
if missing:
raise RuntimeError(f"dump is missing {missing}")
status.update(ok=True, size_mb=round(dump.stat().st_size / 1e6, 1), seconds=round(time.time() - t0),
entries=listing.count("\n"))
except subprocess.CalledProcessError as e:
status["error"] = (e.stderr or str(e))[-400:]
except Exception as e: # noqa: BLE001
status["error"] = str(e)[-400:]
if not status["ok"] and dump.exists():
dump.unlink() # never keep a broken dump around as if it were good
dumps = sorted(OUT.glob("live_*.dump"))
for old in dumps[:-KEEP]:
old.unlink()
status["kept"] = [p.name for p in sorted(OUT.glob("live_*.dump"))]
(OUT / "status.json").write_text(json.dumps(status, indent=1), encoding="utf-8")
print(json.dumps(status, indent=1))
return 0 if status["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
+55 -3
View File
@@ -15,7 +15,18 @@ NEWS_ENRICH_INTERVAL_SECONDS=120
NEWS_ENRICH_LOOKBACK_HOURS=12
NEWS_FEEDS=prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies
NEWS_LOOKBACK_HOURS=8
NEWS_RANK_BOOST=2.0
# BLIND HEADLINE BOOST DISABLED (1.0 = neutral), 2026-09-14. Measured three ways
# over 2026-08-01+: premarket news 7.9% vs 8.6% ran >=50%; controlling for premarket
# range 11.3% vs 21.0%; and an hour-matched event study of intraday arrivals, 16.8%
# vs 24.6% ran >=10% forward. News arrival predicts LOWER forward returns, every time.
# The cause is the feed: of 1,621 headlines it delivered for screened names during
# sessions, 61.7% were reactive roundups ("Why X Shares Are Trading Higher") and 0.9%
# were genuine catalyst PRs. It reports the move after it happens, so boosting on it
# buys the late stage. The LLM path is UNAFFECTED and stays on: catalyst_score is
# conviction * max(0, sentiment), so a roundup scores 0 and earns no boost, while the
# dilution veto still sinks confirmed offerings. Only the blind "has a headline" 2x
# is removed. Restore by setting this to 2.0.
NEWS_RANK_BOOST=1.0
# Ollama lives on 11434 (the standard port). The stack pointed at 30068/4000 with
# a model that was never pulled, so catalyst enrichment produced nothing from
# 2026-08-03 to 2026-09-13 — silently, because enrich_recent_news reports failure
@@ -37,6 +48,15 @@ TRADING_ENTRY_ORDER_TYPE=limit
TRADING_ENTRY_TIMEOUT_BARS=2
TRADING_EOD_FLATTEN_ENABLED=1
TRADING_EOD_FLATTEN_TIME=15:55
# CIRCUIT BREAKER (2026-09-16). The exit retune of 2026-09-13 is running $7.50
# behind the config it replaced over its first 8 live trades. That is the shape the
# change predicted -- bleed small, wait for a tail -- but it is also what a wrong
# call looks like at n=8, and the two are not separable yet. Rather than leave the
# decision to a judgement call on a bad afternoon, halt automatically: at $215
# (-11% from the 241.75 the change started at) trading stops for the session and the
# book flattens. Reads real broker equity; a 0.0 reading short-circuits earlier and
# cannot trip it spuriously.
TRADING_EQUITY_FLOOR=215
TRADING_EXECUTION_ENABLED=1
# EXIT MANAGEMENT — retuned 2026-09-13. The previous config stacked FOUR
# independent ratchets (BE@+5% from the env default, step-trail every 0.25R,
@@ -74,7 +94,23 @@ TRADING_DEFAULT_EQUITY=200
TRADING_MAX_RISK_DOLLARS=50000
TRADING_MIN_DAY_VOLUME=300000
TRADING_MIN_ENTRY_GAP_PCT=0.10
TRADING_MIN_QUALITY_SCORE=0.60
# 0.60 -> 0.20 (2026-09-16). This gates entries on setup_evaluator's quality_score,
# which is calculate_data_quality_score(bars) -- a MINUTE-BAR COVERAGE metric, not a
# setup metric ("Data quality scoring for minute-bar coverage and integrity"). Tested
# against realised outcomes over 62 trades it correlates r=0.0845, and grade C beat
# grade A. It blocked SNDQ on 2026-09-14 for having slightly gappy bars. Dropped to
# 0.20 so it only rejects genuinely broken data instead of acting as a setup filter.
TRADING_MIN_QUALITY_SCORE=0.20
# PREMARKET-RANGE RANK BOOST (2026-09-13). A name that already travelled >=15%
# premarket ran >=50% on the day 15.4% of the time vs 5.0% for the rest -- a 3.1x
# lift, the largest single predictor measured over 1,040 screened symbol-days.
# Replaying the armed-set selection, the gain is real but modest (mean run of the
# top-5 armed 39.8% -> 42.0%, 27 -> 32 big runners per 145 slots) because gap x
# rvol already encodes much of it. BOOST only: a hard filter on the same feature
# scored WORSE than baseline. Set TRADING_PM_RANGE_BOOST=0 to disable.
TRADING_PM_RANGE_BOOST=1
TRADING_PM_RANGE_MIN_PCT=15.0
TRADING_PM_RANGE_GAIN=3.0
TRADING_PRICE_MAX=20.0
TRADING_PRICE_MIN=2.0
TRADING_REENTRY_MIN_LOSS_PCT=0.01
@@ -82,7 +118,23 @@ TRADING_REGIME_GATE_ENABLED=0
TRADING_REGIME_GATE_P_MIN=0.5
TRADING_REQUIRE_ABOVE_VWAP=1
TRADING_REWARD_MULTIPLE=10.0
TRADING_RISK_PER_TRADE_PCT=0.05
# RISK PER TRADE cut 0.05 -> 0.015 (2026-09-16). At 5% the risk budget was
# unreachable: 5% of ~$240 is $12, which on a 4.5% stop needs $267 of notional
# against a $38 position cap (TRADING_MAX_POSITION_PCT=0.16). So the POSITION CAP
# bound on every trade and the risk calc never did — every position was the same
# NOTIONAL and the dollar risk floated with stop width, a measured 17x spread
# ($0.52 to $8.79) across 64 live trades. Wide-stop names carried 3-8x the risk of
# narrow-stop ones; on 2026-09-15/16 the four widest-stop trades produced -$8.57 of
# an -$8.84 week.
#
# At 1.5% the risk calc binds for wide stops while the position cap still binds for
# narrow ones, compressing the spread to 6.8x. Replayed over 64 live trades:
# -$4.09 -> -$0.32, and the gain is NOT just smaller size — against a plain
# shrink to the same average notional it is worth +$3.1 to +$3.3. Below 1% trades
# start being skipped entirely (shares round to zero: 1 lost at 1.0%, 13 at 0.5%),
# so 1.5% is the tightest value that drops nothing. It scales with equity, unlike
# TRADING_MAX_RISK_DOLLARS.
TRADING_RISK_PER_TRADE_PCT=0.015
TRADING_SESSION_ONLY=1
TRADING_TRIGGER_SLIP_PCT=0.004
TRADING_UNIFIED_ENTRY=1
@@ -35,6 +35,8 @@ services:
- CMD-SHELL
- pg_isready -U momentum -d momentum
timeout: 5s
# crash recovery after an unclean shutdown can outlast 5 x 10s (see truenas-compose.yml)
start_period: 300s
ports:
- 5434:5432
restart: unless-stopped
+3
View File
@@ -150,6 +150,9 @@ services:
interval: 10s
timeout: 5s
retries: 5
# crash recovery (WAL replay) after an unclean shutdown can outlast 5 x 10s; without a grace period
# compose marks postgres unhealthy and refuses to start every dependent service (2026-09-30)
start_period: 300s
restart: unless-stopped
loop:
+23
View File
@@ -0,0 +1,23 @@
# Judge v2 paper trial: what it has to show (pre-registered 2026-10-01, before any counted trade)
The v2 model passed its pre-registered out-of-sample SIMULATION test (data/ticksim/PREREG_v2.md: regular-hours
top 20%, +1.11%/trade after spread, day-block lo +0.54%, n=332). The paper trial's job is not to re-prove that edge.
At this sample size it cannot: the backtest is fat-tailed, with median trade about -0.1% and the top 5 trades about
40% of R. Its job is to show that REAL execution matches the simulation, so that the simulated edge can be trusted to
transfer.
Counted trades: completed paper round trips from 2026-10-02 on, the first session with the software stop and the
replaced-stop fix (8dc97a5, f3c8be1). 2026-09-29 to 2026-10-01 were shakedown days and are not counted.
Measured per trade by judge.review: paper R vs the simulator's R from the same fill on the same recorded ticks.
Checkpoint at 60 counted trades:
PRIMARY (execution parity): mean(paper R - sim R) >= -0.15R, with the bootstrap 90% CI upper bound >= 0. A gap
explained by a single bug that has since been fixed is re-measured on later trades.
Reported: fill rate (unfilled orders and the sim R they would have made), entry slippage, exit-reason mismatches,
paper P&L and mean R (for information only; n=60 cannot separate this edge from zero).
Decision:
parity holds: continue paper trading to 150 counted trades for a performance read (mean return > 0 required).
Only after that is a small real-money pilot considered, and only with the owner's go-ahead.
parity fails: find and fix the execution gap before anything else. The backtest edge is irrelevant until the fills
match.
+4
View File
@@ -0,0 +1,4 @@
"""Learned-judgement scalping strategy (v2): a gradient-boosted model scores burst / micro-pullback /
whole-dollar signals on squeeze stocks from tick data, and only the top-scored ones are traded.
Research and validation: data/ticksim (PREREG_v2.md, results.md). Paper trial first; never live money
without a matched paper-vs-simulation review."""
+81
View File
@@ -0,0 +1,81 @@
"""Pure decision logic: trades/quotes up to `now` -> the scored, tradeable signals.
Shared by the live paper trader and the replay/parity check."""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from judge import engine
from judge.model import score
@dataclass
class DayContext:
symbol: str
date: str # YYYY-MM-DD
prev_close: float
gap: float | None # today's open vs prior close (known after 09:30)
med20: float | None # median daily volume over the prior 20 sessions
windows: list = field(default_factory=list)
@dataclass
class Decision:
setup: str
ts: object # signal time (exact crossing trade for micropb)
sp: float # signal price
stop: float
p: float
take: bool
reason: str
feats: dict
def window_trades(T: pd.DataFrame, ctx: DayContext) -> pd.DataFrame:
"""Trades inside the model's data windows. Features and bars must see exactly what training saw."""
if T is None or T.empty:
return T
m = np.zeros(len(T), dtype=bool)
for s, e in ctx.windows:
m |= (T.t.values >= np.datetime64(s)) & (T.t.values < np.datetime64(e))
return T[m].reset_index(drop=True)
def scan(T: pd.DataFrame, Q: pd.DataFrame, ctx: DayContext, art: dict, now=None, after=None,
include_partial: bool = True) -> list[Decision]:
"""Every signal known by `now` (and newer than `after`), scored.
T and Q hold ALL buffered trades/quotes; windowing happens here. `now` defaults to the last trade."""
Tw = window_trades(T, ctx)
if Tw is None or len(Tw) < 50 or Q is None or len(Q) < 50:
return []
now = np.datetime64(now) if now is not None else Tw.t.values[-1]
Tw = Tw[Tw.t.values <= now].reset_index(drop=True)
b_all = engine.bars5(Tw)
closed = b_all[b_all.tc.values <= now].reset_index(drop=True)
tt = Tw.t.values; tp = Tw.p.values.astype(float)
out = []
for setup in engine.SETUPS:
P = dict(engine.EXEC, setup=setup)
bars = b_all if (setup == "micropb" and include_partial) else closed
for sig in engine.signals(bars, P):
ts, sp, stop = sig[0], float(sig[1]), float(sig[2])
if len(sig) > 3 and sig[3] == "cross":
ts = engine.cross_time(tt, tp, ts, sp)
if ts is None:
continue
if ts > now or (after is not None and ts <= np.datetime64(after)):
continue
tsn = pd.Timestamp(ts)
if tsn.time() < pd.Timestamp("09:30").time(): # the v2 rule trades regular hours only
continue
f = engine.feats_at(Tw, Q, ts, ctx.prev_close)
if f is None:
continue
f = engine.finish_features(f, setup, ctx.gap, ctx.med20)
p = score(art, f)
take = p >= art["cutoff"]
out.append(Decision(setup, ts, sp, stop, p, take, "score>=cutoff" if take else "score<cutoff", f))
out.sort(key=lambda d: d.ts)
return out
+179
View File
@@ -0,0 +1,179 @@
"""Judgement-model engine: signals, features and data windows. This is the single source shared by the
research simulator (data/ticksim) and the live paper trader (judge/live.py).
It is a verbatim port of data/ticksim/sim.py (signals, execution checks) and data/ticksim/ml_dataset.py
(features) as validated by PREREG_v2 on 2026-09-28. Changing anything here changes the model's inputs, so
retrain and re-validate after any edit.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
TICK = 0.01
# the execution config the v2 model was trained and tested under (ml_dataset.EXEC)
EXEC = dict(setup="burst", lat_ms=300, max_spread=0.03, max_chase=0.01, lookback_s=120, vol_mult=4.0,
burst_pct=0.02, stop_lookback_s=30, min_stop_pct=0.01, max_stop_pct=0.08, run_pct=0.05, run_s=60,
max_pb=3, lvl_stop_pct=0.015, target_r=0.0, trail_after_r=1.0, trail_pct=0.06, hold_s=1200,
cooldown_s=30, max_trades=6, price_min=1.0, price_max=20.0)
SETUPS = ("burst", "micropb", "dollar")
FEATS = ["ret_10s", "ret_30s", "ret_60s", "ret_300s", "vol_surge_10s", "vol_surge_60s", "trades_10s", "trades_60s",
"rv_60s", "off_hod", "vs_vwap", "gain_prev", "up_from_5m_low", "spread", "imb", "ask_size_usd", "min_since_4am",
"rth", "price", "cumvol_vs_med", "gap_known", "setup_i"]
SETUP_I = {"burst": 0, "micropb": 1, "dollar": 2}
# ----------------------------------------------------------------------------- data windows
def windows(session_date: str, first20_ts: pd.Timestamp | None) -> list[list[pd.Timestamp]]:
"""The tick windows the model was trained on: 09:30-10:30, plus [first +20% minute - 2 min, +60 min]."""
w = [(pd.Timestamp(f"{session_date} 09:30"), pd.Timestamp(f"{session_date} 10:30"))]
if first20_ts is not None:
w.append((max(first20_ts - pd.Timedelta(minutes=2), pd.Timestamp(f"{session_date} 04:00")),
first20_ts + pd.Timedelta(minutes=60)))
w.sort()
merged = [list(w[0])]
for s, e in w[1:]:
if s <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged
def in_windows(ts, ws) -> bool:
return any(s <= ts < e for s, e in ws)
# ----------------------------------------------------------------------------- bars + signals
def bars5(T: pd.DataFrame) -> pd.DataFrame:
b = T.set_index("t").resample("5s").agg(o=("p", "first"), h=("p", "max"), l=("p", "min"), c=("p", "last"),
v=("s", "sum"))
b = b.dropna(subset=["c"])
b["tc"] = b.index + pd.Timedelta(seconds=5)
return b.reset_index()
def signals(b: pd.DataFrame, P: dict) -> list[tuple]:
"""[(t, signal_price, stop[, 'cross'])]. burst/dollar: t = the bar close. micropb: t = the breakout bar START
(resolve the exact crossing trade with cross_time)."""
out = []
n = len(b)
if n < 30:
return out
c = b.c.values; h = b.h.values; l = b.l.values; o = b.o.values; v = b.v.values.astype(float)
t = b.t.values; tc = b.tc.values
med = pd.Series(v).rolling(24, min_periods=6).median().shift(1).values
lb = max(1, P["lookback_s"] // 5)
if P["setup"] == "burst":
prior_hi = pd.Series(h).rolling(lb, min_periods=1).max().shift(1).values
c30 = pd.Series(c).shift(6).values
for i in range(24, n):
if (h[i] > prior_hi[i] and med[i] > 0 and v[i] >= P["vol_mult"] * med[i] and c30[i] > 0
and c[i] / c30[i] - 1 >= P["burst_pct"]):
k = max(0, i - P["stop_lookback_s"] // 5)
out.append((tc[i], c[i], float(l[k:i + 1].min())))
elif P["setup"] == "micropb":
rs = max(1, P["run_s"] // 5)
i = rs
while i < n - 2:
lo = l[i - rs:i + 1].min(); hi = h[i - rs:i + 1].max()
if lo > 0 and hi / lo - 1 >= P["run_pct"] and h[i] == hi:
j = i + 1; pb_low = None
while j < n and j - i <= P["max_pb"]:
if h[j] > hi:
break
if c[j] < o[j] or c[j] < c[j - 1]:
pb_low = l[j] if pb_low is None else min(pb_low, l[j])
if hi - pb_low > 0.5 * (hi - lo):
pb_low = None; break
if j + 1 < n and h[j + 1] > h[j]:
out.append((t[j + 1], float(h[j] + TICK), float(pb_low - TICK), "cross"))
i = j + 1
break
j += 1
i += 1
elif P["setup"] == "dollar":
for i in range(24, n):
for step in (1.0, 0.5):
lvl = np.floor(c[i] / step) * step
if lvl > 0 and c[i - 1] < lvl <= h[i] and med[i] > 0 and v[i] >= P["vol_mult"] * med[i]:
out.append((tc[i], c[i], lvl * (1 - P["lvl_stop_pct"])))
break
return out
def cross_time(tt: np.ndarray, tp: np.ndarray, bar_start, level: float):
"""The first trade at/above `level` inside the 5-s bar starting at bar_start (no look-ahead)."""
k0 = np.searchsorted(tt, bar_start, side="left")
k1 = np.searchsorted(tt, bar_start + np.timedelta64(5, "s"), side="left")
hit = np.nonzero(tp[k0:k1] >= level)[0]
return tt[k0 + hit[0]] if len(hit) else None
def entry_check(bid: float, ask: float, sp: float, stop: float, P: dict = EXEC):
"""Execution gates from the simulator. Returns (entry, stop) or None."""
if bid <= 0 or ask <= 0 or ask < bid:
return None
mid = (bid + ask) / 2
if (ask - bid) / mid > P["max_spread"] or ask > sp * (1 + P["max_chase"]):
return None
if not (P["price_min"] <= sp <= P["price_max"]):
return None
entry = ask
stop = min(stop, entry * (1 - P["min_stop_pct"]))
if (entry - stop) / entry > P["max_stop_pct"] or stop >= entry:
return None
return entry, stop
# ----------------------------------------------------------------------------- features
def feats_at(T: pd.DataFrame, Q: pd.DataFrame, ts, prev_close: float) -> dict | None:
"""Features from trades/quotes strictly before ts (verbatim from ml_dataset.feats_at)."""
tt = T.t.values; k = np.searchsorted(tt, ts, side="left")
if k < 20:
return None
p = T.p.values[:k].astype(float); s = T.s.values[:k].astype(float); t = tt[:k]
last = p[-1]
def back(sec):
return np.searchsorted(t, ts - np.timedelta64(sec, "s"), side="left")
f = {}
for sec in (10, 30, 60, 300):
j = back(sec)
f[f"ret_{sec}s"] = last / p[j] - 1 if j < k and p[j] > 0 else 0.0
f[f"vol_{sec}s"] = s[j:].sum()
f[f"trades_{sec}s"] = k - j
j120 = back(120); j300 = back(300)
base = s[j300:j120].sum() / 180.0 if j120 > j300 else np.nan
f["vol_surge_10s"] = (f["vol_10s"] / 10.0) / base if base and base > 0 else np.nan
f["vol_surge_60s"] = (f["vol_60s"] / 60.0) / base if base and base > 0 else np.nan
j60 = back(60)
lr = np.diff(np.log(p[j60:])) if k - j60 > 3 else np.array([0.0])
f["rv_60s"] = float(np.std(lr)) * np.sqrt(max(1, len(lr)))
hod = p.max(); f["off_hod"] = last / hod - 1
vw = (p * s).sum() / s.sum() if s.sum() else last; f["vs_vwap"] = last / vw - 1
f["gain_prev"] = last / prev_close - 1
lo300 = p[j300:].min() if k > j300 else last; f["up_from_5m_low"] = last / lo300 - 1
qt = Q.t.values; qi = np.searchsorted(qt, ts, side="right") - 1
if qi >= 0:
bp, ap = float(Q.bp.iat[qi]), float(Q.ap.iat[qi]); bs, as_ = float(Q.bs.iat[qi]), float(Q["as"].iat[qi])
mid = (bp + ap) / 2
f["spread"] = (ap - bp) / mid if mid > 0 else np.nan
f["imb"] = bs / (bs + as_) if bs + as_ > 0 else np.nan
f["ask_size_usd"] = as_ * ap
tsn = pd.Timestamp(ts)
f["min_since_4am"] = (tsn - tsn.normalize() - pd.Timedelta(hours=4)).total_seconds() / 60
f["rth"] = float(tsn.time() >= pd.Timestamp("09:30").time())
f["price"] = last
f["cum_vol"] = s.sum()
return f
def finish_features(f: dict, setup: str, gap: float | None, med20: float | None) -> dict:
"""The derived features (ml_fresh.prep): setup code, gap only after 09:30, cumulative volume vs the 20-day median."""
f = dict(f)
f["setup_i"] = SETUP_I[setup]
f["gap_known"] = gap if (f.get("rth") == 1.0 and gap is not None) else np.nan
f["cumvol_vs_med"] = f["cum_vol"] / med20 if med20 else np.nan
return f
+619
View File
@@ -0,0 +1,619 @@
"""Live PAPER trader for the v2 judgement model (Alpaca paper account "Momentum V2").
usage: python -m judge.live (runs one session: ~09:15 ET until 16:00 ET, then exits)
env: ALPACA_V2_KEY / ALPACA_V2_SECRET (paper only), JUDGE_RISK_USD=20, JUDGE_MAX_NOTIONAL=1000,
JUDGE_MAX_OPEN=4, JUDGE_MAX_TRADES_PER_SYMBOL=6, JUDGE_SCAN_INTERVAL_S=30, JUDGE_DRY_RUN=0
It mirrors the validated simulator:
- decisions come from judge.decide.scan on tick data inside the model's windows (regular hours only);
- the entry is a limit at the ask with a broker-side stop (OTO); unfilled after 3 s it is cancelled;
- exits: the stop, a 6% trail under the high-water once +1R, a 20-min time stop, and flat by 15:55 ET.
Every scored signal, order, fill and exit goes to data/judge/journal/<date>.jsonl, and the day's ticks go to
data/judge/ticks/<date>_<sym>.pkl for a paper-vs-simulation comparison.
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env")
from alpaca_paper.client import AlpacaPaperClient # noqa: E402
from alpaca_paper.settings import AlpacaPaperSettings # noqa: E402
from judge import engine # noqa: E402
from judge.decide import DayContext, scan # noqa: E402
from judge.model import load as load_model # noqa: E402
from research.ingestion.snapshot_scan import SnapshotScanConfig, SnapshotScanner # noqa: E402
ET = ZoneInfo("America/New_York")
OUT = ROOT / "data" / "judge"
RISK_USD = float(os.environ.get("JUDGE_RISK_USD", "20"))
MAX_NOTIONAL = float(os.environ.get("JUDGE_MAX_NOTIONAL", "1000"))
MAX_OPEN = int(os.environ.get("JUDGE_MAX_OPEN", "4"))
MAX_TRADES_SYM = int(os.environ.get("JUDGE_MAX_TRADES_PER_SYMBOL", "6"))
SCAN_S = float(os.environ.get("JUDGE_SCAN_INTERVAL_S", "30"))
DRY = os.environ.get("JUDGE_DRY_RUN", "0") == "1"
# Software stop: sell when any trade prints at/through the stop (or the trail level), as the validated simulator does.
# Backs up the broker stop, which Alpaca's paper engine fills late in fast markets and no broker runs outside RTH.
SOFT_STOP = os.environ.get("JUDGE_SOFT_STOP", "1") == "1"
def now_et() -> pd.Timestamp:
return pd.Timestamp(datetime.now(ET).replace(tzinfo=None))
def to_et(iso: str) -> np.datetime64:
return np.datetime64(pd.Timestamp(iso).tz_convert(ET).tz_localize(None))
class Journal:
def __init__(self, day: str):
(OUT / "journal").mkdir(parents=True, exist_ok=True)
self.f = open(OUT / "journal" / f"{day}.jsonl", "a", encoding="utf-8")
def log(self, kind: str, **kw):
kw = {k: (str(v) if isinstance(v, (pd.Timestamp, np.datetime64, datetime)) else v) for k, v in kw.items()}
self.f.write(json.dumps(dict(at=str(now_et()), kind=kind, **kw), default=str) + "\n")
self.f.flush()
@dataclass
class Book:
ctx: DayContext
tt: list = field(default_factory=list); tp: list = field(default_factory=list); ts_: list = field(default_factory=list)
qt: list = field(default_factory=list); qbp: list = field(default_factory=list); qbs: list = field(default_factory=list)
qap: list = field(default_factory=list); qas: list = field(default_factory=list)
ready: bool = False
last_scan: pd.Timestamp | None = None
last_len: int = 0
seen: set = field(default_factory=set)
n_trades: int = 0
cooldown_until: pd.Timestamp | None = None
pending_stream: list = field(default_factory=list)
def T(self) -> pd.DataFrame:
return pd.DataFrame({"t": np.array(self.tt, dtype="datetime64[ns]"), "p": np.array(self.tp, dtype="float64"),
"s": np.array(self.ts_, dtype="int64")})
def Q(self) -> pd.DataFrame:
return pd.DataFrame({"t": np.array(self.qt, dtype="datetime64[ns]"), "bp": self.qbp, "bs": self.qbs,
"ap": self.qap, "as": self.qas})
def add_trade(self, t, p, s):
self.tt.append(t); self.tp.append(float(p)); self.ts_.append(int(s))
def add_quote(self, t, bp, bs, ap, as_):
if ap > 0 and bp > 0 and ap >= bp:
self.qt.append(t); self.qbp.append(float(bp)); self.qbs.append(int(bs)); self.qap.append(float(ap)); self.qas.append(int(as_))
@dataclass
class Pos:
symbol: str
qty: int
entry: float
stop: float
risk: float
fill_time: pd.Timestamp
order_id: str
leg_id: str | None
hw: float
setup: str
p: float
next_try: pd.Timestamp | None = None # throttles exit retries after a broker error
seen_n: int = -1 # index into the book's trade list already checked by the soft stop
class Trader:
def __init__(self):
key, sec = os.environ["ALPACA_V2_KEY"], os.environ["ALPACA_V2_SECRET"]
self.key, self.sec = key, sec
self.client = AlpacaPaperClient(AlpacaPaperSettings.from_env(
{"ALPACA_API_KEY": key, "ALPACA_SECRET_KEY": sec, "ALPACA_DATA_FEED": "sip",
"APCA_API_BASE_URL": "https://paper-api.alpaca.markets"}))
self.art = load_model()
self.day = now_et().strftime("%Y-%m-%d")
self.earlier = self._earlier_today()
self.j = Journal(self.day)
cfg = SnapshotScanConfig.from_env()
cfg.price_min, cfg.price_max, cfg.min_pct, cfg.max_per_sweep = 1.0, 20.0, 20.0, 10
self.scanner = SnapshotScanner(self.client, cfg)
self.books: dict[str, Book] = {}
self.pos: dict[str, Pos] = {}
self.ws = None
self.subscribed: set = set()
self.j.log("start", cutoff=self.art["cutoff"], risk_usd=RISK_USD, max_notional=MAX_NOTIONAL, dry=DRY)
def _earlier_today(self) -> dict:
"""What earlier runs of today's session did (the task relaunches the trader if it dies):
orders per symbol, so the per-symbol cap holds across restarts, and each symbol's last fill."""
out = {"orders": {}, "fills": {}, "watched": {}}
f = OUT / "journal" / f"{self.day}.jsonl"
if not f.exists():
return out
for line in f.read_text(encoding="utf-8").splitlines():
try:
r = json.loads(line)
except ValueError:
continue
if r.get("kind") == "order":
out["orders"][r["symbol"]] = out["orders"].get(r["symbol"], 0) + 1
elif r.get("kind") == "fill":
out["fills"][r["symbol"]] = r
elif r.get("kind") == "add" and r.get("prev_close"):
out["watched"][r["symbol"]] = float(r["prev_close"])
return out
async def rewatch(self):
"""Re-load every symbol an earlier run of today was watching. Without this, a relaunched trader only knew the
names still passing the scanner, so it stopped deciding on the others, and their ticks were never saved for
the paper-vs-sim review (2026-10-02: CABO, APLX)."""
for sym, pc in self.earlier.get("watched", {}).items():
if sym in self.books or len(self.books) >= 30:
continue
try:
await self.add_symbol(sym, pc)
except Exception as e: # noqa: BLE001
self.j.log("error", where="rewatch", symbol=sym, err=str(e)[:200])
async def adopt(self):
"""Take over positions left by an earlier run today, so they still get the trail, time stop and EOD flat.
A position with no known stop (no journalled fill and no working stop order) is closed rather than guessed at."""
try:
held = await asyncio.to_thread(self.client.get_positions, True)
except Exception as e: # noqa: BLE001
self.j.log("error", where="adopt", err=str(e)[:300]); return
for x in held:
sym, qty, avg = x["symbol"], int(float(x["qty"])), float(x["avg_entry_price"])
if qty <= 0:
continue
fill = self.earlier["fills"].get(sym)
try:
orders = await asyncio.to_thread(self.client.get_orders, "open", 50, True, [sym])
except Exception: # noqa: BLE001
orders = []
stops = [o for o in orders if o.get("side") == "sell" and o.get("type") == "stop"]
for o in orders:
stops += [lg for lg in (o.get("legs") or []) if lg.get("side") == "sell" and lg.get("type") == "stop"
and lg.get("status") in ("new", "accepted", "held", "pending_new")]
stop = float(stops[0]["stop_price"]) if stops else (float(fill["stop"]) if fill else None)
if stop is None:
self.j.log("adopt_close", symbol=sym, qty=qty, why="no known stop")
try:
await asyncio.to_thread(self.client.close_position, sym)
except Exception as e: # noqa: BLE001
self.j.log("error", where="adopt_close", symbol=sym, err=str(e)[:300])
continue
base = float(fill["stop"]) if fill else stop # R is measured from the original stop, not a trailed one
risk = avg - base if avg > base else 0.03 * avg
fill_time = pd.Timestamp(fill["at"]) if fill else now_et()
try:
await self.add_symbol(sym, float(x.get("lastday_price") or avg))
except Exception as e: # noqa: BLE001
self.j.log("error", where="adopt_add", symbol=sym, err=str(e)[:300])
pz = Pos(sym, qty, avg, stop, risk, fill_time, "", stops[0]["id"] if stops else None, avg,
fill.get("setup", "?") if fill else "?", float(fill.get("p") or 0) if fill else 0.0)
bk = self.books.get(sym)
if bk and bk.tt: # high-water since the fill, from the backfilled tape
k = np.searchsorted(np.array(bk.tt, dtype="datetime64[ns]"), np.datetime64(fill_time), side="left")
if bk.tp[k:]:
pz.hw = max(avg, max(bk.tp[k:]))
self.pos[sym] = pz
self.j.log("adopt", symbol=sym, qty=qty, entry=avg, stop=stop, leg_id=pz.leg_id, hw=pz.hw, fill_time=fill_time)
await self._reconcile(pz, bk.tp[-1] if bk and bk.tp else avg)
# --------------------------------------------------------------------- discovery + backfill
def _paged(self, path, key, sym, start_utc, end_utc):
rows, tok = [], None
while True:
p = self.client._data("GET", path, params={"symbols": sym, "start": start_utc, "end": end_utc, "limit": 10000,
"feed": "sip", "page_token": tok})
rows += (p.get(key) or {}).get(sym, [])
tok = p.get("next_page_token")
if not tok:
return rows
def _context(self, sym: str, prev_close: float) -> DayContext:
"""first +20% minute (-> windows), today's open gap and the 20-day median daily volume."""
d = self.day
mb = self.client.get_minute_bars([sym], start_iso=f"{d}T08:00:00Z",
end_iso=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), feed="sip").get(sym) or []
first20 = None; open_px = None
for b in mb:
t = pd.Timestamp(b["t"]).tz_convert(ET).tz_localize(None)
if first20 is None and b["h"] >= prev_close * 1.20:
first20 = t.floor("min")
if open_px is None and t.time() >= pd.Timestamp("09:30").time():
open_px = b["o"]
start = (pd.Timestamp(d) - pd.Timedelta(days=45)).strftime("%Y-%m-%d")
daily = self.client.get_daily_bars([sym], start_iso=f"{start}T00:00:00Z", feed="sip").get(sym) or []
vols = [b["v"] for b in daily if b["t"][:10] < d][-20:]
med20 = float(np.median(vols)) if len(vols) >= 5 else None
gap = (open_px / prev_close - 1) if open_px else None
return DayContext(sym, d, prev_close, gap, med20, engine.windows(d, first20))
async def add_symbol(self, sym: str, prev_close: float):
if sym in self.books:
return
ctx = await asyncio.to_thread(self._context, sym, prev_close)
bk = Book(ctx); self.books[sym] = bk
bk.n_trades = self.earlier["orders"].get(sym, 0)
await self.subscribe([sym])
start = min(s for s, _ in ctx.windows)
s_utc = start.tz_localize(ET).tz_convert("UTC").strftime("%Y-%m-%dT%H:%M:%SZ")
e_utc = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
tr = await asyncio.to_thread(self._paged, "/v2/stocks/trades", "trades", sym, s_utc, e_utc)
qu = await asyncio.to_thread(self._paged, "/v2/stocks/quotes", "quotes", sym, s_utc, e_utc)
for x in tr:
bk.add_trade(to_et(x["t"]), x["p"], x["s"])
for x in qu:
bk.add_quote(to_et(x["t"]), x["bp"], x["bs"], x["ap"], x["as"])
last_t = bk.tt[-1] if bk.tt else np.datetime64("1970-01-01")
last_q = bk.qt[-1] if bk.qt else np.datetime64("1970-01-01")
for kind, row in bk.pending_stream: # stream data that arrived during the backfill
if kind == "t" and row[0] > last_t:
bk.add_trade(*row)
elif kind == "q" and row[0] > last_q:
bk.add_quote(*row)
bk.pending_stream.clear(); bk.ready = True
bk.last_scan = now_et() - pd.Timedelta(seconds=10)
self.j.log("add", symbol=sym, prev_close=prev_close, gap=ctx.gap, med20=ctx.med20,
windows=[[str(a), str(b)] for a, b in ctx.windows], backfill_trades=len(tr), backfill_quotes=len(qu))
async def scan_loop(self):
while True:
n = now_et()
if n.time() >= pd.Timestamp("15:30").time():
return
if n.time() >= pd.Timestamp("09:15").time():
try:
res = await asyncio.to_thread(self.scanner.sweep, datetime.now(timezone.utc), set(self.books))
for h in res.hits:
if h.symbol not in self.books and len(self.books) < 30:
await self.add_symbol(h.symbol, h.prev_close)
except Exception as e: # noqa: BLE001
self.j.log("error", where="scan", err=str(e)[:300])
await asyncio.sleep(SCAN_S)
# --------------------------------------------------------------------- streaming
async def subscribe(self, syms):
new = [s for s in syms if s not in self.subscribed]
if not new:
return
self.subscribed.update(new)
if self.ws is not None:
await self.ws.send(json.dumps({"action": "subscribe", "trades": new, "quotes": new}))
async def stream_loop(self):
import websockets
url = "wss://stream.data.alpaca.markets/v2/sip"
while True:
try:
async with websockets.connect(url, max_size=2 ** 24, ping_interval=20) as ws:
await ws.send(json.dumps({"action": "auth", "key": self.key, "secret": self.sec}))
self.ws = ws
if self.subscribed:
s = sorted(self.subscribed)
await ws.send(json.dumps({"action": "subscribe", "trades": s, "quotes": s}))
self.j.log("stream", status="connected", symbols=len(self.subscribed))
async for raw in ws:
for m in json.loads(raw):
T = m.get("T"); sym = m.get("S")
bk = self.books.get(sym)
if bk is None:
continue
if T == "t":
row = (to_et(m["t"]), m["p"], m["s"])
(bk.add_trade(*row) if bk.ready else bk.pending_stream.append(("t", row)))
elif T == "q":
row = (to_et(m["t"]), m["bp"], m["bs"], m["ap"], m["as"])
(bk.add_quote(*row) if bk.ready else bk.pending_stream.append(("q", row)))
elif T in ("error", "subscription"):
self.j.log("stream", msg=m)
except Exception as e: # noqa: BLE001
self.ws = None
self.j.log("error", where="stream", err=str(e)[:300])
await asyncio.sleep(3)
# --------------------------------------------------------------------- decisions + entries
async def decide_loop(self):
while True:
n = now_et()
if n.time() >= pd.Timestamp("15:50").time():
return
for sym, bk in list(self.books.items()):
if not bk.ready or len(bk.tt) == bk.last_len:
continue
if not (n.time() >= pd.Timestamp("09:30").time() and engine.in_windows(n, bk.ctx.windows)):
bk.last_len = len(bk.tt); continue
bk.last_len = len(bk.tt)
try:
decs = await asyncio.to_thread(scan, bk.T(), bk.Q(), bk.ctx, self.art, n,
bk.last_scan - pd.Timedelta(seconds=10))
except Exception as e: # noqa: BLE001
self.j.log("error", where="scan_signals", symbol=sym, err=str(e)[:300]); continue
bk.last_scan = n
for d in decs:
key = (d.setup, str(d.ts))
if key in bk.seen:
continue
bk.seen.add(key)
age = (n - pd.Timestamp(d.ts)).total_seconds()
self.j.log("signal", symbol=sym, setup=d.setup, ts=d.ts, sp=d.sp, stop=d.stop, p=round(d.p, 4),
take=d.take, age_s=age, feats={k: (None if isinstance(v, float) and math.isnan(v) else v)
for k, v in d.feats.items()})
if d.take and age <= 5:
await self.enter(sym, bk, d)
await asyncio.sleep(1.0)
def _order(self, oid):
return self.client._trading("GET", f"/orders/{oid}", params={"nested": "true"})
async def enter(self, sym, bk, d):
why = None
if sym in self.pos:
why = "already in position"
elif len(self.pos) >= MAX_OPEN:
why = "max open positions"
elif bk.n_trades >= MAX_TRADES_SYM:
why = "max trades for symbol"
elif bk.cooldown_until is not None and now_et() < bk.cooldown_until:
why = "cooldown"
if why:
self.j.log("skip", symbol=sym, setup=d.setup, why=why); return
if not bk.qt:
return
bid, ask = bk.qbp[-1], bk.qap[-1]
chk = engine.entry_check(bid, ask, d.sp, d.stop)
if chk is None:
self.j.log("skip", symbol=sym, setup=d.setup, why="entry_check (spread/chase/stop)", bid=bid, ask=ask, sp=d.sp); return
entry, stop = chk
qty = int(min(RISK_USD / (entry - stop), MAX_NOTIONAL / entry))
if qty < 1:
self.j.log("skip", symbol=sym, why="qty<1"); return
bk.n_trades += 1
coid = f"jv2-{sym}-{now_et():%H%M%S}"
self.j.log("order", symbol=sym, setup=d.setup, qty=qty, limit=round(entry, 2), stop=round(stop, 2), p=d.p, coid=coid)
if DRY:
return
try:
o = await asyncio.to_thread(self.client.submit_order, sym, qty, "buy", "limit", "day", round(entry, 2), None,
round(stop, 2), None, coid)
except Exception as e: # noqa: BLE001
self.j.log("error", where="submit", symbol=sym, err=str(e)[:300]); return
oid = o.get("id")
filled = None
for _ in range(12):
await asyncio.sleep(0.25)
try:
st = await asyncio.to_thread(self._order, oid)
except Exception: # noqa: BLE001
continue
if st.get("status") == "filled":
filled = st; break
if filled is None:
try:
await asyncio.to_thread(self.client.cancel_order, oid)
st = await asyncio.to_thread(self._order, oid)
if float(st.get("filled_qty") or 0) > 0:
filled = st
except Exception: # noqa: BLE001
pass
if filled is None:
self.j.log("unfilled", symbol=sym, coid=coid); return
fq = int(float(filled["filled_qty"])); fpx = float(filled["filled_avg_price"])
legs = filled.get("legs") or []
leg_id = legs[0]["id"] if legs else None
self.pos[sym] = pz = Pos(sym, fq, fpx, stop, fpx - stop, now_et(), oid, leg_id, fpx, d.setup, d.p)
self.j.log("fill", symbol=sym, qty=fq, price=fpx, stop=stop, leg_id=leg_id, slippage_vs_ask=fpx - entry)
if filled.get("status") != "filled" or not leg_id:
# a partial fill whose parent was cancelled takes its OTO stop leg down with it: re-arm a plain stop
await self._arm_stop(pz)
async def _open_sell_stop(self, sym):
"""Id of a working sell stop for this symbol at the broker, if any (top level or an OTO leg)."""
orders = await asyncio.to_thread(self.client.get_orders, "open", 50, True, [sym])
for o in orders:
for x in [o, *(o.get("legs") or [])]:
if x.get("side") == "sell" and x.get("type") == "stop" and x.get("status") in ("new", "accepted", "held", "pending_new", "partially_filled"):
return x
return None
async def _arm_stop(self, pz: Pos, last: float | None = None):
if last is not None and pz.stop >= last - 0.01:
# already through the stop: the broker would reject a stop above the market, so get out now
self.j.log("stop_breached", symbol=pz.symbol, stop=round(pz.stop, 2), last=last)
try:
await asyncio.to_thread(self.client.close_position, pz.symbol)
except Exception as e: # noqa: BLE001
self.j.log("error", where="close_breached", symbol=pz.symbol, err=str(e)[:300])
return
try:
o = await asyncio.to_thread(self.client.submit_order, pz.symbol, pz.qty, "sell", "stop", "day", None,
round(pz.stop, 2), None, None, f"jv2s-{pz.symbol}-{now_et():%H%M%S}")
pz.leg_id = o.get("id")
self.j.log("stop_armed", symbol=pz.symbol, qty=pz.qty, stop=round(pz.stop, 2), leg_id=pz.leg_id)
except Exception as e: # noqa: BLE001
pz.leg_id = None
self.j.log("error", where="arm_stop", symbol=pz.symbol, err=str(e)[:300])
async def _broker_qty(self, sym) -> int:
ps = await asyncio.to_thread(self.client.get_positions, True)
return sum(int(float(x["qty"])) for x in ps if x.get("symbol") == sym)
def _closed(self, pz: Pos, reason: str, price: float):
self.j.log("exit", symbol=pz.symbol, reason=reason, price=price, entry=pz.entry, r_est=(price - pz.entry) / pz.risk)
self.pos.pop(pz.symbol, None)
bk = self.books.get(pz.symbol)
if bk:
bk.cooldown_until = now_et() + pd.Timedelta(seconds=engine.EXEC["cooldown_s"])
async def _reconcile(self, pz: Pos, last: float) -> bool:
"""Match our record to the broker. True if the position is gone (and has been recorded as closed).
If the stop leg is no longer working but shares remain, a fresh stop is armed."""
leg = None
if pz.leg_id:
try:
leg = await asyncio.to_thread(self._order, pz.leg_id)
for _ in range(5): # a moved stop is a new order: follow the replacement chain
if leg.get("status") != "replaced" or not leg.get("replaced_by"):
break
pz.leg_id = leg["replaced_by"]
leg = await asyncio.to_thread(self._order, pz.leg_id)
except Exception: # noqa: BLE001
leg = None
if leg and leg.get("status") == "filled":
self._closed(pz, "stop", float(leg.get("filled_avg_price") or pz.stop)); return True
try:
held = await self._broker_qty(pz.symbol)
except Exception as e: # noqa: BLE001
self.j.log("error", where="reconcile", symbol=pz.symbol, err=str(e)[:200]); return False
if held <= 0:
self._closed(pz, "flat_at_broker", last); return True
if held != pz.qty:
self.j.log("qty_sync", symbol=pz.symbol, ours=pz.qty, broker=held); pz.qty = held
if leg is None or leg.get("status") in ("canceled", "expired", "rejected", "replaced", "done_for_day"):
try:
working = await self._open_sell_stop(pz.symbol)
except Exception: # noqa: BLE001
working = None
if working: # a stop we lost track of already holds the shares: adopt it, don't double up
pz.leg_id = working["id"]
else:
await self._arm_stop(pz, last)
return False
# --------------------------------------------------------------------- exits
async def exit_pos(self, pz: Pos, reason: str, last: float):
if pz.next_try is not None and now_et() < pz.next_try:
return
if await self._reconcile(pz, last): # the broker stop may already have closed it
return
try:
# cancel every working order on the symbol (the stop may have been replaced under a new id);
# otherwise its shares stay reserved and close_position is refused
try:
orders = await asyncio.to_thread(self.client.get_orders, "open", 50, True, [pz.symbol])
except Exception: # noqa: BLE001
orders = [{"id": pz.leg_id}] if pz.leg_id else []
for o in orders:
try:
await asyncio.to_thread(self.client.cancel_order, o["id"])
except Exception: # noqa: BLE001 (filled/cancelled in the meantime: close_position decides)
pass
await asyncio.to_thread(self.client.close_position, pz.symbol)
except Exception as e: # noqa: BLE001
self.j.log("error", where="exit", symbol=pz.symbol, err=str(e)[:300])
if not await self._reconcile(pz, last):
pz.next_try = now_et() + pd.Timedelta(seconds=5)
return
self._closed(pz, reason, last)
@staticmethod
def _soft_stop_hit(pz: Pos, prices) -> str | None:
"""Walk the new trades in order like the simulator: update the high-water, then exit if a print is at or
below the stop, or below the 6% trail once +1R. Returns 'soft_stop' / 'soft_trail' or None."""
P = engine.EXEC
for px in prices:
pz.hw = max(pz.hw, px)
level = pz.stop
if (pz.hw - pz.entry) / pz.risk >= P["trail_after_r"]:
level = max(level, pz.hw * (1 - P["trail_pct"]), pz.entry)
if px <= level:
return "soft_stop" if level <= pz.stop else "soft_trail"
return None
async def manage_loop(self):
tick = 0
while True:
n = now_et(); tick += 1
for sym, pz in list(self.pos.items()):
bk = self.books.get(sym)
if bk is None: # adopted position whose backfill failed: manage it off the entry price
bk = self.books[sym] = Book(DayContext(sym, self.day, pz.entry, None, None, []))
bk.ready = True
if tick % 5 == 0 and await self._reconcile(pz, bk.tp[-1] if bk.tp else pz.entry):
continue
k = np.searchsorted(np.array(bk.tt[-5000:], dtype="datetime64[ns]"), np.datetime64(pz.fill_time), side="left")
recent = bk.tp[-5000:][k:]
last = recent[-1] if recent else pz.entry
if SOFT_STOP:
start = max(pz.seen_n, len(bk.tp) - len(bk.tp[-5000:]) + int(k))
hit = self._soft_stop_hit(pz, bk.tp[start:])
pz.seen_n = len(bk.tp)
if hit is not None:
await self.exit_pos(pz, hit, last); continue
if recent:
pz.hw = max(pz.hw, max(recent))
if n.time() >= pd.Timestamp("15:55").time():
await self.exit_pos(pz, "eod", last); continue
if (n - pz.fill_time).total_seconds() >= engine.EXEC["hold_s"]:
await self.exit_pos(pz, "time", last); continue
if (pz.hw - pz.entry) / pz.risk >= engine.EXEC["trail_after_r"]:
new = max(pz.stop, pz.hw * (1 - engine.EXEC["trail_pct"]), pz.entry)
if new >= last:
await self.exit_pos(pz, "trail", last); continue
if new > pz.stop + 0.01 and pz.leg_id:
try:
o = await asyncio.to_thread(self.client.replace_order, pz.leg_id, stop_price=round(new, 2))
if o and o.get("id"):
pz.leg_id = o["id"] # Alpaca's replace returns a new order; the old id is dead
self.j.log("stop_move", symbol=sym, frm=pz.stop, to=round(new, 2), leg_id=pz.leg_id); pz.stop = new
except Exception as e: # noqa: BLE001
self.j.log("error", where="replace", symbol=sym, err=str(e)[:200])
await self._reconcile(pz, last)
if n.time() >= pd.Timestamp("16:00").time() and not self.pos:
return
await asyncio.sleep(1.0)
def save_ticks(self):
(OUT / "ticks").mkdir(parents=True, exist_ok=True)
for sym, bk in self.books.items():
pd.to_pickle(dict(trades=bk.T(), quotes=bk.Q(), windows=bk.ctx.windows, prev_close=bk.ctx.prev_close,
gap=bk.ctx.gap, med20=bk.ctx.med20), OUT / "ticks" / f"{self.day}_{sym}.pkl")
def trading_day(self) -> bool:
"""False on weekends/holidays: the broker clock's next open is not today and the market is closed."""
try:
c = self.client.get_clock()
if c.get("is_open"):
return True
nxt = pd.Timestamp(c.get("next_open")).tz_convert(ET).strftime("%Y-%m-%d")
return nxt == self.day
except Exception: # noqa: BLE001
return now_et().weekday() < 5
async def run(self):
if not self.trading_day():
self.j.log("stop", why="not a trading day"); return
tasks = [asyncio.create_task(self.stream_loop()), asyncio.create_task(self.scan_loop()),
asyncio.create_task(self.decide_loop())]
await self.adopt()
await self.rewatch()
await self.manage_loop()
for t in tasks:
t.cancel()
self.save_ticks()
self.j.log("stop", books=len(self.books))
if __name__ == "__main__":
if sys.stdout is None or sys.stderr is None: # pythonw (no console window): keep crash output in the log
(OUT / "logs").mkdir(parents=True, exist_ok=True)
_log = open(OUT / "logs" / f"live_{now_et():%Y-%m-%d}.log", "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = _log
asyncio.run(Trader().run())
+68
View File
@@ -0,0 +1,68 @@
"""Train + freeze the v2 judgement model exactly as validated in data/ticksim/PREREG_v2.md.
usage: python -m judge.model -> writes data/judge/model_v2.joblib (+ .json metadata)
"""
from __future__ import annotations
import json
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier
from judge.engine import FEATS, SETUP_I
ROOT = Path(__file__).resolve().parent.parent
TICKSIM = ROOT / "data" / "ticksim"
OUT = ROOT / "data" / "judge"
PARAMS = dict(max_iter=200, learning_rate=0.05, max_leaf_nodes=15, min_samples_leaf=40, l2_regularization=1.0,
random_state=0)
def _prep(df: pd.DataFrame) -> pd.DataFrame:
daily = pd.read_pickle(ROOT / "data" / "ross" / "daily_all.pkl")
med = daily.sort_values("date").groupby("symbol").v.transform(lambda s: s.shift(1).rolling(20, min_periods=5).median())
daily = daily.assign(med20=med)[["symbol", "date", "gap", "med20"]]
if "gap" not in df:
df = df.merge(daily, on=["symbol", "date"], how="left")
df["cumvol_vs_med"] = df.cum_vol / df.med20
df["setup_i"] = df.setup.map(SETUP_I)
df["gap_known"] = np.where(df.rth == 1, df.gap, np.nan)
return df
def train() -> dict:
s1 = _prep(pd.read_pickle(TICKSIM / "ml_data.pkl"))
s2 = _prep(pd.read_pickle(TICKSIM / "ml_fresh_data.pkl"))
tr = pd.concat([s1, s2], ignore_index=True)
tr = tr[tr.date < "2026-08-01"]
m = HistGradientBoostingClassifier(**PARAMS).fit(tr[FEATS], (tr.r > 0).astype(int))
cutoff = float(np.quantile(m.predict_proba(tr[tr.rth == 1][FEATS])[:, 1], 0.8))
meta = dict(version="v2", trained_on="samples 1+2, signals dated < 2026-08-01", n_train=int(len(tr)),
cutoff=cutoff, features=FEATS, params=PARAMS, validation="PREREG_v2 PASS: sample-3 Aug-Sep RTH "
"top20% n=332 +1.11%/trade (day-block lo +0.54%)")
return dict(model=m, **meta)
def save(art: dict) -> Path:
OUT.mkdir(parents=True, exist_ok=True)
p = OUT / "model_v2.joblib"
joblib.dump(art, p)
(OUT / "model_v2.json").write_text(json.dumps({k: v for k, v in art.items() if k != "model"}, indent=2, default=str))
return p
def load(path: Path | None = None) -> dict:
return joblib.load(path or OUT / "model_v2.joblib")
def score(art: dict, feats: dict) -> float:
x = pd.DataFrame([{k: feats.get(k, np.nan) for k in art["features"]}])
return float(art["model"].predict_proba(x)[:, 1][0])
if __name__ == "__main__":
a = train()
print("saved", save(a), "cutoff", round(a["cutoff"], 4), "n_train", a["n_train"])
+79
View File
@@ -0,0 +1,79 @@
"""Parity check: feed recorded tick days through judge.decide in live-like 1-second steps and compare the
scored signals with the research pipeline (data/ticksim/ml_s3_data.pkl + the v2 model).
usage: python -m judge.replay [n_days]
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from judge import engine
from judge.decide import DayContext, scan
from judge.model import load, ROOT
TICKSIM = ROOT / "data" / "ticksim"
def ctx_for(path: Path, d: dict) -> DayContext:
date, sym = path.stem.split("_", 1)
daily = _daily()
row = daily[(daily.symbol == sym) & (daily.date == date)]
gap = float(row.gap.iloc[0]) if len(row) else None
med20 = float(row.med20.iloc[0]) if len(row) and pd.notna(row.med20.iloc[0]) else None
ws = [[pd.Timestamp(s), pd.Timestamp(e)] for s, e in d["windows"]]
return DayContext(sym, date, float(d["prev_close"]), gap, med20, ws)
_D = None
def _daily():
global _D
if _D is None:
daily = pd.read_pickle(ROOT / "data" / "ross" / "daily_all.pkl")
med = daily.sort_values("date").groupby("symbol").v.transform(lambda s: s.shift(1).rolling(20, min_periods=5).median())
_D = daily.assign(med20=med)[["symbol", "date", "gap", "med20"]]
return _D
def replay_day(path: Path, art: dict, step_s: int = 1) -> list:
d = pd.read_pickle(path)
T = d["trades"].sort_values("t").reset_index(drop=True)
Q = d["quotes"].sort_values("t").reset_index(drop=True)
Q = Q[(Q.ap > 0) & (Q.bp > 0) & (Q.ap >= Q.bp)].reset_index(drop=True)
ctx = ctx_for(path, d)
start = max(pd.Timestamp(f"{ctx.date} 09:30"), T.t.min())
end = T.t.max()
seen = {}
now = start
last = None
while now <= end:
k = np.searchsorted(T.t.values, np.datetime64(now), side="right")
kq = np.searchsorted(Q.t.values, np.datetime64(now), side="right")
for dcs in scan(T.iloc[:k], Q.iloc[:kq], ctx, art, now=now, after=last):
key = (dcs.setup, str(dcs.ts))
if key not in seen:
seen[key] = dcs
last = now - pd.Timedelta(seconds=10) # re-check a short overlap (partial-bar signals)
now += pd.Timedelta(seconds=step_s)
return list(seen.values())
if __name__ == "__main__":
n = int(sys.argv[1]) if len(sys.argv) > 1 else 3
art = load()
research = pd.read_pickle(TICKSIM / "ml_s3_data.pkl")
files = sorted(TICKSIM.glob("ticks/*.pkl"))
s3 = set(research.date + "_" + research.symbol)
files = [f for f in files if f.stem in s3][:n]
for f in files:
date, sym = f.stem.split("_", 1)
live = replay_day(f, art, step_s=5)
r = research[(research.date == date) & (research.symbol == sym) & (research.rth == 1)]
live_take = sorted((x.setup, round(x.p, 4)) for x in live if x.take)
print(f"{f.stem}: live-style signals {len(live)} taken {len(live_take)} | research executed RTH trades {len(r)}")
print(" live taken:", live_take[:8])
+160
View File
@@ -0,0 +1,160 @@
"""Per-trade review of a judge paper-trading day against the simulator, on the day's recorded ticks.
For every order the trader sent, it lines up what the paper account did with what the validated simulator
(data/ticksim/sim.py rules) does on the same ticks:
- filled orders: the sim exit from the actual fill (stop / 6% trail after +1R / 20-min time stop, sell at the bid
300 ms after the trigger) vs the paper exit. Flagged when R differs by > FLAG_R or the exit reason differs.
- unfilled orders: the R the sim would have made from the order's limit price (the paper-only cost of missing).
The day summary is the evidence for the paper-vs-simulation question before any real money.
usage: python -m judge.review [YYYY-MM-DD ...] (default: the latest journal with saved ticks)
writes data/judge/reviews/<date>.md
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from judge import engine
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "data" / "judge"
FLAG_R = 0.3
LAT = np.timedelta64(int(engine.EXEC["lat_ms"]), "ms")
def sim_exit(T: pd.DataFrame, Q: pd.DataFrame, t0, entry: float, stop: float) -> dict | None:
"""The simulator's exit for a long entered at `entry` at time t0 with initial `stop` (sim.py exit loop)."""
if stop >= entry or T.empty or Q.empty:
return None
tt, tp = T.t.values, T.p.values
qt = Q.t.values
P = engine.EXEC
risk = entry - stop
k = np.searchsorted(tt, np.datetime64(t0), side="right")
deadline = np.datetime64(t0) + np.timedelta64(int(P["hold_s"]), "s")
hw, cur = entry, stop
reason, trig = "eod", tt[-1]
while k < len(tt):
px, tk = tp[k], tt[k]
if tk >= deadline:
reason, trig = "time", tk
break
hw = max(hw, px)
if (hw - entry) / risk >= P["trail_after_r"]:
cur = max(cur, hw * (1 - P["trail_pct"]), entry)
if px <= cur:
reason, trig = ("stop" if cur <= stop else "trail"), tk
break
k += 1
qj = np.searchsorted(qt, trig + LAT, side="right") - 1
px = float(Q.bp.iat[max(qj, 0)])
return dict(exit=px, r=(px - entry) / risk, reason=reason, at=pd.Timestamp(trig))
def load_ticks(day: str, sym: str):
f = OUT / "ticks" / f"{day}_{sym}.pkl"
if not f.exists():
return None, None
d = pd.read_pickle(f)
T = d["trades"].sort_values("t").reset_index(drop=True)
Q = d["quotes"].sort_values("t").reset_index(drop=True)
return T, Q[(Q.ap > 0) & (Q.bp > 0) & (Q.ap >= Q.bp)].reset_index(drop=True)
def review(day: str) -> tuple[pd.DataFrame, str]:
rows = []
for line in (OUT / "journal" / f"{day}.jsonl").read_text(encoding="utf-8").splitlines():
try:
rows.append(json.loads(line))
except ValueError:
pass
out, open_ = [], {}
for r in rows:
k = r.get("kind")
if k == "order":
open_[r["symbol"]] = dict(order=r, fill=None)
elif k == "unfilled" and r["symbol"] in open_:
o = open_.pop(r["symbol"])["order"]
out.append(_row(day, o, None, None))
elif k == "fill" and r["symbol"] in open_:
open_[r["symbol"]]["fill"] = r
elif k == "exit" and r["symbol"] in open_ and open_[r["symbol"]]["fill"]:
o = open_.pop(r["symbol"])
out.append(_row(day, o["order"], o["fill"], r))
df = pd.DataFrame(out)
return df, _markdown(day, df)
def _row(day, o, f, x) -> dict:
sym = o["symbol"]
T, Q = load_ticks(day, sym)
base = dict(sym=sym, setup=o.get("setup"), p=round(float(o.get("p") or 0), 3), order_at=o["at"][11:19],
limit=o.get("limit"), stop=o.get("stop"))
if f is None:
s = sim_exit(T, Q, pd.Timestamp(o["at"]), float(o["limit"]), float(o["stop"])) if T is not None else None
return {**base, "status": "unfilled", "sim_r": None if s is None else round(s["r"], 2),
"sim_reason": None if s is None else s["reason"]}
entry, stop = float(f["price"]), float(f["stop"])
risk = entry - stop
paper_px = x.get("price") if x.get("price") is not None else x.get("last")
paper_r = (float(paper_px) - entry) / risk if paper_px is not None and risk > 0 else None
s = sim_exit(T, Q, pd.Timestamp(f["at"]), entry, stop) if T is not None else None
row = {**base, "status": "filled", "fill": entry, "entry_slip": round(entry - float(o["limit"]), 4),
"paper_r": None if paper_r is None else round(paper_r, 2), "paper_reason": x.get("reason"),
"exit_at": x["at"][11:19],
"sim_r": None if s is None else round(s["r"], 2), "sim_reason": None if s is None else s["reason"],
"sim_exit_at": None if s is None else str(s["at"])[11:19]}
flags = []
if T is None:
flags.append("no ticks")
elif s and paper_r is not None:
if abs(paper_r - s["r"]) > FLAG_R:
flags.append(f"R gap {paper_r - s['r']:+.2f}")
why = (x.get("reason") or "").replace("soft_", "")
if why not in (s["reason"], "flat_at_broker") and not (why == "stop" and s["reason"] == "trail"):
flags.append(f"reason {x.get('reason')} vs sim {s['reason']}")
if abs(row["entry_slip"]) > 0.005:
flags.append(f"entry slip {row['entry_slip']:+.3f}")
row["flags"] = "; ".join(flags)
return row
def _markdown(day: str, df: pd.DataFrame) -> str:
if df.empty:
return f"# Judge paper review {day}\n\nNo orders.\n"
fl = df[df.status == "filled"]
uf = df[df.status == "unfilled"]
lines = [f"# Judge paper review {day}", "",
f"- orders {len(df)}: filled {len(fl)}, unfilled {len(uf)}",
f"- filled: paper R sum {fl.paper_r.sum():+.2f} (mean {fl.paper_r.mean():+.2f}) | "
f"sim on the same fills {fl.sim_r.sum():+.2f} (mean {fl.sim_r.mean():+.2f})" if len(fl) else "- filled: none",
f"- unfilled: the sim would have made {uf.sim_r.sum():+.2f}R on them" if len(uf) else "- unfilled: none",
f"- flagged trades: {int((fl['flags'] != '').sum()) if len(fl) else 0}", "",
_table(df.fillna("")), ""]
return "\n".join(lines)
def _table(df: pd.DataFrame) -> str:
cols = list(df.columns)
out = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
out += ["| " + " | ".join(str(v) for v in r) + " |" for r in df.itertuples(index=False)]
return "\n".join(out)
def main(argv=None) -> None:
days = (argv if argv is not None else sys.argv[1:]) or [
sorted({p.stem.split("_")[0] for p in (OUT / "ticks").glob("*.pkl")})[-1]]
(OUT / "reviews").mkdir(parents=True, exist_ok=True)
for day in days:
df, md = review(day)
(OUT / "reviews" / f"{day}.md").write_text(md, encoding="utf-8")
print(md)
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
@echo off
rem Paper trial of the v2 judgement model (Alpaca paper "Momentum V2"). Started by Task Scheduler at 08:10 local
rem (09:10 ET); the process exits by itself after 16:00 ET. Log: data\judge\logs\live_YYYY-MM-DD.log
rem The task re-fires every 5 min until 15:00 local (IgnoreNew): if the trader died it is relaunched, and it adopts
rem any open position and today's per-symbol order counts from the journal.
setlocal
cd /d "%~dp0.."
set PYTHONIOENCODING=utf-8
if not exist data\judge\logs mkdir data\judge\logs
for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyy-MM-dd"') do set RUNDATE=%%i
data\ross\.venv\Scripts\python.exe -m judge.live >> "data\judge\logs\live_%RUNDATE%.log" 2>&1
exit /b %ERRORLEVEL%
+1
View File
@@ -0,0 +1 @@
"""Real-time press-release / SEC filing recorder (premarket news-timing research)."""
+161
View File
@@ -0,0 +1,161 @@
"""Record every press release and SEC 8-K / 6-K / 424B filing the moment we first see it.
Why: premarket news timing could not be tested on history. The Benzinga feed carries almost no PR-wire items for
microcaps, and wire RSS feeds only show the latest few dozen items. From now on each item is logged with OUR
first-seen time (UTC) and the tickers it names, so later we can ask: "a fresh release hits the wire and the move
starts N minutes later; is that tradable?"
usage: python -m prwire.recorder (runs until killed; Task Scheduler re-fires it if it dies)
out: data/prwire/<YYYY-MM-DD ET>.jsonl, one JSON object per new item:
seen_utc, feed, title, link, guid, published, tickers, cik, form, snippet
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from research.ingestion.rss import parse_rss # noqa: E402
from research.ingestion.sec_dilution import USER_AGENT # noqa: E402
ET = ZoneInfo("America/New_York")
OUT = ROOT / "data" / "prwire"
POLL_S = float(os.environ.get("PRWIRE_POLL_S", "20"))
_SEC = ("https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type={t}&company=&dateb=&owner=include"
"&start=0&count=100&output=atom")
FEEDS = {
"prnewswire": "https://www.prnewswire.com/rss/news-releases-list.rss",
"globenewswire": "https://www.globenewswire.com/RssFeed/feedTitle/GlobeNewswire%20-%20All%20News",
"businesswire": "https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeEFtRWA==",
"sec_8k": _SEC.format(t="8-K"),
"sec_6k": _SEC.format(t="6-K"),
"sec_424b": _SEC.format(t="424B"),
}
# "(Nasdaq: ABCD)", "NASDAQ CM: ABCD", "NYSE American: XYZ", "OTCQB: ABCD", "Nasdaq:ABCD" ...
TICKER_RE = re.compile(
r"\b(?:NASDAQ|Nasdaq|NYSE(?:\s+(?:American|MKT|Arca))?|AMEX|OTCQ[BX]|OTC(?:\s+Markets)?|OTC\s+Pink|Cboe)"
r"(?:\s*(?:CM|GM|GS|Capital\s+Market|Global\s+(?:Select\s+)?Market))?\s*[:\-]\s*\$?([A-Z]{1,5}(?:\.[A-Z]{1,2})?)\b")
CIK_RE = re.compile(r"\((\d{10})\)")
FORM_RE = re.compile(r"^\s*([0-9A-Z\-/]+)\s+-\s+")
def extract_tickers(text: str) -> list[str]:
return sorted(set(TICKER_RE.findall(text or "")))
def _cik_map() -> dict[int, str]:
f = ROOT / "data" / "ross" / "judge" / "edgar_cache" / "company_tickers.json"
if not f.exists() or time.time() - f.stat().st_mtime > 7 * 86400:
try:
req = urllib.request.Request("https://www.sec.gov/files/company_tickers.json",
headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as r:
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(r.read())
except Exception: # noqa: BLE001
if not f.exists():
return {}
return {int(v["cik_str"]): v["ticker"].upper() for v in json.loads(f.read_text()).values()}
def item_key(feed: str, it: dict) -> str:
return hashlib.sha1(f"{feed}|{it.get('url') or ''}|{it.get('title') or ''}".encode()).hexdigest()
def to_record(feed: str, it: dict, seen_utc: str, ciks: dict[int, str]) -> dict:
title, snippet = it.get("title") or "", it.get("snippet") or ""
rec = dict(seen_utc=seen_utc, feed=feed, title=title, link=it.get("url"), guid=item_key(feed, it),
published=it.get("published"), tickers=extract_tickers(f"{title} {snippet}"), cik=None, form=None,
snippet=snippet[:400])
if feed.startswith("sec_"):
m = CIK_RE.search(title)
if m:
rec["cik"] = int(m.group(1))
t = ciks.get(rec["cik"])
rec["tickers"] = [t] if t else []
f = FORM_RE.match(title)
rec["form"] = f.group(1) if f else None
return rec
def fetch(url: str) -> list[dict]:
ua = USER_AGENT if "sec.gov" in url else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) momentum-research/1.0"
req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "application/rss+xml,application/xml,*/*"})
with urllib.request.urlopen(req, timeout=15) as r:
return parse_rss(r.read())
class Recorder:
def __init__(self):
OUT.mkdir(parents=True, exist_ok=True)
self.ciks = _cik_map()
self.seen: set[str] = set()
for f in sorted(OUT.glob("*.jsonl"))[-3:]: # don't re-log items already recorded before a restart
for line in f.read_text(encoding="utf-8").splitlines():
try:
g = json.loads(line).get("guid")
except ValueError:
continue
if g:
self.seen.add(g)
self.errors: dict[str, int] = {}
self.skip_until: dict[str, float] = {}
self.pool = ThreadPoolExecutor(max(1, len(FEEDS)))
def poll_once(self, fetcher=fetch, now: datetime | None = None) -> int:
"""Fetch every due feed in parallel (one slow feed never delays the rest) and log the new items.
A failing feed backs off 20 s, 40 s, 80 s... up to 10 min instead of being hit every poll."""
now = now or datetime.now(timezone.utc)
seen_utc = now.isoformat(timespec="seconds")
day = now.astimezone(ET).strftime("%Y-%m-%d")
mono = time.monotonic()
due = {f: u for f, u in FEEDS.items() if self.skip_until.get(f, 0.0) <= mono}
def get(url):
try:
return fetcher(url), None
except Exception as e: # noqa: BLE001
return None, e
results = dict(zip(due, self.pool.map(get, due.values())))
new = 0
with open(OUT / f"{day}.jsonl", "a", encoding="utf-8") as fh:
for feed, (items, err) in results.items():
if err is not None:
n = self.errors[feed] = self.errors.get(feed, 0) + 1
self.skip_until[feed] = mono + min(600.0, POLL_S * 2 ** (n - 1))
if n in (1, 10, 100): # log an outage, not every failed poll
fh.write(json.dumps(dict(seen_utc=seen_utc, feed=feed, error=str(err)[:200], guid=None)) + "\n")
continue
if self.errors.get(feed):
fh.write(json.dumps(dict(seen_utc=seen_utc, feed=feed, recovered_after=self.errors[feed],
guid=None)) + "\n")
self.errors[feed] = 0
for it in items:
k = item_key(feed, it)
if k in self.seen:
continue
self.seen.add(k)
fh.write(json.dumps(to_record(feed, it, seen_utc, self.ciks)) + "\n")
new += 1
return new
def run(self):
while True:
t0 = time.time()
self.poll_once()
time.sleep(max(1.0, POLL_S - (time.time() - t0)))
if __name__ == "__main__":
Recorder().run()
+9
View File
@@ -0,0 +1,9 @@
@echo off
rem Press-release / SEC filing recorder. Task Scheduler "Momentum\PrWireRecorder" re-fires this every 10 min
rem (IgnoreNew), so it restarts by itself if it dies. Output: data\prwire\YYYY-MM-DD.jsonl
setlocal
cd /d "%~dp0.."
set PYTHONIOENCODING=utf-8
if not exist data\prwire mkdir data\prwire
data\ross\.venv\Scripts\python.exe -m prwire.recorder >> "data\prwire\recorder.log" 2>&1
exit /b %ERRORLEVEL%
+274
View File
@@ -0,0 +1,274 @@
"""Daily after-close report: the two things that matter right now, plus the plumbing they depend on.
1. the live opening-range bot's frozen 80-trade test (research/live_test_freeze.json)
2. the judge v2 paper trial (judge/PAPER_TRIAL.md): paper vs simulator, trade by trade
plus the old-vs-new exit A/B (paper stack 5433 vs live 5434) and stack / recorder health.
ALERTS at the top list anything the owner has to act on. Runs from Task Scheduler "Momentum\\DailyReport" on
weekdays after the close; read-only everywhere (PG_SKIP_SCHEMA, plain SELECTs).
usage: python -m research.daily_report [YYYY-MM-DD]
out: data/reports/daily_<date>.md
"""
from __future__ import annotations
import json
import os
import random
import statistics as st
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ["PG_SKIP_SCHEMA"] = "1"
from dotenv import dotenv_values # noqa: E402
from research.live_scorecard import (bootstrap_mean_ci, config_drift, load_freeze, # noqa: E402
load_trades, verdict)
ET = ZoneInfo("America/New_York")
OUT = ROOT / "data" / "reports"
TRIAL_START = "2026-10-02"
AB_SINCE = "2026-09-14"
class Con:
"""Just enough of the research-DB interface for live_scorecard's helpers ('?' placeholders)."""
def __init__(self, port: int):
import psycopg2
u = urlparse(dotenv_values(ROOT / ".env")["DATABASE_URL"])
self.c = psycopg2.connect(host=u.hostname, port=port, user=u.username, password=u.password,
dbname=u.path[1:], connect_timeout=10)
self.c.autocommit = True
with self.c.cursor() as cur:
cur.execute("SET statement_timeout = 120000")
def execute(self, sql, params=()):
cur = self.c.cursor()
cur.execute(sql.replace("%", "%%").replace("?", "%s"), list(params))
return cur
def live_section(con, today, alerts) -> list[str]:
freeze = load_freeze()
since = freeze["frozen_on"]
trades = load_trades(con, since)
rs = [t["r"] for t in trades if t["r"] is not None]
pnl = sum(t["pnl"] for t in trades)
halts = len({ts.date() for ts, _ in con.execute(
"SELECT timestamp, message FROM events WHERE event_type='risk_rule_triggered' AND timestamp >= ? "
"AND message LIKE 'EQUITY FLOOR%'", [since]).fetchall() if 9 <= ts.hour < 16})
eq = [json.loads(r[0]).get("total_equity") for r in con.execute(
"SELECT payload_json FROM events WHERE event_type='account_summary_updated' AND timestamp >= ? "
"ORDER BY timestamp DESC LIMIT 1", [today]).fetchall()]
current = dict(con.execute("SELECT key, value FROM config_overrides").fetchall())
drift = config_drift(freeze["settings"], current)
v = verdict(rs, pnl, halts)
lines = ["## 1. Live opening-range bot: frozen 80-trade test", "",
f"- **{v}** (since {since})"]
if rs:
lo, hi = bootstrap_mean_ci(rs)
lines.append(f"- mean R {st.mean(rs):+.3f} (90% CI {lo:+.2f} .. {hi:+.2f}), win {sum(r > 0 for r in rs)}/{len(rs)}, "
f"realized P&L ${pnl:+.2f}")
if eq and eq[0]:
floor = float(freeze["settings"].get("TRADING_EQUITY_FLOOR", 215))
lines.append(f"- equity ${float(eq[0]):.2f} (floor ${floor:.0f}, headroom ${float(eq[0]) - floor:.2f})")
if float(eq[0]) - floor < 5:
alerts.append(f"Live equity ${float(eq[0]):.2f} is within $5 of the ${floor:.0f} floor.")
todays = [t for t in trades if str(t["ts"])[:10] == today]
missed_today = [t for t in todays if t.get("source") == "broker"]
if missed_today:
alerts.append("Journal missed " + ", ".join(t["symbol"] for t in missed_today)
+ " today: the broker filled a round trip with no position_closed event "
"(counted in the scorecard from the fills).")
lines.append(f"- today: {len(todays)} trade(s)" + "".join(
f"\n - {t['symbol']} {t['r']:+.2f}R ${t['pnl']:+.2f} ({t['reason']})" if t["r"] is not None else
f"\n - {t['symbol']} ${t['pnl']:+.2f} ({t['reason']})" for t in todays))
for d in freeze.get("known_deviations", []):
if not d.get("to"):
lines.append(f"- open deviation since {d['from']}: {d['what'][:140]}...")
if drift:
alerts.append("Live settings drifted from the freeze: " + ", ".join(f"{k} {a!r}->{b!r}" for k, (a, b) in drift.items()))
if v.startswith("FAIL") or v.startswith("PASS"):
alerts.append(f"Live test verdict: {v}")
return lines + [""]
def ab_section(paper, live, alerts) -> list[str]:
P, L = load_trades(paper, AB_SINCE, broker_check=False), load_trades(live, AB_SINCE)
used, diff = set(), []
for x in L:
for i, p in enumerate(P):
if i not in used and str(p["ts"])[:10] == str(x["ts"])[:10] and p["symbol"] == x["symbol"] \
and p["r"] is not None and x["r"] is not None:
used.add(i); diff.append(x["r"] - p["r"]); break
lines = ["## Exit A/B: live 12% trail vs paper old tight exits (same stock, same day)", ""]
if diff:
rng = random.Random(1)
bs = sorted(st.mean(rng.choices(diff, k=len(diff))) for _ in range(10000))
lines.append(f"- pairs {len(diff)}; live minus old {st.mean(diff):+.3f}R (90% CI {bs[500]:+.2f} .. {bs[9500]:+.2f})")
if len(diff) >= 50:
alerts.append(f"Exit A/B reached {len(diff)} pairs: decision point on the live exits (after the 80-trade test).")
return lines + [""]
def judge_section(today, alerts) -> list[str]:
from judge.review import review
jdir = ROOT / "data" / "judge"
days = sorted(p.stem for p in (jdir / "journal").glob("*.jsonl") if p.stem >= TRIAL_START)
lines = ["## 2. Judge v2 paper trial (judge/PAPER_TRIAL.md)", ""]
all_f, today_df = [], None
for d in days:
if not any((jdir / "ticks").glob(f"{d}_*.pkl")):
continue
df, md = review(d)
(jdir / "reviews").mkdir(exist_ok=True)
(jdir / "reviews" / f"{d}.md").write_text(md, encoding="utf-8")
if d == today:
today_df = df
if len(df):
all_f.append(df.assign(day=d))
import pandas as pd
allx = pd.concat(all_f, ignore_index=True) if all_f else pd.DataFrame()
fl = allx[allx.status == "filled"] if len(allx) else allx
n = len(fl)
lines.append(f"- counted trades {n}/60 (since {TRIAL_START}); orders {len(allx)}, unfilled "
f"{int((allx.status == 'unfilled').sum()) if len(allx) else 0}")
if n:
gap = (fl.paper_r - fl.sim_r).dropna().tolist()
if gap:
rng = random.Random(2)
bs = sorted(st.mean(rng.choices(gap, k=len(gap))) for _ in range(10000))
lines.append(f"- execution parity: paper minus sim {st.mean(gap):+.3f}R per trade "
f"(90% CI {bs[500]:+.2f} .. {bs[9500]:+.2f}); target >= -0.15R")
if n >= 60:
ok = st.mean(gap) >= -0.15 and bs[9500] >= 0
alerts.append(f"Judge paper trial reached {n} trades: parity {'HOLDS' if ok else 'FAILS'} "
f"({st.mean(gap):+.3f}R). Decision per judge/PAPER_TRIAL.md.")
lines.append(f"- paper R sum {fl.paper_r.sum():+.2f} (mean {fl.paper_r.mean():+.2f}); "
f"sim on the same fills {fl.sim_r.sum():+.2f}")
if today_df is not None and len(today_df):
tf = today_df[today_df.status == "filled"]
flagged = tf[tf["flags"] != ""] if len(tf) else tf
lines.append(f"- today: {len(tf)} filled, {len(today_df) - len(tf)} unfilled, {len(flagged)} flagged "
f"(details: data/judge/reviews/{today}.md)")
for r in flagged.itertuples():
lines.append(f" - {r.sym}: paper {r.paper_r} vs sim {r.sim_r} [{r.flags}]")
j = jdir / "journal" / f"{today}.jsonl"
if j.exists():
rows = [json.loads(x) for x in j.read_text(encoding="utf-8").splitlines() if x.strip()]
errs = [r for r in rows if r.get("kind") == "error"]
starts = [r for r in rows if r.get("kind") == "start"]
stopped = any(r.get("kind") == "stop" for r in rows)
lines.append(f"- trader: {len(starts)} start(s), {len(errs)} error row(s), "
f"{'ended cleanly' if stopped else 'NO clean stop row'}")
if len(errs) >= 10:
from collections import Counter
top = Counter((e.get("where"), str(e.get("err", ""))[:60]) for e in errs).most_common(1)[0]
alerts.append(f"Judge trader logged {len(errs)} errors today (top: {top[0]} x{top[1]}). Possible loop.")
if len(starts) > 1:
alerts.append(f"Judge trader restarted {len(starts) - 1} time(s) today (crash or closed window).")
elif datetime.now(ET).weekday() < 5:
alerts.append("Judge trader wrote no journal today: did it run?")
return lines + [""]
def health_section(paper, live, today, alerts) -> list[str]:
lines = ["## Health", ""]
for name, con in (("live 5434", live), ("paper 5433", paper)):
try:
first, last, n = con.execute(
"SELECT min(timestamp), max(timestamp), count(*) FROM events WHERE event_type='module_tick' "
"AND timestamp >= ? AND timestamp < ?", [f"{today} 09:30", f"{today} 16:00"]).fetchone()
if first is not None and first.strftime("%H:%M") > "09:40":
alerts.append(f"The {name} stack was down at the open: first heartbeat {first:%H:%M} ET.")
gap = con.execute(
"SELECT max(g) FROM (SELECT timestamp - lag(timestamp) OVER (ORDER BY timestamp) AS g FROM events "
"WHERE event_type='module_tick' AND timestamp >= ? AND timestamp < ?) x",
[f"{today} 09:30", f"{today} 16:00"]).fetchone()[0]
gap_min = gap.total_seconds() / 60 if gap is not None else None
lines.append(f"- {name}: {n} heartbeats in market hours, last {last}"
+ (f", longest gap {gap_min:.0f} min" if gap_min is not None else ""))
if gap_min is not None and gap_min >= 10:
alerts.append(f"The {name} stack was silent for {gap_min:.0f} min during market hours.")
if n == 0:
alerts.append(f"The {name} stack wrote no heartbeats during market hours.")
elif last and (datetime.now(ET).replace(tzinfo=None) - last).total_seconds() > 900 \
and last.strftime("%H:%M") < "15:45": # silent for 15+ min, before the close
alerts.append(f"The {name} stack went silent at {last:%H:%M} ET.")
except Exception as e: # noqa: BLE001
lines.append(f"- {name}: ERROR {str(e)[:120]}")
alerts.append(f"Could not read the {name} database: {str(e)[:80]}")
bk = Path(os.environ.get("LIVE_BACKUP_DIR", r"C:\Users\Admin\momentum-backups")) / "status.json"
if bk.exists():
b = json.loads(bk.read_text(encoding="utf-8"))
age_h = (datetime.now(ET) - datetime.fromisoformat(b["at"])).total_seconds() / 3600
lines.append(f"- live DB backup: {'ok' if b.get('ok') else 'FAILED'} {b['at'][:16]} "
f"({b.get('size_mb', '?')} MB, {len(b.get('kept', []))} kept)")
if not b.get("ok"):
alerts.append(f"Last live-DB backup FAILED: {str(b.get('error'))[:120]}")
elif age_h > 36:
alerts.append(f"Last good live-DB backup is {age_h:.0f} h old (nightly task not running?).")
else:
alerts.append("No live-DB backup status found (the Momentum LiveDbBackup task has never run).")
pw = ROOT / "data" / "prwire" / f"{today}.jsonl"
if pw.exists():
rows = [json.loads(x) for x in pw.read_text(encoding="utf-8").splitlines() if x.strip()]
items = [r for r in rows if r.get("guid")]
lines.append(f"- press-release recorder: {len(items)} items today, last seen {items[-1]['seen_utc'] if items else '-'}")
if not items:
alerts.append("The press-release recorder logged nothing today.")
else:
alerts.append("The press-release recorder has no file for today.")
return lines + [""]
def main(argv=None) -> str:
argv = sys.argv[1:] if argv is None else argv
dates = [a for a in argv if not a.startswith("--")]
today = dates[0] if dates else datetime.now(ET).strftime("%Y-%m-%d")
alerts: list[str] = []
body: list[str] = []
live = paper = None
try:
live = Con(5434)
body += live_section(live, today, alerts)
except Exception as e: # noqa: BLE001
alerts.append(f"Live database unreachable: {str(e)[:100]}")
body += judge_section(today, alerts)
try:
paper = Con(5433)
if live:
body += ab_section(paper, live, alerts)
except Exception as e: # noqa: BLE001
alerts.append(f"Paper database unreachable: {str(e)[:100]}")
if live and paper:
body += health_section(paper, live, today, alerts)
head = [f"# Daily report {today}", "", "### ALERTS" if alerts else "### No alerts: nothing needs action.", ""]
head += [f"- {a}" for a in alerts] + ([""] if alerts else [])
md = "\n".join(head + body)
OUT.mkdir(parents=True, exist_ok=True)
(OUT / f"daily_{today}.md").write_text(md, encoding="utf-8")
print(md)
if alerts and "--no-telegram" not in argv:
notify(today, alerts, body)
return md
def notify(today: str, alerts: list[str], body: list[str]) -> bool:
"""Phone push via the stack's Telegram bot (credentials in .env). Sent only when there are alerts."""
from runtime.notifier import send_telegram
env = dotenv_values(ROOT / ".env")
status = next((b for b in body if b.startswith("- **")), "").replace("*", "").lstrip("- ")
text = (f"Momentum {today}: {len(alerts)} alert(s)\n" + "\n".join(f"- {a}" for a in alerts)
+ (f"\nLive test: {status}" if status else "") + f"\nReport: data/reports/daily_{today}.md")
return send_telegram(text[:3900], token=env.get("TELEGRAM_BOT_TOKEN"), chat_id=env.get("TELEGRAM_CHAT_ID"))
if __name__ == "__main__":
main()
+58 -12
View File
@@ -13,6 +13,8 @@ hardcoded ``is_regular_hours = TRUE`` on every row. Here we:
from __future__ import annotations
import re
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta, timezone
@@ -188,6 +190,33 @@ def upsert_daily_bars(con, symbol: str, bars: list[dict]) -> int:
return len(rows)
def data_feed(client) -> str:
"""The feed a client's market-data calls actually use. A broker wrapper
(TradierClient) carries its OWN settings and delegates data to the Alpaca
client in ``data_client``, so read the feed through it. Reading
``client.settings.feed`` on the wrapper fell back to "iex", and on the live
Tradier stack that multiplied real SIP volume by the IEX->SIP ratios
(80-160x over-count, 2026-07-31..09-25)."""
src = getattr(client, "data_client", None) or client
return getattr(getattr(src, "settings", None), "feed", "iex")
_US_TICKER = re.compile(r"^[A-Z]{1,5}$")
def is_us_ticker(symbol: str) -> bool:
"""Plain US common-stock ticker (1-5 letters). Alpaca's movers screener can return
foreign listings like 'TSX:STC'. One of those in a batched bars request makes Alpaca
400 the WHOLE batch ("invalid symbol"), which stopped all live ingestion from
09:35 ET on 2026-09-28."""
return bool(symbol) and bool(_US_TICKER.match(symbol))
def _invalid_symbol(exc) -> str | None:
m = re.search(r"invalid symbol: ([^\"\s]+)", str(exc))
return m.group(1) if m else None
def ingest_live_minute_bars(
con,
client,
@@ -217,21 +246,37 @@ def ingest_live_minute_bars(
now = datetime.now(timezone.utc)
start = now - timedelta(minutes=lookback_minutes)
end = now - timedelta(seconds=30)
try:
bars_by_symbol = client.get_minute_bars(
symbols,
start_iso=start.isoformat().replace("+00:00", "Z"),
end_iso=end.isoformat().replace("+00:00", "Z"),
feed=feed,
)
except Exception as exc: # noqa: BLE001 - report, don't crash the loop
result.errors.append(f"minute bars fetch failed: {exc}")
bad = [s for s in symbols if not is_us_ticker(s)]
symbols = [s for s in symbols if is_us_ticker(s)]
if bad:
result.errors.append(f"skipped non-US/invalid symbols: {bad}")
bars_by_symbol = None
for _attempt in range(3): # drop any symbol Alpaca rejects and retry the rest
if not symbols:
return result
try:
bars_by_symbol = client.get_minute_bars(
symbols,
start_iso=start.isoformat().replace("+00:00", "Z"),
end_iso=end.isoformat().replace("+00:00", "Z"),
feed=feed,
)
break
except Exception as exc: # noqa: BLE001 - report, don't crash the loop
reject = _invalid_symbol(exc)
if reject and reject in symbols:
symbols = [s for s in symbols if s != reject]
result.errors.append(f"dropped invalid symbol {reject} and retried")
continue
result.errors.append(f"minute bars fetch failed: {exc}")
return result
if bars_by_symbol is None:
return result
# SAFETY: the ratio correction exists ONLY for the thin IEX feed. Once the live
# feed is SIP (paid plan), volume is already consolidated — applying the ~25-300x
# ratios again would massively over-count. Resolve the EFFECTIVE feed and refuse
# to scale anything that isn't IEX.
effective_feed = feed or getattr(getattr(client, "settings", None), "feed", "iex")
effective_feed = feed or data_feed(client)
if effective_feed != "iex":
volume_scale = None
for symbol, bars in bars_by_symbol.items():
@@ -302,7 +347,8 @@ def discover_active_symbols(
for g in client.get_movers(top=15):
sym = (g.get("symbol") or "").strip()
# drop warrants/units/rights noise (BKSY.WS, EVLVW-style)
if not sym or "." in sym or (len(sym) >= 5 and sym.endswith("W")):
if (not is_us_ticker(sym)
or (len(sym) >= 5 and sym.endswith("W"))):
continue
gainers.append(sym)
except Exception: # noqa: BLE001 — movers is additive; actives still work
@@ -310,7 +356,7 @@ def discover_active_symbols(
# Alpaca caps the most-actives `top` at 100; never request more.
actives = client.get_most_actives(top=min(top * 3, 100), by="volume")
symbols = list(dict.fromkeys(
gainers + [a["symbol"] for a in actives if a.get("symbol")]))
gainers + [a["symbol"] for a in actives if is_us_ticker(a.get("symbol") or "")]))
if not symbols:
return []
trades = client.get_latest_trades(symbols) # batched internally; price them all
+47 -16
View File
@@ -41,6 +41,20 @@ CATALYST_TYPES = {
"none",
}
# Coverage that only REPORTS a move or a halt is not a new catalyst, but the model rated "halted to the upside,
# stock up 172%" at conviction 0.8 / sentiment +1.0 (a bigger boost than most real news), which ranks up names
# that already ran. The prompt says so; this deterministic cap does not depend on the model listening.
HALT_RE = re.compile(r"\b(halted|trading halt|halt status|circuit breaker|luld|resum(?:e|es|ed|ption) (?:of )?trad)",
re.I)
REACTIVE_RE = re.compile(
r"\b(why|what'?s going on|what'?s driving|here'?s why)\b.{0,80}\b(stock|shares)\b"
r"|\b(stock|shares)\b.{0,40}\b(surg|soar|jump|spik|rall|climb|gain|ris|trend|tumbl|plung|slid|sink|drop|fall)\w*"
r"|\b(stock|shares)\b.{0,40}\btrading (?:higher|lower)"
r"|stocks? moving|\bmovers\b|(?:top|biggest) (?:gainers|losers)|unusual (?:options|volume)|52-week (?:high|low)",
re.I)
HALT_MAX_CONVICTION = 0.1
REACTIVE_MAX_CONVICTION = 0.3
# Cheap stdlib floor for the dilution veto so it still works when Ollama is down.
# The LLM CONFIRMS dilution; this regex is the safety net, not the primary signal.
# "offering" is matched ONLY as a securities offering — a qualifier in front of it
@@ -129,7 +143,12 @@ def build_prompt(headline: str, snippet: str = "", tickers: str = "") -> str:
'everything else, including an ETF/fund "offering exposure", a product or '
"service offering, an IPO of a different company, a buyback, or a reverse "
"split\n"
' "rationale": one short sentence (max ~200 chars)\n\n'
' "rationale": one short sentence (max ~200 chars)\n'
"A trading halt/resumption notice, a 'stocks moving'/movers roundup, or an article that only reports or "
"explains a price move ('Why is X stock up', 'X shares surge') is NOT a new catalyst: use catalyst_type "
'"none" (or "halt_resumption" for a halt) with conviction at most 0.1. If such an article names a NEW '
"company announcement as the cause, classify that announcement but with conviction at most 0.3, because "
"the move has already happened.\n\n"
f"<news>\n{news}\n</news>"
)
@@ -141,7 +160,7 @@ def _clamp(value, lo: float, hi: float, default: float) -> float:
return default
def _parse(raw: str, fallback_text: str = "") -> CatalystAnalysis | None:
def _parse(raw: str, fallback_text: str = "", headline: str = "") -> CatalystAnalysis | None:
"""Tolerant parse of the model's JSON string. None on unrecoverable failure."""
try:
data = json.loads(raw)
@@ -149,6 +168,9 @@ def _parse(raw: str, fallback_text: str = "") -> CatalystAnalysis | None:
return None
if not isinstance(data, dict):
return None
if not {"catalyst_type", "sentiment", "conviction", "is_dilutive"} & set(data):
# e.g. "{}" from a thinking model under format=json: a failure, not a confident "other"
return None
ctype = str(data.get("catalyst_type", "other")).strip().lower()
if ctype not in CATALYST_TYPES:
ctype = "other"
@@ -159,10 +181,16 @@ def _parse(raw: str, fallback_text: str = "") -> CatalystAnalysis | None:
if not is_dilutive and looks_dilutive(fallback_text):
is_dilutive = True
rationale = str(data.get("rationale", "") or "")[:200]
conviction = _clamp(data.get("conviction", 0.0), 0.0, 1.0, 0.0)
# cap reactive coverage (never touches is_dilutive: an offering is still an offering)
if ctype == "halt_resumption" or HALT_RE.search(headline or ""):
conviction = min(conviction, HALT_MAX_CONVICTION)
elif REACTIVE_RE.search(headline or ""):
conviction = min(conviction, REACTIVE_MAX_CONVICTION)
return CatalystAnalysis(
catalyst_type=ctype,
sentiment=_clamp(data.get("sentiment", 0.0), -1.0, 1.0, 0.0),
conviction=_clamp(data.get("conviction", 0.0), 0.0, 1.0, 0.0),
conviction=conviction,
is_dilutive=is_dilutive,
rationale=rationale,
)
@@ -178,26 +206,28 @@ def classify_headline(
timeout: int = 30,
temperature: float = 0.3,
max_tokens: int = 256,
think: bool | None = None,
) -> CatalystAnalysis | None:
"""Ask a local Ollama model to classify a headline. None on any failure."""
if not (headline or snippet):
return None
prompt = build_prompt(headline, snippet, tickers)
try:
body = {
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
if think is not None:
body["think"] = think
req = urllib.request.Request(
f"{host}/api/generate",
data=json.dumps(
{
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
).encode(),
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
@@ -208,7 +238,7 @@ def classify_headline(
return None
if not response:
return None
return _parse(response, fallback_text=f"{headline} {snippet}")
return _parse(response, fallback_text=f"{headline} {snippet}", headline=headline)
# ---------------------------------------------------------------------------
@@ -306,6 +336,7 @@ def enrich_recent_news(con, cfg, lookback_hours: int | None = None, limit: int |
title or "", snippet or "", raw_tickers or "",
host=cfg.host, model=cfg.model, timeout=cfg.timeout_seconds,
temperature=cfg.temperature, max_tokens=cfg.max_tokens,
think=getattr(cfg, "think", None),
)
if analysis is None:
# Ollama down / unparseable — DON'T poison the cache; retry next pass.
+9
View File
@@ -32,6 +32,12 @@ class GapperCandidate:
# 29.4M). None when no baseline. Legacy relative_volume is kept untouched —
# validated boundaries (gap-squeeze rvol>=5) are calibrated in its units.
relative_volume_tod: float | None = None
# Squeeze-verification rvol: cum volume vs MEDIAN(20d), NOT prorated — the
# legacy units the gap-squeeze rvol>=5 boundary was calibrated in, but on a
# baseline one or two prior spike days can't poison. KITT 2026-09-25 (+557%,
# 9.3M shares) read legacy 1.54 because two 11-14M days put the 20d mean at
# 1.66M vs a 333k median, and was benched as "squeeze unverified".
relative_volume_med: float | None = None
def _session_elapsed_fraction(now: datetime | None = None) -> float:
@@ -111,6 +117,8 @@ def scan_gappers(
rvol = (float(cum_volume) / float(adv)) if adv and adv > 0 else 0.0
rvol_tod = (float(cum_volume) / (float(med_vol) * frac)
if med_vol and float(med_vol) > 0 else None)
rvol_med = (float(cum_volume) / float(med_vol)
if med_vol and float(med_vol) > 0 else None)
# either rvol flavour clears the bar: legacy is ~0.1-0.3 early in the
# session even for a heavy name (cum-so-far vs full-day mean), so the
# tod variant keeps real early movers from being silently dropped
@@ -127,6 +135,7 @@ def scan_gappers(
relative_volume=rvol,
vwap=float(vwap) if vwap is not None else None,
relative_volume_tod=rvol_tod,
relative_volume_med=rvol_med,
)
)
candidates.sort(key=lambda c: (c.gap_pct * max(c.relative_volume, 0.1)), reverse=True)
+351
View File
@@ -0,0 +1,351 @@
"""Whole-market premarket squeeze scanner (Alpaca snapshots).
Why this exists (measured 2026-09-25): the screener-based discovery
(``discover_active_symbols`` -> Alpaca movers / most-actives) only reflects the
REGULAR session, so a premarket squeeze is invisible until 09:30. Against 53
trades by a professional small-cap momentum trader, discovery had found only 4
of his names before his entry, and 27 of his 28 entries were premarket. KITT on
2026-09-25 ran $3.50 -> $8.39 at 09:00 ET (prior close $0.49); we first
"discovered" it at 09:35.
This sweeps EVERY active, tradable, listed US equity through
``GET /v2/stocks/snapshots`` (latestTrade / minuteBar / dailyBar /
prevDailyBar), which is live from 04:00 ET, and qualifies a name when:
* the LATEST TRADE price is inside the watcher price band (never the prior
close: a squeeze's prior close is routinely < $1),
* % change vs the prior session close >= ``min_pct``,
* it is actually trading now: last trade within ``max_trade_age_s``, the
latest minute bar within ``max_bar_age_s`` with >= ``min_bar_volume``
shares, and >= ``min_cum_volume`` shares traded today (from 04:00 ET).
Discovery/ingestion only: a qualifying name is added to the watchlist, and
every trading gate still decides whether it is ever traded.
Snapshot quirk handled here: ``dailyBar`` OHLC ignores extended-hours prints
(KITT 2026-09-25: dailyBar high $3.24, premarket high $8.39 at 09:01 ET), so
before today's first regular-session print it may still be the PREVIOUS
session's bar. The prior close is therefore ``prevDailyBar.c`` only when
``dailyBar`` is dated today, otherwise ``dailyBar.c``. Cumulative volume for the
short list comes from today's minute bars since 04:00 ET, which do include
premarket (one extra batched request per sweep).
"""
from __future__ import annotations
import logging
import os
import re
import time
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from research.ingestion.market_data import EASTERN, parse_alpaca_timestamp
logger = logging.getLogger(__name__)
SOURCE = "snapshot_scan"
# Alpaca caps snapshots by URL length, not symbol count: 8,000 symbols per
# request worked and ~12,700 returned HTTP 414 (probed 2026-09-25). 2,000 keeps
# the URL around 12 KB and a full sweep at ~7 requests.
DEFAULT_BATCH = 2000
_DERIV_NAME = re.compile(r"\bwarrants?\b|\brights\b|\bunits?\s*$|\bwhen[- ]issued\b", re.I)
_SCAN_START_ET = (4, 0)
_SCAN_END_ET = (16, 0)
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
try:
return float(raw) if raw else default
except ValueError:
return default
@dataclass
class SnapshotScanConfig:
price_min: float = 1.0
price_max: float = 20.0
min_pct: float = 20.0 # % change vs prior close
max_per_sweep: int = 5 # new names injected per sweep (top by %)
min_bar_volume: int = 5_000 # shares in the latest minute bar
min_cum_volume: int = 100_000 # shares traded today since 04:00 ET
max_trade_age_s: float = 180.0 # latest trade must be this fresh
max_bar_age_s: float = 300.0 # latest minute bar must be this fresh
batch_size: int = DEFAULT_BATCH
interval_s: float = 45.0
@classmethod
def from_env(cls) -> "SnapshotScanConfig":
return cls(
price_min=_env_float("WATCHER_PRICE_MIN", 1.0),
price_max=_env_float("WATCHER_PRICE_MAX", 20.0),
min_pct=_env_float("DISCOVER_SNAPSHOT_MIN_PCT", 20.0),
max_per_sweep=int(_env_float("DISCOVER_SNAPSHOT_MAX_PER_SWEEP", 5)),
min_bar_volume=int(_env_float("DISCOVER_SNAPSHOT_MIN_BAR_VOLUME", 5_000)),
min_cum_volume=int(_env_float("DISCOVER_SNAPSHOT_MIN_CUM_VOLUME", 100_000)),
max_trade_age_s=_env_float("DISCOVER_SNAPSHOT_MAX_TRADE_AGE_SECONDS", 180.0),
max_bar_age_s=_env_float("DISCOVER_SNAPSHOT_MAX_BAR_AGE_SECONDS", 300.0),
batch_size=int(_env_float("DISCOVER_SNAPSHOT_BATCH", DEFAULT_BATCH)),
interval_s=_env_float("DISCOVER_SNAPSHOT_INTERVAL_SECONDS", 45.0),
)
@dataclass
class SqueezeHit:
symbol: str
price: float
prev_close: float
pct_change: float
minute_volume: int
cum_volume: int | None = None
trade_age_s: float | None = None
def as_data(self) -> dict:
return {
"source": SOURCE,
"price": round(self.price, 4),
"prev_close": round(self.prev_close, 4),
"pct_change": round(self.pct_change, 1),
"minute_volume": self.minute_volume,
"cum_volume": self.cum_volume,
"trade_age_s": None if self.trade_age_s is None else round(self.trade_age_s, 1),
}
@dataclass
class ScanResult:
scanned: int = 0
requests: int = 0
duration_s: float = 0.0
hits: list[SqueezeHit] = field(default_factory=list) # all qualifiers, ranked
injected: list[SqueezeHit] = field(default_factory=list) # new this sweep (<= cap)
errors: list[str] = field(default_factory=list)
def data_client(client):
"""The Alpaca client that serves market data (unwraps TradierClient)."""
return getattr(client, "data_client", None) or client
def _is_derivative_listing(symbol: str, name: str) -> bool:
"""Warrants / units / rights / preferreds / when-issued — not the common."""
if not symbol or "." in symbol or "/" in symbol or "-" in symbol:
return True
# Nasdaq 5th-letter convention: W = warrant, U = unit, R = rights
if len(symbol) >= 5 and symbol[-1] in "WUR":
return True
# Name check is deliberately narrow: "Common Units" (MLPs like ET/PAA) and
# ADS "the right to receive" are ordinary equity (checked against the live
# asset list 2026-09-25).
return bool(_DERIV_NAME.search(name or ""))
def build_universe(assets: list[dict], excluded: set[str] | None = None) -> list[str]:
"""Active, tradable, listed (non-OTC) US equities minus derivatives/excludes."""
excluded = excluded or set()
out = []
for a in assets:
sym = (a.get("symbol") or "").strip().upper()
if not sym or sym in excluded:
continue
if a.get("status", "active") != "active" or not a.get("tradable"):
continue
if (a.get("exchange") or "").upper() in {"OTC", ""}:
continue
if _is_derivative_listing(sym, a.get("name") or ""):
continue
out.append(sym)
return sorted(set(out))
def _et_date(ts_utc: datetime) -> date:
return ts_utc.astimezone(EASTERN).date()
def prior_close(snap: dict, today_et: date) -> float | None:
"""The previous session's close. ``dailyBar`` OHLC ignores extended-hours
prints, so before today's first RTH print it can still be yesterday's bar."""
daily = snap.get("dailyBar") or {}
prev = snap.get("prevDailyBar") or {}
try:
if daily.get("t") and _et_date(parse_alpaca_timestamp(daily["t"])) < today_et:
return float(daily["c"]) or None
return float(prev["c"]) if prev.get("c") else None
except (KeyError, TypeError, ValueError):
return None
def qualify(snapshots: dict, cfg: SnapshotScanConfig, now: datetime) -> list[SqueezeHit]:
"""Pure: snapshot map -> qualifying names ranked by % change (desc).
Cumulative volume is NOT checked here (see ``apply_cum_volume``)."""
today = _et_date(now)
hits: list[SqueezeHit] = []
for sym, snap in (snapshots or {}).items():
if not snap:
continue
trade = snap.get("latestTrade") or {}
bar = snap.get("minuteBar") or {}
try:
price = float(trade.get("p") or 0.0)
if not (cfg.price_min <= price <= cfg.price_max):
continue
if not trade.get("t") or not bar.get("t"):
continue
trade_age = (now - parse_alpaca_timestamp(trade["t"])).total_seconds()
bar_age = (now - parse_alpaca_timestamp(bar["t"])).total_seconds()
if trade_age > cfg.max_trade_age_s or bar_age > cfg.max_bar_age_s:
continue
bar_vol = int(bar.get("v") or 0)
if bar_vol < cfg.min_bar_volume:
continue
pc = prior_close(snap, today)
if not pc or pc <= 0:
continue
pct = (price - pc) / pc * 100.0
if pct < cfg.min_pct:
continue
except (TypeError, ValueError, KeyError):
continue
hits.append(SqueezeHit(sym, price, pc, pct, bar_vol, trade_age_s=max(0.0, trade_age)))
hits.sort(key=lambda h: h.pct_change, reverse=True)
return hits
def fetch_snapshots(client, symbols: list[str], batch_size: int = DEFAULT_BATCH):
"""(snapshots, requests, errors). One failed batch doesn't sink the sweep."""
dc = data_client(client)
feed = getattr(getattr(dc, "settings", None), "feed", "sip")
out: dict = {}
requests = 0
errors: list[str] = []
step = max(1, int(batch_size))
for i in range(0, len(symbols), step):
chunk = symbols[i:i + step]
requests += 1
try:
payload = dc._data("GET", "/v2/stocks/snapshots",
params={"symbols": ",".join(chunk), "feed": feed})
except Exception as exc: # noqa: BLE001
errors.append(f"snapshots batch {i // step}: {exc}"[:300])
continue
if isinstance(payload, dict):
# multi-symbol response is {SYM: snapshot}; tolerate a wrapped shape
inner = payload.get("snapshots")
out.update(inner if isinstance(inner, dict) else payload)
return out, requests, errors
def apply_cum_volume(client, hits: list[SqueezeHit], cfg: SnapshotScanConfig,
now: datetime) -> tuple[list[SqueezeHit], int, list[str]]:
"""Fill today's cumulative volume (04:00 ET -> now) from minute bars for the
short list and drop names under the floor. One batched request. If the bar
fetch fails the floor is skipped (recency + minute volume still applied),
so an API blip can't blind premarket discovery."""
if not hits or cfg.min_cum_volume <= 0:
return hits, 0, []
now_et = now.astimezone(EASTERN)
start = now_et.replace(hour=4, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
try:
bars = client.get_minute_bars(
[h.symbol for h in hits],
start_iso=start.isoformat().replace("+00:00", "Z"),
end_iso=now.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
) or {}
except Exception as exc: # noqa: BLE001
return hits, 1, [f"cum-volume bars fetch failed (floor skipped): {exc}"[:300]]
kept = []
for h in hits:
h.cum_volume = int(sum(int(b.get("v") or 0) for b in bars.get(h.symbol) or []))
if h.cum_volume >= cfg.min_cum_volume:
kept.append(h)
return kept, 1, []
def in_scan_window(now: datetime) -> bool:
et = now.astimezone(EASTERN)
if et.weekday() >= 5:
return False
return _SCAN_START_ET <= (et.hour, et.minute) < _SCAN_END_ET
class SnapshotScanner:
"""Stateful sweep driver: per-day universe cache + per-day dedupe of names
already injected, so each squeeze is announced once (its first sighting)."""
def __init__(self, client, cfg: SnapshotScanConfig | None = None,
excluded: set[str] | None = None):
self.client = client
self.cfg = cfg or SnapshotScanConfig.from_env()
self.excluded = excluded
self._universe: list[str] = []
self._universe_day: date | None = None
self._announced: set[str] = set()
self._announced_day: date | None = None
def universe(self, today: date) -> tuple[list[str], int]:
"""(universe, requests made). The asset list is fetched once per ET day."""
if self._universe_day == today and self._universe:
return self._universe, 0
from research.ingestion.discovery import _excluded_symbols
excluded = self.excluded if self.excluded is not None else _excluded_symbols()
assets = data_client(self.client).get_assets()
self._universe = build_universe(assets, excluded)
self._universe_day = today
return self._universe, 1
def sweep(self, now: datetime | None = None,
already_watched: set[str] | frozenset = frozenset()) -> ScanResult:
now = now or datetime.now(timezone.utc)
today = _et_date(now)
if self._announced_day != today:
self._announced, self._announced_day = set(), today
res = ScanResult()
t0 = time.monotonic()
try:
universe, n_req = self.universe(today)
res.requests += n_req
except Exception as exc: # noqa: BLE001
res.errors.append(f"assets fetch failed: {exc}"[:300])
res.duration_s = time.monotonic() - t0
return res
snaps, n_req, errs = fetch_snapshots(self.client, universe, self.cfg.batch_size)
res.scanned = len(snaps)
res.requests += n_req
res.errors.extend(errs)
hits = qualify(snaps, self.cfg, now)
fresh = [h for h in hits
if h.symbol not in self._announced and h.symbol not in already_watched]
# volume-floor a bounded short list (cap x3) so one request covers it
short = fresh[: max(1, self.cfg.max_per_sweep) * 3]
short, n_req, errs = apply_cum_volume(data_client(self.client), short, self.cfg, now)
res.requests += n_req
res.errors.extend(errs)
res.hits = hits
res.injected = short[: self.cfg.max_per_sweep]
self._announced.update(h.symbol for h in res.injected)
res.duration_s = time.monotonic() - t0
return res
def emit_discoveries(store, hits: list[SqueezeHit], *, mode, correlation_id=None,
scan_duration_s: float | None = None) -> None:
"""One symbol_discovered per injected name, source=snapshot_scan, with the
evidence (pct/price/volume) so first-sighting latency is measurable."""
from storage.event_schema import SymbolDiscoveredEvent
for rank, h in enumerate(hits, 1):
data = {**h.as_data(), "rank": rank}
if scan_duration_s is not None:
data["scan_duration_s"] = round(scan_duration_s, 2)
store.emit(SymbolDiscoveredEvent(
timestamp=datetime.now(),
mode=mode,
correlation_id=correlation_id,
message=(f"Discovered {h.symbol} via {SOURCE}: +{h.pct_change:.0f}% "
f"${h.price:g} (prev ${h.prev_close:g}), "
f"min vol {h.minute_volume:,}"),
symbol=h.symbol,
symbol_data=data,
payload={"symbol": h.symbol, "source": SOURCE},
))
+11 -1
View File
@@ -71,6 +71,9 @@ class ResearchWatchlistProvider:
avg_daily_volume=research_query.query_avg_daily_volume(
self.con, symbol, session_date
),
median_daily_volume=research_query.query_median_daily_volume(
self.con, symbol, session_date
),
source="manual",
)
)
@@ -89,6 +92,9 @@ class ResearchWatchlistProvider:
avg_daily_volume=research_query.query_avg_daily_volume(
self.con, symbol, session_date
),
median_daily_volume=research_query.query_median_daily_volume(
self.con, symbol, session_date
),
source="research",
)
)
@@ -124,7 +130,7 @@ class LiveWatchlistProvider:
for symbol in self.symbols:
daily = self._daily_bars(symbol)
prev_close = None
adv = None
adv = med = None
if daily:
# last bar strictly before the session date
prior = [
@@ -135,11 +141,15 @@ class LiveWatchlistProvider:
prev_close = float(prior[-1]["c"])
recent = prior[-20:]
adv = sum(float(b["v"]) for b in recent) / len(recent)
vols = sorted(float(b["v"]) for b in recent)
mid = len(vols) // 2
med = vols[mid] if len(vols) % 2 else (vols[mid - 1] + vols[mid]) / 2
candidates.append(
WatchCandidate(
symbol=symbol,
previous_close=prev_close,
avg_daily_volume=adv,
median_daily_volume=med,
source="live",
)
)
+235
View File
@@ -0,0 +1,235 @@
"""Live-test scorecard: is the frozen strategy making money on the real account?
The test was pre-registered on 2026-09-25, after the commission leak was closed
(first clean session 2026-09-24):
PASS after >= 80 round trips since the freeze, mean R > 0 AND realized P&L > 0.
FAIL the equity-floor kill switch halts a session.
Otherwise the test is still running. Any change to a strategy setting in
research/live_test_freeze.json restarts the count, so this report flags drift.
Why 80: per-trade R ranges roughly -1 to +10 with a stdev of ~1.4, so under ~80
trades a positive mean cannot be told apart from zero. Why no filters were
changed at the freeze: the 2026-09-25 study (239 real fills, re-simulated under
the current exit) found that capping the entry chase lost R at every cap, and
that no break-time factor, including the L1/L2 book, held its sign across both
halves of the sample.
Run against the LIVE (tradier) database:
DATABASE_URL=<tradier dsn> PG_SKIP_SCHEMA=1 python -m research.live_scorecard
"""
from __future__ import annotations
import argparse
import json
import random
from pathlib import Path
from research.multi_schema import open_research_db
FREEZE_FILE = Path(__file__).with_name("live_test_freeze.json")
TARGET_TRADES = 80
# safety knobs: changing these does not change the strategy under test
SAFETY_KEYS = {"TRADING_EQUITY_FLOOR", "TRADING_DEFAULT_EQUITY"}
def load_freeze(path: Path = FREEZE_FILE) -> dict:
return json.loads(path.read_text())
def config_drift(frozen: dict, current: dict) -> dict:
"""{key: (frozen, current)} for strategy settings that differ from the freeze."""
keys = set(frozen) | {k for k in current if k.startswith(("TRADING_", "TRIGGER_"))}
return {k: (frozen.get(k), current.get(k)) for k in sorted(keys - SAFETY_KEYS)
if frozen.get(k) != current.get(k)}
def bootstrap_mean_ci(xs: list[float], n: int = 4000, seed: int = 7) -> tuple[float, float]:
if not xs:
return (float("nan"), float("nan"))
rng = random.Random(seed)
means = sorted(sum(rng.choices(xs, k=len(xs))) / len(xs) for _ in range(n))
return means[int(0.05 * n)], means[int(0.95 * n) - 1]
def verdict(r_values: list[float], pnl: float, floor_halts: int,
target: int = TARGET_TRADES) -> str:
if floor_halts:
return "FAIL: the equity floor halted trading"
if len(r_values) < target:
return f"RUNNING: {len(r_values)}/{target} trades"
mean_r = sum(r_values) / len(r_values)
if mean_r > 0 and pnl > 0:
return "PASS: positive per-trade R and positive P&L"
return "FAIL: not profitable after the full sample"
def journal_trades(con, since: str) -> list[dict]:
"""Closed round trips the event store journaled since ``since``, with R measured
against the stop the order was submitted with (position_closed carries the
final, trailed stop)."""
closes = con.execute(
"SELECT timestamp, payload_json FROM events WHERE event_type='position_closed' "
"AND timestamp >= ? ORDER BY timestamp", [since]).fetchall()
out = []
for ts, payload in closes:
p = json.loads(payload or "{}")
sym = p.get("symbol")
row = con.execute(
"SELECT payload_json FROM events WHERE event_type='order_approval_requested' "
"AND payload_json LIKE ? AND timestamp::date = ?::date AND timestamp < ? "
"ORDER BY timestamp DESC LIMIT 1", [f'%"symbol": "{sym}"%', ts, ts]).fetchone()
stop0 = None
if row:
stop0 = (json.loads(row[0]).get("execution_request") or {}).get("stop_loss_price")
entry, exit_p = p.get("entry_price"), p.get("exit_price")
r = None
if stop0 and entry and exit_p is not None and float(entry) > float(stop0):
r = (float(exit_p) - float(entry)) / (float(entry) - float(stop0))
out.append({"ts": ts, "symbol": sym, "pnl": float(p.get("realized_pnl") or 0.0),
"r": r, "reason": p.get("exit_reason"), "source": "journal"})
return out
# The broker's order list is polled into account_orders_updated snapshots. Each
# row below is one FILLED order, taken from the latest snapshot that showed it
# (prices are final there) with the time it was FIRST seen filled as its fill time.
_BROKER_FILLS_SQL = """
SELECT ts, first_seen, sym, side, typ, fq, fpx, stop_px FROM (
SELECT e.timestamp AS ts,
min(e.timestamp) OVER (PARTITION BY o->>'broker_order_id') AS first_seen,
o->>'symbol' AS sym, o->>'side' AS side, o->>'type' AS typ,
(o->>'filled_quantity')::float AS fq, (o->>'filled_avg_price')::float AS fpx,
(o->>'stop_price')::float AS stop_px,
row_number() OVER (PARTITION BY o->>'broker_order_id' ORDER BY e.timestamp DESC) AS rn
FROM events e, jsonb_array_elements(e.payload_json::jsonb->'orders') o
WHERE e.event_type='account_orders_updated' AND e.timestamp >= ?
AND o->>'status'='filled' AND o->>'type' IS NOT NULL
AND (o->>'filled_quantity')::float > 0 AND o->>'filled_avg_price' IS NOT NULL
) x WHERE rn = 1 ORDER BY first_seen
"""
def broker_fills(con, since: str) -> list[dict]:
"""Filled broker orders since ``since`` (one row per broker order id)."""
rows = con.execute(_BROKER_FILLS_SQL, [since]).fetchall()
return [{"ts": first_seen, "symbol": sym, "side": side, "type": typ, "qty": float(fq),
"price": float(fpx), "stop": float(stop_px) if stop_px is not None else None}
for _ts, first_seen, sym, side, typ, fq, fpx, stop_px in rows]
def reconstruct_missing(fills: list[dict], journal: list[dict]) -> list[dict]:
"""Round trips the broker filled but the journal never closed.
TNON on 2026-10-02 was bought and stopped out 49 s later; the stop filled
before a position record existed, so no position_closed was ever written and
the scorecard under-counted the test by one full -1R loser. For every
(session date, symbol) with a filled buy AND a filled sell at the broker but
no journaled close, this synthesises the trade from the fills: entry/exit are
the fill VWAPs, the stop is the sell-stop order's stop price (so R is measured
the same way as journaled trades). Pure: no DB, unit-tested.
"""
journaled = {(str(t["ts"])[:10], t["symbol"]) for t in journal}
by_day: dict[tuple[str, str], list[dict]] = {}
for f in fills:
by_day.setdefault((str(f["ts"])[:10], f["symbol"]), []).append(f)
out = []
for key, fs in sorted(by_day.items()):
if key in journaled:
continue
buys = [f for f in fs if f["side"] == "buy"]
sells = [f for f in fs if f["side"] == "sell"]
if not buys or not sells:
continue
qty = min(sum(f["qty"] for f in buys), sum(f["qty"] for f in sells))
if qty <= 0:
continue
entry = sum(f["qty"] * f["price"] for f in buys) / sum(f["qty"] for f in buys)
exit_p = sum(f["qty"] * f["price"] for f in sells) / sum(f["qty"] for f in sells)
stops = [f["stop"] for f in sells if f["type"] == "stop" and f["stop"]]
stop0 = stops[0] if stops else None
r = (exit_p - entry) / (entry - stop0) if stop0 and entry > stop0 else None
out.append({"ts": max(f["ts"] for f in sells), "symbol": key[1],
"pnl": round(qty * (exit_p - entry), 2), "r": r,
"reason": ("stop_loss" if stops else "market_exit") + " [broker fill; journal missed]",
"source": "broker"})
return out
def load_trades(con, since: str, broker_check: bool = True) -> list[dict]:
"""All round trips since ``since``: the journaled ones plus any the broker
filled that the journal missed (see reconstruct_missing). ``broker_check``
is for the LIVE Tradier store; the paper store's order snapshots run to
gigabytes and its Alpaca journal is reconciled by alpaca_paper/sync instead."""
journal = journal_trades(con, since)
if not broker_check:
return journal
try:
extra = reconstruct_missing(broker_fills(con, since), journal)
except Exception as exc: # noqa: BLE001 — the cross-check must never hide the journal
print(f"(broker cross-check unavailable: {str(exc)[:120]})")
extra = []
return sorted(journal + extra, key=lambda t: str(t["ts"]))
def equity_path(con, since: str) -> tuple[float | None, float | None]:
rows = con.execute(
"SELECT payload_json FROM events WHERE event_type='account_summary_updated' "
"AND timestamp >= ? ORDER BY timestamp", [since]).fetchall()
eq = [json.loads(r[0]).get("total_equity") for r in rows]
eq = [float(e) for e in eq if e]
return (eq[0], eq[-1]) if eq else (None, None)
def floor_halts(con, since: str) -> int:
"""Sessions the kill switch halted during market hours (the midnight re-check
that fires while equity is still below the floor is not a new halt)."""
rows = con.execute(
"SELECT timestamp, message FROM events WHERE event_type='risk_rule_triggered' "
"AND timestamp >= ? AND message LIKE 'EQUITY FLOOR%'", [since]).fetchall()
return len({ts.date() for ts, _ in rows if 9 <= ts.hour < 16})
def main() -> None:
ap = argparse.ArgumentParser(description="live-test scorecard since the freeze")
ap.add_argument("--since", help="override the freeze date (YYYY-MM-DD)")
args = ap.parse_args()
freeze = load_freeze()
since = args.since or freeze["frozen_on"]
con = open_research_db("market")
trades = load_trades(con, since)
rs = [t["r"] for t in trades if t["r"] is not None]
pnl = sum(t["pnl"] for t in trades)
halts = floor_halts(con, since)
eq0, eq1 = equity_path(con, since)
current = dict(con.execute("SELECT key, value FROM config_overrides").fetchall())
drift = config_drift(freeze["settings"], current)
print(f"Live test since {since} ({len(trades)} round trips)\n")
for t in trades:
r = f"{t['r']:+.2f}R" if t["r"] is not None else " n/a"
print(f" {str(t['ts'])[:16]} {t['symbol']:6s} {r} ${t['pnl']:+8.2f} {t['reason']}")
if rs:
lo, hi = bootstrap_mean_ci(rs)
wins = sum(1 for r in rs if r > 0)
print(f"\nmean R {sum(rs) / len(rs):+.3f} (90% CI {lo:+.3f} .. {hi:+.3f})"
f" win rate {wins}/{len(rs)}")
print(f"realized P&L ${pnl:+.2f}")
missed = [t for t in trades if t.get("source") == "broker"]
if missed:
print(f"journal missed {len(missed)} round trip(s) the broker filled; counted from the fills: "
+ ", ".join(f"{str(t['ts'])[:10]} {t['symbol']}" for t in missed))
if eq0 is not None:
print(f"equity ${eq0:.2f} -> ${eq1:.2f} (difference from realized P&L = fees + deposits/refunds)")
print(f"floor halts: {halts}")
if drift:
print("\nSETTINGS CHANGED SINCE THE FREEZE (the count restarts from the change):")
for k, (a, b) in drift.items():
print(f" {k}: {a!r} -> {b!r}")
print(f"\n{verdict(rs, pnl, halts)}")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
{
"frozen_on": "2026-09-29",
"source": "config_overrides on the tradier live DB (port 5434), snapshotted 2026-09-28 after the close",
"note": "Restarted 2026-09-29: >=150% squeeze exception OFF (TRADING_GAP_SQUEEZE_ENABLED=0, commit 0c8a27e). Otherwise the same as the 2026-09-28 period.",
"previous_periods": [
{
"from": "2026-09-24",
"to": "2026-09-25",
"trades": 4,
"note": "pre-fix settings; not counted"
},
{
"from": "2026-09-28",
"to": "2026-09-28",
"trades": 4,
"realized": -5.93,
"note": "fixes deployed, squeeze exception still on"
}
],
"settings": {
"NEWS_CATALYST_SCORE_ENABLED": "1",
"NEWS_DILUTION_VETO_ENABLED": "1",
"NEWS_RANK_BOOST": "1.0",
"TRADING_AUTO_APPROVE": "1",
"TRADING_BACKOUT_COOLDOWN_SECONDS": "180",
"TRADING_BLOCK_REENTRY_AFTER_EXIT": "1",
"TRADING_DEFAULT_EQUITY": "200",
"TRADING_ENTRY_CONFIRM_BY_MINUTE": "0",
"TRADING_ENTRY_FILL_MODEL": "marketable",
"TRADING_ENTRY_INVALIDATE_PCT": "0.015",
"TRADING_ENTRY_ORDER_TYPE": "limit",
"TRADING_ENTRY_TIMEOUT_BARS": "2",
"TRADING_EOD_FLATTEN_ENABLED": "1",
"TRADING_EOD_FLATTEN_TIME": "15:55",
"TRADING_EQUITY_FLOOR": "215",
"TRADING_EXECUTION_ENABLED": "1",
"TRADING_EXIT_BREAKEVEN_PCT": "0.0",
"TRADING_EXIT_BREAKEVEN_R": "1.0",
"TRADING_EXIT_FAIL_FAST_R": "0.3",
"TRADING_EXIT_FAIL_FAST_SECONDS": "60",
"TRADING_EXIT_MANAGE_INTERVAL_SECONDS": "4",
"TRADING_EXIT_PROFIT_TIERS": "",
"TRADING_EXIT_THREAD_ENABLED": "1",
"TRADING_EXIT_THREAD_INTERVAL_SECONDS": "1.0",
"TRADING_EXIT_TRAIL_AFTER_R": "1.0",
"TRADING_EXIT_TRAIL_MODE": "pct",
"TRADING_EXIT_TRAIL_PCT": "0.12",
"TRADING_EXIT_TRAIL_R_STEP": "0.0",
"TRADING_GAP_SQUEEZE_ENABLED": "0",
"TRADING_LIQUIDITY_MAX_VOLUME_PCT": "0.025",
"TRADING_MAX_BACKOUTS_PER_SYMBOL": "3",
"TRADING_MAX_CONCURRENT_POSITIONS": "10",
"TRADING_MAX_DAILY_LOSS_PCT": "0.05",
"TRADING_MAX_FRESH_ENTRIES_PER_DAY": "5",
"TRADING_MAX_GROSS_NOTIONAL_PCT": "0.60",
"TRADING_MAX_ORDERS_PER_TICK": "1",
"TRADING_MAX_POSITION_PCT": "0.16",
"TRADING_MAX_RISK_DOLLARS": "1000",
"TRADING_MIN_DAY_VOLUME": "300000",
"TRADING_MIN_ENTRY_GAP_PCT": "0.10",
"TRADING_MIN_QUALITY_SCORE": "0.20",
"TRADING_PRICE_MAX": "20.0",
"TRADING_PRICE_MIN": "2.0",
"TRADING_REENTRY_MIN_LOSS_PCT": "0.01",
"TRADING_REGIME_GATE_ENABLED": "0",
"TRADING_REGIME_GATE_P_MIN": "0.5",
"TRADING_REQUIRE_ABOVE_VWAP": "1",
"TRADING_REWARD_MULTIPLE": "10.0",
"TRADING_RISK_PER_TRADE_PCT": "0.015",
"TRADING_SESSION_ONLY": "1",
"TRADING_TRIGGER_SLIP_PCT": "0.004",
"TRADING_UNIFIED_ENTRY": "1",
"TRIGGER_ENABLED": "1",
"TRIGGER_GAP_MAX": "35",
"TRIGGER_GAP_MIN": "10.0",
"TRIGGER_GAP_SQUEEZE_MIN": "150",
"TRIGGER_GAP_SQUEEZE_RVOL": "5.0",
"TRIGGER_INTERVAL_SECONDS": "2",
"TRIGGER_MAX_ARMED": "10",
"TRIGGER_MIN_DOLLAR_VOL": "8000",
"TRIGGER_PRICE_MAX_AGE": "12",
"TRIGGER_RVOL_MIN": "2.0",
"WATCHER_PRICE_MAX": "20",
"WATCHER_PRICE_MIN": "2"
},
"known_deviations": [
{
"from": "2026-09-30 ~20:00 ET",
"to": "2026-10-01 22:37 ET",
"what": "TrueNAS ollama2 app turned off by the owner (host was out of memory: 843 MiB free of 15 GiB; tools:latest held 7.8 GB at a 131k context). Settings unchanged, but the LLM catalyst score (NEWS_CATALYST_SCORE_ENABLED) returns nothing while it is off, so the rank boost is inert. The dilution veto falls back to its regex floor.",
"restore": "Owner chose qwen3:14b on mason instead of re-enabling TrueNAS Ollama (2026-10-01). config_overrides set OLLAMA_HOST=http://192.168.1.178:11434, OLLAMA_MODEL=qwen3:14b, OLLAMA_THINK=false (needs image >= eed4ec0). Takes effect at the next live-stack restart; set 'to' to that time. The scoring model is then qwen3:14b, not the frozen tools:latest. That is a known, recorded change to the news-scoring input.",
"outcome": "News scoring restored with qwen3:14b on mason (OLLAMA_THINK=false, image 4361f80). The first restart (22:22) ran the OLD image (TrueNAS had not pulled), and 320 empty-answer rows were cached as 'other'; they were deleted after the container was recreated at 22:36. From 22:37 the scorer is qwen3:14b, not the frozen tools:latest; known weakness: reactive articles and halt-up notices can score as bullish catalysts."
},
{
"from": "2026-10-02 10:10 ET",
"to": "2026-10-02 10:10 ET",
"what": "News scorer prompt + deterministic cap (3e523e1): halt notices and after-the-fact 'why is X up' / movers coverage capped at conviction 0.1 / 0.3, so they no longer give a catalyst rank boost. Real catalysts and dilution flags unchanged (20-headline check: reactive mean boost 0.318 -> 0.055, real 0.353 -> 0.362). Deployed by a restart DURING market hours (10:10 ET) with TNMG open; its Tradier bracket stop (4.43) stayed in place and the held-symbol guard blocks a re-entry.",
"resolution": "Adopted as the baseline on 2026-10-10: the reactive-coverage cap has run unchanged since it went live, so it is part of the strategy under test from here. Only TNON (09:38 ET that day) traded without it. Closed so the daily report stops listing it as open."
}
]
}
+16
View File
@@ -69,6 +69,22 @@ def query_previous_close(con, symbol: str, session_date: date) -> float | None:
return float(row[0]) if row else None
def query_median_daily_volume(con, symbol: str, session_date: date, days: int = 20) -> float | None:
"""Median of the last ``days`` daily volumes — the spike-robust baseline for
squeeze verification (one or two prior squeeze days can multiply the mean)."""
row = con.execute(
"""
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume) FROM (
SELECT volume FROM daily_bars
WHERE symbol = ? AND trade_date < ?
ORDER BY trade_date DESC LIMIT ?
) recent
""",
[symbol, session_date, days],
).fetchone()
return float(row[0]) if row and row[0] is not None else None
def query_avg_daily_volume(con, symbol: str, session_date: date, days: int = 20) -> float | None:
row = con.execute(
"""
+108 -12
View File
@@ -44,6 +44,7 @@ from api.main import DashboardState, serve_in_background
from research.ingestion.market_data import (
IngestionResult,
classify_session,
data_feed,
discover_active_symbols,
ingest_daily_history,
ingest_live_minute_bars,
@@ -65,6 +66,8 @@ from runtime.market_calendar import (
)
from runtime.triggers import ArmedTriggerBook
from runtime.watcher import Watcher, WatcherConfig
from strategy.evaluation.premarket_range import (
PremarketRangeConfig, premarket_range_pct, premarket_rank_multiplier)
from strategy.population import PopulationConfig
from strategy.exits import ExitConfig
from storage.event_schema import EventMode, ModuleTickEvent
@@ -480,7 +483,11 @@ def build_runtime(args: argparse.Namespace) -> dict:
max_armed=int(os.environ.get("TRIGGER_MAX_ARMED", "6")),
gap_min=float(os.environ.get("TRIGGER_GAP_MIN", "3.0")),
gap_max=float(os.environ.get("TRIGGER_GAP_MAX", "1e9")),
gap_squeeze_min=float(os.environ.get("TRIGGER_GAP_SQUEEZE_MIN", "0")),
# TRADING_GAP_SQUEEZE_ENABLED=0 turns the >=150% squeeze exception off here too, so
# one switch covers the trigger book, the watcher pre-filter and the entry gate.
gap_squeeze_min=(float(os.environ.get("TRIGGER_GAP_SQUEEZE_MIN", "0"))
if os.environ.get("TRADING_GAP_SQUEEZE_ENABLED", "1").strip().lower()
not in {"0", "false", "no", "off"} else 0.0),
gap_squeeze_rvol=float(os.environ.get("TRIGGER_GAP_SQUEEZE_RVOL", "5.0")),
rvol_min=float(os.environ.get("TRIGGER_RVOL_MIN", "2.0")),
min_range_pct=float(os.environ.get("TRIGGER_MIN_RANGE_PCT", "0.004")),
@@ -622,20 +629,26 @@ def main(argv: list[str] | None = None) -> int:
_ingest_stale_block = os.environ.get("INGEST_STALE_BLOCK", "0").strip() in {"1", "true", "yes"}
_data_stale = {"v": False}
def _minutes_since_premarket() -> float:
try:
from zoneinfo import ZoneInfo
now_et = datetime.now(ZoneInfo("America/New_York"))
pre_et = now_et.replace(hour=4, minute=0, second=0, microsecond=0)
return max(0.0, (now_et - pre_et).total_seconds() / 60.0)
except Exception: # noqa: BLE001
return 0.0
def _first_pass_lookback() -> int:
# The first ingest must cover the whole session-so-far so the
# opening-range bars land in the DB (even on a mid-session restart);
# after that we only pull the last few minutes — closed bars never
# change, and re-fetching a 4-hour window for 80 symbols every minute
# was the ~46s step that froze the loop.
try:
from zoneinfo import ZoneInfo
now_et = datetime.now(ZoneInfo("America/New_York"))
open_et = now_et.replace(hour=9, minute=30, second=0, microsecond=0)
since_open = max(0.0, (now_et - open_et).total_seconds() / 60.0)
except Exception: # noqa: BLE001
since_open = 0.0
return int(max(lookback, since_open + 15))
# "Session" starts at the 04:00 ET premarket, not 09:30: KITT was found at
# 09:35 on 2026-09-25 and got bars from 09:13 only, losing the 09:00
# squeeze and ~6M premarket shares — its volume, gap context and rvol
# were all computed from the tail of the move.
return int(max(lookback, _minutes_since_premarket() + 15))
def _freshest_bar_age():
"""Seconds since the newest stored minute bar (UTC), or None. The bar
@@ -704,7 +717,7 @@ def main(argv: list[str] | None = None) -> int:
(ingest_live_minute_bars refuses too; this just saves the API calls)."""
if not client:
return "skipped (no keys)"
if getattr(client.settings, "feed", "iex") != "iex":
if data_feed(client) != "iex":
return "off (live feed is SIP)"
from research.ingestion.volume_ratio import compute_ratios, persist_ratios
fresh = compute_ratios(client, list(symbols))
@@ -911,6 +924,9 @@ def main(argv: list[str] | None = None) -> int:
cat_map = {}
news_rank_gain = float(os.environ.get("NEWS_RANK_BOOST_GAIN", "2.0"))
dilutive_penalty = float(os.environ.get("NEWS_DILUTIVE_PENALTY", "0.1"))
# premarket travel: the one measured predictor of a >=50% session (3.1x
# lift at >=15%). See strategy/evaluation/premarket_range.py for the study.
pm_cfg = PremarketRangeConfig.from_env()
_PEGGED = ("acqui", "merger", "buyout", "to be acquired", "take-private")
@@ -967,6 +983,8 @@ def main(argv: list[str] | None = None) -> int:
bars = rq.query_minute_bars(rt["research_con"], g.symbol, session_date)
hi, lo, complete = opening_range(bars, orb_bars=orb_bars_v)
stop = lo * (1.0 - stop_cushion_v) if lo else None
# premarket travel off the SAME frame — no extra query
pm_range = premarket_range_pct(bars)
catalyst = news_map.get(g.symbol, "")
# recent $-volume (last 5 regular-hours bars) — the liquidity gate.
# rvol is relative; this is the absolute "can it absorb an order" check.
@@ -1007,6 +1025,7 @@ def main(argv: list[str] | None = None) -> int:
"gap_open_pct": gap_open_pct,
"rvol": g.relative_volume,
"rvol_tod": g.relative_volume_tod,
"rvol_med": g.relative_volume_med,
"trigger": hi,
"stop": stop,
"range_pct": ((hi - lo) / hi) if (hi and lo and hi > 0) else 0.0,
@@ -1016,8 +1035,10 @@ def main(argv: list[str] | None = None) -> int:
"day_open": day_open,
# a fresh catalyst is WHY a small-cap runs — boost it up the rank;
# times an optional manual per-symbol weight (default 1.0)
"pm_range_pct": pm_range,
"_score": (g.gap_pct * max(g.relative_volume, 0.1)
* _cat_mult(g.symbol, bool(catalyst))
* premarket_rank_multiplier(pm_range, pm_cfg)
* weights.get(g.symbol, 1.0)),
"complete": complete,
})
@@ -1044,7 +1065,15 @@ def main(argv: list[str] | None = None) -> int:
pass
armed = sum(1 for t in book.triggers.values() if t.state == "armed")
n_news = sum(1 for c in candidates if c.get("catalyst"))
return f"tracking={len(book.triggers)} armed={armed}" + (f" news={n_news}" if n_news else "")
# NOTE: no pm-range counter here. The arm step never emits a module_tick
# (verified against 2026-09-11 and 2026-09-14: only discovery, ingestion,
# enrichment and triggers do), so anything added to this string reaches
# container stdout and nothing else. Adding a tick just for it would put
# ~4k events/day back after the watcher filter removed 88% of them. The
# per-candidate pm_range_pct above is on the armed rows for the board;
# persist it on the approval payload if it needs to be measurable per trade.
return (f"tracking={len(book.triggers)} armed={armed}"
+ (f" news={n_news}" if n_news else ""))
def step_quotes():
"""Record NBBO bid/ask SIZES for tracked + held names (order-book-imbalance
@@ -1122,7 +1151,7 @@ def main(argv: list[str] | None = None) -> int:
day_open=t.day_open, rank=t.rank,
# STATIC overnight gap only — never the dynamic book gap
gap_pct=(t.gap_open_pct / 100.0) if t.gap_open_pct is not None else None,
rvol=t.rvol, price=t.price,
rvol=t.squeeze_rvol, price=t.price,
)
if res.get("ok"):
book.mark_fired(t.symbol)
@@ -1533,6 +1562,73 @@ def main(argv: list[str] | None = None) -> int:
threading.Thread(target=discover_loop, name="discover", daemon=True).start()
print(f"[boot] discovery thread started (every {_disc_interval:.0f}s) — scheduler discover step OFF")
# Whole-market premarket squeeze scan (Alpaca snapshots) on its OWN light
# thread. The movers/most-actives screeners only see the regular session, so
# premarket squeezes stayed invisible until 09:30 (KITT 2026-09-25: ran at
# 09:00, found 09:35). Discovery only: names join the watchlist and flow
# through the normal new-symbol ingest (full-session first pass); every
# trading gate still applies. Same thread-safety pattern as plan 019: own DB
# connection, thread-safe Watchlist, single writer per step.
if (_flag("DISCOVER_SNAPSHOT_SCAN_ENABLED", "1") and _flag("DISCOVER_ENABLED", "1")
and client is not None):
from research.ingestion.snapshot_scan import (
SnapshotScanConfig, SnapshotScanner, emit_discoveries, in_scan_window)
from runtime.exit_thread import run_exit_loop as _run_scan_loop # generic driver
_scan_cfg = SnapshotScanConfig.from_env()
_scanner = SnapshotScanner(client, _scan_cfg)
_scan_con: dict = {"c": None}
def step_snapshot_scan():
now = datetime.now(timezone.utc)
if not in_scan_window(now): # 04:00-16:00 ET, weekdays
return
res = _scanner.sweep(now, already_watched=set(symbols.snapshot()))
names = [h.symbol for h in res.injected]
if names:
emit_discoveries(rt["store"], res.injected, mode=EventMode.PAPER,
correlation_id=rt["session_id"],
scan_duration_s=res.duration_s)
# prior close / 20d volume baseline BEFORE the name is watched,
# so its first gap/RVOL read isn't blank
try:
ingest_daily_history(_scan_con["c"] or rt["research_con"], client,
names, days=30)
except Exception as exc: # noqa: BLE001
res.errors.append(f"daily backfill failed: {exc}"[:300])
symbols.extend(names) # next step_ingest: new-symbol full-session pass
symbols.trim(int(os.environ.get("WATCHER_MAX_UNIVERSE", "80")))
if names or res.errors:
rt["store"].emit(ModuleTickEvent(
timestamp=datetime.now(), mode=EventMode.PAPER,
correlation_id=rt["session_id"],
message=(f"snapshot scan: {res.scanned} scanned, {len(res.hits)} "
f"qualify, +{len(names)} new"),
module="discovery", stage="snapshot_scan",
duration_ms=res.duration_s * 1000.0,
input_count=res.scanned, output_count=len(names),
metrics={"requests": res.requests, "injected": names,
"top": [{"symbol": h.symbol, **h.as_data()}
for h in res.hits[:10]]},
errors=[{"error": e} for e in res.errors],
))
if names:
print(f"[snapscan] +{', '.join(names)} ({res.scanned} scanned, "
f"{res.duration_s:.1f}s, {res.requests} req)", flush=True)
def snapshot_scan_loop():
try:
_scan_con["c"] = open_research_db("market")
except Exception as exc: # noqa: BLE001
print(f"[snapscan-thread] dedicated DB unavailable, using shared: {exc}", flush=True)
class _ScanStep:
def manage(_self): # adapt to the generic loop driver
step_snapshot_scan()
_run_scan_loop(_ScanStep(), stop_event, _scan_cfg.interval_s)
threading.Thread(target=snapshot_scan_loop, name="snapscan", daemon=True).start()
print(f"[boot] snapshot squeeze scan started (every {_scan_cfg.interval_s:.0f}s, "
f">= {_scan_cfg.min_pct:g}% , cap {_scan_cfg.max_per_sweep}/sweep, 04:00-16:00 ET)")
# news enrichment on its OWN thread (never blocks the scheduler's trade steps)
if ollama_cfg.enabled and ollama_cfg.enrichment_enabled:
threading.Thread(target=enrich_loop, name="enrich", daemon=True).start()
+57 -2
View File
@@ -29,6 +29,14 @@ logger = logging.getLogger(__name__)
_FETCH_FAILED = object()
@dataclass
class _FailFast:
entry_ts: object # UTC-naive fill time
risk: float # entry - the stop seen at first sight (the original 1R)
low: float # lowest price sampled since first sight
done: bool = False
@dataclass
class _Tracked:
entry_price: float
@@ -42,12 +50,12 @@ def _is_active(cfg: ExitConfig) -> bool:
or cfg.trail_mode != TRAIL_NONE
or cfg.scale_out_r or cfg.first_red_exit
or cfg.profit_lock_tiers or cfg.catastrophe_pct
or cfg.enforce_stop_grace_passes))
or cfg.enforce_stop_grace_passes or cfg.fail_fast_seconds))
class LiveExitManager:
def __init__(self, client, store, bars_fn, cfg: ExitConfig | None = None,
session_id=None, mode: EventMode = EventMode.PAPER):
session_id=None, mode: EventMode = EventMode.PAPER, now_fn=None):
self.client = client
self.store = store
self.bars_fn = bars_fn # (symbol) -> today's RTH minute bars DataFrame
@@ -58,6 +66,8 @@ class LiveExitManager:
self._naked_passes: dict[str, int] = {} # consecutive passes a held position had no live stop
self._open_anchored: set[str] = set() # breakeven carries re-anchored to today's opening range
self._empty_snapshot_streak = 0 # consecutive empty-but-successful order snapshots (Alpaca can silently return [])
self._ff: dict[str, _FailFast] = {} # fail-fast state per held symbol
self._now = now_fn or (lambda: pd.Timestamp.utcnow().tz_localize(None))
# -- broker helpers ----------------------------------------------------
@@ -168,6 +178,44 @@ class LiveExitManager:
best = ts
return best
def _fail_fast(self, sym, entry, cur, cur_stop, orders, actions) -> bool:
"""Exit a breakout that failed inside its first ``fail_fast_seconds``.
Tracks the lowest price sampled since the fill (this runs every exit-thread
pass, ~1 s). At the first pass at/after the window it checks ONCE: if the low
went >= fail_fast_r * R below entry, flatten. A position first seen well after
its window (restart, carry) is never judged late. Returns True if it exited."""
st = self._ff.get(sym)
now = self._now()
if st is None:
ets = self._entry_time(sym, orders)
risk = entry - cur_stop if cur_stop is not None else 0.0
if ets is None or risk <= 0:
self._ff[sym] = _FailFast(ets, 0.0, cur, done=True)
return False
late = (now - ets).total_seconds() > self.cfg.fail_fast_seconds + 30
st = self._ff[sym] = _FailFast(ets, risk, cur, done=late)
if st.done:
return False
st.low = min(st.low, cur)
if (now - st.entry_ts).total_seconds() < self.cfg.fail_fast_seconds:
return False
st.done = True
dip_r = (entry - st.low) / st.risk
if dip_r < self.cfg.fail_fast_r:
return False
try:
if self._flatten(sym, orders):
self._emit(sym, f"fail-fast exit {sym} @~{cur:.4f}: dipped {dip_r:.2f}R in the first "
f"{self.cfg.fail_fast_seconds:g}s", "exit_fail_fast",
{"price": cur, "entry": entry, "low": st.low, "dip_r": dip_r})
actions.append(f"{sym} fail-fast exit (-{dip_r:.2f}R in {self.cfg.fail_fast_seconds:g}s)")
self._tracked.pop(sym, None)
return True
except Exception as exc: # noqa: BLE001
logger.warning("fail-fast exit failed %s: %s", sym, exc)
return False
def _emit(self, symbol: str, message: str, rule: str, state: dict) -> None:
from datetime import datetime
self.store.emit(RiskRuleTriggeredEvent(
@@ -190,6 +238,9 @@ class LiveExitManager:
if sym not in held:
self._tracked.pop(sym, None)
self._open_anchored.discard(sym)
for sym in list(self._ff):
if sym not in held:
self._ff.pop(sym, None)
if not held:
return []
# fetch the orders snapshot ONCE per pass (status="all" is the heavy call;
@@ -310,6 +361,10 @@ class LiveExitManager:
if not mkt_open:
continue
if (self.cfg.fail_fast_seconds and is_long and entry > 0 and cur > 0
and self._fail_fast(sym, entry, cur, cur_stop, orders, actions)):
continue
try:
bars = self.bars_fn(sym)
except Exception: # noqa: BLE001
+14 -1
View File
@@ -62,6 +62,9 @@ class ArmedTrigger:
# nothing early (07-20: IREX 0.76/IRE 0.58/BIYA 1.27 all stuck "weak" while
# breaking out; tod = 16.8/11.9/481). None -> fall back to legacy rvol.
rvol_tod: float | None = None
# squeeze-verification rvol on the median(20d) baseline (legacy units) — see
# GapperCandidate.relative_volume_med. None -> legacy rvol only.
rvol_med: float | None = None
trigger: float | None = None # opening-range high (entry breakout level)
stop: float | None = None # opening-range low, cushioned (protective stop)
range_pct: float = 0.0 # (high-low)/high — how wide the opening range is
@@ -90,6 +93,13 @@ class ArmedTrigger:
return (self.price - self.trigger) / self.trigger
return None
@property
def squeeze_rvol(self) -> float:
"""The rvol that verifies a >=150% squeeze: the stronger of the legacy
full-day-mean reading and the median-baseline one (same units), so a
prior spike day in the 20d window can't bench a real squeeze."""
return max(self.rvol, self.rvol_med or 0.0)
def as_dict(self) -> dict:
d = self.distance_pct()
return {
@@ -99,6 +109,7 @@ class ArmedTrigger:
"gap": round(self.gap_pct, 2),
"rvol": round(self.rvol, 2),
"rvol_tod": round(self.rvol_tod, 2) if self.rvol_tod is not None else None,
"rvol_med": round(self.rvol_med, 2) if self.rvol_med is not None else None,
"trigger": round(self.trigger, 4) if self.trigger else None,
"stop": round(self.stop, 4) if self.stop else None,
"price": round(self.price, 4) if self.price else None,
@@ -150,7 +161,7 @@ class ArmedTriggerBook:
return True
return (self.gap_squeeze_min > 0
and g >= self.gap_squeeze_min
and t.rvol >= self.gap_squeeze_rvol)
and t.squeeze_rvol >= self.gap_squeeze_rvol)
def _eligible(self, t: ArmedTrigger) -> bool:
return (
@@ -193,6 +204,8 @@ class ArmedTriggerBook:
t.rvol = float(c.get("rvol") or 0.0)
_rt = c.get("rvol_tod")
t.rvol_tod = float(_rt) if _rt is not None else None
_rm = c.get("rvol_med")
t.rvol_med = float(_rm) if _rm is not None else None
t.trigger = c.get("trigger")
t.stop = c.get("stop")
t.range_pct = float(c.get("range_pct") or 0.0)
+39 -5
View File
@@ -43,6 +43,8 @@ class WatchCandidate:
previous_close: float | None = None
avg_daily_volume: float | None = None
source: str = "research"
# spike-robust baseline for squeeze verification (see _population_reject)
median_daily_volume: float | None = None
class WatchlistProvider(Protocol):
@@ -109,6 +111,7 @@ class Watcher:
self.config.session_id = f"session-{date.today().isoformat()}"
# symbol -> current state for this session
self._states: dict[str, str] = {}
self._tick_no = 0 # rotates which out-of-population names get re-checked
# -- state helpers -------------------------------------------------
@@ -143,7 +146,20 @@ class Watcher:
never silently bench a symbol.
"""
try:
open_px = float(bars["open"].iloc[0])
# The SESSION open is the first REGULAR-HOURS bar's open. bars[0] is a
# PREMARKET bar before 09:30, which produces a fabricated gap: FTFT was
# benched 2026-09-14 as "gap -12.5%" when its true static open gap was
# +14.6%, and it never recovered because the frame keeps its premarket
# bars all session and _transition only fires on change.
if "is_regular_hours" in bars:
rth = bars[bars["is_regular_hours"] == True] # noqa: E712 — pandas mask
if len(rth) == 0:
return None # session has not opened — gap unknowable, fail open
open_px = float(rth["open"].iloc[0])
else:
# no session flag: the frame is already regular-hours only (tests,
# replay). Using bars[0] is correct there.
open_px = float(bars["open"].iloc[0])
except Exception: # noqa: BLE001
return None
prev_close = candidate.previous_close
@@ -156,9 +172,13 @@ class Watcher:
volume = rvol = None
try:
volume = float(bars["volume"].sum())
adv = candidate.avg_daily_volume
if adv and float(adv) > 0:
rvol = volume / float(adv)
# squeeze verification takes the stronger of the mean- and
# median-baseline readings (same units): a prior spike day in the
# 20d window must not bench a real squeeze (KITT 2026-09-25).
readings = [volume / float(b) for b in (candidate.avg_daily_volume,
candidate.median_daily_volume)
if b and float(b) > 0]
rvol = max(readings) if readings else None
except Exception: # noqa: BLE001
volume = rvol = None
return population_reject(
@@ -201,7 +221,21 @@ class Watcher:
result.errors.append(f"provider: {exc}")
return result
for candidate in candidates[: self.config.max_symbols]:
# The evaluation budget is max_symbols candidates per tick. Spend it on
# names that can still trade: on 2026-10-08 the live list was 15 scan-
# injected intraday movers (placed first by the provider, never expiring)
# plus research rows ranked by bar count, so every in-population gapper
# fell past position 25 and stopped being scored at 09:43 while 50
# already-rejected names were re-rejected every 8 s. Rejected names go to
# the back and rotate, so a squeeze can still be re-admitted when there is
# budget left, but they never crowd out a live candidate.
front = [c for c in candidates if self._states.get(c.symbol) != "out_of_population"]
back = [c for c in candidates if self._states.get(c.symbol) == "out_of_population"]
if back:
k = self._tick_no % len(back)
back = back[k:] + back[:k]
self._tick_no += 1
for candidate in (front + back)[: self.config.max_symbols]:
symbol = candidate.symbol
try:
if symbol not in self._states:
+133
View File
@@ -0,0 +1,133 @@
"""Premarket range as a rank feature — the one measured predictor of a big day.
Measured 2026-09-13 over 1,040 screened symbol-days (2026-08-01 onward), asking
which premarket property predicts the regular session running >= 50% open-to-high:
premarket range n avg run ran >=50%
>= 30% 201 34.7% 15.9%
15-30% 136 28.3% 14.7%
5-15% 303 15.2% 5.9%
< 5% 400 16.8% 4.3%
A name that already travelled >= 15% premarket is 3.1x more likely to run >= 50%
in the session (15.4% vs 5.0%). That is the largest single lift found anywhere in
this dataset -- larger than gap %, relative volume, or the news flag the ranking
previously leaned on -- and it costs nothing: it is computed from minute bars the
loop already stores.
Two results shaped this module and are worth keeping in mind before "improving" it:
* It is a STEP, not a gradient. 15-30% and >= 30% are indistinguishable (14.7 vs
15.9), as are 5-15% and < 5% (5.9 vs 4.3). So the multiplier is a threshold,
not a curve. A linear scale would be inventing precision the data does not have.
* Premarket VOLUME is not predictive and must not be used here. Bucketed by
premarket volume as a fraction of 20-day average, the >= 50% rate went
10.0 / 4.2 / 7.9 / 9.0% -- non-monotonic, no signal. Raw premarket volume is
worse: it runs INVERSELY to the outcome, because illiquid names have low
premarket volume and larger percentage moves. ``premarket_volume`` being NULL
in scanner_snapshots costs nothing; premarket range is the field that matters.
A/B ON THE ACTUAL RANKING, and the reason to keep expectations low: replaying the
armed-set selection over the same 29 sessions, the univariate 3.1x does NOT carry
through to a 3x improvement, because gap% x rvol already encodes much of the same
information -- a name that gapped hard on heavy relative volume has usually already
travelled premarket. Top-5 armed set, mean/median run of the names armed and how
many >= 50% runners were captured out of 145 slots:
gap x rvol (baseline) 39.77% / 10.97% 27 runners
x1.5 if PM range >= 10% 41.75% / 12.14% 30
x3.0 if PM range >= 15% 42.00% / 13.16% 32
x3.0 if PM range >= 20% 42.20% / 13.21% 31
HARD FILTER PM range >= 15% 37.27% / 12.67% 24 <- worse than baseline
PM range alone 32.86% / 12.65% 20 <- much worse
So: BOOST, never gate, and never let it replace gap/rvol. The gain is real but
modest -- roughly +2 points of mean run and 4-5 extra big runners per 145 armed
slots. Every threshold in 10-20% and every gain in 1.5-5.0 beat the baseline,
which is the flat plateau that says this is not fitted. The shipped values (15%,
3.0x) are taken from the univariate study -- the measured step and the measured
lift -- not from the peak cell of that sweep.
Fail-open throughout: an unknown range returns a neutral 1.0 multiplier, never a
penalty, so a data gap can only cost a name its boost -- never bench it.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass
class PremarketRangeConfig:
"""Threshold boost applied to a candidate's rank score."""
enabled: bool = True
# premarket travel (high-to-low as a % of the low) at or above which a name is
# treated as "already in motion". 15% is the measured step, not a fitted value.
min_range_pct: float = 15.0
# rank multiplier applied at/above the threshold. Set to the measured 3.1x
# lift rather than a sweep peak -- see the A/B note below. This reorders the
# armed set, it does not gate entries; population and entry gates still decide.
gain: float = 3.0
@classmethod
def from_env(cls, env: dict | None = None) -> "PremarketRangeConfig":
v = dict(os.environ)
if env:
v.update(env)
def _f(key: str, default: str) -> float:
try:
return float(v.get(key, default))
except (TypeError, ValueError):
return float(default)
return cls(
enabled=str(v.get("TRADING_PM_RANGE_BOOST", "1")).strip().lower()
in {"1", "true", "yes", "on"},
min_range_pct=_f("TRADING_PM_RANGE_MIN_PCT", "15.0"),
gain=_f("TRADING_PM_RANGE_GAIN", "3.0"),
)
def describe(self) -> str:
if not self.enabled:
return "pm-range boost off"
return f"pm-range >={self.min_range_pct:g}% -> x{self.gain:g}"
def premarket_range_pct(bars) -> float | None:
"""Premarket high-to-low travel as a percentage of the low. None if unknowable.
``bars`` is a minute-bar frame carrying ``is_premarket``, ``high`` and ``low``
(what ``research.query.query_minute_bars`` returns). Anything missing, empty or
non-positive yields None rather than a number the caller might trust.
"""
if bars is None or len(bars) == 0:
return None
try:
if "is_premarket" not in bars:
return None
pm = bars[bars["is_premarket"] == True] # noqa: E712 — pandas mask
if len(pm) == 0:
return None
hi = float(pm["high"].max())
lo = float(pm["low"].min())
except Exception: # noqa: BLE001 — a malformed frame must not break ranking
return None
if not (lo > 0) or not (hi > 0) or hi < lo:
return None
return (hi - lo) / lo * 100.0
def premarket_rank_multiplier(range_pct: float | None,
cfg: PremarketRangeConfig) -> float:
"""Rank multiplier for a candidate whose premarket range is ``range_pct``.
A threshold, not a curve -- see the module docstring. Returns 1.0 (neutral)
when disabled or when the range is unknown.
"""
if not cfg.enabled or range_pct is None:
return 1.0
return cfg.gain if range_pct >= cfg.min_range_pct else 1.0
+16
View File
@@ -112,6 +112,18 @@ class ExitConfig:
# fraction of entry) used only until the opening range has formed, so a carry still
# ratchets up from the first pass. Only ever ratchets up. 0 = leave the stop frozen.
default_trail_r_pct: float = 0.10
# FAIL-FAST: at the first check >= fail_fast_seconds after the fill, exit at market
# if price has already traded fail_fast_r * R below entry. A breakout that fails
# inside its first minute rarely recovers into a runner: over 1,093 live-band v1
# ORB breaks (Jun-Sep 2026, tick data) exiting at +60 s after a >= 0.3R dip added
# +0.060R/trade (Jun-Jul) and +0.062R (Aug-Sep); neighbouring settings (-0.15..-0.4R)
# were positive in both halves, and it cut 22 of ~190 runners. On our own 194 real
# fills it was +0.015R (same sign, not yet significant). Only checked ONCE, early:
# the later "bail out below +1R" variants killed runners (tape study, LLM manager
# pilot, orderflow H1). Live-only: needs sub-minute prices, so simulate_exit (minute
# bars) does not model it. 0 = off.
fail_fast_seconds: float = 0.0
fail_fast_r: float = 0.3
@classmethod
def from_env(cls, env: dict | None = None) -> "ExitConfig":
@@ -148,6 +160,8 @@ class ExitConfig:
# clamp to [0, 1): a synthetic risk distance must be a positive fraction of
# entry below it; >=1 or negative would put the synthetic stop at/above entry.
default_trail_r_pct=min(max(f("TRADING_EXIT_DEFAULT_TRAIL_R_PCT", "0.10"), 0.0), 0.95),
fail_fast_seconds=max(f("TRADING_EXIT_FAIL_FAST_SECONDS", "0"), 0.0),
fail_fast_r=max(f("TRADING_EXIT_FAIL_FAST_R", "0.3"), 0.0),
)
def describe(self) -> str:
@@ -174,6 +188,8 @@ class ExitConfig:
parts.append(f"enforce-stop({self.enforce_stop_grace_passes}p)")
if self.default_trail_r_pct:
parts.append(f"synth-trail {self.default_trail_r_pct:.0%}")
if self.fail_fast_seconds:
parts.append(f"fail-fast -{self.fail_fast_r:g}R@{self.fail_fast_seconds:g}s")
return ", ".join(parts)
+11
View File
@@ -38,6 +38,13 @@ class PopulationConfig:
gap_dead_band_lo: float = 0.35
gap_dead_band_hi: float = 1.50
squeeze_rvol_min: float = 5.0
# The >=150% "verified squeeze" exception. Env TRADING_GAP_SQUEEZE_ENABLED (default on).
# 2026-09-28: under the current exits, live-band ORB breaks with a >=150% gap lost in both
# halves (Jun-Jul -0.21R n=65, Aug-Sep -0.30R n=53), while the 10-35% band made +0.27R /
# +0.13R (n=342 / 327). Our own squeeze fills were -0.31R / -0.33R realized (n=16).
# These are the day's top gainers by the time their opening range breaks: the move has
# already happened.
squeeze_enabled: bool = True
@classmethod
def from_env(cls, env: dict | None = None) -> "PopulationConfig":
@@ -60,6 +67,8 @@ class PopulationConfig:
price_max=float(v.get("TRADING_PRICE_MAX", "20.0")),
min_entry_gap_pct=_qfloat(v.get("TRADING_MIN_ENTRY_GAP_PCT", "0.0")),
min_day_volume=float(v.get("TRADING_MIN_DAY_VOLUME", "0.0")),
squeeze_enabled=str(v.get("TRADING_GAP_SQUEEZE_ENABLED", "1")).strip().lower()
not in {"0", "false", "no", "off"},
)
@@ -93,6 +102,8 @@ def population_reject(
# reverse-split fakes show rvol ~0.01).
if cfg.gap_dead_band_lo < g < cfg.gap_dead_band_hi:
return f"gap {g:.0%} in dead band 35-150%"
if g >= cfg.gap_dead_band_hi and not cfg.squeeze_enabled:
return f"gap {g:.0%} above band (squeeze exception off)"
if g >= cfg.gap_dead_band_hi and (rvol is None or float(rvol) < cfg.squeeze_rvol_min):
return f"gap {g:.0%} squeeze unverified (rvol {rvol})"
except (TypeError, ValueError):
@@ -346,3 +346,72 @@ def test_broker_rejection_not_reconciled(store):
assert not result.ok
assert client.lookup_calls == 0
assert store.query_events(event_type="order_cancelled")
def _today_iso(hhmm):
"""An ISO timestamp on today's ET session date, so the same-session filter admits it."""
from datetime import datetime
from zoneinfo import ZoneInfo
d = datetime.now(ZoneInfo("America/New_York")).date()
return f"{d.isoformat()}T{hhmm}:00-04:00"
def test_round_trip_between_polls_is_journaled_once(store):
# TNON 2026-10-02: bought 7 @4.62 and the bracket stop filled 49 s later — no positions
# snapshot ever held it, so the snapshot diff alone journaled nothing.
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = []
sync.sync_all() # baseline: flat, no orders
c.orders = [
{"id": "p-1", "symbol": "TNON", "side": "buy", "qty": "7", "filled_qty": "0", "type": None,
"status": "filled", "stop_price": None, "filled_avg_price": None, "submitted_at": _today_iso("09:38")},
{"id": "b-1", "symbol": "TNON", "side": "buy", "qty": "7", "filled_qty": "7", "type": "limit",
"status": "filled", "stop_price": None, "filled_avg_price": "4.62", "submitted_at": _today_iso("09:38")},
{"id": "tp-1", "symbol": "TNON", "side": "sell", "qty": "7", "filled_qty": "0", "type": "limit",
"status": "canceled", "stop_price": None, "filled_avg_price": None, "submitted_at": _today_iso("09:38")},
{"id": "s-1", "symbol": "TNON", "side": "sell", "qty": "7", "filled_qty": "7", "type": "stop",
"status": "filled", "stop_price": "4.30", "filled_avg_price": "4.30", "submitted_at": _today_iso("09:38")},
]
sync.sync_all() # still flat: the whole trade happened between polls
rows = _closed(store)
assert len(rows) == 1
p = rows[0]
assert (p["symbol"], p["entry_price"], p["exit_price"], p["exit_reason"]) == ("TNON", 4.62, 4.30, "stop_loss")
assert p["quantity"] == 7 and p["realized_pnl"] == -2.24 and p["stop_loss_price"] == 4.30
assert p["position_id"] == "s-1"
sync.sync_all() # the next poll must not journal it again
assert len(_closed(store)) == 1
# ...and neither must a fresh sync object after a restart (seeded from the store)
AlpacaPaperSync(store, client=c, session_id="t2").sync_all()
assert len(_closed(store)) == 1
def test_round_trip_pass_leaves_snapshot_diff_closes_alone(store):
# the normal path: GOOD opens in one poll and is gone in the next; exactly one close,
# even though the orders list then shows a completed buy+sell for a flat symbol
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("GOOD", 13.90, 14.10)]
c.orders = [_order("buy-1", "GOOD", "buy", "market", "filled", 13.90, at=_today_iso("09:31"))]
sync.sync_all()
c.positions = []
c.orders.append(_order("stop-1", "GOOD", "sell", "stop", "filled", 13.45, stop=13.45, at=_today_iso("09:40")))
sync.sync_all(); sync.sync_all()
assert [p["position_id"] for p in _closed(store)] == ["stop-1"]
def test_round_trip_pass_ignores_other_sessions_and_open_names(store):
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("HOLD", 5.0, 5.2)]
c.orders = [
# yesterday's completed trade must not be re-journaled today
_order("ob", "OLD", "buy", "limit", "filled", 2.0, at="2026-06-22T13:31:00Z"),
_order("os", "OLD", "sell", "stop", "filled", 1.9, stop=1.9, at="2026-06-22T13:35:00Z"),
# a name that is still open: its partial sell is the diff path's business
_order("hb", "HOLD", "buy", "limit", "filled", 5.0, at=_today_iso("09:31")),
_order("hs", "HOLD", "sell", "limit", "filled", 5.3, at=_today_iso("09:50")),
]
sync.sync_all(); sync.sync_all()
assert _closed(store) == []
@@ -0,0 +1,62 @@
"""Live-test scorecard: pure verdict / drift logic."""
from research.live_scorecard import config_drift, load_freeze, verdict
def test_running_until_target():
assert verdict([0.5] * 10, 5.0, 0, target=80).startswith("RUNNING: 10/80")
def test_floor_halt_fails_immediately():
assert verdict([2.0] * 100, 50.0, 1).startswith("FAIL: the equity floor")
def test_pass_needs_positive_r_and_pnl():
assert verdict([0.2] * 80, 10.0, 0).startswith("PASS")
assert verdict([0.2] * 80, -1.0, 0).startswith("FAIL")
assert verdict([-0.1] * 80, 5.0, 0).startswith("FAIL")
def test_drift_ignores_safety_knobs_and_catches_new_strategy_keys():
frozen = {"TRADING_EXIT_TRAIL_PCT": "0.12", "TRADING_EQUITY_FLOOR": "215"}
current = {"TRADING_EXIT_TRAIL_PCT": "0.12", "TRADING_EQUITY_FLOOR": "190",
"TRIGGER_GAP_MAX": "40", "OLLAMA_MODEL": "x"}
assert config_drift(frozen, current) == {"TRIGGER_GAP_MAX": (None, "40")}
def test_freeze_file_is_valid():
f = load_freeze()
assert f["frozen_on"] == "2026-09-29"
assert f["settings"]["TRADING_EXIT_TRAIL_PCT"] == "0.12"
def _fill(ts, sym, side, typ, qty, price, stop=None):
return {"ts": ts, "symbol": sym, "side": side, "type": typ, "qty": qty, "price": price, "stop": stop}
def test_reconstruct_missing_counts_the_tnon_style_trade():
# 2026-10-02: bought 7 TNON @4.62, sell-stop 4.30 filled 49 s later, no position_closed ever written
from research.live_scorecard import reconstruct_missing
fills = [_fill("2026-10-02 09:38:40", "TNON", "buy", "limit", 7, 4.62),
_fill("2026-10-02 09:39:30", "TNON", "sell", "stop", 7, 4.30, stop=4.30),
# a journaled trade the same day must NOT be duplicated
_fill("2026-10-02 10:28:10", "CYCU", "buy", "limit", 5, 3.27),
_fill("2026-10-02 10:40:05", "CYCU", "sell", "stop", 5, 4.32, stop=4.33)]
journal = [{"ts": "2026-10-02 10:40:03", "symbol": "CYCU", "pnl": 5.25, "r": 1.8,
"reason": "stop_loss", "source": "journal"}]
out = reconstruct_missing(fills, journal)
assert [t["symbol"] for t in out] == ["TNON"]
t = out[0]
assert t["pnl"] == -2.24 and abs(t["r"] + 1.0) < 1e-9
assert t["source"] == "broker" and t["reason"].startswith("stop_loss")
assert str(t["ts"]).startswith("2026-10-02 09:39")
def test_reconstruct_missing_ignores_one_sided_and_journaled_days():
from research.live_scorecard import reconstruct_missing
fills = [_fill("2026-10-05 09:36:30", "SBS", "buy", "limit", 5, 6.33), # still open: no sell
_fill("2026-10-06 11:10:00", "AIFA", "buy", "limit", 4, 7.425),
_fill("2026-10-06 11:10:30", "AIFA", "sell", "market", 4, 7.88)] # journaled below
journal = [{"ts": "2026-10-06 11:10:31", "symbol": "AIFA", "pnl": 1.82, "r": 0.6,
"reason": "stop_loss", "source": "journal"}]
assert reconstruct_missing(fills, journal) == []
@@ -221,3 +221,70 @@ def test_catalyst_map_latest_per_symbol(con, monkeypatch):
assert "BIO" in m
assert m["BIO"]["is_dilutive"] is True
assert m["BIO"]["catalyst_type"] == "offering_dilution"
def test_parse_empty_object_is_a_failure_not_other():
# qwen3 with thinking on + format=json answers "{}": that must not read as a confident "other"
assert ne._parse("{}") is None
assert ne._parse('{"rationale": "x"}') is None
def test_classify_headline_sends_think_only_when_set(monkeypatch):
sent = []
class Resp(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake(req, timeout=None):
sent.append(json.loads(req.data))
return Resp(json.dumps({"response": '{"catalyst_type": "fda_approval", "sentiment": 0.8, '
'"conviction": 0.9, "is_dilutive": false}'}).encode())
monkeypatch.setattr(ne.urllib.request, "urlopen", fake)
ne.classify_headline("FDA approves X", model="qwen3:14b", think=False)
ne.classify_headline("FDA approves X", model="tools:latest")
assert sent[0]["think"] is False and "think" not in sent[1]
def test_ollama_think_env():
from config import OllamaConfig
assert OllamaConfig.from_env({"OLLAMA_THINK": "false"}).think is False
assert OllamaConfig.from_env({"OLLAMA_THINK": ""}).think is None
@pytest.mark.parametrize("headline,cap", [
("Veea Shares Halted On Circuit Breaker To The Upside, Stock Now Up 172.05%", 0.1),
("Trading Halt: Halt status updated at 9:45:42 AM ET: Halt News Pending", 0.1),
("Why Is Veea Stock Gaining Thursday?", 0.3),
("Xos Stock Surges on US Air Force Contract Win", 0.3),
("Why FingerMotion Shares Are Trading Higher By Around 18%; Here Are 20 Stocks Moving Premarket", 0.3),
])
def test_reactive_coverage_conviction_is_capped(headline, cap):
raw = '{"catalyst_type": "partnership_contract", "sentiment": 0.9, "conviction": 0.9, "is_dilutive": false}'
a = ne._parse(raw, fallback_text=headline, headline=headline)
assert a.conviction <= cap
@pytest.mark.parametrize("headline", [
"BioCardia Receives FDA Confirmation For Its Biologics Evaluation And Research Pathway",
"Zedge Q3 Adj. EPS $0.07 Beats $0.05 Estimate, Sales $7.992M Beat $7.008M Estimate",
"Expion Energy Commences $9M Offering Of 2.117M Common Shares At $4.25 Per Share",
])
def test_real_news_is_not_capped(headline):
raw = '{"catalyst_type": "fda_approval", "sentiment": 0.6, "conviction": 0.85, "is_dilutive": false}'
assert ne._parse(raw, fallback_text=headline, headline=headline).conviction == 0.85
def test_halt_type_capped_and_dilution_untouched():
raw = '{"catalyst_type": "halt_resumption", "sentiment": 1.0, "conviction": 0.8, "is_dilutive": true}'
a = ne._parse(raw, headline="ABC resumes trading")
assert a.conviction <= 0.1 and a.is_dilutive is True
def test_prompt_tells_the_model_reactive_coverage_is_not_a_catalyst():
p = ne.build_prompt("Why Is Veea Stock Gaining Thursday?")
assert "NOT a new catalyst" in p and "<news>" in p
+213
View File
@@ -0,0 +1,213 @@
"""Whole-market premarket squeeze scanner (research/ingestion/snapshot_scan.py).
The screener-based discovery only sees the regular session: KITT ran
$3.50 -> $8.39 at 09:00 ET on 2026-09-25 (prior close $0.49) and we first saw
it at 09:35. These pin the qualification rules with a fake client (no network).
"""
from datetime import datetime, timedelta, timezone
from research.ingestion.snapshot_scan import (
SOURCE,
SnapshotScanConfig,
SnapshotScanner,
build_universe,
emit_discoveries,
fetch_snapshots,
in_scan_window,
prior_close,
qualify,
)
from storage.event_schema import EventMode, EventType
# 2026-09-25 09:00 ET (EDT = UTC-4) — premarket
NOW = datetime(2026, 9, 25, 13, 0, 0, tzinfo=timezone.utc)
def _iso(dt):
return dt.isoformat().replace("+00:00", "Z")
def snap(price, prev_close, *, trade_age=5, bar_age=60, bar_vol=50_000,
daily_today=False, prev2_close=None):
"""Premarket-shaped snapshot: dailyBar is YESTERDAY's (regular-session-only)
unless daily_today, in which case prevDailyBar is yesterday's."""
today_bar = {"c": price, "t": "2026-09-25T04:00:00Z", "v": 1_000_000}
yday_bar = {"c": prev_close, "t": "2026-09-24T04:00:00Z", "v": 700_000}
older = {"c": prev2_close or prev_close * 0.9, "t": "2026-09-23T04:00:00Z", "v": 1}
return {
"latestTrade": {"p": price, "t": _iso(NOW - timedelta(seconds=trade_age))},
"minuteBar": {"c": price, "v": bar_vol, "t": _iso(NOW - timedelta(seconds=bar_age))},
"dailyBar": today_bar if daily_today else yday_bar,
"prevDailyBar": yday_bar if daily_today else older,
}
CFG = SnapshotScanConfig(price_min=1.0, price_max=20.0, min_pct=20.0, max_per_sweep=2,
min_bar_volume=5_000, min_cum_volume=100_000,
max_trade_age_s=180, max_bar_age_s=300)
def test_price_band_uses_latest_trade_not_prior_close():
# KITT: prior close $0.49 (below the $1 band floor), trading $3.50 premarket
hits = qualify({"KITT": snap(3.50, 0.49)}, CFG, NOW)
assert [h.symbol for h in hits] == ["KITT"]
assert round(hits[0].pct_change) == 614
# latest price outside the band is rejected even with a huge move
assert qualify({"BIG": snap(25.0, 10.0)}, CFG, NOW) == []
assert qualify({"PENNY": snap(0.80, 0.30)}, CFG, NOW) == []
def test_pct_threshold():
assert qualify({"A": snap(5.9, 5.0)}, CFG, NOW) == [] # +18%
assert [h.symbol for h in qualify({"A": snap(6.0, 5.0)}, CFG, NOW)] == ["A"] # +20%
def test_prior_close_premarket_vs_rth():
# premarket: dailyBar is still yesterday's -> prior close = dailyBar.c
assert prior_close(snap(6.0, 5.0), NOW.date()) == 5.0
# after the first RTH print: dailyBar is today's -> prior close = prevDailyBar.c
s = snap(6.0, 5.0, daily_today=True)
assert prior_close(s, NOW.date()) == 5.0
assert [h.symbol for h in qualify({"A": s}, CFG, NOW)] == ["A"]
def test_stale_or_dead_names_rejected():
snaps = {
"OLDTRADE": snap(6.0, 4.0, trade_age=3600), # last print an hour ago
"OLDBAR": snap(6.0, 4.0, bar_age=900), # no minute bar for 15 min
"THIN": snap(6.0, 4.0, bar_vol=300), # 300 shares last minute
"LIVE": snap(6.0, 4.0),
}
snaps["NOTRADE"] = {**snap(6.0, 4.0), "latestTrade": {}}
snaps["NOBAR"] = {**snap(6.0, 4.0), "minuteBar": None}
assert [h.symbol for h in qualify(snaps, CFG, NOW)] == ["LIVE"]
def test_universe_excludes_warrants_units_rights_otc_and_excludes():
assets = [
{"symbol": "KITT", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "Nauticus Robotics, Inc. Common Stock"},
{"symbol": "ABCDW", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "ABCD Corp Warrant"},
{"symbol": "ABCDU", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "ABCD Corp Unit"},
{"symbol": "ABCDR", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "ABCD Corp Right"},
{"symbol": "FOO.WS", "exchange": "NYSE", "status": "active", "tradable": True,
"name": "Foo Redeemable warrants"},
{"symbol": "BCTXZ", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "BriaCell Therapeutics Corp. Warrant expiring 2030"},
{"symbol": "ET", "exchange": "NYSE", "status": "active", "tradable": True,
"name": "Energy Transfer LP Common Units representing limited partner interests"},
{"symbol": "OTCX", "exchange": "OTC", "status": "active", "tradable": True, "name": "x"},
{"symbol": "HALT", "exchange": "NYSE", "status": "active", "tradable": False, "name": "x"},
{"symbol": "SOXL", "exchange": "ARCA", "status": "active", "tradable": True, "name": "x"},
]
assert build_universe(assets, excluded={"SOXL"}) == ["ET", "KITT"]
def test_ranking_cap_dedupe_and_cum_volume():
class FakeData:
def __init__(self):
self.settings = type("S", (), {"feed": "sip"})()
self.snapshot_calls = 0
self.bar_calls = []
self.snaps = {
"AAA": snap(3.0, 1.0), # +200%
"BBB": snap(4.0, 2.0), # +100%
"CCC": snap(6.0, 4.0), # +50%
"DDD": snap(5.5, 5.0), # +10% (below threshold)
"LOW": snap(9.0, 3.0), # +200%, but under the cum-volume floor
}
def get_assets(self):
return [{"symbol": s, "exchange": "NASDAQ", "status": "active",
"tradable": True, "name": s} for s in self.snaps]
def _data(self, method, path, params=None):
assert path == "/v2/stocks/snapshots" and params["feed"] == "sip"
self.snapshot_calls += 1
return {s: self.snaps[s] for s in params["symbols"].split(",")}
def get_minute_bars(self, symbols, start_iso, end_iso=None, **kw):
self.bar_calls.append((tuple(symbols), start_iso))
return {s: [{"v": 10_000 if s == "LOW" else 400_000}] for s in symbols}
class Wrapper: # TradierClient-style: data delegated to data_client
def __init__(self, dc):
self.data_client = dc
dc = FakeData()
cfg = SnapshotScanConfig(**{**CFG.__dict__, "batch_size": 2})
scanner = SnapshotScanner(Wrapper(dc), cfg, excluded=set())
res = scanner.sweep(NOW)
assert dc.snapshot_calls == 3 # 5 symbols / batch 2
assert res.requests == 1 + 3 + 1 # assets + snapshots + cum-volume bars
assert res.scanned == 5
assert [h.symbol for h in res.hits][:2] in (["AAA", "LOW"], ["LOW", "AAA"])
assert "DDD" not in {h.symbol for h in res.hits}
# LOW dropped by the cum-volume floor; cap of 2 keeps the top two by pct
assert [h.symbol for h in res.injected] == ["AAA", "BBB"]
assert res.injected[0].cum_volume == 400_000
assert dc.bar_calls[0][1] == "2026-09-25T08:00:00Z" # 04:00 ET
# next sweep: already-announced names aren't re-injected; watched ones skipped
res2 = scanner.sweep(NOW + timedelta(seconds=45), already_watched={"CCC"})
assert res2.injected == []
assert res2.requests == 3 + 1 # universe cached for the day
def test_fetch_snapshots_survives_a_failed_batch():
class Flaky:
settings = type("S", (), {"feed": "sip"})()
n = 0
def _data(self, method, path, params=None):
Flaky.n += 1
if Flaky.n == 1:
raise RuntimeError("503")
return {s: {} for s in params["symbols"].split(",")}
out, reqs, errs = fetch_snapshots(Flaky(), ["A", "B", "C"], batch_size=2)
assert reqs == 2 and len(errs) == 1 and set(out) == {"C"}
def test_emitted_event_source_and_evidence():
class Store:
def __init__(self):
self.events = []
def emit(self, ev):
self.events.append(ev)
store = Store()
hits = qualify({"KITT": snap(3.50, 0.49)}, CFG, NOW)
hits[0].cum_volume = 2_500_000
emit_discoveries(store, hits, mode=EventMode.PAPER, correlation_id="s1",
scan_duration_s=3.2)
(ev,) = store.events
assert ev.event_type == EventType.SYMBOL_DISCOVERED
assert ev.symbol == "KITT"
assert ev.symbol_data["source"] == SOURCE == "snapshot_scan"
assert ev.payload["source"] == "snapshot_scan"
assert ev.symbol_data["price"] == 3.5
assert ev.symbol_data["prev_close"] == 0.49
assert ev.symbol_data["pct_change"] > 600
assert ev.symbol_data["minute_volume"] == 50_000
assert ev.symbol_data["cum_volume"] == 2_500_000
assert "via snapshot_scan" in ev.message
def test_scan_window_04_to_16_et_weekdays():
assert in_scan_window(datetime(2026, 9, 25, 8, 0, tzinfo=timezone.utc)) # 04:00 ET
assert not in_scan_window(datetime(2026, 9, 25, 7, 59, tzinfo=timezone.utc)) # 03:59 ET
assert not in_scan_window(datetime(2026, 9, 25, 20, 0, tzinfo=timezone.utc)) # 16:00 ET
assert not in_scan_window(datetime(2026, 9, 26, 13, 0, tzinfo=timezone.utc)) # Saturday
def test_config_from_env(monkeypatch):
monkeypatch.setenv("WATCHER_PRICE_MIN", "2")
monkeypatch.setenv("DISCOVER_SNAPSHOT_MIN_PCT", "35")
monkeypatch.setenv("DISCOVER_SNAPSHOT_MAX_PER_SWEEP", "3")
cfg = SnapshotScanConfig.from_env()
assert (cfg.price_min, cfg.min_pct, cfg.max_per_sweep) == (2.0, 35.0, 3)
@@ -49,16 +49,33 @@ class FakeProvider:
return self._bars.get(symbol, pd.DataFrame())
def _bars(open_px: float, n: int = 30, volume: int = 50_000) -> pd.DataFrame:
"""n flat-ish minute bars starting at open_px."""
return pd.DataFrame({
"timestamp": pd.date_range("2026-09-10 13:30", periods=n, freq="1min"),
"open": [open_px] * n,
"high": [open_px * 1.01] * n,
"low": [open_px * 0.99] * n,
"close": [open_px] * n,
"volume": [volume] * n,
})
def _bars(open_px: float, n: int = 30, volume: int = 50_000,
premarket_open: float | None = None) -> pd.DataFrame:
"""n regular-hours minute bars from open_px, optionally preceded by premarket.
``premarket_open`` prepends 5 PREMARKET bars at that price. The session open
must still be read off the first REGULAR-HOURS bar — reading bars[0] instead
fabricates a gap (FTFT, 2026-09-14).
"""
rows = []
if premarket_open is not None:
rows += [{"timestamp": pd.Timestamp("2026-09-10 09:00") + pd.Timedelta(minutes=i),
"is_regular_hours": False, "open": premarket_open,
"high": premarket_open * 1.01, "low": premarket_open * 0.99,
"close": premarket_open, "volume": volume} for i in range(5)]
rows += [{"timestamp": pd.Timestamp("2026-09-10 13:30") + pd.Timedelta(minutes=i),
"is_regular_hours": True, "open": open_px,
"high": open_px * 1.01, "low": open_px * 0.99,
"close": open_px, "volume": volume} for i in range(n)]
return pd.DataFrame(rows)
def _premarket_only(open_px: float, n: int = 10) -> pd.DataFrame:
"""Bars before the session opens — the gap is not yet knowable."""
return pd.DataFrame([{
"timestamp": pd.Timestamp("2026-09-10 09:00") + pd.Timedelta(minutes=i),
"is_regular_hours": False, "open": open_px, "high": open_px * 1.01,
"low": open_px * 0.99, "close": open_px, "volume": 1000} for i in range(n)])
def _watcher(store, candidates, bars, population=None):
@@ -161,3 +178,84 @@ def test_dead_band_gap_is_skipped():
avg_daily_volume=500_000)],
{"MID": _bars(3.0)}, population=GAPPER)
assert w.tick(SESSION).skipped == 1
# --- the FTFT bug (2026-09-14) ------------------------------------------------
# The gap was read off bars["open"].iloc[0], which premarket is a PREMARKET bar.
# FTFT opened 3.30 against a 2.88 previous close (+14.6%, comfortably tradeable)
# but its premarket bars started near 2.52, so the filter computed -12.5% and
# benched it. It never self-corrected: the frame keeps its premarket bars all
# session, and _transition only emits on change.
def test_gap_is_measured_from_the_session_open_not_a_premarket_bar():
"""FTFT: premarket 2.52, session open 3.30, previous close 2.88 -> +14.6%."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("FTFT", previous_close=2.88,
avg_daily_volume=500_000)],
{"FTFT": _bars(3.30, premarket_open=2.52)}, population=GAPPER)
r = w.tick(SESSION)
assert r.skipped == 0 # +14.6% is inside the 10-35% band
assert r.evaluated == 1
def test_premarket_bars_alone_fail_open():
"""Before the session opens the static gap is unknowable — never bench on it."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("FTFT", previous_close=2.88)],
{"FTFT": _premarket_only(2.52)}, population=GAPPER)
r = w.tick(SESSION)
assert r.skipped == 0
assert store.count("SymbolStateChangedEvent") >= 0 # never out_of_population
def test_a_genuinely_flat_name_is_still_skipped_with_premarket_present():
"""The filter must still work when premarket bars exist — NOK opens flat."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("NOK", previous_close=4.0)],
{"NOK": _bars(4.0, premarket_open=3.6)}, population=GAPPER)
assert w.tick(SESSION).skipped == 1
def test_squeeze_verified_against_median_when_a_spike_day_inflates_the_mean():
"""KITT 2026-09-25: the 20d mean (1.66M) carried two 11-14M squeeze days, so
6.7M shares read as rvol ~4 and the +557% squeeze was benched. Against the
333k median it is ~20x — verified."""
cfg = PopulationConfig(price_min=2.0, price_max=20.0, min_entry_gap_pct=0.10,
gap_dead_band_lo=0.35, gap_dead_band_hi=1.50,
squeeze_rvol_min=5.0)
bars = {"KITT": _bars(3.23, volume=223_000)} # 30 bars ~= 6.7M shares
mean_only = WatchCandidate("KITT", previous_close=0.4915, avg_daily_volume=1_663_373)
store = FakeStore()
assert _watcher(store, [mean_only], bars, population=cfg).tick(SESSION).skipped == 1
both = WatchCandidate("KITT", previous_close=0.4915, avg_daily_volume=1_663_373,
median_daily_volume=333_146)
store = FakeStore()
assert _watcher(store, [both], bars, population=cfg).tick(SESSION).evaluated == 1
def test_rejected_names_do_not_crowd_out_live_candidates():
"""2026-10-08: 51 out-of-population names sat ahead of every real gapper in the
provider's list, so with max_symbols=25 the gappers were never scored after
09:43. Rejected names must yield their slots to names that can still trade."""
store = FakeStore()
flat = [WatchCandidate(f"FLAT{i:02d}", previous_close=10.0, avg_daily_volume=500_000) for i in range(30)]
gapper = WatchCandidate("GAP", previous_close=10.0, avg_daily_volume=500_000)
bars = {c.symbol: _bars(10.1) for c in flat} # +1%: rejected by the gate
bars["GAP"] = _bars(12.0) # +20%: a real gapper
w = _watcher(store, flat + [gapper], bars, population=GAPPER)
w.config.max_symbols = 25
# tick 1: the list is cold, so the first 25 slots go to the flat names and the
# gapper (position 31) is not reached — exactly the live failure mode
r1 = w.tick(SESSION)
assert r1.evaluated == 0 and r1.skipped == 25
# tick 2: the 25 rejected names are known and move to the back; GAP is scored
r2 = w.tick(SESSION)
assert "GAP" in [e.symbol for e in store.events if type(e).__name__ == "CriteriaEvaluatedEvent"]
assert r2.evaluated == 1
# the remaining budget still re-checks rejected names, rotating through them
assert r2.skipped == 24
# tick 3: still scored, and no re-discovery churn (state only emits on change)
before = store.count("CriteriaEvaluatedEvent")
w.tick(SESSION)
assert store.count("CriteriaEvaluatedEvent") == before + 1
@@ -0,0 +1,97 @@
"""Premarket range as a rank feature.
Measured 2026-09-13 over 1,040 screened symbol-days: a name that travelled >=15%
premarket ran >=50% in the session 15.4% of the time vs 5.0% for the rest -- a
3.1x lift, the largest single predictor in the dataset. The relationship is a
STEP at 15%, not a gradient, and premarket VOLUME carries no signal at all.
"""
import pandas as pd
import pytest
from strategy.evaluation.premarket_range import (
PremarketRangeConfig,
premarket_range_pct,
premarket_rank_multiplier,
)
CFG = PremarketRangeConfig(enabled=True, min_range_pct=15.0, gain=3.0)
def _bars(rows):
"""rows = [(is_premarket, high, low), ...]"""
return pd.DataFrame(
[{"is_premarket": pm, "high": h, "low": lo, "close": lo} for pm, h, lo in rows]
)
def test_range_is_measured_over_premarket_bars_only():
"""A wild regular-hours session must not inflate the premarket reading."""
bars = _bars([(True, 11.0, 10.0), # premarket: 10.0 -> 11.0 = +10%
(False, 40.0, 9.0)]) # RTH: ignored
assert premarket_range_pct(bars) == pytest.approx(10.0)
def test_range_spans_the_premarket_high_and_low():
bars = _bars([(True, 10.5, 10.0), (True, 12.0, 9.5), (True, 11.0, 10.2)])
# high 12.0, low 9.5 -> (12.0-9.5)/9.5
assert premarket_range_pct(bars) == pytest.approx(26.3157, rel=1e-3)
def test_boost_applies_at_and_above_the_threshold():
assert premarket_rank_multiplier(15.0, CFG) == 3.0
assert premarket_rank_multiplier(30.0, CFG) == 3.0
assert premarket_rank_multiplier(200.0, CFG) == 3.0
def test_no_boost_below_the_threshold():
assert premarket_rank_multiplier(14.9, CFG) == 1.0
assert premarket_rank_multiplier(0.0, CFG) == 1.0
def test_it_is_a_step_not_a_gradient():
"""15-30% and >=30% were indistinguishable in the study (14.7% vs 15.9%), so
the multiplier must not scale with magnitude."""
assert premarket_rank_multiplier(16.0, CFG) == premarket_rank_multiplier(500.0, CFG)
@pytest.mark.parametrize("bars", [
None,
pd.DataFrame(),
pd.DataFrame([{"high": 11.0, "low": 10.0}]), # no is_premarket column
_bars([(False, 11.0, 10.0)]), # no premarket bars
_bars([(True, 11.0, 0.0)]), # zero low
_bars([(True, 0.0, 0.0)]), # dead frame
])
def test_unknowable_range_is_none(bars):
assert premarket_range_pct(bars) is None
def test_unknown_range_is_neutral_never_a_penalty():
"""A data gap may cost a name its boost; it must never rank it below peers."""
assert premarket_rank_multiplier(None, CFG) == 1.0
def test_disabled_config_is_a_no_op():
off = PremarketRangeConfig(enabled=False, min_range_pct=15.0, gain=3.0)
assert premarket_rank_multiplier(99.0, off) == 1.0
def test_from_env_defaults_and_overrides():
d = PremarketRangeConfig.from_env({})
assert d.enabled and d.min_range_pct == 15.0 and d.gain == 3.0
off = PremarketRangeConfig.from_env({"TRADING_PM_RANGE_BOOST": "0"})
assert not off.enabled
tuned = PremarketRangeConfig.from_env(
{"TRADING_PM_RANGE_MIN_PCT": "20", "TRADING_PM_RANGE_GAIN": "1.5"})
assert tuned.min_range_pct == 20.0 and tuned.gain == 1.5
# a junk value must fall back, not raise
assert PremarketRangeConfig.from_env({"TRADING_PM_RANGE_GAIN": "abc"}).gain == 3.0
def test_ranking_reorders_a_mover_above_a_bigger_gapper():
"""The point of the feature: a 20% gapper already in motion outranks a 30%
gapper that has not moved."""
quiet = 30.0 * 2.0 * premarket_rank_multiplier(3.0, CFG) # big gap, flat PM
mover = 20.0 * 2.0 * premarket_rank_multiplier(25.0, CFG) # smaller gap, moving
assert mover > quiet
+99
View File
@@ -0,0 +1,99 @@
"""Fail-fast: a breakout that dips >= fail_fast_r * R inside its first window is exited once."""
import pandas as pd
from runtime.exit_manager import LiveExitManager
from strategy.exits import ExitConfig
FILL = pd.Timestamp("2026-09-28 13:40:00") # UTC-naive, as _entry_time returns
class _Store:
def __init__(self):
self.events = []
def emit(self, e):
self.events.append(e)
class _Broker:
def __init__(self):
self.price = 10.0
self.closed = []
self.canceled = []
def get_positions(self):
return [{"symbol": "AAA", "avg_entry_price": "10.0", "qty": "100", "side": "long",
"current_price": str(self.price)}]
def get_orders(self, status="all", limit=500, nested=True, symbols=None):
return [{"id": "buy1", "symbol": "AAA", "side": "buy", "filled_qty": "100",
"filled_at": FILL.isoformat() + "Z", "status": "filled",
"legs": [{"id": "stop1", "symbol": "AAA", "type": "stop", "side": "sell",
"stop_price": "9.0", "status": "held"}]}]
def get_clock(self):
return {"is_open": True}
def cancel_order(self, oid):
self.canceled.append(oid)
def close_position(self, symbol, qty=None, percentage=None):
self.closed.append(symbol)
return {"id": "c"}
def _mgr(broker, clock, seconds=60.0, r=0.3):
cfg = ExitConfig(catastrophe_pct=0.0, enforce_stop_grace_passes=0, fail_fast_seconds=seconds,
fail_fast_r=r, default_trail_r_pct=0.0)
return LiveExitManager(broker, _Store(), lambda s: pd.DataFrame(), cfg=cfg,
session_id="t", now_fn=lambda: clock["now"])
def _run(prices_by_second, seconds=60.0, first_seen=5):
b = _Broker(); clock = {"now": FILL}
m = _mgr(b, clock, seconds)
for s, px in prices_by_second:
if s < first_seen:
continue
clock["now"] = FILL + pd.Timedelta(seconds=s); b.price = px
m.manage()
return b, m
def test_deep_early_dip_exits_once_at_the_window():
# R = 1.00 (entry 10, stop 9); dips to 9.65 (-0.35R) at 20 s, recovers, checked at 60 s
b, m = _run([(5, 10.0), (20, 9.65), (40, 9.9), (59, 10.02), (60, 10.05), (61, 10.1)])
assert b.closed == ["AAA"]
def test_shallow_dip_holds():
b, _ = _run([(5, 10.0), (20, 9.8), (60, 9.95), (75, 9.9)]) # -0.2R only
assert b.closed == []
def test_no_exit_before_the_window_ends():
b, _ = _run([(5, 10.0), (20, 9.5), (59, 9.5)])
assert b.closed == []
def test_position_first_seen_late_is_never_judged():
b, _ = _run([(200, 9.5), (201, 9.4)], first_seen=200)
assert b.closed == []
def test_off_by_default_does_nothing():
cfg = ExitConfig()
assert cfg.fail_fast_seconds == 0.0
b = _Broker(); b.price = 9.0
clock = {"now": FILL + pd.Timedelta(seconds=90)}
LiveExitManager(b, _Store(), lambda s: pd.DataFrame(), cfg=ExitConfig(catastrophe_pct=0.0,
enforce_stop_grace_passes=0, default_trail_r_pct=0.0),
now_fn=lambda: clock["now"]).manage()
assert b.closed == []
def test_env_parsing():
cfg = ExitConfig.from_env({"TRADING_EXIT_FAIL_FAST_SECONDS": "60", "TRADING_EXIT_FAIL_FAST_R": "0.3"})
assert cfg.fail_fast_seconds == 60.0 and cfg.fail_fast_r == 0.3
assert "fail-fast -0.3R@60s" in cfg.describe()
+30
View File
@@ -0,0 +1,30 @@
"""judge.engine: data windows, execution gates and exact-cross timing (the look-ahead fix)."""
import numpy as np
import pandas as pd
from judge import engine
def test_windows_merge_open_and_squeeze_window():
ws = engine.windows("2026-09-28", pd.Timestamp("2026-09-28 10:00"))
assert ws == [[pd.Timestamp("2026-09-28 09:30"), pd.Timestamp("2026-09-28 11:00")]]
ws = engine.windows("2026-09-28", pd.Timestamp("2026-09-28 06:03"))
assert ws[0] == [pd.Timestamp("2026-09-28 06:01"), pd.Timestamp("2026-09-28 07:03")]
assert engine.in_windows(pd.Timestamp("2026-09-28 09:45"), ws) and not engine.in_windows(pd.Timestamp("2026-09-28 08:00"), ws)
def test_entry_check_gates():
assert engine.entry_check(4.99, 5.00, 5.00, 4.80) == (5.00, 4.80)
assert engine.entry_check(4.70, 5.00, 5.00, 4.80) is None # spread 6% > 3%
assert engine.entry_check(5.09, 5.10, 5.00, 4.80) is None # ask > signal price + 1% chase
assert engine.entry_check(4.99, 5.00, 5.00, 4.00) is None # stop 20% away > 8% max
entry, stop = engine.entry_check(4.99, 5.00, 5.00, 4.99) # stop inside 1% -> widened
assert stop == 5.00 * (1 - engine.EXEC["min_stop_pct"])
def test_cross_time_uses_the_first_trade_through_the_level_not_the_bar_start():
base = np.datetime64("2026-09-28T09:40:00")
tt = np.array([base + np.timedelta64(ms, "ms") for ms in (0, 1500, 3200, 4100)])
tp = np.array([4.95, 4.99, 5.01, 5.05])
assert engine.cross_time(tt, tp, base, 5.00) == base + np.timedelta64(3200, "ms")
assert engine.cross_time(tt, tp, base, 6.00) is None
+142
View File
@@ -0,0 +1,142 @@
"""judge.live exit/stop bookkeeping against a fake broker. Each case is a failure seen on the paper account:
2026-09-29 BKYI (stop already filled -> endless 422 exit retries), MSGY (partial fill left unprotected);
2026-09-30 PWCM (a moved stop is a new order id; the stale id made the trader double-arm and block its own exit)."""
import asyncio
import pandas as pd
import pytest
pytest.importorskip("websockets")
from judge import live # noqa: E402
from judge.live import Pos, Trader # noqa: E402
class J:
def __init__(self):
self.rows = []
def log(self, kind, **kw):
self.rows.append((kind, kw))
class Broker:
def __init__(self, orders=None, open_orders=None, held=33, submit_ok=True):
self.orders, self.open, self.held, self.submit_ok = orders or {}, open_orders or [], held, submit_ok
self.subs, self.cancelled, self.closed = [], [], []
def _trading(self, method, path, params=None):
return self.orders[path.split("/")[-1]]
def get_orders(self, status, limit, nested, symbols):
return self.open
def get_positions(self, fresh=False):
return [{"symbol": "X", "qty": str(self.held)}] if self.held else []
def submit_order(self, *a):
self.subs.append(a)
if not self.submit_ok:
raise RuntimeError("403 insufficient qty")
return {"id": "armed"}
def replace_order(self, oid, stop_price=None):
return {"id": "new2"}
def cancel_order(self, oid):
self.cancelled.append(oid)
def close_position(self, sym):
self.closed.append(sym)
def trader(broker, leg="old"):
t = Trader.__new__(Trader)
t.j, t.client, t.books = J(), broker, {}
pz = Pos("X", 33, 1.30, 1.20, 0.10, live.now_et(), "o", leg, 1.30, "burst", 0.4)
t.pos = {"X": pz}
return t, pz
def test_filled_stop_is_recorded_as_exit():
b = Broker({"old": {"status": "filled", "filled_avg_price": "1.21"}}, held=0)
t, pz = trader(b)
asyncio.run(t.exit_pos(pz, "trail", 1.25))
assert "X" not in t.pos and t.j.rows[-1][1]["reason"] == "stop"
def test_cancelled_leg_with_shares_rearms_a_stop():
t, pz = trader(Broker({"old": {"status": "canceled"}}))
assert asyncio.run(t._reconcile(pz, 1.28)) is False
assert pz.leg_id == "armed" and t.client.subs
def test_replaced_leg_follows_the_chain():
b = Broker({"old": {"status": "replaced", "replaced_by": "new1"}, "new1": {"status": "new"}}, submit_ok=False)
t, pz = trader(b)
asyncio.run(t._reconcile(pz, 1.28))
assert pz.leg_id == "new1" and not b.subs
def test_working_stop_at_broker_is_adopted_not_doubled():
legs = [{"id": "legX", "side": "sell", "type": "stop", "status": "new"}]
b = Broker({"old": {"status": "canceled"}}, [{"id": "p", "side": "buy", "type": "limit", "legs": legs}], submit_ok=False)
t, pz = trader(b)
asyncio.run(t._reconcile(pz, 1.28))
assert pz.leg_id == "legX" and not b.subs
def test_price_through_stop_closes_instead_of_arming():
b = Broker({"old": {"status": "canceled"}})
t, pz = trader(b)
asyncio.run(t._reconcile(pz, 1.19))
assert b.closed == ["X"] and not b.subs
def test_exit_cancels_every_open_order_first():
opens = [{"id": "a", "side": "sell", "type": "stop", "status": "new"}, {"id": "b", "side": "sell", "type": "stop", "status": "new"}]
b = Broker({"old": {"status": "new"}}, opens)
t, pz = trader(b)
asyncio.run(t.exit_pos(pz, "trail", 1.36))
assert b.cancelled == ["a", "b"] and b.closed == ["X"] and "X" not in t.pos
def test_earlier_today_counts_orders_and_last_fill(tmp_path, monkeypatch):
(tmp_path / "journal").mkdir()
(tmp_path / "journal" / "2026-09-30.jsonl").write_text(
'{"kind":"order","symbol":"X"}\n{"kind":"order","symbol":"X"}\n'
'{"kind":"fill","symbol":"X","stop":1.2,"at":"2026-09-30 09:40:00"}\nnot json\n', encoding="utf-8")
monkeypatch.setattr(live, "OUT", tmp_path)
t = Trader.__new__(Trader)
t.day = "2026-09-30"
e = t._earlier_today()
assert e["orders"] == {"X": 2} and e["fills"]["X"]["stop"] == 1.2
assert pd.Timestamp(e["fills"]["X"]["at"]).hour == 9
def test_soft_stop_walks_trades_in_order():
pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4)
assert Trader._soft_stop_hit(pz, [10.1, 9.8, 9.6]) is None # above the stop
assert Trader._soft_stop_hit(pz, [9.7, 9.5]) == "soft_stop" # a print at the stop
pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4)
assert Trader._soft_stop_hit(pz, [10.6, 12.0, 11.5]) is None # +1R reached; trail 6% under 12.0 = 11.28
assert Trader._soft_stop_hit(pz, [11.25]) == "soft_trail"
pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4)
assert Trader._soft_stop_hit(pz, [9.4, 12.0]) == "soft_stop" # order matters: the dip came first
def test_restart_rewatches_symbols_from_earlier_runs(tmp_path, monkeypatch):
(tmp_path / "journal").mkdir()
(tmp_path / "journal" / "2026-10-02.jsonl").write_text(
'{"kind":"add","symbol":"CABO","prev_close":11.5}\n{"kind":"add","symbol":"APLX","prev_close":7.2}\n',
encoding="utf-8")
monkeypatch.setattr(live, "OUT", tmp_path)
t = Trader.__new__(Trader)
t.day, t.books, t.j = "2026-10-02", {}, J()
t.earlier = t._earlier_today()
added = []
async def fake_add(sym, pc):
added.append((sym, pc)); t.books[sym] = object()
t.add_symbol = fake_add
asyncio.run(t.rewatch())
assert added == [("CABO", 11.5), ("APLX", 7.2)]
+31
View File
@@ -0,0 +1,31 @@
"""judge.review.sim_exit reproduces the simulator's exit rules on synthetic ticks."""
import pandas as pd
from judge.review import sim_exit
T0 = pd.Timestamp("2026-09-30 10:00:00")
def tape(prices, step_s=1):
t = [T0 + pd.Timedelta(seconds=step_s * (i + 1)) for i in range(len(prices))]
T = pd.DataFrame({"t": t, "p": prices, "s": 100})
Q = pd.DataFrame({"t": t, "bp": [p - 0.01 for p in prices], "bs": 100, "ap": [p + 0.01 for p in prices], "as": 100})
return T, Q
def test_stop_sells_at_the_bid_after_the_breach():
T, Q = tape([10.0, 9.9, 9.5, 9.4])
s = sim_exit(T, Q, T0, 10.0, 9.5)
assert s["reason"] == "stop" and round(s["exit"], 2) == 9.49 and round(s["r"], 2) == -1.02
def test_trail_after_one_r_gives_back_six_percent():
T, Q = tape([10.5, 11.0, 12.0, 11.2, 11.0]) # risk 0.5: +1R at 10.5; trail 6% under 12.0 = 11.28
s = sim_exit(T, Q, T0, 10.0, 9.5)
assert s["reason"] == "trail" and round(s["exit"], 2) == 11.19
def test_time_stop_after_twenty_minutes():
T, Q = tape([10.1] * 30, step_s=60)
s = sim_exit(T, Q, T0, 10.0, 9.5)
assert s["reason"] == "time"
+1
View File
@@ -87,3 +87,4 @@ def test_gap_gate_disabled_when_min_zero():
assert g(gap_pct=0.05) is None # below-floor not checked
assert g(gap_pct=0.40) is None # dead band not checked
assert g(gap_pct=1.60, rvol=1.0) is None # squeeze not checked
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime, timezone
import pytest
from prwire import recorder
from prwire.recorder import Recorder, extract_tickers, to_record
@pytest.mark.parametrize("text,want", [
("Acme Corp (NASDAQ: ACME) announces FDA clearance", ["ACME"]),
("Beta Inc. (Nasdaq CM: BETA) and Gamma (NYSE American: GMA)", ["BETA", "GMA"]),
("Delta (OTCQB: DLTA) signs deal", ["DLTA"]),
("Epsilon Ltd. (NASDAQ:EPSN)", ["EPSN"]),
("No exchange mention here, just $SPY chatter", []),
])
def test_extract_tickers(text, want):
assert extract_tickers(text) == want
def test_sec_item_maps_cik_to_ticker_and_form():
it = {"title": "8-K - Dare Bioscience, Inc. (0001401914) (Filer)", "url": "u", "published": "p", "snippet": ""}
rec = to_record("sec_8k", it, "2026-10-01T13:00:00+00:00", {1401914: "DARE"})
assert rec["tickers"] == ["DARE"] and rec["form"] == "8-K" and rec["cik"] == 1401914
def test_poll_logs_each_item_once_and_survives_a_feed_error(tmp_path, monkeypatch):
monkeypatch.setattr(recorder, "OUT", tmp_path)
monkeypatch.setattr(recorder, "_cik_map", lambda: {})
monkeypatch.setattr(recorder, "FEEDS", {"a": "A", "b": "B"})
items = [{"title": "X (NASDAQ: XX) news", "url": "l1", "published": "p", "snippet": ""}]
def fetcher(url):
if url == "B":
raise OSError("down")
return items
r = Recorder()
now = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
assert r.poll_once(fetcher, now) == 1
assert r.poll_once(fetcher, now) == 0 # same item again: not re-logged
lines = (tmp_path / "2026-10-01.jsonl").read_text().splitlines()
assert len(lines) == 2 # the item + one error row for feed b
assert Recorder().seen == r.seen # a restart reloads what was already recorded
def test_failing_feed_backs_off(tmp_path, monkeypatch):
monkeypatch.setattr(recorder, "OUT", tmp_path)
monkeypatch.setattr(recorder, "_cik_map", lambda: {})
monkeypatch.setattr(recorder, "FEEDS", {"bad": "B"})
calls = []
def fetcher(url):
calls.append(url)
raise OSError("timeout")
r = Recorder()
r.poll_once(fetcher)
r.poll_once(fetcher) # still inside the back-off window: not fetched again
assert calls == ["B"] and r.errors["bad"] == 1
+206
View File
@@ -0,0 +1,206 @@
"""Pure pieces of the Ross recap extractor: verbatim-quote verification, chunking, and the
merge of per-chunk results. The Ollama call itself is exercised by the gold-set evaluation."""
from collections import Counter
from youtube_claims.recap.extract import (chunk_bundle, clean_ticker, fix_spoken_prices, merge_records,
scanner_prices, verify_record)
from youtube_claims.recap.quotes import Transcript, transcript_text_from_bundle
TX = Transcript(
"So I got in on that dip right there, and I got a nice little trade as it rallied back up. "
"Then we come back up right here looking for a curl through VWAP. It fails. Doesn't happen. "
"I took a small loss right there. GLND. This is not an easy one to day trade because it's very thickly traded. "
"Generally, the right approach that I have is to avoid stocks that are giving us those big topping tails.")
# ---------------------------------------------------------------- quotes
def test_exact_quote_is_found_and_snapped_to_transcript_words():
got = TX.find("looking for a curl through vwap")
assert got == "looking for a curl through VWAP."
def test_quote_matching_ignores_punctuation_and_case():
assert TX.find("It fails, doesn't happen, I took a small loss") == "It fails. Doesn't happen. I took a small loss"
def test_one_miscopied_word_in_a_long_quote_is_tolerated():
q = "This is not an easy one to trade because it's very thickly traded" # dropped "day"
got = TX.find(q)
assert got is not None and got.startswith("This is not an easy one") and "thickly traded" in got
def test_invented_quote_is_rejected():
assert TX.find("I made ten thousand dollars on the open with a huge short squeeze") is None
# a real 40-char prefix with an invented tail is NOT enough (the old prefix check accepted it)
assert TX.find("So I got in on that dip right there, and then I sold the whole thing at twelve dollars") is None
def test_too_short_quote_is_not_evidence():
assert TX.find("it fails") is None
assert TX.find("") is None
def test_transcript_text_from_bundle_strips_timestamps():
b = "video_id: x\nTRANSCRIPT (mm:ss text):\n[00:01] Hello there.\n[00:05] Second line."
assert transcript_text_from_bundle(b) == "Hello there. Second line."
# ---------------------------------------------------------------- verification
def _raw():
return {
"video_kind": "daily_recap", "session_date": "2026-09-24",
"day": {"net_pnl": "$2,992.62", "green": True, "market_temperature": "Cold",
"market_quote": "totally made up sentence about the market being slow today"},
"trades": [
{"ticker_heard": "a pus", "ticker_guess": "$apus", "setup": "dip_and_curl",
"setup_quote": "I got in on that dip right there", "entry_price": "5.50", "outcome": "WIN",
"why_entered": [{"reason": "dip", "quote": "So I got in on that dip right there"},
{"reason": "fake", "quote": "because the level two was stacked with buyers everywhere"}],
"stock_context": {"catalyst": "headline", "catalyst_quote": "a quote that does not exist anywhere here",
"other": ["topping tails", "rocket ship"]}},
{"ticker_heard": "ZZZ", "ticker_guess": "ZZZ", "setup": "bull_flag",
"setup_quote": "I bought the bull flag at the high of day for a big win",
"why_entered": [{"reason": "x", "quote": "entirely invented reason for entering this trade"}]},
],
"passed_on": [
{"ticker_heard": "glnd", "ticker_guess": "GLND", "reason": "grinder",
"quote": "not an easy one to day trade because it's very thickly traded"},
{"ticker_heard": "pmax", "ticker_guess": "PMAX", "reason": "halts",
"quote": "you cannot possibly manage your risk on these halting stocks"},
],
"rules_stated": [{"rule": "avoid topping tails",
"quote": "avoid stocks that are giving us those big topping tails"},
{"rule": "fake", "quote": "always buy the first red candle after a halt resume"}],
}
def test_verify_drops_unquoted_items_and_snaps_the_rest():
st = Counter()
rec = verify_record(_raw(), TX, st)
assert [t["ticker_guess"] for t in rec["trades"]] == ["APUS"] # ZZZ had no real quote -> dropped
t = rec["trades"][0]
assert t["entry_price"] is None and t["outcome"] == "win" # 5.50 is never said -> dropped
assert [w["reason"] for w in t["why_entered"]] == ["dip"]
assert t["stock_context"]["catalyst"] is None # its quote was invented
assert t["stock_context"]["other"] == ["topping tails"]
assert [p["ticker_guess"] for p in rec["passed_on"]] == ["GLND"]
assert rec["passed_on"][0]["quote"].startswith("not an easy one to day trade")
assert len(rec["rules_stated"]) == 1
assert rec["day"]["net_pnl"] == 2992.62 and rec["day"]["market_temperature"] == "cold"
assert rec["day"]["market_quote"] is None
assert st["dropped_trades"] == 1 and st["dropped_passed_on"] == 1 and st["dropped_rules"] == 1
def test_clean_ticker():
assert clean_ticker("$apus") == "APUS"
assert clean_ticker("K I T T") == "KITT"
assert clean_ticker("null") is None and clean_ticker("Greenland stocks") is None
# ---------------------------------------------------------------- chunking
def test_short_bundle_is_one_chunk():
b = "video_id: x\nTRANSCRIPT (mm:ss text):\n[00:01] hi"
assert chunk_bundle(b, 10_000) == [b]
def test_long_bundle_chunks_keep_header_and_overlap():
head = "video_id: x\nupload_date: 20260924\nTRANSCRIPT (mm:ss text):"
lines = [f"[{i // 60:02d}:{i % 60:02d}] line number {i} " + "x" * 80 for i in range(200)]
b = head + "\n" + "\n".join(lines)
chunks = chunk_bundle(b, 4000, overlap_lines=3)
assert len(chunks) > 3
assert all(c.startswith(head) for c in chunks)
body = [c.split("TRANSCRIPT (mm:ss text):\n", 1)[1].splitlines() for c in chunks]
assert body[0][-3:] == body[1][:3] # overlap carried over
seen = {ln for part in body for ln in part}
assert seen == set(lines) # nothing lost
# ---------------------------------------------------------------- merge
def _trade(tk, setup_q, reasons=(), **kw):
return {"ticker_heard": tk, "ticker_guess": tk, "setup": kw.pop("setup", "other"), "setup_quote": setup_q,
"entry_price": kw.pop("entry_price", None), "exit_price": None, "outcome": kw.pop("outcome", None),
"stock_context": {"catalyst": None, "other": kw.pop("other", [])},
"why_entered": [{"reason": r, "quote": q} for r, q in reasons], "tape_reads": [], "why_exited": [],
"mistake_or_lesson": []}
def test_merge_dedupes_the_overlap_and_unions_fields():
a = {"video_kind": "daily_recap", "session_date": "2026-09-24",
"day": {"net_pnl": 100.0, "market_temperature": "cold", "market_quote": None},
"trades": [_trade("APUS", "i bought the dip right there", [("dip", "i bought the dip right there")],
other=["grinder"])],
"passed_on": [{"ticker_guess": "GLND", "ticker_heard": "glnd", "reason": "r", "quote": "q1"},
{"ticker_guess": "PFSA", "ticker_heard": "pfsa", "reason": "r", "quote": "q2"}],
"rules_stated": [{"rule": "a", "quote": "cut losses quickly and let winners run"}]}
b = {"video_kind": "daily_recap", "session_date": None,
"day": {"net_pnl": None, "market_temperature": "cold", "market_quote": "slow market today"},
"trades": [_trade("APUS", "i bought the dip right there and it rallied", entry_price=5.5, outcome="win",
other=["sympathy"]),
_trade("PFSA", "curl through vwap", setup="vwap_break")],
"passed_on": [{"ticker_guess": "GLND", "ticker_heard": "glnd", "reason": "r", "quote": "q1 again"}],
"rules_stated": [{"rule": "a", "quote": "cut losses quickly and let winners run"},
{"rule": "b", "quote": "size down when the market is cold"}]}
m = merge_records([a, b])
assert [t["ticker_guess"] for t in m["trades"]] == ["APUS", "PFSA"]
apus = m["trades"][0]
assert apus["entry_price"] == 5.5 and apus["outcome"] == "win" # filled from the later chunk
assert apus["stock_context"]["other"] == ["grinder", "sympathy"]
assert [p["ticker_guess"] for p in m["passed_on"]] == ["GLND", "PFSA"] # GLND deduped across chunks
assert len(m["rules_stated"]) == 2
assert m["day"] == {"net_pnl": 100.0, "market_temperature": "cold", "market_quote": "slow market today"}
assert m["session_date"] == "2026-09-24"
def test_merge_keeps_distinct_trades_on_the_same_ticker():
a = {"video_kind": "daily_recap", "day": {}, "passed_on": [], "rules_stated": [],
"trades": [_trade("APUS", "first entry on the dip at the open")]}
b = {"video_kind": "daily_recap", "day": {}, "passed_on": [], "rules_stated": [],
"trades": [_trade("APUS", "then i got back in for the break through seven")]}
assert len(merge_records([a, b])["trades"]) == 2
def test_merge_temperature_conflict_becomes_mixed():
parts = [{"video_kind": "daily_recap", "day": {"market_temperature": t}, "trades": [], "passed_on": [],
"rules_stated": []} for t in ("hot", "cold")]
assert merge_records(parts)["day"]["market_temperature"] == "mixed"
def test_spoken_prices_are_rescaled_for_small_caps_only():
b = ("OUR SCANNER LIST (symbol, gap %, price) by session date:\n"
" 2026-06-23: ICCM 80.1% $7.2, BIG 5.0% $150.0\nTRANSCRIPT (mm:ss text):\n")
px = scanner_prices(b)
assert px == {"ICCM": 7.2, "BIG": 150.0}
rec = {"trades": [{"ticker_guess": "ICCM", "entry_price": 699.0, "exit_price": 7.5, "stated_high": None,
"stated_low": None},
{"ticker_guess": "BIG", "entry_price": 151.0}]}
assert fix_spoken_prices(rec, px) == 1
assert rec["trades"][0]["entry_price"] == 6.99 and rec["trades"][1]["entry_price"] == 151.0
def test_paragraph_quotes_are_verified_then_cut_to_a_pointer():
from youtube_claims.recap.extract import MAX_QUOTE_WORDS, _snap
words = [f"w{i}" for i in range(120)]
tx = Transcript(" ".join(words))
got = _snap(tx, " ".join(words[5:100]))
assert got == " ".join(words[5:5 + MAX_QUOTE_WORDS])
def test_passed_on_names_must_be_said():
from youtube_claims.recap.extract import name_mentioned
tx = Transcript("Then K I T T popped up and Greenland stocks ran but I passed on PMAX")
assert name_mentioned(tx, "Kit", "KITT") # spelled out
assert name_mentioned(tx, "Greenland stocks", None) # heard phrase
assert name_mentioned(tx, "p max", "PMAX")
assert not name_mentioned(tx, "benf", "BENF") # only on the scanner list
def test_prices_must_be_spoken_in_the_transcript():
from youtube_claims.recap.extract import price_mentioned
tx = Transcript("I got in at 550 and sold at $6.25, the high was 7 and it traded 1,200 shares")
assert price_mentioned(tx, 5.5) and price_mentioned(tx, 6.25) and price_mentioned(tx, 7.0)
assert not price_mentioned(tx, 8.4)
rec = verify_record({"trades": [{"ticker_guess": "ABC", "setup_quote": "I got in at 550 and sold at",
"entry_price": 5.5, "exit_price": 9.1}]}, tx)
assert rec["trades"][0]["entry_price"] == 5.5 and rec["trades"][0]["exit_price"] is None
+228
View File
@@ -0,0 +1,228 @@
"""Ross recap loop: ticker/price verification against fake tapes, the report diff, the gold
scorer, and the captions-fallback error surfacing."""
import datetime as dt
import sys
import types
import pytest
from youtube_claims.recap.evaluate import pair_trades, score_video
from youtube_claims.recap.report import compute, diff_metrics, render
from youtube_claims.recap.tape import entry_bucket, entry_time, path_score, resolve, tape_stats
def bars(path, start="07:00"):
"""Fake 1-min tape from a list of (high, low); open/close at the midpoint, volume 1000."""
t0 = dt.datetime.fromisoformat(f"2026-09-24T{start}")
return [(t0 + dt.timedelta(minutes=i), (h + l) / 2, h, l, (h + l) / 2, 1000.0) for i, (h, l) in enumerate(path)]
RUNNER = bars([(3.0, 2.9), (3.6, 3.0), (4.2, 3.5), (5.1, 4.1), (4.6, 4.0), (4.3, 3.9)]) # 3 -> 5.1 -> 3.9
FLAT = bars([(10.0, 9.8), (10.1, 9.9), (10.2, 9.9)])
OTHER_RUNNER = bars([(3.4, 3.3), (3.6, 3.4), (5.3, 3.5), (5.0, 4.6)])
def test_path_score_accepts_the_story_the_tape_can_tell():
assert path_score(RUNNER, 3.5, None, None) == 0.5 # entry traded, no high named
s = path_score(RUNNER, 3.5, 5.0, None) # entry then a high near 5.0 (5.1: 2%)
assert s is not None and s < 0.025
assert path_score(RUNNER, 3.5, 5.0, 3.9) is not None # ...then a low near 3.9
def test_path_score_rejects_impossible_stories():
assert path_score(FLAT, 3.5, None, None) is None # never traded 3.5
assert path_score(RUNNER, 3.5, 7.0, None) is None # never reached 7
assert path_score(RUNNER, 3.5, 5.0, 2.0) is None # never came back to 2
assert path_score([], 3.5, None, None) is None
assert path_score(RUNNER, None, None, None) is None # no price -> nothing to verify
def _bars_for(tapes):
return lambda s: tapes.get(s, [])
def test_resolve_verifies_his_ticker_when_the_tape_fits():
item = {"ticker_heard": "a pus", "ticker_guess": "APUS", "entry_price": 3.5, "stated_high": 5.0}
assert resolve(item, _bars_for({"APUS": RUNNER}), ["FLAT"]) == ("APUS", "price_verified")
def test_resolve_prefers_his_name_when_levels_fit_out_of_order():
# he says "squeezed to 5.1 ... it had come from 2.95": the low came FIRST on the tape
item = {"ticker_heard": "apus", "ticker_guess": "APUS", "entry_price": None, "stated_high": 5.1,
"stated_low": 2.95}
other = bars([(5.2, 5.0), (3.1, 2.9)]) # a scanner name with high-then-low in order
assert resolve(item, _bars_for({"APUS": RUNNER, "OTH": other}), ["OTH"]) == ("APUS", "price_verified_loose")
def test_resolve_finds_the_unique_scanner_name_when_his_ticker_is_misheard():
item = {"ticker_heard": "kit", "ticker_guess": "KIT", "entry_price": 3.5, "stated_high": 5.0}
tapes = {"KIT": FLAT, "KITT": RUNNER, "BIG": FLAT}
assert resolve(item, _bars_for(tapes), ["BIG", "KITT"]) == ("KITT", "price_matched_scanner")
def test_resolve_reports_ambiguity_instead_of_guessing():
item = {"ticker_heard": "x", "ticker_guess": "XX", "entry_price": 3.5, "stated_high": 5.1}
got = resolve(item, _bars_for({"AAA": RUNNER, "BBB": RUNNER}), ["AAA", "BBB"])
assert got[0] is None and got[1].startswith("ambiguous:") and "AAA" in got[1] and "BBB" in got[1]
def test_resolve_no_price_match_and_unverified():
item = {"ticker_heard": "zz", "ticker_guess": "ZZ", "entry_price": 50.0, "stated_high": None}
assert resolve(item, _bars_for({"ZZ": RUNNER}), ["ZZ"]) == ("ZZ", "no_price_match")
item = {"ticker_heard": "zz", "ticker_guess": "ZZ", "entry_price": None, "stated_high": None}
assert resolve(item, _bars_for({}), []) == ("ZZ", "unverified")
assert resolve({"ticker_heard": "Greenland", "ticker_guess": ""}, _bars_for({}), []) == (None, "unverified")
def test_entry_time_is_first_touch_before_his_stated_high():
t = entry_time(RUNNER, 4.1, 5.1)
assert t.strftime("%H:%M") == "07:02"
assert entry_time(RUNNER, 9.9, None) is None
assert entry_bucket(t) == "7:00-9:30" and entry_bucket(dt.datetime(2026, 9, 24, 9, 31)) == "9:30-9:45"
def test_tape_stats_splits_premarket_and_rth():
tape = bars([(2.0, 1.9), (2.8, 2.0)], start="09:28") + bars([(3.0, 2.5), (2.6, 2.2)], start="09:30")
s = tape_stats(tape, prev_close=2.0)
assert s["pm_high"] == 2.8 and s["hod"] == 3.0 and s["hod_time"] == "09:30"
assert s["open"] == pytest.approx(2.75) and s["gap_pct"] == pytest.approx(0.375)
assert s["first_30pct_time"] == "09:29"
# ---------------------------------------------------------------- report
def _view(disc=None, orders=None, oop=None):
side = {"discovered": [[disc, "Discovered"]] if disc else [], "orders": orders or [],
"states": [["08:00", f"x out_of_population: price 1.5 < 2"]] if oop else [],
"top_blocks": [["spread too wide", 3]]}
return {"paper": side, "live": None, "tape": {"prev_close": 2.0, "open": 3.0, "close": 3.3,
"open_to_hod_pct": 0.4}}
def _rows(n_videos=2):
videos = [{"video_id": f"v{i}", "session_date": f"2026-09-2{i}", "video_kind": "daily_recap",
"extractor": "gold", "market_temperature": "cold"} for i in range(n_videos)]
trades = [{"video_id": "v0", "session_date": "2026-09-20", "symbol": "APUS", "resolve": "price_verified",
"setup": "micro_pullback", "outcome": "win", "pnl": 1200, "entry_price": 3.0,
"entry_time_tape": "07:05", "stock_context": {"catalyst": "headline"},
"our_view": _view(disc="06:59", orders=[["07:10", "buy"]])},
{"video_id": "v1", "session_date": "2026-09-21", "symbol": None, "resolve": "ambiguous:A,B",
"setup": "other", "outcome": None, "pnl": None, "entry_price": 5.0, "entry_time_tape": None,
"stock_context": {}, "our_view": None}]
passed = [{"video_id": "v0", "symbol": "GLND", "our_view": _view()}]
return videos, trades, passed
def test_compute_aggregates_coverage_and_buckets():
m = compute(*_rows())
assert m["videos"] == 2 and m["trades"] == 2 and m["verified"] == 1
assert m["resolution"] == {"price_verified": 1, "ambiguous": 1}
assert m["entry_buckets"]["7:00-9:30"] == 1
assert m["coverage"] == {"verified": 1, "discovered": 1, "discovered_before_entry": 1, "we_ordered": 1,
"out_of_population": 0}
assert m["passed_tape"]["with_tape"] == 1 and m["passed_tape"]["median_open_to_close"] == pytest.approx(0.1)
md = render(m, ["x"], today="2026-09-25")
assert "## What changed since last run" in md and "## Our coverage of his verified trades" in md
def test_diff_first_run_and_no_change():
m = compute(*_rows())
assert diff_metrics(None, m) == ["First report -- no previous run to compare with."]
assert diff_metrics(m, m) == ["No change since the previous run."]
def test_diff_reports_new_videos_trades_and_metric_moves():
prev = compute(*_rows())
videos, trades, passed = _rows(3)
trades.append({"video_id": "v2", "session_date": "2026-09-22", "symbol": "KITT", "resolve": "price_verified",
"setup": "bull_flag", "outcome": "loss", "pnl": -500, "entry_price": 6.0,
"entry_time_tape": "09:31", "stock_context": {}, "our_view": _view()})
cur = compute(videos, trades, passed)
lines = diff_metrics(prev, cur)
text = "\n".join(lines)
assert "New videos (1): v2" in text
assert "trades: 2 -> 3 (+1)" in text and "verified: 1 -> 2 (+1)" in text
assert "entry_buckets.9:30-9:45: 0 -> 1" in text
assert "New trades (1): KITT|bull_flag|6.0" in text
# ---------------------------------------------------------------- evaluation scorer
def test_score_video_perfect_and_partial():
gold = {"video_kind": "daily_recap", "day": {"market_temperature": "hot", "net_pnl": 1000},
"trades": [{"ticker_guess": "APUS", "setup": "micro_pullback", "entry_price": 5.0, "exit_price": 5.5},
{"ticker_guess": "APUS", "setup": "vwap_break", "entry_price": 4.0, "exit_price": None},
{"ticker_guess": "PFSA", "setup": "other", "entry_price": None, "exit_price": None}],
"passed_on": [{"ticker_guess": "GLND"}, {"ticker_guess": "PMAX"}], "rules_stated": []}
s = score_video(gold, gold)
assert s["matched_trades"] == 3 and s["setup_ok"] == 3 and s["tick_tp"] == 2 and s["pass_tp"] == 2
pred = {"video_kind": "daily_recap", "day": {"market_temperature": "cold", "net_pnl": 1010},
"trades": [{"ticker_guess": "APUS", "setup": "vwap_break", "entry_price": 4.05, "exit_price": 9.0},
{"ticker_guess": "ZZZ", "setup": "other"}],
"passed_on": [{"ticker_guess": "GLND"}, {"ticker_guess": "BENF"}], "rules_stated": []}
s = score_video(gold, pred)
assert s["matched_trades"] == 1 and s["setup_ok"] == 1 # paired with the vwap_break trade
assert s["entry_price_ok"] == 1 and s["exit_price_ok"] == 0 and s["exit_price_pred"] == 1
assert (s["tick_tp"], s["tick_pred"], s["tick_gold"]) == (1, 2, 2)
assert (s["pass_tp"], s["pass_pred"], s["pass_gold"]) == (1, 2, 2)
assert s["temp_ok"] == 0 and s["pnl_ok"] == 1
def test_pair_trades_matches_each_trade_once():
g = [{"ticker_guess": "A", "setup": "x"}]
p = [{"ticker_guess": "A", "setup": "x"}, {"ticker_guess": "A", "setup": "x"}]
assert len(pair_trades(g, p)) == 1
# ---------------------------------------------------------------- captions fallback
def _fake_api(monkeypatch, cls):
mod = types.ModuleType("youtube_transcript_api")
mod.YouTubeTranscriptApi = cls
monkeypatch.setitem(sys.modules, "youtube_transcript_api", mod)
class IpBlocked(Exception):
pass
def test_captions_fallback_surfaces_the_real_error(monkeypatch):
from youtube_claims import transcription
class NewApi: # >=1.0 shape, no get_transcript
def fetch(self, vid):
raise IpBlocked("YouTube is blocking requests from your IP")
_fake_api(monkeypatch, NewApi)
with pytest.raises(IpBlocked, match="blocking requests"):
transcription._captions_fallback("abc")
def test_captions_fallback_old_api_still_works(monkeypatch):
from youtube_claims import transcription
class OldApi:
@staticmethod
def get_transcript(vid):
return [{"start": 1.0, "duration": 2.0, "text": "hi"}]
_fake_api(monkeypatch, OldApi)
assert transcription._captions_fallback("abc") == [{"start": 1.0, "end": 3.0, "text": "hi"}]
def test_transcribe_reports_both_causes(monkeypatch, tmp_path):
from youtube_claims import config, transcription
class NewApi:
def fetch(self, vid):
raise IpBlocked("blocked")
_fake_api(monkeypatch, NewApi)
monkeypatch.setattr(config, "TRANSCRIPT_DIR", str(tmp_path))
def no_audio(vid, td):
raise RuntimeError("Sign in to confirm you're not a bot")
monkeypatch.setattr(transcription, "_download_audio", no_audio)
with pytest.raises(RuntimeError) as ei:
transcription.transcribe("abc", watchlist=[])
msg = str(ei.value)
assert "not a bot" in msg and "IpBlocked: blocked" in msg and "get_transcript" not in msg
+65
View File
@@ -0,0 +1,65 @@
"""Round-robin + failover across the owner's Ollama endpoints (no network: fake transport)."""
import urllib.error
import pytest
from youtube_claims.recap.llm import OllamaPool, parse_hosts
def test_parse_hosts_with_per_host_model_and_options():
eps = parse_hosts(" http://a:11434/ , http://b:11434=qwen3:30b#ctx=8192#think=false,, ")
assert [e.host for e in eps] == ["http://a:11434", "http://b:11434"]
assert eps[0].model is None and eps[0].think == "default"
assert eps[1].model == "qwen3:30b" and eps[1].num_ctx == 8192 and eps[1].think is False
def _pool(spec, models, fail=(), default="m1"):
calls = []
def call(system, user, *, model, host, num_ctx, think, **kw):
calls.append((host, model, num_ctx, think))
if host in fail:
raise urllib.error.URLError("connection refused")
return "{}", {"seconds": 1}
pool = OllamaPool(parse_hosts(spec), default, default_ctx=24576, default_think="low", call=call,
list_models=lambda h: models[h])
return pool, calls
def test_round_robin_over_hosts_that_have_the_model():
pool, calls = _pool("http://a,http://b,http://c", {"http://a": {"m1"}, "http://b": {"other"},
"http://c": {"m1:latest"}})
for _ in range(4):
pool.chat("s", "u")
assert [c[0] for c in calls] == ["http://a", "http://c", "http://c", "http://a"]
def test_failover_parks_a_dead_host():
pool, calls = _pool("http://a,http://b", {"http://a": {"m1"}, "http://b": {"m1"}}, fail={"http://a"})
_, st = pool.chat("s", "u")
assert st["host"] == "http://b"
pool.chat("s", "u")
assert [c[0] for c in calls] == ["http://a", "http://b", "http://b"] # a parked after failing
def test_per_host_model_and_options_are_used():
pool, calls = _pool("http://a=qwen#ctx=8192#think=false", {"http://a": {"qwen"}})
_, st = pool.chat("s", "u")
assert calls == [("http://a", "qwen", 8192, False)] and st["model"] == "qwen"
def test_no_endpoint_with_the_model_is_an_error():
pool, _ = _pool("http://a", {"http://a": {"x"}})
with pytest.raises(RuntimeError, match="no reachable Ollama endpoint"):
pool.chat("s", "u")
def test_client_errors_are_not_retried_elsewhere():
def call(*a, **kw):
raise RuntimeError("ollama 400: invalid think value")
pool = OllamaPool(parse_hosts("http://a,http://b"), "m1", call=call, list_models=lambda h: {"m1"})
with pytest.raises(RuntimeError, match="400"):
pool.chat("s", "u")
+19
View File
@@ -0,0 +1,19 @@
"""TRADING_GAP_SQUEEZE_ENABLED=0 turns the >=150% squeeze exception off everywhere."""
from strategy.population import PopulationConfig, population_reject
from trading_execution import ExecutionSettings
def test_population_config_switch():
on = PopulationConfig.from_env({"TRADING_MIN_ENTRY_GAP_PCT": "0.10"})
off = PopulationConfig.from_env({"TRADING_MIN_ENTRY_GAP_PCT": "0.10", "TRADING_GAP_SQUEEZE_ENABLED": "0"})
assert on.squeeze_enabled and not off.squeeze_enabled
assert population_reject(on, gap_pct=2.0, price=5.0, volume=None, rvol=20.0) is None
reason = population_reject(off, gap_pct=2.0, price=5.0, volume=None, rvol=20.0)
assert reason and "squeeze exception off" in reason
assert population_reject(off, gap_pct=0.20, price=5.0, volume=None, rvol=3.0) is None # core band untouched
def test_execution_settings_carry_the_switch():
assert ExecutionSettings.from_env({}).gap_squeeze_enabled is True
assert ExecutionSettings.from_env({"TRADING_GAP_SQUEEZE_ENABLED": "0"}).gap_squeeze_enabled is False
+26
View File
@@ -459,3 +459,29 @@ def test_fire_normal_cross_still_works():
assert b.fires() == []
b.update_price("NORM", 10.05)
assert [t.symbol for t in b.fires()] == ["NORM"]
def test_squeeze_verified_on_median_baseline_when_spike_days_poison_the_mean():
"""KITT 2026-09-25: +557%, 9.3M shares, but two 11-14M days in the 20d window
put the mean at 1.66M (legacy rvol 1.54) vs a 333k median. The median-baseline
reading (same units) verifies the squeeze; a reverse-split fake stays out."""
b = ArmedTriggerBook(gap_min=10, gap_max=35, gap_squeeze_min=150,
gap_squeeze_rvol=5.0, rvol_min=2)
kitt = _cand("KITT", gap=557.0, rvol=1.54)
kitt["rvol_med"] = 20.0
kitt["rvol_tod"] = 150.0 # time-of-day arm signal, as the scanner would report
fake = _cand("SPLIT", gap=557.0, rvol=0.01)
fake["rvol_med"] = 0.02
b.arm([kitt, fake])
assert b.triggers["KITT"].squeeze_rvol == 20.0
assert b.triggers["KITT"].state == ARMED
assert b.triggers["SPLIT"].state == WEAK
def test_squeeze_rvol_falls_back_to_legacy_without_a_median():
b = ArmedTriggerBook(gap_min=10, gap_max=35, gap_squeeze_min=150,
gap_squeeze_rvol=5.0, rvol_min=2)
b.arm([_cand("LHSW", gap=278.0, rvol=40.0)])
assert b.triggers["LHSW"].rvol_med is None
assert b.triggers["LHSW"].squeeze_rvol == 40.0
assert b.triggers["LHSW"].state == ARMED
+42
View File
@@ -94,3 +94,45 @@ def test_sip_feed_refuses_double_scaling():
flat = [v for row in con.rows for v in row]
assert 2500 in flat and 62500 not in flat # sip volume kept, NOT re-scaled
assert "alpaca_sip" in flat and "alpaca_iex_scaled" not in flat
def test_tradier_wrapper_reads_the_wrapped_feed():
# the live Tradier stack wraps the Alpaca data client; the wrapper's own
# settings have no `feed`, which used to read as "iex" and re-scale real SIP
# volume by the IEX ratios (the 80-160x live over-count, 2026-07-31..09-25)
from tradier_broker.client import TradierClient
from tradier_broker.settings import TradierSettings
wrapped = TradierClient(TradierSettings(access_token="t", account_id="a"),
data_client=_SipClient())
con = _Con()
ingest_live_minute_bars(con, wrapped, ["GOOD"], volume_scale={"GOOD": 25.0})
flat = [v for row in con.rows for v in row]
assert 2500 in flat and 62500 not in flat
assert "alpaca_sip" in flat and "alpaca_iex_scaled" not in flat
class _RejectingClient(_SipClient):
"""Alpaca 400s the WHOLE batched request when any symbol is invalid."""
def __init__(self):
super().__init__()
self.calls = []
def get_minute_bars(self, symbols, start_iso, end_iso=None, feed=None, limit=10_000):
self.calls.append(list(symbols))
if "ZZZZ" in symbols:
raise RuntimeError('Alpaca API error 400: {"message":"invalid symbol: ZZZZ"}')
return super().get_minute_bars(symbols, start_iso, end_iso, feed, limit)
def test_one_bad_symbol_cannot_blind_the_whole_ingest():
# 2026-09-28 09:35 ET: 'TSX:STC' from the movers screener 400'd every batched
# bars request and live ingestion stopped for the session.
client = _RejectingClient()
con = _Con()
res = ingest_live_minute_bars(con, client, ["GOOD", "TSX:STC", "ZZZZ"])
assert "TSX:STC" not in client.calls[0] # never sent at all
assert client.calls[-1] == ["GOOD"] # rejected one dropped, retried
assert res.minute_rows > 0
assert any("TSX:STC" in e for e in res.errors) and any("ZZZZ" in e for e in res.errors)
+3
View File
@@ -122,6 +122,7 @@ class ExecutionSettings:
# 0 = disabled. Fail-open: a signal missing gap/volume data is never blocked.
min_entry_gap_pct: float = 0.0
min_day_volume: float = 0.0
gap_squeeze_enabled: bool = True # TRADING_GAP_SQUEEZE_ENABLED, see strategy/population.py
# entries only during the regular session (ET). SIP prints extended-hours
# trades, so without this gate after-hours price crosses fire real orders
# (TDTH/SUNE resting entries submitted 18:53-20:09 ET on 2026-07-09).
@@ -319,6 +320,7 @@ class ExecutionSettings:
entry_confirm_by_minute=int(values.get("TRADING_ENTRY_CONFIRM_BY_MINUTE", "0")),
min_entry_gap_pct=_qfloat(values.get("TRADING_MIN_ENTRY_GAP_PCT", "0.0")),
min_day_volume=float(values.get("TRADING_MIN_DAY_VOLUME", "0.0")),
gap_squeeze_enabled=flag("TRADING_GAP_SQUEEZE_ENABLED", "1"),
session_only=flag("TRADING_SESSION_ONLY", "1"),
session_start=values.get("TRADING_SESSION_START", "09:30"),
session_end=values.get("TRADING_SESSION_END", "15:55"),
@@ -957,6 +959,7 @@ class TradingExecutionService:
price_max=self.settings.price_band_max,
min_entry_gap_pct=self.settings.min_entry_gap_pct,
min_day_volume=self.settings.min_day_volume,
squeeze_enabled=self.settings.gap_squeeze_enabled,
)
return population_reject(
cfg, gap_pct=gap_pct, price=price, volume=cum_volume, rvol=rvol)
+12
View File
@@ -0,0 +1,12 @@
"""Ross Cameron daily-recap learning loop.
A nightly, DESCRIPTIVE pipeline over the Warrior Trading recap videos: list new uploads ->
yt-dlp audio + faster-whisper -> bundle (meta + our scanner list + transcript) -> local Ollama
extraction per EXTRACT_SPEC (verbatim quotes enforced in code) -> price-verify tickers against
the SIP tape + attach what OUR bot saw that day -> Postgres + a cumulative markdown report.
It records what he SAID and what our system DID; it never changes trading behaviour.
python -m youtube_claims.recap.nightly # the whole nightly pass
python -m youtube_claims.recap.evaluate # score extraction vs the gold set
"""
+142
View File
@@ -0,0 +1,142 @@
"""Channel listing, audio download, whisper transcription and bundle building.
Runs on the owner's Windows workstation: YouTube blocks the TrueNAS server's IP, and the caption
API rate-limits (IpBlocked after ~20 requests), so we pull AUDIO with yt-dlp -- one polite
request per new video -- and transcribe locally with faster-whisper."""
from __future__ import annotations
import datetime as dt
import json
import time
from pathlib import Path
from youtube_claims.recap import settings
_whisper = None
def list_uploads(limit: int | None = None) -> list[dict]:
"""Newest-first [{id, title, duration}] from the channel's /videos tab (flat, one request)."""
import yt_dlp
opts = {"extract_flat": "in_playlist", "quiet": True, "no_warnings": True,
"playlistend": limit or settings.LIST_LIMIT}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(settings.CHANNEL_URL, download=False)
out = []
for e in info.get("entries") or []:
if e and e.get("id"):
out.append({"id": e["id"], "title": e.get("title") or "", "duration": e.get("duration")})
return out
def video_meta(vid: str, title: str = "", duration=None) -> dict:
"""{id, title, duration, upload_date} -- cached in transcripts/<id>.meta.json (the flat
listing has no upload date, so new ids cost one metadata request each)."""
f = settings.dirs()["transcripts"] / f"{vid}.meta.json"
if f.exists():
m = json.loads(f.read_text(encoding="utf-8"))
if m.get("upload_date"):
return m
import yt_dlp
with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True, "skip_download": True}) as ydl:
info = ydl.extract_info(f"https://www.youtube.com/watch?v={vid}", download=False)
m = {"id": vid, "title": info.get("title") or title, "duration": str(info.get("duration") or duration or ""),
"upload_date": info.get("upload_date") or ""}
f.write_text(json.dumps(m), encoding="utf-8")
return m
def download_audio(vid: str) -> Path:
"""Best m4a audio (no ffmpeg re-encode needed; faster-whisper decodes it directly)."""
ad = settings.dirs()["audio"]
have = next(iter(ad.glob(f"{vid}.*")), None)
if have and not have.name.endswith(".part"):
return have
import yt_dlp
opts = {"format": "ba[ext=m4a]/ba", "outtmpl": str(ad / "%(id)s.%(ext)s"), "quiet": True,
"no_warnings": True, "noprogress": True}
with yt_dlp.YoutubeDL(opts) as ydl:
ydl.download([f"https://www.youtube.com/watch?v={vid}"])
got = next((p for p in ad.glob(f"{vid}.*") if not p.name.endswith(".part")), None)
if got is None:
raise RuntimeError(f"yt-dlp produced no audio file for {vid}")
return got
def transcribe(vid: str, audio: Path) -> list[dict]:
"""faster-whisper segments, cached in transcripts/<id>.whisper.json (the pilot's format)."""
out = settings.dirs()["transcripts"] / f"{vid}.whisper.json"
if out.exists():
return json.loads(out.read_text(encoding="utf-8"))["segments"]
global _whisper
if _whisper is None:
from faster_whisper import WhisperModel
_whisper = WhisperModel(settings.WHISPER_MODEL, device="cpu", compute_type="int8",
cpu_threads=settings.WHISPER_THREADS)
t0 = time.time()
segs, _ = _whisper.transcribe(str(audio), initial_prompt=settings.WHISPER_PROMPT, vad_filter=True,
language="en")
rows = [{"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text} for s in segs]
out.write_text(json.dumps({"video_id": vid, "source": "whisper", "whisper_model": settings.WHISPER_MODEL,
"seconds": round(time.time() - t0, 1), "segments": rows}), encoding="utf-8")
return rows
def best_transcript(vid: str) -> tuple[str | None, list[dict] | None]:
"""Local whisper (small.en) > repo whisper (medium) > YouTube captions -- as the pilot."""
td = settings.dirs()["transcripts"]
repo_tx = settings.DATA_DIR.parent / "transcripts" / f"{vid}.json" # youtube_claims whisper-medium
for path, src in ((td / f"{vid}.whisper.json", "whisper-small"), (repo_tx, "whisper-medium")):
if path.exists():
return src, json.loads(path.read_text(encoding="utf-8"))["segments"]
cap = td / f"{vid}.captions.json"
if cap.exists():
rows = json.loads(cap.read_text(encoding="utf-8"))
return "captions", [{"start": r["start"], "text": r["text"]} for r in rows]
return None, None
def prior_weekdays(upload_date: str, n: int = 1) -> list[str]:
"""The upload date (if a weekday) and the n weekdays before it, as YYYY-MM-DD."""
d = dt.date(int(upload_date[:4]), int(upload_date[4:6]), int(upload_date[6:]))
out = [d.isoformat()] if d.weekday() < 5 else []
while len(out) < (n + 1 if d.weekday() < 5 else n):
d -= dt.timedelta(days=1)
if d.weekday() < 5:
out.append(d.isoformat())
return out
def scanner_for(upload_date: str, ourside=None) -> dict:
"""{session date: [(symbol, gap %, price)]} for the upload day and the weekday before --
cached in scanner/<upload_date>.json (the pilot's format)."""
f = settings.dirs()["scanner"] / f"{upload_date}.json"
if f.exists():
return json.loads(f.read_text())
if ourside is None:
return {}
scan = {d: [list(r) for r in ourside.day_universe(d)] for d in prior_weekdays(upload_date)}
if any(scan.values()):
f.write_text(json.dumps(scan))
return scan
def bundle_source(bundle: str) -> str | None:
for ln in bundle.splitlines()[:6]:
if ln.startswith("transcript_source: "):
return ln.split(": ", 1)[1].strip()
return None
def make_bundle(meta: dict, source: str, segments: list[dict], scanner: dict) -> str:
"""One plain-text bundle: meta + our scanner list + timestamped transcript (pilot format)."""
lines = [f"video_id: {meta['id']}", f"upload_date: {meta['upload_date']}", f"title: {meta.get('title', '')}",
f"transcript_source: {source}", "", "OUR SCANNER LIST (symbol, gap %, price) by session date:"]
for d, rows in scanner.items():
lines.append(f" {d}: " + ", ".join(f"{s} {g}% ${p}" if p else f"{s} {g}%" for s, g, p in rows))
lines += ["", "TRANSCRIPT (mm:ss text):"]
for s in segments:
t = int(s["start"])
lines.append(f"[{t // 60:02d}:{t % 60:02d}] {s['text'].strip()}")
return "\n".join(lines)
+182
View File
@@ -0,0 +1,182 @@
"""Score the local-model extraction against the GOLD extractions (data/ross/extracted/, done by
a strong model on the same bundles). Per video: trade count, traded tickers, entry/exit price
(+-2%), setup label, passed-on tickers, market temperature, video kind, net P&L.
python -m youtube_claims.recap.evaluate --run v1 # extract (cached) + score
python -m youtube_claims.recap.evaluate --run v1 --model tools:latest --host http://192.168.1.5:11434 --num-ctx 0
python -m youtube_claims.recap.evaluate --run v1 --score-only # re-score cached outputs
Model outputs are cached per run under data/ross/eval/<run>/ so scoring changes are free."""
from __future__ import annotations
import argparse
import json
import time
from collections import Counter
from pathlib import Path
from youtube_claims.recap import settings
def _close(a, b, tol=0.02) -> bool:
return a is not None and b is not None and abs(a - b) <= tol * max(abs(b), 1e-9)
def _tick(x: dict) -> str | None:
return (x.get("ticker_guess") or x.get("ticker_heard") or "").upper().replace(" ", "") or None
def pair_trades(gold: list[dict], pred: list[dict]) -> list[tuple[dict, dict]]:
"""Match predicted to gold trades on the same ticker, greedily by agreement (setup, entry
price, exit price), ties broken by order. A trade matches at most once."""
pairs, used = [], set()
cand = []
for gi, g in enumerate(gold):
for pi, p in enumerate(pred):
if _tick(g) and _tick(g) == _tick(p):
score = (g.get("setup") == p.get("setup")) + _close(p.get("entry_price"), g.get("entry_price")) \
+ _close(p.get("exit_price"), g.get("exit_price"))
cand.append((-score, abs(gi - pi), gi, pi))
gdone = set()
for _, _, gi, pi in sorted(cand):
if gi in gdone or pi in used:
continue
gdone.add(gi); used.add(pi)
pairs.append((gold[gi], pred[pi]))
return pairs
def score_video(gold: dict, pred: dict) -> dict:
gt, pt = gold.get("trades") or [], pred.get("trades") or []
pairs = pair_trades(gt, pt)
s = Counter({k: 0 for k in ("entry_price_ok", "entry_price_gold", "entry_price_pred", "exit_price_ok",
"exit_price_gold", "exit_price_pred", "setup_ok", "outcome_ok", "temp_gold",
"temp_ok", "pnl_gold", "pnl_ok")})
s["videos"] = 1
s["gold_trades"], s["pred_trades"], s["matched_trades"] = len(gt), len(pt), len(pairs)
s["trade_count_exact"] = int(len(gt) == len(pt))
s["trade_count_abs_err"] = abs(len(gt) - len(pt))
gts, pts = {_tick(t) for t in gt} - {None}, {_tick(t) for t in pt} - {None}
s["tick_tp"], s["tick_gold"], s["tick_pred"] = len(gts & pts), len(gts), len(pts)
for field in ("entry_price", "exit_price"):
for g, p in pairs:
gv, pv = g.get(field), p.get(field)
if gv is not None:
s[f"{field}_gold"] += 1
if pv is not None:
s[f"{field}_pred"] += 1
if _close(pv, gv):
s[f"{field}_ok"] += 1
for g, p in pairs:
s["setup_ok"] += int(g.get("setup") == p.get("setup"))
s["outcome_ok"] += int(g.get("outcome") == p.get("outcome"))
gp = {_tick(x) for x in gold.get("passed_on") or []} - {None}
pp = {_tick(x) for x in pred.get("passed_on") or []} - {None}
s["pass_tp"], s["pass_gold"], s["pass_pred"] = len(gp & pp), len(gp), len(pp)
gtemp = (gold.get("day") or {}).get("market_temperature")
ptemp = (pred.get("day") or {}).get("market_temperature")
if gtemp:
s["temp_gold"] += 1
s["temp_ok"] += int(gtemp == ptemp)
s["kind_ok"] = int(gold.get("video_kind") == pred.get("video_kind"))
gp_, pp_ = (gold.get("day") or {}).get("net_pnl"), (pred.get("day") or {}).get("net_pnl")
if gp_ is not None:
s["pnl_gold"] += 1
s["pnl_ok"] += int(_close(pp_, gp_))
s["rules_gold"], s["rules_pred"] = len(gold.get("rules_stated") or []), len(pred.get("rules_stated") or [])
return dict(s)
def _pr(tp, npred, ngold) -> str:
p = tp / npred if npred else 0.0
r = tp / ngold if ngold else 0.0
f = 2 * p * r / (p + r) if p + r else 0.0
return f"P {p:.2f} R {r:.2f} F1 {f:.2f} ({tp}/{npred} pred, {tp}/{ngold} gold)"
def summarize(per_video: dict[str, dict]) -> tuple[str, dict]:
tot = Counter()
for s in per_video.values():
tot.update(s)
n = tot["videos"] or 1
lines = [
f"videos scored: {tot['videos']}",
f"trades (matched on ticker): {_pr(tot['matched_trades'], tot['pred_trades'], tot['gold_trades'])}",
f"trade count exact: {tot['trade_count_exact']}/{tot['videos']} mean |err| {tot['trade_count_abs_err'] / n:.2f}",
f"traded tickers: {_pr(tot['tick_tp'], tot['tick_pred'], tot['tick_gold'])}",
f"entry price +-2% (matched trades): {_pr(tot['entry_price_ok'], tot['entry_price_pred'], tot['entry_price_gold'])}",
f"exit price +-2% (matched trades): {_pr(tot['exit_price_ok'], tot['exit_price_pred'], tot['exit_price_gold'])}",
f"setup label agrees: {tot['setup_ok']}/{tot['matched_trades']} outcome agrees: {tot['outcome_ok']}/{tot['matched_trades']}",
f"passed-on tickers: {_pr(tot['pass_tp'], tot['pass_pred'], tot['pass_gold'])}",
f"market temperature: {tot['temp_ok']}/{tot['temp_gold']} video kind: {tot['kind_ok']}/{tot['videos']}",
f"net P&L +-2%: {tot['pnl_ok']}/{tot['pnl_gold']} rules: {tot['rules_pred']} pred vs {tot['rules_gold']} gold",
]
return "\n".join(lines), dict(tot)
def main() -> None:
ap = argparse.ArgumentParser(description="Score recap extraction vs the gold set")
ap.add_argument("--run", required=True, help="name of this configuration (cache dir)")
ap.add_argument("--model")
ap.add_argument("--host")
ap.add_argument("--num-ctx", type=int, help="0 = the model's default context")
ap.add_argument("--think", help="false|true|low|medium|high (model-dependent)")
ap.add_argument("--chunk-chars", type=int)
ap.add_argument("--system-file", help="alternative system prompt (prompt iteration)")
ap.add_argument("--split", choices=["0", "1"], help="two focused calls per chunk (default: settings)")
ap.add_argument("--limit", type=int)
ap.add_argument("--only", help="comma-separated substrings of bundle names")
ap.add_argument("--score-only", action="store_true")
a = ap.parse_args()
from youtube_claims.recap.extract import extract_bundle
d = settings.dirs()
gold_dir, bundles = d["extracted"], d["bundles"]
out_dir = settings.DATA_DIR / "eval" / a.run
out_dir.mkdir(parents=True, exist_ok=True)
think = None if a.think is None else {"false": False, "true": True}.get(a.think.lower(), a.think)
golds = sorted(gold_dir.glob("*.json"))
if a.only:
keys = a.only.split(",")
golds = [g for g in golds if any(k in g.stem for k in keys)]
if a.limit:
golds = golds[:a.limit]
per, secs = {}, []
for g in golds:
cache = out_dir / g.name
if not cache.exists() and not a.score_only:
b = (bundles / f"{g.stem}.txt").read_text(encoding="utf-8")
t0 = time.time()
try:
rec, run = extract_bundle(b, model=a.model, host=a.host, num_ctx=a.num_ctx, think=think,
chunk_chars=a.chunk_chars,
split=None if a.split is None else a.split == "1",
system=Path(a.system_file).read_text(encoding="utf-8")
if a.system_file else None)
except Exception as e: # noqa: BLE001 -- score the rest; a failure scores as empty
print(g.stem, "FAILED:", e, flush=True)
rec, run = {"trades": [], "passed_on": [], "rules_stated": [], "day": {}}, {"error": str(e)}
run["wall_seconds"] = round(time.time() - t0, 1)
cache.write_text(json.dumps({**rec, "_run": run}, indent=1), encoding="utf-8")
if not cache.exists():
continue
pred = json.loads(cache.read_text(encoding="utf-8"))
gold = json.loads(g.read_text(encoding="utf-8"))
per[g.stem] = score_video(gold, pred)
r = pred.get("_run") or {}
if r.get("wall_seconds"):
secs.append(r["wall_seconds"])
s = per[g.stem]
print(f"{g.stem}: trades {s['pred_trades']}/{s['gold_trades']} matched {s['matched_trades']} "
f"tickers {s['tick_tp']}/{s['tick_gold']} passed {s['pass_tp']}/{s['pass_gold']} "
f"temp {'ok' if s.get('temp_ok') else '-'} {r.get('wall_seconds', '?')}s drops {r.get('stats', {})}",
flush=True)
text, tot = summarize(per)
if secs:
text += f"\nextraction seconds/video: mean {sum(secs) / len(secs):.0f}, max {max(secs):.0f}"
print("\n" + text)
(out_dir / "_summary.json").write_text(json.dumps({"totals": tot, "per_video": per, "text": text}, indent=1))
if __name__ == "__main__":
main()
+455
View File
@@ -0,0 +1,455 @@
"""Bundle -> structured recap record with the owner's local Ollama.
Pipeline per video: split the bundle into overlapping transcript chunks (only if it is longer
than the model comfortably handles) -> one structured-output call per chunk -> verify every
quote against the FULL transcript (snap to verbatim, drop what can't be found) -> merge the
chunk records (dedupe the overlap) -> normalise tickers. The verification + merge are pure and
unit-tested; only `ollama_chat` touches the network."""
from __future__ import annotations
import json
import re
import time
import urllib.error
import urllib.request
from collections import Counter
from youtube_claims.recap import prompt as P
from youtube_claims.recap import settings
from youtube_claims.recap.quotes import Transcript, _tok, transcript_text_from_bundle
_HEADER_MARK = "TRANSCRIPT (mm:ss text):"
_TICKER = re.compile(r"[A-Z]{1,5}")
_NULLISH = {"", "NULL", "NONE", "N/A", "NA", "UNKNOWN", "TBD", "?", "-"}
# ---------------------------------------------------------------- Ollama
def ollama_chat(system: str, user: str, *, model: str, host: str, schema: dict | None = None,
num_ctx: int | None = None, think=None, timeout: int = 1800,
temperature: float = 0.0, num_predict: int = 12000) -> tuple[str, dict]:
"""One non-streaming /api/chat call in structured-output mode. Returns (content, stats)."""
payload = {"model": model, "stream": False, "format": schema or "json",
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"options": {"temperature": temperature, "num_predict": num_predict}}
if num_ctx:
payload["options"]["num_ctx"] = num_ctx
if think is not None:
payload["think"] = think
req = urllib.request.Request(host.rstrip("/") + "/api/chat", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
body = json.load(r)
except urllib.error.HTTPError as e: # surface Ollama's own message (bad model, bad think...)
raise RuntimeError(f"ollama {e.code}: {e.read().decode(errors='replace')[:300]}") from e
stats = {"seconds": round(time.time() - t0, 1), "prompt_tokens": body.get("prompt_eval_count"),
"output_tokens": body.get("eval_count"), "done_reason": body.get("done_reason")}
return (body.get("message") or {}).get("content") or "", stats
def parse_json(raw: str) -> dict:
"""Structured mode returns bare JSON; tolerate a stray code fence or preamble anyway."""
raw = (raw or "").strip()
try:
return json.loads(raw)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", raw, re.S)
if not m:
raise
return json.loads(m.group(0))
# ---------------------------------------------------------------- chunking
def split_bundle(bundle: str) -> tuple[str, list[str]]:
"""(header incl. the transcript marker, transcript lines)."""
if _HEADER_MARK not in bundle:
return "", [ln for ln in bundle.splitlines() if ln.strip()]
head, body = bundle.split(_HEADER_MARK, 1)
return head + _HEADER_MARK, [ln for ln in body.splitlines() if ln.strip()]
def chunk_bundle(bundle: str, max_chars: int, overlap_lines: int = 4) -> list[str]:
"""The bundle itself if it fits, else header + consecutive transcript slices of about
max_chars each, consecutive slices sharing `overlap_lines` lines so nothing said across
a boundary is lost (the merge dedupes what both halves extracted)."""
if len(bundle) <= max_chars:
return [bundle]
head, lines = split_bundle(bundle)
budget = max(2000, max_chars - len(head))
out, i = [], 0
while i < len(lines):
n, j = 0, i
while j < len(lines) and (n + len(lines[j]) + 1 <= budget or j == i):
n += len(lines[j]) + 1
j += 1
out.append(head + "\n" + "\n".join(lines[i:j]))
if j >= len(lines):
break
i = max(i + 1, j - overlap_lines)
return out
# ---------------------------------------------------------------- normalisation
def clean_ticker(raw) -> str | None:
t = re.sub(r"[\s.$]", "", str(raw or "")).upper()
if t in _NULLISH or not _TICKER.fullmatch(t):
return None
return t
def _num(x):
if x is None or isinstance(x, bool):
return None
if isinstance(x, (int, float)):
return float(x)
m = re.search(r"-?\d[\d,]*\.?\d*", str(x))
if not m:
return None
try:
return float(m.group(0).replace(",", ""))
except ValueError:
return None
def _price_forms(p: float) -> set[str]:
"""How a spoken price shows up in a whisper transcript: 5.5 -> 5.50, 5.5, 550, $5.50;
6.0 -> 6, 6.00, 600; 12.25 -> 12.25, 1225."""
cents = int(round(p * 100))
forms = {f"{p:.2f}", f"{p:.2f}".rstrip("0").rstrip("."), str(cents)}
if cents % 100 == 0:
forms.add(str(cents // 100))
if p < 1:
forms.add(str(cents)) # "73 cents"
return forms
def price_mentioned(tx: Transcript, p) -> bool:
"""Anti-hallucination for numbers: the price (in some spoken form) occurs in the transcript."""
if p is None:
return True
toks = tx.token_set
return any(f in toks for f in _price_forms(float(p)))
def name_mentioned(tx: Transcript, heard, guess) -> bool:
"""The stock was actually named: the heard name (as a phrase) or the guessed symbol occurs
in the transcript, or the symbol spelled out letter by letter ("K I T T")."""
ht = _tok(str(heard or ""))
if ht and (" " + " ".join(ht) + " ") in tx.joined:
return True
g = (str(guess or "")).lower()
return bool(g) and (g in tx.token_set or (" " + " ".join(g) + " ") in tx.joined)
def _enum(x, allowed, default=None):
x = (str(x).strip().lower() if x is not None else None)
return x if x in allowed else default
# ---------------------------------------------------------------- verification
MAX_QUOTE_WORDS = 50
def _snap(tx: Transcript, q) -> str | None:
"""The transcript's own words for quote q (None if not found). A model sometimes pastes a
whole paragraph as the "quote"; it is verified in full, then cut to its first
MAX_QUOTE_WORDS words -- still verbatim, and short enough to stay a pointer, not a copy."""
s = tx.find(q) if isinstance(q, str) and q.strip() else None
if s is not None:
words = s.split()
if len(words) > MAX_QUOTE_WORDS:
s = " ".join(words[:MAX_QUOTE_WORDS])
return s
def _quoted_list(tx: Transcript, items, key: str, stats: Counter, label: str) -> list[dict]:
out = []
for it in items or []:
if not isinstance(it, dict):
continue
s = _snap(tx, it.get("quote"))
if s is None:
stats[f"dropped_{label}"] += 1
continue
out.append({key: str(it.get(key) or "").strip(), "quote": s})
return out
def _descriptor_ok(tx: Transcript, s: str) -> bool:
toks = _tok(s)
if not toks:
return False
if len(toks) >= 3:
return tx.find(s) is not None
return (" " + " ".join(toks) + " ") in tx.joined
def verify_record(rec: dict, tx: Transcript, stats: Counter | None = None) -> dict:
"""Enforce the verbatim-quote rule in code. Quoted items whose quote is not in the
transcript are dropped; accepted quotes are replaced by the transcript's exact words;
a trade with no surviving quote at all (no evidence he described it) is dropped."""
stats = stats if stats is not None else Counter()
day_in = rec.get("day") or {}
mq = _snap(tx, day_in.get("market_quote"))
if day_in.get("market_quote") and mq is None:
stats["dropped_market_quote"] += 1
day = {
"net_pnl": _num(day_in.get("net_pnl")),
"green": day_in.get("green") if isinstance(day_in.get("green"), bool) else None,
"market_temperature": _enum(day_in.get("market_temperature"), {"hot", "cold", "mixed"}),
"market_quote": mq,
"max_loss_hit": day_in.get("max_loss_hit") if isinstance(day_in.get("max_loss_hit"), bool) else None,
"daily_goal_comment": day_in.get("daily_goal_comment") or None,
"sizing_comment": day_in.get("sizing_comment") or None,
}
trades = []
for t in rec.get("trades") or []:
if not isinstance(t, dict):
continue
sc_in = t.get("stock_context") or {}
cq = _snap(tx, sc_in.get("catalyst_quote"))
sc = {"catalyst": (sc_in.get("catalyst") or None) if cq else None,
"catalyst_quote": cq,
"float": sc_in.get("float") or None,
"pct_up_at_entry": sc_in.get("pct_up_at_entry") or None,
"leading_gainer_rank": _num(sc_in.get("leading_gainer_rank")),
"other": [s for s in (sc_in.get("other") or []) if isinstance(s, str) and _descriptor_ok(tx, s)]}
out = {
"ticker_heard": str(t.get("ticker_heard") or "").strip() or None,
"ticker_guess": clean_ticker(t.get("ticker_guess")) or clean_ticker(t.get("ticker_heard")),
"entry_time_hint": t.get("entry_time_hint") or None,
"setup": _enum(t.get("setup"), set(P.SETUPS), "other"),
"setup_quote": _snap(tx, t.get("setup_quote")),
"entry_price": _num(t.get("entry_price")), "exit_price": _num(t.get("exit_price")),
"stated_high": _num(t.get("stated_high")), "stated_low": _num(t.get("stated_low")),
"shares": _num(t.get("shares")), "pnl": _num(t.get("pnl")),
"outcome": _enum(t.get("outcome"), {"win", "loss", "scratch"}),
"add_on": bool(t.get("add_on")),
"stock_context": sc,
"why_entered": _quoted_list(tx, t.get("why_entered"), "reason", stats, "why_entered"),
"tape_reads": _quoted_list(tx, t.get("tape_reads"), "read", stats, "tape_reads"),
"why_exited": _quoted_list(tx, t.get("why_exited"), "reason", stats, "why_exited"),
"mistake_or_lesson": _quoted_list(tx, t.get("mistake_or_lesson"), "lesson", stats, "lessons"),
}
for k in ("entry_price", "exit_price", "stated_high", "stated_low"):
if out[k] is not None and not price_mentioned(tx, out[k]):
stats["dropped_unspoken_price"] += 1
out[k] = None
if t.get("setup_quote") and out["setup_quote"] is None:
stats["dropped_setup_quote"] += 1
if not (out["setup_quote"] or out["why_entered"] or out["why_exited"] or out["tape_reads"]
or out["mistake_or_lesson"]):
stats["dropped_trades"] += 1
continue
trades.append(out)
passed = []
for p in rec.get("passed_on") or []:
if not isinstance(p, dict):
continue
s = _snap(tx, p.get("quote"))
if s is None:
stats["dropped_passed_on"] += 1
continue
if not name_mentioned(tx, p.get("ticker_heard"), p.get("ticker_guess")):
stats["dropped_passed_unnamed"] += 1 # e.g. a scanner-list name he never said
continue
passed.append({"ticker_heard": str(p.get("ticker_heard") or "").strip() or None,
"ticker_guess": clean_ticker(p.get("ticker_guess")) or clean_ticker(p.get("ticker_heard")),
"reason": str(p.get("reason") or "").strip(), "quote": s})
rules = _quoted_list(tx, rec.get("rules_stated"), "rule", stats, "rules")
return {"video_kind": _enum(rec.get("video_kind"), set(P.KINDS), "other"),
"session_date": rec.get("session_date") if _is_date(rec.get("session_date")) else None,
"day": day, "trades": trades, "passed_on": passed, "rules_stated": rules}
def _is_date(s) -> bool:
return isinstance(s, str) and bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", s))
# ---------------------------------------------------------------- merge
def _qkey(q) -> str:
return " ".join(_tok(q or ""))
def _overlaps(a: str, b: str) -> bool:
return bool(a) and bool(b) and (a in b or b in a)
def _same_trade(a: dict, b: dict) -> bool:
if (a.get("ticker_guess") or a.get("ticker_heard")) != (b.get("ticker_guess") or b.get("ticker_heard")):
return False
qa = {_qkey(a.get("setup_quote"))} | {_qkey(x["quote"]) for k in ("why_entered", "why_exited") for x in a[k]}
qb = {_qkey(b.get("setup_quote"))} | {_qkey(x["quote"]) for k in ("why_entered", "why_exited") for x in b[k]}
qa.discard(""); qb.discard("")
return any(_overlaps(x, y) for x in qa for y in qb)
def _union_quoted(a: list[dict], b: list[dict]) -> list[dict]:
out = list(a)
for x in b:
if not any(_overlaps(_qkey(x["quote"]), _qkey(y["quote"])) for y in out):
out.append(x)
return out
def merge_records(parts: list[dict]) -> dict:
"""Merge verified per-chunk records into one. Chunks overlap, so the same trade / pass /
rule can come back twice: trades are the same when ticker matches and a quote overlaps
(their fields are then unioned, first non-null wins); passes dedupe by ticker; rules by
quote. (A traded ticker may also be passed on -- he often skips a second entry.)"""
parts = [p for p in parts if p]
if not parts:
return {"video_kind": "other", "session_date": None, "day": {}, "trades": [], "passed_on": [],
"rules_stated": []}
kinds = Counter(p.get("video_kind") for p in parts)
kind = max(kinds, key=lambda k: (kinds[k], -[p.get("video_kind") for p in parts].index(k)))
day: dict = {}
for p in parts:
for k, v in (p.get("day") or {}).items():
if day.get(k) is None and v is not None:
day[k] = v
temps = Counter(p["day"].get("market_temperature") for p in parts if (p.get("day") or {}).get("market_temperature"))
if len(temps) > 1:
top = temps.most_common()
day["market_temperature"] = top[0][0] if top[0][1] > top[1][1] else "mixed"
trades: list[dict] = []
for p in parts:
for t in p.get("trades") or []:
dup = next((x for x in trades if _same_trade(x, t)), None)
if dup is None:
trades.append(json.loads(json.dumps(t)))
continue
for k, v in t.items():
if k in ("why_entered", "tape_reads", "why_exited", "mistake_or_lesson"):
dup[k] = _union_quoted(dup[k], v)
elif k == "stock_context":
for ck, cv in (v or {}).items():
if ck == "other":
dup[k]["other"] = list(dict.fromkeys((dup[k].get("other") or []) + (cv or [])))
elif dup[k].get(ck) is None:
dup[k][ck] = cv
elif dup.get(k) is None and v is not None:
dup[k] = v
passed, seen = [], set()
for p in parts:
for x in p.get("passed_on") or []:
key = x.get("ticker_guess") or (x.get("ticker_heard") or "").upper()
if key in seen:
continue
seen.add(key)
passed.append(x)
rules: list[dict] = []
for p in parts:
rules = _union_quoted(rules, p.get("rules_stated") or [])
return {"video_kind": kind, "session_date": next((p["session_date"] for p in parts if p.get("session_date")), None),
"day": day, "trades": trades, "passed_on": passed, "rules_stated": rules}
# ---------------------------------------------------------------- spoken prices
def scanner_prices(bundle: str) -> dict[str, float]:
"""{symbol: price} from the bundle's OUR SCANNER LIST lines ('SYM 12.3% $4.5')."""
out: dict[str, float] = {}
head = bundle.split(_HEADER_MARK, 1)[0]
for sym, px in re.findall(r"\b([A-Z]{1,5}) -?[\d.]+% \$([\d.]+)", head):
out[sym] = max(out.get(sym, 0.0), float(px))
return out
def fix_spoken_prices(rec: dict, prices: dict[str, float]) -> int:
"""ASR/LLM render "six ninety-nine" as 699 -- Ross trades small caps, so a trade price
>= 100 is divided by 100 unless the scanner saw that ticker above $50. Returns #fixes."""
n = 0
for t in rec.get("trades") or []:
if prices.get(t.get("ticker_guess") or "", 0) >= 50:
continue
for k in ("entry_price", "exit_price", "stated_high", "stated_low"):
v = t.get(k)
if v is not None and v >= 100:
t[k] = round(v / 100, 4)
n += 1
return n
# ---------------------------------------------------------------- driver
def bundle_meta(bundle: str) -> dict:
meta = {}
for ln in bundle.splitlines()[:6]:
k, _, v = ln.partition(": ")
if k in ("video_id", "upload_date", "title", "transcript_source"):
meta[k] = v.strip()
return meta
def extract_bundle(bundle: str, *, model: str | None = None, host: str | None = None,
num_ctx: int | None = None, think=None, chunk_chars: int | None = None,
system: str | None = None, split: bool | None = None, log=print) -> tuple[dict, dict]:
"""Extract one video. Returns (record per EXTRACT_SPEC, run metadata incl. drop stats).
split=True asks for trades/day and for passed-on/rules in two focused calls per chunk
(better recall from a small local model at ~2x the time)."""
split = settings.SPLIT_CALLS if split is None else split
think = settings.OLLAMA_THINK if think is None else think
from youtube_claims.recap.llm import Endpoint, OllamaPool, from_settings
if host: # an explicit endpoint (evaluation runs)
pool = OllamaPool([Endpoint(host.rstrip("/"))], model or settings.OLLAMA_MODEL,
settings.OLLAMA_NUM_CTX if num_ctx is None else (num_ctx or None), think,
call=ollama_chat)
else:
pool = from_settings()
if model:
pool.default_model = model
if num_ctx is not None:
pool.default_ctx = num_ctx or None
pool.default_think = think
model = pool.default_model
chunk_chars = chunk_chars or settings.CHUNK_CHARS
meta = bundle_meta(bundle)
tx = Transcript(transcript_text_from_bundle(bundle))
chunks = chunk_bundle(bundle, chunk_chars)
stats: Counter = Counter()
parts, calls = [], []
tasks = P.SPLIT_TASKS if split else [(None, P.SCHEMA)]
for i, ch in enumerate(chunks, 1):
note = P.PART_NOTE.format(i=i, n=len(chunks)) if len(chunks) > 1 else ""
data: dict | None = {}
for task, schema in tasks:
user = P.USER_TMPL.format(part_note=note + (task or ""), bundle=ch)
got = None
for attempt in range(2):
try:
raw, st = pool.chat(system or P.SYSTEM, user, schema=schema, timeout=settings.OLLAMA_TIMEOUT,
temperature=0.0 if attempt == 0 else 0.3,
num_predict=settings.OLLAMA_NUM_PREDICT)
calls.append(st)
got = parse_json(raw)
break
except (json.JSONDecodeError, ValueError) as e:
stats["json_errors"] += 1
log(f" chunk {i}/{len(chunks)} bad JSON ({e}); retrying" if attempt == 0 else
f" chunk {i}/{len(chunks)} bad JSON again; skipping")
if got is None:
data = None
break
data.update({k: v for k, v in got.items() if k in schema["properties"]}) # a call may echo other keys empty
if data is None:
stats["chunks_failed"] += 1
continue
for k in ("trades", "passed_on", "rules_stated"):
stats[f"raw_{k}"] += len(data.get(k) or [])
parts.append(verify_record(data, tx, stats))
if not parts:
raise RuntimeError(f"extraction produced nothing for {meta.get('video_id')} ({dict(stats)})")
rec = merge_records(parts)
fixed = fix_spoken_prices(rec, scanner_prices(bundle))
if fixed:
stats["prices_rescaled"] += fixed
up = meta.get("upload_date") or ""
out = {"video_id": meta.get("video_id"), "upload_date": up,
"session_date": rec.get("session_date"), "video_kind": rec["video_kind"],
"day": rec["day"], "trades": rec["trades"], "passed_on": rec["passed_on"],
"rules_stated": rec["rules_stated"]}
run = {"model": model, "hosts": sorted({c.get("host") for c in calls if c.get("host")}),
"chunks": len(chunks), "split": split, "calls": calls,
"seconds": round(sum(c["seconds"] for c in calls), 1), "stats": dict(stats)}
return out, run
+44
View File
@@ -0,0 +1,44 @@
"""Join one extraction record to OUR data: price-verify each ticker against the SIP tape, locate
his entry on the tape, and attach what our bot saw/did that day (both stacks)."""
from __future__ import annotations
from youtube_claims.recap.tape import entry_time, resolve
def session_for(rec: dict) -> str:
sd = rec.get("session_date")
if sd:
return str(sd)
u = str(rec.get("upload_date") or "")
return f"{u[:4]}-{u[4:6]}-{u[6:]}"
def join_record(rec: dict, ourside, scanner: dict) -> tuple[list[dict], list[dict]]:
"""-> (trades, passed): the record's items plus symbol, resolve, our_view (and, for trades,
entry_time_tape HH:MM ET)."""
d = session_for(rec)
scanner_syms = [row[0] for rows in (scanner or {}).values() for row in rows]
bars_for = lambda s: ourside.bars.bars(s, d) # noqa: E731
views: dict = {}
def view(sym):
if sym and sym not in views:
try:
views[sym] = ourside.view(d, sym)
except Exception as e: # noqa: BLE001 -- one bad lookup shouldn't sink the video
views[sym] = {"error": str(e)[:200]}
return views.get(sym)
trades = []
for t in rec.get("trades") or []:
sym, how = resolve(t, bars_for, scanner_syms)
et = entry_time(bars_for(sym), t.get("entry_price"), t.get("stated_high")) \
if sym and how in ("price_verified", "price_verified_loose", "price_matched_scanner") else None
trades.append({**t, "symbol": sym, "resolve": how, "our_view": view(sym),
"entry_time_tape": et.strftime("%H:%M") if et else None})
passed = []
for p in rec.get("passed_on") or []:
sym, how = resolve(p, bars_for, scanner_syms)
passed.append({**p, "symbol": sym, "resolve": how, "our_view": view(sym)})
return trades, passed
+133
View File
@@ -0,0 +1,133 @@
"""A small pool over the owner's several LAN Ollama endpoints: round-robin across the endpoints
that actually have the requested model, fail over on connection errors / timeouts / 5xx, and
park a dead endpoint for a few minutes. Endpoints come from RECAP_OLLAMA_HOSTS:
RECAP_OLLAMA_HOSTS="http://127.0.0.1:11434, http://192.168.1.178:11434=qwen3:30b-a3b-instruct-2507-q4_K_M"
An entry may pin its own model with `=model` (and `#ctx=N` / `#think=V` options), otherwise it
serves RECAP_OLLAMA_MODEL with the global options. The transport (`call`) is injected so the
rotation/failover logic is unit-tested without a network."""
from __future__ import annotations
import json
import socket
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Callable
_THINK = {"false": False, "true": True, "none": None}
@dataclass
class Endpoint:
host: str
model: str | None = None # None -> the pool's default model
num_ctx: int | None = None # None -> the pool's default
think: object = "default" # "default" -> the pool's default
down_until: float = 0.0
models: set | None = None
models_at: float = 0.0
def parse_hosts(spec: str) -> list[Endpoint]:
"""'h1, h2=model#ctx=8192#think=false' -> endpoints (blank entries ignored)."""
out = []
for raw in (spec or "").split(","):
raw = raw.strip()
if not raw:
continue
head, *opts = raw.split("#")
host, _, model = head.partition("=")
ep = Endpoint(host=host.strip().rstrip("/"), model=model.strip() or None)
for o in opts:
k, _, v = o.partition("=")
k, v = k.strip().lower(), v.strip()
if k == "ctx" and v.isdigit():
ep.num_ctx = int(v) or None
elif k == "think":
ep.think = _THINK.get(v.lower(), v)
out.append(ep)
return out
def _tags(host: str) -> set[str]:
with urllib.request.urlopen(host + "/api/tags", timeout=5) as r:
return {m["name"] for m in json.load(r).get("models", [])}
@dataclass
class OllamaPool:
endpoints: list[Endpoint]
default_model: str
default_ctx: int | None = None
default_think: object = None
call: Callable | None = None # (system, user, model=, host=, ...) -> (str, stats)
list_models: Callable[[str], set] = _tags
down_seconds: float = 300.0
tags_ttl: float = 300.0
_rr: int = 0
_lock: threading.Lock = field(default_factory=threading.Lock)
def _serves(self, ep: Endpoint) -> str | None:
"""The model this endpoint would run, if it has it (tags cached); None otherwise."""
want = ep.model or self.default_model
now = time.monotonic()
if ep.models is None or now - ep.models_at > self.tags_ttl:
try:
ep.models, ep.models_at = self.list_models(ep.host), now
except Exception: # noqa: BLE001 -- unreachable: park it
ep.down_until = now + self.down_seconds
ep.models, ep.models_at = None, 0.0
return None
names = ep.models or set()
return want if (want in names or f"{want}:latest" in names) else None
def candidates(self) -> list[tuple[Endpoint, str]]:
now = time.monotonic()
with self._lock:
start = self._rr
self._rr += 1
n = len(self.endpoints)
order = [self.endpoints[(start + i) % n] for i in range(n)] if n else []
out = []
for ep in order:
if ep.down_until > now:
continue
m = self._serves(ep)
if m:
out.append((ep, m))
return out
def chat(self, system: str, user: str, **kw) -> tuple[str, dict]:
"""One chat call on the next healthy endpoint; fails over to the others."""
cands = self.candidates()
if not cands:
raise RuntimeError(f"no reachable Ollama endpoint serves {self.default_model!r} "
f"(tried {[e.host for e in self.endpoints]})")
last: Exception | None = None
for ep, model in cands:
think = self.default_think if ep.think == "default" else ep.think
ctx = ep.num_ctx if ep.num_ctx is not None else self.default_ctx
try:
content, stats = self.call(system, user, model=model, host=ep.host, num_ctx=ctx, think=think, **kw)
stats = {**stats, "host": ep.host, "model": model}
return content, stats
except (urllib.error.URLError, ConnectionError, TimeoutError, socket.timeout) as e:
last = e
except RuntimeError as e: # ollama_chat's HTTP errors: only 5xx is the host's fault
if "ollama 5" not in str(e):
raise
last = e
ep.down_until = time.monotonic() + self.down_seconds
raise RuntimeError(f"all Ollama endpoints failed; last error: {last}")
def from_settings(call=None) -> OllamaPool:
from youtube_claims.recap import settings
from youtube_claims.recap.extract import ollama_chat
return OllamaPool(parse_hosts(settings.OLLAMA_HOSTS), settings.OLLAMA_MODEL, settings.OLLAMA_NUM_CTX,
settings.OLLAMA_THINK, call=call or ollama_chat)
+16
View File
@@ -0,0 +1,16 @@
@echo off
rem Nightly Ross recap loop -- run by Windows Task Scheduler on the owner's workstation.
rem Uses the durable venv data\ross\.venv (py -3.13 --system-site-packages + faster-whisper, yt-dlp):
rem py -3.13 -m venv --system-site-packages data\ross\.venv
rem data\ross\.venv\Scripts\python -m pip install faster-whisper yt-dlp
rem RECAP_* settings (RECAP_OLLAMA_HOSTS, RECAP_OLLAMA_MODEL, ...) can live in the repo .env.
rem Extra args pass through (e.g. --backfill, --report-only). Log: data\ross\logs\nightly_YYYY-MM-DD.log
setlocal
cd /d "%~dp0..\.."
set PYTHONIOENCODING=utf-8
if not exist data\ross\logs mkdir data\ross\logs
for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyy-MM-dd"') do set RUNDATE=%%i
set PY=data\ross\.venv\Scripts\python.exe
if not exist %PY% set PY=py -3.13
%PY% -m youtube_claims.recap.nightly %* >> "data\ross\logs\nightly_%RUNDATE%.log" 2>&1
exit /b %ERRORLEVEL%
+197
View File
@@ -0,0 +1,197 @@
"""The nightly pass (run on the owner's Windows workstation, ~20:30 ET weekdays):
1. list the channel's newest uploads (yt-dlp flat listing) and drop the ones already in
transcripts.recap_videos
2. per new video: metadata -> audio -> faster-whisper -> bundle (meta + our scanner list +
transcript) -> extraction (the gold file if the pilot has one, else local Ollama with the
verbatim-quote check) -> join (tape-verified symbol + our paper/live view) -> Postgres
3. write the cumulative report data/ross/reports/YYYY-MM-DD.md (+ .json for the next diff)
python -m youtube_claims.recap.nightly # normal nightly run
python -m youtube_claims.recap.nightly --backfill # also ingest every local bundle not in the DB
python -m youtube_claims.recap.nightly --reprocess ID # force one video through again
python -m youtube_claims.recap.nightly --report-only
Each video is independent: a failure is recorded on its recap_videos row and retried next
night (up to RECAP_MAX_ATTEMPTS), never aborting the run."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import sys
import time
import traceback
from youtube_claims.recap import settings
def log(*a):
print(dt.datetime.now().strftime("%H:%M:%S"), *a, flush=True)
def _stem(meta: dict) -> str:
return f"{meta['upload_date']}_{meta['id']}"
def extraction_for(stem: str, bundle: str, *, force_llm: bool = False) -> tuple[dict, str, dict | None]:
"""(record, extractor, run_meta). A pilot gold file wins; else the cached local-model
output; else a fresh Ollama extraction (cached to data/ross/llm/)."""
from youtube_claims.recap.extract import extract_bundle
d = settings.dirs()
gold = d["extracted"] / f"{stem}.json"
if gold.exists() and not force_llm:
return json.loads(gold.read_text(encoding="utf-8")), "gold", None
cache = d["llm"] / f"{stem}.json"
if cache.exists() and not force_llm:
rec = json.loads(cache.read_text(encoding="utf-8"))
run = rec.pop("_run", None)
return rec, (run or {}).get("model") or settings.OLLAMA_MODEL, run
rec, run = extract_bundle(bundle, log=log)
cache.write_text(json.dumps({**rec, "_run": run}, indent=1), encoding="utf-8")
return rec, run["model"], run
def process(meta: dict, ourside, *, force_llm: bool = False) -> dict:
"""One video end to end. Returns a small summary for the log."""
from youtube_claims.recap import channel, store
from youtube_claims.recap.join import join_record
vid, stem = meta["id"], _stem(meta)
d = settings.dirs()
t0 = time.time()
scanner = channel.scanner_for(meta["upload_date"], ourside)
bfile = d["bundles"] / f"{stem}.txt"
if bfile.exists(): # built before (pilot or an earlier night): reuse as-is
bundle = bfile.read_text(encoding="utf-8")
src = channel.bundle_source(bundle)
else:
src, segs = channel.best_transcript(vid)
if not segs:
log(f"{vid}: downloading audio")
audio = channel.download_audio(vid)
log(f"{vid}: transcribing {audio.name}")
segs = channel.transcribe(vid, audio)
src = "whisper-small"
try:
audio.unlink() # the transcript is what we keep; audio is ~10 MB/video
except OSError:
pass
bundle = channel.make_bundle(meta, src, segs, scanner)
bfile.write_text(bundle, encoding="utf-8")
t_tx = time.time() - t0
t1 = time.time()
rec, extractor, run = extraction_for(stem, bundle, force_llm=force_llm)
rec.setdefault("video_id", vid)
rec.setdefault("upload_date", meta["upload_date"])
t_ex = time.time() - t1
t2 = time.time()
trades, passed = join_record(rec, ourside, scanner)
t_join = time.time() - t2
run = {**(run or {}), "timing_s": {"transcribe": round(t_tx), "extract": round(t_ex), "join": round(t_join)}}
store.upsert_video(rec, meta, extractor=extractor, transcript_source=src, run_meta=run,
trades=trades, passed=passed)
return {"kind": rec.get("video_kind"), "trades": len(trades), "passed": len(passed), "extractor": extractor,
"seconds": round(time.time() - t0)}
def _local_bundle_metas() -> list[dict]:
"""Every pilot bundle on disk as a meta dict (for --backfill)."""
out = []
for f in sorted(settings.dirs()["bundles"].glob("*.txt")):
head = f.read_text(encoding="utf-8").splitlines()[:3]
kv = dict(ln.split(": ", 1) for ln in head if ": " in ln)
mf = settings.dirs()["transcripts"] / f"{kv.get('video_id')}.meta.json"
dur = json.loads(mf.read_text(encoding="utf-8")).get("duration") if mf.exists() else None
out.append({"id": kv["video_id"], "upload_date": kv["upload_date"], "title": kv.get("title", ""),
"duration": dur})
return out
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Nightly Ross recap learning loop")
ap.add_argument("--backfill", action="store_true", help="ingest every local bundle not yet in the DB")
ap.add_argument("--reprocess", action="append", default=[], help="video id to force through again")
ap.add_argument("--force-llm", action="store_true", help="ignore gold/cached extractions for processed videos")
ap.add_argument("--report-only", action="store_true")
ap.add_argument("--no-list", action="store_true", help="skip the channel listing (offline)")
ap.add_argument("--max-new", type=int, default=settings.MAX_NEW_PER_RUN)
a = ap.parse_args(argv)
from youtube_claims.recap import channel, report, store
from youtube_claims.recap.ourside import OurSide
d = settings.dirs()
store.apply_schema()
known = store.video_status()
max_attempts = int(os.getenv("RECAP_MAX_ATTEMPTS", "3"))
processed: list[str] = []
failures: list[str] = []
if not a.report_only:
local = _local_bundle_metas()
local_ids = {m["id"] for m in local}
todo: list[dict] = []
if not a.no_list:
try:
ups = channel.list_uploads()
log(f"channel listing: {len(ups)} newest uploads")
except Exception as e: # noqa: BLE001 -- still ingest/report what we have
log(f"channel listing FAILED: {e}")
failures.append(f"listing: {e}")
ups = []
for u in ups:
k = known.get(u["id"])
if k and (k["status"] in ("done", "skipped") or
(k["status"] == "failed" and (k.get("attempts") or 0) >= max_attempts)):
continue
if u["id"] in a.reprocess:
continue
try:
m = channel.video_meta(u["id"], u["title"], u.get("duration"))
except Exception as e: # noqa: BLE001
log(f"{u['id']}: metadata failed: {e}")
failures.append(f"{u['id']} meta: {e}")
continue
if not m.get("upload_date") or m["upload_date"] < settings.MIN_UPLOAD_DATE:
continue
todo.append(m)
if a.backfill:
have = {m["id"] for m in todo}
todo += [m for m in local
if m["id"] not in have and (known.get(m["id"]) or {}).get("status") != "done"]
for vid in a.reprocess:
todo.append(channel.video_meta(vid))
todo.sort(key=lambda m: m["upload_date"])
new_from_channel = [m for m in todo if m["id"] not in local_ids]
if len(new_from_channel) > a.max_new: # politeness cap on downloads per night
drop = {m["id"] for m in new_from_channel[:-a.max_new]}
todo = [m for m in todo if m["id"] not in drop]
log(f"capped downloads at {a.max_new}; {len(drop)} older new videos wait for the next run")
log(f"{len(todo)} video(s) to process")
ours = OurSide()
try:
for m in todo:
try:
s = process(m, ours, force_llm=a.force_llm and m["id"] in a.reprocess)
processed.append(m["id"])
log(f"{m['id']} {m['upload_date']} {m.get('title', '')[:50]!r}: {s}")
except Exception as e: # noqa: BLE001 -- one bad video never aborts the night
log(f"{m['id']}: FAILED {type(e).__name__}: {e}")
traceback.print_exc()
failures.append(f"{m['id']}: {type(e).__name__}: {str(e)[:200]}")
store.mark(m["id"], "failed", error=f"{type(e).__name__}: {e}", meta=m)
finally:
ours.close()
ev = settings.DATA_DIR / "eval" / "_best.txt"
out = report.write_report(d["reports"], new_video_ids=processed,
eval_text=ev.read_text(encoding="utf-8") if ev.exists() else None)
log(f"report: {out} (processed {len(processed)}, failures {len(failures)})")
for f in failures:
log(" failure:", f)
return 1 if failures and not processed else 0
if __name__ == "__main__":
sys.exit(main())
+112
View File
@@ -0,0 +1,112 @@
"""What OUR system saw/did for a (date, symbol): discovery, state changes, gate verdicts,
signals, orders, closes, risk rules -- from BOTH stacks' event logs (paper DB :5433 and live
Tradier DB :5434) -- plus the day's SIP tape stats, float and news. Ported from the pilot's
ourside.py; read-only against the trading DBs. Event timestamps are ET-naive; minute_bars are
UTC-naive."""
from __future__ import annotations
import re
from collections import Counter
import psycopg2
from youtube_claims.recap import settings
from youtube_claims.recap.tape import BarSource, tape_stats
_EVENT_TYPES = ["symbol_discovered", "symbol_state_changed", "signal_blocked", "entry_gate_evaluated",
"signal_ready", "order_submitted", "position_closed", "risk_rule_triggered"]
def norm_reason(m: str | None) -> str:
"""Collapse numbers so '3.2x < 5x' and '4.1x < 5x' count as one reason."""
return re.sub(r"\d+(\.\d+)?", "#", m or "")[:120]
def summarize_events(ev: list[tuple]) -> dict:
"""ev: [(ts, event_type, message)] ordered by time -> the compact per-stack view."""
hm = lambda t: t.strftime("%H:%M") # noqa: E731
blocks = Counter(norm_reason(e[2].split("blocked:", 1)[-1]) for e in ev if e[1] == "signal_blocked")
gates = Counter(norm_reason(e[2].split("rejected:", 1)[-1]) for e in ev
if e[1] == "entry_gate_evaluated" and "rejected" in (e[2] or ""))
states: list[tuple] = []
for e in ev:
if e[1] == "symbol_state_changed" and (not states or states[-1][1] != e[2][:160]):
states.append((hm(e[0]), (e[2] or "")[:160]))
pick = lambda typ, n, w: [(hm(e[0]), (e[2] or "")[:w]) for e in ev if e[1] == typ][:n] # noqa: E731
return {"discovered": pick("symbol_discovered", 2, 200), "states": states[:8],
"top_blocks": blocks.most_common(3), "gate_rejects": gates.most_common(3),
"signals": pick("signal_ready", 3, 120), "orders": pick("order_submitted", 3, 100),
"closes": pick("position_closed", 3, 120), "risk": pick("risk_rule_triggered", 4, 140)}
class OurSide:
def __init__(self):
self.paper = psycopg2.connect(settings.database_url(), connect_timeout=10)
self.paper.autocommit = True
try:
self.live = psycopg2.connect(settings.live_database_url(), connect_timeout=10)
self.live.autocommit = True
except Exception as e: # noqa: BLE001 -- the paper view alone is still useful
print(f"[ourside] live DB unavailable: {e}")
self.live = None
self.bars = BarSource(settings.dirs()["cache"] / "bars", paper_con=self.paper)
def close(self):
for c in (self.paper, self.live):
if c is not None:
c.close()
def _q(self, con, sql, args=()):
with con.cursor() as cur:
cur.execute(sql, args)
return cur.fetchall()
def events(self, con, d: str, sym: str) -> list[tuple]:
return self._q(con, "select timestamp, event_type, message from events where event_type = any(%s) "
"and timestamp between %s and %s and payload_json like %s order by timestamp",
(_EVENT_TYPES, f"{d} 04:00", f"{d} 20:00", f'%"symbol": "{sym}"%'))
def prev_close(self, d: str, sym: str) -> float | None:
r = self._q(self.paper, "select close from daily_bars where symbol=%s and trade_date<%s "
"order by trade_date desc limit 1", (sym, d))
return float(r[0][0]) if r and r[0][0] else None
def view(self, d: str, sym: str) -> dict:
d, sym = str(d), sym.upper()
out: dict = {"date": d, "symbol": sym}
for name, con in (("paper", self.paper), ("live", self.live)):
out[name] = summarize_events(self.events(con, d, sym)) if con is not None else None
bars = self.bars.bars(sym, d)
out["tape"] = tape_stats(bars, self.prev_close(d, sym))
local = self._q(self.paper, "select min(timestamp), count(*) from minute_bars where symbol=%s "
"and session_date=%s", (sym, d))
if local and local[0][0]:
import datetime as dt
from zoneinfo import ZoneInfo
first = local[0][0].replace(tzinfo=dt.timezone.utc).astimezone(ZoneInfo("America/New_York"))
out["tape"]["our_first_bar"] = first.strftime("%H:%M")
out["tape"]["our_bar_count"] = local[0][1] if local else 0
fl = self._q(self.paper, "select float_shares from float_cache where symbol=%s", (sym,))
out["float"] = float(fl[0][0]) if fl and fl[0][0] else None
out["news"] = [r[0][:140] for r in self._q(
self.paper, "select headline from news_events where symbol=%s and published_at::date "
"between %s::date - 1 and %s::date order by published_at limit 3", (sym, d, d))]
return out
def day_universe(self, d: str) -> list[tuple]:
"""Symbols our scanner ranked that day: [(symbol, gap %, price)] (the bundle's list)."""
rows = self._q(self.paper, "select symbol, max(gap_pct), max(price) from scanner_snapshots "
"where snapshot_time::date=%s group by symbol "
"order by max(gap_pct) desc nulls last limit 40", (str(d),))
if not rows:
rows = self._q(self.paper, "select symbol, gap_pct, null from historical_gappers "
"where session_date=%s order by rank limit 40", (str(d),))
return [(s, round(float(g or 0) * (100 if g and abs(g) < 20 else 1), 1),
round(float(p), 4) if p else None) for s, g, p in rows]
def discovery_time(view: dict | None) -> str | None:
"""Earliest HH:MM either stack discovered the symbol."""
ts = [t for side in ("paper", "live") for t, _ in ((view or {}).get(side) or {}).get("discovered", [])]
return min(ts) if ts else None
+126
View File
@@ -0,0 +1,126 @@
"""The extraction instructions (EXTRACT_SPEC.md, adapted for a local model) and the JSON
schema handed to Ollama's structured-output mode. The spec file itself lives in the
git-ignored data dir; this is the versioned copy the pipeline actually uses."""
from __future__ import annotations
SETUPS = ["micro_pullback", "bull_flag", "first_pullback", "breakout_whole_dollar",
"break_of_premarket_high", "halt_resume", "dip_and_curl", "vwap_break", "abcd",
"red_to_green", "other"]
KINDS = ["daily_recap", "watchlist", "education", "challenge_summary", "other"]
SYSTEM = """You extract a structured record from ONE transcript of Ross Cameron's (Warrior Trading)
day-trading video. Record what he SAID, not what you think is right.
QUOTES ARE CHECKED BY A PROGRAM. Every `quote`, `setup_quote`, `catalyst_quote` and `market_quote`
must be copied word-for-word from the TRANSCRIPT (a contiguous run of 6-30 words, ASR noise
included; do not fix grammar or numbers). Anything whose quote is not found verbatim is deleted.
If you cannot quote it, leave the item out.
The transcript is speech-to-text: tickers and prices are often misheard. Put the ticker AS HEARD in
`ticker_heard` and your best guess in `ticker_guess`, preferring a symbol from OUR SCANNER LIST when
it sounds alike and the price fits. Spoken prices: "550" usually means $5.50, "750" means $7.50,
"a dollar twenty" = 1.20, "four and a half" = 4.50. Prices are verified against the tape later, so
only give a price he actually said.
What goes where:
- video_kind: daily_recap (he recaps his own trading of a session -- even if he mentions a challenge
or account milestone), watchlist (mainly stocks to watch for the next session), challenge_summary
(ONLY when the video summarises a multi-day/multi-week challenge as a whole), education (a lesson
or story not about one session), other.
- session_date: the trading day described (usually the upload date; the prior Friday for a weekend
watchlist; null if no specific day).
- trades: walk through the transcript IN ORDER and create ONE entry every time he says he bought,
got in, took a trade, added, or got back in ("I got in", "I bought", "I was in", "I added",
"I took a trade on", "I jumped in"). Several trades on the same stock are normal -- each re-entry
or add is its own entry (add_on=true). Trades he took but mentions only briefly still count, and
watchlist or challenge videos can contain trades too. Do NOT create trades for stocks he only
watched, showed on a chart, or talks about hypothetically.
setup: one of micro_pullback, bull_flag, first_pullback, breakout_whole_dollar,
break_of_premarket_high, halt_resume, dip_and_curl, vwap_break, abcd, red_to_green, other.
outcome: win / loss / scratch as he describes it, else null. pnl/shares only if stated.
- passed_on: EVERY other stock he names that he did NOT trade -- top gainers he skipped, stocks on
his watchlist, names he says he missed or avoided -- with the reason (or what he said about it) and
a quote naming or describing it. Usually 3-10 per video.
- ticker_guess: when the heard name is close to a symbol in OUR SCANNER LIST (sound-alike, spelled
letters, or the price he mentions fits), use that scanner symbol.
- rules_stated: general principles he states (risk, sizing, stock selection, when to stop,
statistics about his accuracy or P&L), each with a quote.
- day.market_temperature: hot (lots of big clean movers, strong momentum), cold (slow, few good
setups, choppy, tough month), mixed, or null if he gives no read. market_quote: the sentence where
he characterises the market.
- day.net_pnl: the day's $ result as stated (negative for red); green = true if the day is positive.
Keep paraphrases (reason, rule, lesson, read) short. Skip sponsor segments, course promotion and
filler. Return ONLY the JSON object."""
USER_TMPL = """{part_note}Upload metadata and the day's context follow, then the transcript.
{bundle}"""
PART_NOTE = ("This is PART {i} of {n} of one transcript (parts overlap slightly). Extract only what "
"is said in this part; fill `day` fields only if this part states them.\n\n")
def _nstr():
return {"type": ["string", "null"]}
def _nnum():
return {"type": ["number", "null"]}
def _quoted(key: str) -> dict:
return {"type": "array", "items": {"type": "object", "properties": {
key: {"type": "string"}, "quote": {"type": "string"}}, "required": [key, "quote"]}}
SCHEMA = {
"type": "object",
"properties": {
"video_kind": {"type": "string", "enum": KINDS},
"session_date": _nstr(),
"day": {"type": "object", "properties": {
"net_pnl": _nnum(), "green": {"type": ["boolean", "null"]},
"market_temperature": {"type": ["string", "null"], "enum": ["hot", "cold", "mixed", None]},
"market_quote": _nstr(), "max_loss_hit": {"type": ["boolean", "null"]},
"daily_goal_comment": _nstr(), "sizing_comment": _nstr()},
"required": ["net_pnl", "green", "market_temperature", "market_quote"]},
"trades": {"type": "array", "items": {"type": "object", "properties": {
"ticker_heard": {"type": "string"}, "ticker_guess": {"type": "string"},
"entry_time_hint": _nstr(),
"setup": {"type": "string", "enum": SETUPS}, "setup_quote": {"type": "string"},
"entry_price": _nnum(), "exit_price": _nnum(), "stated_high": _nnum(), "stated_low": _nnum(),
"shares": _nnum(), "pnl": _nnum(),
"outcome": {"type": ["string", "null"], "enum": ["win", "loss", "scratch", None]},
"add_on": {"type": "boolean"},
"stock_context": {"type": "object", "properties": {
"catalyst": _nstr(), "catalyst_quote": _nstr(), "float": _nstr(),
"pct_up_at_entry": _nstr(), "leading_gainer_rank": _nnum(),
"other": {"type": "array", "items": {"type": "string"}}}},
"why_entered": _quoted("reason"), "tape_reads": _quoted("read"),
"why_exited": _quoted("reason"), "mistake_or_lesson": _quoted("lesson")},
"required": ["ticker_heard", "ticker_guess", "setup", "setup_quote", "entry_price",
"exit_price", "outcome", "why_entered"]}},
"passed_on": {"type": "array", "items": {"type": "object", "properties": {
"ticker_heard": {"type": "string"}, "ticker_guess": {"type": "string"},
"reason": {"type": "string"}, "quote": {"type": "string"}},
"required": ["ticker_heard", "ticker_guess", "reason", "quote"]}},
"rules_stated": _quoted("rule"),
},
"required": ["video_kind", "session_date", "day", "trades", "passed_on", "rules_stated"],
}
_TASK_A = ("TASK FOR THIS CALL: your main job is `trades` -- list EVERY position he took, in order, "
"with its quotes and prices. Also fill video_kind, session_date and day (net P&L, "
"market_temperature). Passed-on stocks and rules are extracted in a separate call.\n\n")
_TASK_B = ("TASK FOR THIS CALL: fill passed_on (every stock he names but did NOT trade) and "
"rules_stated (every general principle, statistic or lesson he states -- usually 5-12). "
"Trades are extracted in a separate call.\n\n")
SCHEMA_A = {"type": "object", "properties": {k: SCHEMA["properties"][k] for k in
("video_kind", "session_date", "day", "trades")},
"required": ["video_kind", "session_date", "day", "trades"]}
SCHEMA_B = {"type": "object", "properties": {k: SCHEMA["properties"][k] for k in ("passed_on", "rules_stated")},
"required": ["passed_on", "rules_stated"]}
SPLIT_TASKS = [(_TASK_A, SCHEMA_A), (_TASK_B, SCHEMA_B)]
+94
View File
@@ -0,0 +1,94 @@
"""Anti-hallucination: every quote the LLM returns must be findable in the transcript.
Extends youtube_claims.extraction._quote_supported (whitespace-normalized 40-char prefix match,
which accepts a quote whose tail is invented) with a full-length, punctuation-insensitive token
match plus a small fuzzy tolerance for a mis-copied word,
and SNAPS an accepted quote to the transcript's own words -- so what we store is verbatim
source text, never the model's paraphrase of it. A quote that can't be located is rejected
and the caller drops the item it supported."""
from __future__ import annotations
import re
from difflib import SequenceMatcher
from youtube_claims.extraction import _norm
_TOKEN = re.compile(r"[a-z0-9]+(?:[.,'][a-z0-9]+)*")
def _tok(s: str) -> list[str]:
"""Lowercased word tokens; '$5.50' -> '5.50', "don't" -> "don't", 'up-to' -> 'up','to'."""
return _TOKEN.findall((s or "").lower())
class Transcript:
"""A transcript indexed for quote lookup. Keeps the original words so a matched quote can
be returned exactly as transcribed (ASR noise included)."""
def __init__(self, text: str):
self.text = text or ""
self.ntext = _norm(self.text)
self.words = self.text.split()
self.wtoks = [_tok(w) for w in self.words] # tokens per original word
self.toks: list[str] = []
self.tok_word: list[int] = [] # token index -> word index
for wi, ts in enumerate(self.wtoks):
for t in ts:
self.toks.append(t)
self.tok_word.append(wi)
self.joined = " " + " ".join(self.toks) + " "
# number tokens also without thousands separators / leading '$' for price lookups
self.token_set = set(self.toks) | {t.replace(",", "") for t in self.toks}
self._first: dict[str, list[int]] = {}
for i, t in enumerate(self.toks):
self._first.setdefault(t, []).append(i)
def _span(self, i: int, n: int) -> str:
a, b = self.tok_word[i], self.tok_word[min(i + n, len(self.toks)) - 1]
return " ".join(self.words[a:b + 1])
def find(self, quote: str, min_ratio: float = 0.9) -> str | None:
"""The transcript text matching `quote`, or None if it isn't there.
Exact (token-level, punctuation-blind) first; then a fuzzy window of the same length
whose tokens agree >= min_ratio (tolerates one mis-copied word in ten)."""
q = _tok(quote)
if len(q) < 3: # too short to be evidence of anything
return None
needle = " " + " ".join(q) + " "
pos = self.joined.find(needle)
if pos >= 0:
start = self.joined[:pos + 1].count(" ") - 1
return self._span(start, len(q))
if len(q) < 6:
return None
n = len(q)
cands: set[int] = set()
for k in range(min(4, n)): # windows anchored on any of the
for i in self._first.get(q[k], ()): # quote's first four tokens
if i - k >= 0:
cands.add(i - k)
best, best_i = 0.0, None
for i in sorted(cands):
win = self.toks[i:i + n]
if len(win) < n * 0.8:
continue
r = SequenceMatcher(None, q, win, autojunk=False).ratio()
if r > best:
best, best_i = r, i
if best_i is None or best < min_ratio:
return None
# a dropped/added word shifts the true end: pick the window length that fits best
best_n = max(range(max(1, n - 2), n + 3),
key=lambda m: (SequenceMatcher(None, q, self.toks[best_i:best_i + m], autojunk=False).ratio(), -abs(m - n)))
return self._span(best_i, best_n)
def supports(self, quote: str) -> bool:
return self.find(quote) is not None
def transcript_text_from_bundle(bundle: str) -> str:
"""The transcript body of a bundle, without the [mm:ss] prefixes (segments joined by
spaces so a quote spanning two segments still matches)."""
body = bundle.split("TRANSCRIPT (mm:ss text):", 1)[-1]
return " ".join(re.sub(r"^\[\d+:\d+\]\s?", "", ln) for ln in body.splitlines() if ln.strip())
+231
View File
@@ -0,0 +1,231 @@
"""Cumulative gap report: Ross's trades vs what our system saw/did (the pilot's report.py
sections) plus "what changed since last run". compute()/render()/diff_metrics() are pure;
write_report() loads from Postgres and writes data/ross/reports/YYYY-MM-DD.{md,json}."""
from __future__ import annotations
import datetime as dt
import json
import re
import statistics
from collections import Counter, defaultdict
from pathlib import Path
GOOD = ("price_verified", "price_verified_loose", "price_matched_scanner")
BUCKETS = ["pre-7:00", "7:00-9:30", "9:30-9:45", "9:45+"]
def _f(x):
try:
return float(x) if x is not None else None
except (TypeError, ValueError):
return None
def _bucket(hhmm: str | None) -> str | None:
if not hhmm:
return None
return ("pre-7:00" if hhmm < "07:00" else "7:00-9:30" if hhmm < "09:30"
else "9:30-9:45" if hhmm < "09:45" else "9:45+")
def _disc(view: dict | None) -> str | None:
ts = [t for side in ("paper", "live") for t, *_ in ((view or {}).get(side) or {}).get("discovered", [])]
return min(ts) if ts else None
def _describe(xs: list[float]) -> dict:
if not xs:
return {"count": 0}
xs = sorted(xs)
q = statistics.quantiles(xs, n=4) if len(xs) > 1 else [xs[0]] * 3
return {"count": len(xs), "mean": round(statistics.mean(xs), 2), "min": round(xs[0], 2),
"p25": round(q[0], 2), "median": round(q[1], 2), "p75": round(q[2], 2), "max": round(xs[-1], 2)}
def trade_facts(t: dict) -> dict:
"""The per-trade columns the pilot report aggregated (also the CSV row)."""
o = t.get("our_view") or {}
tp = o.get("tape") or {}
prev, e = _f(tp.get("prev_close")), _f(t.get("entry_price"))
how = (t.get("resolve") or "").split(":")[0]
et = t.get("entry_time_tape") if how in GOOD else None
disc = _disc(o)
states = [s for side in ("paper", "live") for _, s in ((o.get(side) or {}).get("states") or [])]
blocks = [b for side in ("paper", "live") for b, _ in ((o.get(side) or {}).get("top_blocks") or [])]
sc = t.get("stock_context") or {}
return {
"video": t.get("video_id"), "date": str(t.get("session_date") or o.get("date") or ""),
"sym": t.get("symbol"), "resolve": how, "setup": t.get("setup"), "outcome": t.get("outcome"),
"pnl": _f(t.get("pnl")), "entry": e, "entry_t": et, "entry_bucket": _bucket(et),
"gap_at_entry": (e / prev - 1) if (e and prev) else None,
"catalyst": sc.get("catalyst"), "discovered": disc,
"discovered_before_entry": bool(disc and et and disc <= et),
"ours_traded": any(((o.get(s) or {}).get("orders")) for s in ("paper", "live")),
"out_of_population": next((s for s in states if "out_of_population" in s), None),
"top_block": blocks[0] if blocks else None,
}
def compute(videos: list[dict], trades: list[dict], passed: list[dict]) -> dict:
"""All report numbers as a JSON-able dict (the diff compares two of these)."""
T = [trade_facts(t) for t in trades]
V = [r for r in T if r["resolve"] in GOOD]
m: dict = {
"videos": len(videos), "trades": len(T), "passed_on": len(passed),
"video_ids": sorted(v["video_id"] for v in videos),
"video_kinds": dict(Counter(v.get("video_kind") for v in videos)),
"extractors": dict(Counter(v.get("extractor") for v in videos)),
"resolution": dict(Counter(r["resolve"] for r in T).most_common()),
"verified": len(V),
"entry_buckets": {b: sum(r["entry_bucket"] == b for r in V) for b in BUCKETS + [None]},
}
m["entry_buckets"]["unlocated"] = m["entry_buckets"].pop(None)
wl: dict = defaultdict(Counter)
pn: dict = defaultdict(list)
for r in V:
if r["entry_bucket"]:
wl[r["entry_bucket"]][r["outcome"] or "unstated"] += 1
if r["pnl"] is not None:
pn[r["entry_bucket"]].append(r["pnl"])
m["outcome_by_bucket"] = {b: dict(wl[b]) for b in BUCKETS if b in wl}
m["pnl_by_bucket"] = {b: {"count": len(pn[b]), "sum": round(sum(pn[b])), "median": round(statistics.median(pn[b]))}
for b in BUCKETS if pn.get(b)}
m["setups"] = dict(Counter(r["setup"] for r in V).most_common())
m["setups_all"] = dict(Counter(r["setup"] for r in T).most_common())
m["coverage"] = {"verified": len(V), "discovered": sum(r["discovered"] is not None for r in V),
"discovered_before_entry": sum(r["discovered_before_entry"] for r in V),
"we_ordered": sum(r["ours_traded"] for r in V),
"out_of_population": sum(r["out_of_population"] is not None for r in V)}
prices = [r["entry"] for r in V if r["entry"] is not None]
m["price_at_entry"] = _describe(prices)
m["gap_at_entry"] = _describe([r["gap_at_entry"] for r in V if r["gap_at_entry"] is not None])
m["entries_under_2"] = sum(p < 2 for p in prices)
m["entries_over_20"] = sum(p > 20 for p in prices)
m["oop_reasons"] = Counter(re.sub(r"[\d.]+", "#", r["out_of_population"].split("out_of_population", 1)[1])[:80]
for r in V if r["out_of_population"]).most_common(8)
m["top_blocks"] = Counter(r["top_block"] for r in V if r["top_block"]).most_common(6)
m["catalysts"] = Counter((r["catalyst"] if isinstance(r["catalyst"], str) else "none/unstated").lower()[:30]
for r in V).most_common(10)
pr = []
for p in passed:
tp = ((p.get("our_view") or {}).get("tape") or {})
if p.get("symbol") and tp.get("open"):
pr.append((tp.get("open_to_hod_pct"), (tp["close"] / tp["open"] - 1) if tp.get("close") else None))
oh = [a for a, _ in pr if a is not None]
oc = [b for _, b in pr if b is not None]
m["passed_tape"] = {"with_tape": len(pr), "median_open_to_hod": round(statistics.median(oh), 4) if oh else None,
"median_open_to_close": round(statistics.median(oc), 4) if oc else None}
temps = {}
for v in videos:
if v.get("session_date") and v.get("market_temperature"):
temps[str(v["session_date"])] = v["market_temperature"]
m["temperature_by_day"] = dict(sorted(temps.items()))
m["temperature"] = dict(Counter(temps.values()))
m["trade_keys"] = sorted(f"{r['video']}|{r['sym'] or '?'}|{r['setup']}|{r['entry']}" for r in T)
return m
def diff_metrics(prev: dict | None, cur: dict) -> list[str]:
"""Human lines describing what changed between two compute() results."""
if not prev:
return ["First report -- no previous run to compare with."]
out = []
new_v = sorted(set(cur.get("video_ids", [])) - set(prev.get("video_ids", [])))
gone_v = sorted(set(prev.get("video_ids", [])) - set(cur.get("video_ids", [])))
if new_v:
out.append(f"New videos ({len(new_v)}): " + ", ".join(new_v))
if gone_v:
out.append(f"Videos no longer in the set ({len(gone_v)}): " + ", ".join(gone_v))
for k in ("videos", "trades", "passed_on", "verified"):
if prev.get(k) != cur.get(k):
out.append(f"{k}: {prev.get(k)} -> {cur.get(k)} ({cur.get(k, 0) - (prev.get(k) or 0):+d})")
for group in ("coverage", "entry_buckets", "temperature", "setups", "resolution"):
a, b = prev.get(group) or {}, cur.get(group) or {}
for k in sorted(set(a) | set(b), key=str):
if a.get(k, 0) != b.get(k, 0):
out.append(f"{group}.{k}: {a.get(k, 0)} -> {b.get(k, 0)}")
pa, pb = (prev.get("price_at_entry") or {}).get("median"), (cur.get("price_at_entry") or {}).get("median")
if pa != pb:
out.append(f"median entry price: {pa} -> {pb}")
new_t = sorted(set(cur.get("trade_keys", [])) - set(prev.get("trade_keys", [])))
if new_t:
out.append(f"New trades ({len(new_t)}): " + "; ".join(k.split("|", 1)[1] for k in new_t[:20]))
return out or ["No change since the previous run."]
def _table(rows: list[list], head: list[str]) -> str:
out = ["| " + " | ".join(head) + " |", "|" + "---|" * len(head)]
out += ["| " + " | ".join("" if c is None else str(c) for c in r) + " |" for r in rows]
return "\n".join(out)
def render(m: dict, changes: list[str], *, today: str, new_trades: list[dict] | None = None,
eval_text: str | None = None) -> str:
pct = lambda x: "-" if x is None else f"{x:.1%}" # noqa: E731
cov = m["coverage"]
L = [f"# Ross recap vs our bot -- {today}", "",
f"Videos {m['videos']} ({', '.join(f'{k} {v}' for k, v in m['video_kinds'].items())}), "
f"trades {m['trades']}, passed-on {m['passed_on']}. Extractors: "
+ ", ".join(f"{k} {v}" for k, v in m["extractors"].items()) + ".", "",
"## What changed since last run", ""] + [f"- {c}" for c in changes] + [""]
if new_trades:
L += ["## New trades this run", "", _table(
[[t.get("session_date"), t.get("symbol") or f"?{t.get('ticker_guess')}", t.get("resolve"), t.get("setup"),
t.get("entry_price"), t.get("outcome"), t.get("entry_time_tape"), _disc(t.get("our_view")),
"yes" if any(((t.get("our_view") or {}).get(s) or {}).get("orders") for s in ("paper", "live")) else ""]
for t in new_trades],
["date", "symbol", "resolve", "setup", "entry", "outcome", "entry@tape", "we discovered", "we ordered"]), ""]
L += ["## Ticker resolution (trades)", "",
", ".join(f"{k} {v}" for k, v in m["resolution"].items()) or "-", "",
f"Price-verified trades: {m['verified']} of {m['trades']}", "",
"## When he entered (verified, entry located on tape)", "",
_table([[b, m["entry_buckets"].get(b, 0)] for b in BUCKETS + ["unlocated"]], ["window", "trades"]), "",
"## Win/loss by entry window (self-reported)", ""]
outs = sorted({o for d in m["outcome_by_bucket"].values() for o in d})
L += [_table([[b] + [m["outcome_by_bucket"][b].get(o, 0) for o in outs] for b in m["outcome_by_bucket"]],
["window"] + outs) if outs else "-", "",
"## Stated $ P&L by entry window", "",
_table([[b, v["count"], v["sum"], v["median"]] for b, v in m["pnl_by_bucket"].items()],
["window", "count", "sum", "median"]) if m["pnl_by_bucket"] else "-", "",
"## Setups (verified trades)", "", ", ".join(f"{k} {v}" for k, v in m["setups"].items()) or "-", "",
"## Our coverage of his verified trades", "",
f"- discovered by our scanner at all: {cov['discovered']} / {cov['verified']}",
f"- discovered BEFORE his entry: {cov['discovered_before_entry']}",
f"- we placed an order that day: {cov['we_ordered']}",
f"- rejected as out_of_population: {cov['out_of_population']}", "",
"## Price and gap at his entry", "",
f"- price: {m['price_at_entry']}",
f"- gap vs prior close: {m['gap_at_entry']}",
f"- entries under $2: {m['entries_under_2']}, over $20: {m['entries_over_20']}", "",
"## out_of_population reasons", ""] + [f"- {k} ({v})" for k, v in m["oop_reasons"]] + [
"", "## Our top block reasons on his names", ""] + [f"- {k} ({v})" for k, v in m["top_blocks"]] + [
"", "## Catalysts he cited", "", ", ".join(f"{k} {v}" for k, v in m["catalysts"]) or "-", "",
"## Passed-on names: what happened after he passed", "",
f"{m['passed_tape']['with_tape']} with tape; median open->HOD {pct(m['passed_tape']['median_open_to_hod'])}, "
f"open->close {pct(m['passed_tape']['median_open_to_close'])}", "",
"## Market temperature", "", ", ".join(f"{k} {v}" for k, v in m["temperature"].items()) or "-", "",
", ".join(f"{d} {t}" for d, t in list(m["temperature_by_day"].items())[-15:]), ""]
if eval_text:
L += ["## Extraction quality (local model vs gold set)", "", "```", eval_text, "```", ""]
return "\n".join(L)
def previous_metrics(reports_dir: Path, today: str) -> dict | None:
prior = sorted(p for p in reports_dir.glob("*.json") if p.stem < today)
return json.loads(prior[-1].read_text(encoding="utf-8")) if prior else None
def write_report(reports_dir: Path, *, new_video_ids: list[str] | None = None, eval_text: str | None = None,
today: str | None = None) -> Path:
from youtube_claims.recap import store
today = today or dt.date.today().isoformat()
videos, trades, passed = store.load_rows()
m = compute(videos, trades, passed)
changes = diff_metrics(previous_metrics(reports_dir, today), m)
new_trades = [t for t in trades if t["video_id"] in set(new_video_ids or [])]
md = render(m, changes, today=today, new_trades=new_trades, eval_text=eval_text)
out = reports_dir / f"{today}.md"
out.write_text(md, encoding="utf-8")
(reports_dir / f"{today}.json").write_text(json.dumps(m, default=str, indent=1), encoding="utf-8")
return out
+94
View File
@@ -0,0 +1,94 @@
-- Ross Cameron recap learning loop (youtube_claims.recap). Idempotent; {SCHEMA} is substituted
-- at apply time (default: transcripts). Every child table is keyed by video_id and rewritten
-- whole on re-ingest, so re-running a video never duplicates rows.
CREATE SCHEMA IF NOT EXISTS {SCHEMA};
-- one row per recap video the loop has processed (or tried to)
CREATE TABLE IF NOT EXISTS {SCHEMA}.recap_videos (
video_id TEXT PRIMARY KEY,
upload_date DATE,
session_date DATE,
title TEXT,
duration_s INT,
video_kind TEXT, -- daily_recap|watchlist|education|challenge_summary|other
transcript_source TEXT, -- whisper-small|whisper-medium|captions
extractor TEXT, -- 'gold' (strong-model pilot file) or the Ollama model name
status TEXT NOT NULL DEFAULT 'pending', -- pending|done|failed|skipped
last_error TEXT,
attempts INT NOT NULL DEFAULT 0, -- failed attempts (nightly retries up to a cap)
run_meta JSONB, -- chunks, seconds, quote-drop counts
record JSONB, -- the full extraction record (EXTRACT_SPEC)
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS recap_videos_session_idx ON {SCHEMA}.recap_videos(session_date);
-- the day as he described it (one per video; several videos can describe one session)
CREATE TABLE IF NOT EXISTS {SCHEMA}.recap_day_summary (
video_id TEXT PRIMARY KEY REFERENCES {SCHEMA}.recap_videos(video_id) ON DELETE CASCADE,
session_date DATE,
net_pnl NUMERIC,
green BOOLEAN,
market_temperature TEXT, -- hot|cold|mixed
market_quote TEXT,
max_loss_hit BOOLEAN,
daily_goal_comment TEXT,
sizing_comment TEXT
);
-- trades he described, joined to the tape and to what OUR bot saw
CREATE TABLE IF NOT EXISTS {SCHEMA}.recap_trades (
video_id TEXT NOT NULL REFERENCES {SCHEMA}.recap_videos(video_id) ON DELETE CASCADE,
seq INT NOT NULL,
session_date DATE,
ticker_heard TEXT,
ticker_guess TEXT,
symbol TEXT, -- after price verification (NULL if unresolved)
resolve TEXT, -- price_verified|price_matched_scanner|ambiguous:..|no_price_match|unverified
setup TEXT,
setup_quote TEXT,
entry_price NUMERIC,
exit_price NUMERIC,
stated_high NUMERIC,
stated_low NUMERIC,
shares NUMERIC,
pnl NUMERIC,
outcome TEXT,
add_on BOOLEAN,
entry_time_hint TEXT,
entry_time_tape TEXT, -- HH:MM ET the tape first traded his entry price
stock_context JSONB,
why_entered JSONB,
tape_reads JSONB,
why_exited JSONB,
mistake_or_lesson JSONB,
our_view JSONB, -- discovery/gates/orders on paper+live, tape stats, float, news
PRIMARY KEY (video_id, seq)
);
CREATE INDEX IF NOT EXISTS recap_trades_symbol_idx ON {SCHEMA}.recap_trades(symbol, session_date);
-- stocks he looked at and did NOT trade
CREATE TABLE IF NOT EXISTS {SCHEMA}.recap_passed_on (
video_id TEXT NOT NULL REFERENCES {SCHEMA}.recap_videos(video_id) ON DELETE CASCADE,
seq INT NOT NULL,
session_date DATE,
ticker_heard TEXT,
ticker_guess TEXT,
symbol TEXT,
resolve TEXT,
reason TEXT,
quote TEXT,
our_view JSONB,
PRIMARY KEY (video_id, seq)
);
-- general principles he stated, verbatim-quoted
CREATE TABLE IF NOT EXISTS {SCHEMA}.recap_rules (
video_id TEXT NOT NULL REFERENCES {SCHEMA}.recap_videos(video_id) ON DELETE CASCADE,
seq INT NOT NULL,
rule TEXT,
quote TEXT NOT NULL,
PRIMARY KEY (video_id, seq)
);
+75
View File
@@ -0,0 +1,75 @@
"""Recap-loop settings. Env-driven, deliberately decoupled from the trading bot's OLLAMA_*
vars (those point the bot's news enrichment elsewhere and are rewritten by config_overrides).
Secrets (DATABASE_URL, Alpaca keys) come only from the repo .env."""
from __future__ import annotations
import os
from pathlib import Path
# RECAP_ENV_FILE lets a worktree/other checkout borrow the main checkout's .env.
if os.getenv("RECAP_ENV_FILE"):
try:
from dotenv import load_dotenv
load_dotenv(os.environ["RECAP_ENV_FILE"])
except Exception: # noqa: BLE001
pass
from youtube_claims import config as _yc # noqa: E402 -- loads the repo .env (DATABASE_URL, Alpaca)
ROOT = Path(__file__).resolve().parents[2]
# Data lives in the git-ignored data dir of the checkout that runs the loop.
DATA_DIR = Path(os.getenv("RECAP_DATA_DIR") or ROOT / "data" / "ross")
CHANNEL_URL = os.getenv("RECAP_CHANNEL_URL", "https://www.youtube.com/@DaytradeWarrior/videos")
CHANNEL_NAME = "Ross Cameron - Warrior Trading"
# Only videos uploaded on/after this date are processed (the pilot's window start).
MIN_UPLOAD_DATE = os.getenv("RECAP_MIN_UPLOAD_DATE", "20260601")
LIST_LIMIT = int(os.getenv("RECAP_LIST_LIMIT", "40")) # newest N uploads checked per run
MAX_NEW_PER_RUN = int(os.getenv("RECAP_MAX_NEW_PER_RUN", "8"))
# Extraction LLM: the owner's LOCAL Ollama on this workstation (gpt-oss:20b on its GPU). It was
# the more precise of the two available models on the 36-video gold set (fewer invented prices);
# the server's 4B tools:latest had slightly higher recall -- see evaluate.py / the commit message.
OLLAMA_HOST = os.getenv("RECAP_OLLAMA_HOST", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.getenv("RECAP_OLLAMA_MODEL", "gpt-oss:20b")
# All endpoints to spread/fail over across (comma list; "host=model#ctx=N#think=V" pins per host).
# Defaults to the single RECAP_OLLAMA_HOST. See youtube_claims/recap/llm.py.
OLLAMA_HOSTS = os.getenv("RECAP_OLLAMA_HOSTS") or OLLAMA_HOST
OLLAMA_NUM_PREDICT = int(os.getenv("RECAP_OLLAMA_NUM_PREDICT", "12000"))
OLLAMA_NUM_CTX = int(os.getenv("RECAP_OLLAMA_NUM_CTX", "24576")) or None # 0 -> model default
# gpt-oss reasoning effort ("low"; "medium" ran past the token budget and took ~10 min/video).
# "false" for non-reasoning models.
OLLAMA_THINK = {"false": False, "true": True, "": None}.get(os.getenv("RECAP_OLLAMA_THINK", "low").lower(),
os.getenv("RECAP_OLLAMA_THINK", "low").lower())
OLLAMA_TIMEOUT = int(os.getenv("RECAP_OLLAMA_TIMEOUT", "1800"))
# Two focused calls per chunk (trades+day, then passed-on+rules) instead of one.
SPLIT_CALLS = os.getenv("RECAP_SPLIT_CALLS", "1").lower() in ("1", "true", "yes")
# Transcripts longer than this (chars) are split into overlapping chunks and merged.
CHUNK_CHARS = int(os.getenv("RECAP_CHUNK_CHARS", "24000"))
# Whisper (CPU int8; the pilot's settings -- ~7 min per 15-min video with 6 threads under load).
WHISPER_MODEL = os.getenv("RECAP_WHISPER_MODEL", "small.en")
WHISPER_THREADS = int(os.getenv("RECAP_WHISPER_THREADS", "8")) # more threads was slower here
WHISPER_PROMPT = ("Ross Cameron day trading recap. Tickers, prices, float, relative volume, level 2, "
"micro pullback, bull flag, halt, VWAP, premarket high, short squeeze.")
PG_SCHEMA = _yc.PG_SCHEMA
def database_url() -> str:
return _yc.database_url()
def live_database_url() -> str:
"""The live (Tradier) stack's DB: same server/credentials, port 5434."""
return os.getenv("RECAP_LIVE_DATABASE_URL") or database_url().replace(":5433", ":5434")
def dirs() -> dict[str, Path]:
d = {k: DATA_DIR / k for k in ("transcripts", "audio", "bundles", "extracted", "llm",
"scanner", "reports", "cache")}
for p in d.values():
p.mkdir(parents=True, exist_ok=True)
return d
+139
View File
@@ -0,0 +1,139 @@
"""Postgres persistence for the recap loop (transcripts schema on the paper/research DB).
Idempotent: DDL is IF NOT EXISTS; ingest upserts the video row and rewrites its child rows in
one transaction, keyed by video_id."""
from __future__ import annotations
import json
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
import psycopg2
import psycopg2.extras
from youtube_claims.recap import settings
S = settings.PG_SCHEMA
@contextmanager
def connect():
conn = psycopg2.connect(settings.database_url(), connect_timeout=10)
try:
yield conn
finally:
conn.close()
def apply_schema() -> None:
ddl = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8").replace("{SCHEMA}", S)
with connect() as conn:
with conn.cursor() as cur:
cur.execute(ddl)
conn.commit()
def video_status() -> dict[str, dict]:
with connect() as conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(f"SELECT video_id, status, extractor, upload_date, attempts FROM {S}.recap_videos")
return {r["video_id"]: dict(r) for r in cur.fetchall()}
def _date(s):
if not s:
return None
s = str(s)
return f"{s[:4]}-{s[4:6]}-{s[6:]}" if len(s) == 8 and s.isdigit() else s
def _j(x):
return json.dumps(x, default=str) if x is not None else None
def mark(video_id: str, status: str, *, error: str | None = None, meta: dict | None = None) -> None:
"""Record a video's status without touching its extraction (used for failures/skips)."""
meta = meta or {}
with connect() as conn, conn.cursor() as cur:
cur.execute(
f"INSERT INTO {S}.recap_videos (video_id, upload_date, title, duration_s, status, last_error, attempts) "
f"VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (video_id) DO UPDATE SET status=EXCLUDED.status, "
f"last_error=EXCLUDED.last_error, "
f"attempts={S}.recap_videos.attempts + (CASE WHEN EXCLUDED.status='failed' THEN 1 ELSE 0 END), title=COALESCE(EXCLUDED.title, {S}.recap_videos.title), "
f"upload_date=COALESCE(EXCLUDED.upload_date, {S}.recap_videos.upload_date), updated_at=now()",
(video_id, _date(meta.get("upload_date")), meta.get("title"),
int(meta["duration"]) if str(meta.get("duration") or "").isdigit() else None, status,
(error or "")[:2000] or None, 1 if status == "failed" else 0))
conn.commit()
def upsert_video(rec: dict, meta: dict, *, extractor: str, transcript_source: str | None,
run_meta: dict | None, trades: list[dict], passed: list[dict]) -> None:
"""rec: the extraction record; trades/passed: rec items enriched with symbol/resolve/our_view
(and entry_time_tape for trades)."""
vid = rec["video_id"]
sd = _date(rec.get("session_date")) or _date(rec.get("upload_date"))
day = rec.get("day") or {}
with connect() as conn:
with conn.cursor() as cur:
cur.execute(
f"""INSERT INTO {S}.recap_videos (video_id, upload_date, session_date, title, duration_s,
video_kind, transcript_source, extractor, status, last_error, run_meta, record, processed_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'done',NULL,%s,%s,%s)
ON CONFLICT (video_id) DO UPDATE SET upload_date=EXCLUDED.upload_date,
session_date=EXCLUDED.session_date, title=EXCLUDED.title, duration_s=EXCLUDED.duration_s,
video_kind=EXCLUDED.video_kind, transcript_source=EXCLUDED.transcript_source,
extractor=EXCLUDED.extractor, status='done', last_error=NULL, attempts=0, run_meta=EXCLUDED.run_meta,
record=EXCLUDED.record, processed_at=EXCLUDED.processed_at, updated_at=now()""",
(vid, _date(rec.get("upload_date")), sd, meta.get("title"),
int(meta["duration"]) if str(meta.get("duration") or "").isdigit() else None,
rec.get("video_kind"), transcript_source, extractor, _j(run_meta), _j(rec),
datetime.now(timezone.utc)))
for t in ("recap_day_summary", "recap_trades", "recap_passed_on", "recap_rules"):
cur.execute(f"DELETE FROM {S}.{t} WHERE video_id=%s", (vid,))
cur.execute(
f"""INSERT INTO {S}.recap_day_summary (video_id, session_date, net_pnl, green, market_temperature,
market_quote, max_loss_hit, daily_goal_comment, sizing_comment)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(vid, sd, day.get("net_pnl"), day.get("green"), day.get("market_temperature"),
day.get("market_quote"), day.get("max_loss_hit"), day.get("daily_goal_comment"),
day.get("sizing_comment")))
for i, t in enumerate(trades):
cur.execute(
f"""INSERT INTO {S}.recap_trades (video_id, seq, session_date, ticker_heard, ticker_guess, symbol,
resolve, setup, setup_quote, entry_price, exit_price, stated_high, stated_low, shares, pnl,
outcome, add_on, entry_time_hint, entry_time_tape, stock_context, why_entered, tape_reads,
why_exited, mistake_or_lesson, our_view)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(vid, i, sd, t.get("ticker_heard"), t.get("ticker_guess"), t.get("symbol"), t.get("resolve"),
t.get("setup"), t.get("setup_quote"), t.get("entry_price"), t.get("exit_price"),
t.get("stated_high"), t.get("stated_low"), t.get("shares"), t.get("pnl"), t.get("outcome"),
bool(t.get("add_on")), t.get("entry_time_hint"), t.get("entry_time_tape"),
_j(t.get("stock_context")), _j(t.get("why_entered")), _j(t.get("tape_reads")),
_j(t.get("why_exited")), _j(t.get("mistake_or_lesson")), _j(t.get("our_view"))))
for i, p in enumerate(passed):
cur.execute(
f"""INSERT INTO {S}.recap_passed_on (video_id, seq, session_date, ticker_heard, ticker_guess,
symbol, resolve, reason, quote, our_view) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(vid, i, sd, p.get("ticker_heard"), p.get("ticker_guess"), p.get("symbol"), p.get("resolve"),
p.get("reason"), p.get("quote"), _j(p.get("our_view"))))
for i, r in enumerate(rec.get("rules_stated") or []):
if r.get("quote"):
cur.execute(f"INSERT INTO {S}.recap_rules (video_id, seq, rule, quote) VALUES (%s,%s,%s,%s)",
(vid, i, r.get("rule"), r["quote"]))
conn.commit()
def load_rows() -> tuple[list[dict], list[dict], list[dict]]:
"""(videos, trades, passed) for the report -- every processed video."""
with connect() as conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(f"""SELECT v.video_id, v.upload_date, v.session_date, v.title, v.video_kind, v.extractor,
d.net_pnl, d.green, d.market_temperature, d.market_quote
FROM {S}.recap_videos v LEFT JOIN {S}.recap_day_summary d USING (video_id)
WHERE v.status='done' ORDER BY v.upload_date, v.video_id""")
videos = [dict(r) for r in cur.fetchall()]
cur.execute(f"SELECT * FROM {S}.recap_trades ORDER BY session_date, video_id, seq")
trades = [dict(r) for r in cur.fetchall()]
cur.execute(f"SELECT * FROM {S}.recap_passed_on ORDER BY session_date, video_id, seq")
passed = [dict(r) for r in cur.fetchall()]
return videos, trades, passed
+209
View File
@@ -0,0 +1,209 @@
"""Ticker verification against the tape + tape stats (ported from the pilot's join.py/ourside.py).
A transcript ticker is only trusted when that symbol's SIP minute tape that day can produce the
story he told: his entry price traded, then (if he named one) a high near his stated high, then
(if named) a low near his stated low. Otherwise we search the day's scanner names for the one
unique symbol whose tape fits. The scoring/resolution is pure -- bars come from an injected
`bars_for(symbol)` callable, so it is unit-tested with fake bars."""
from __future__ import annotations
import datetime as dt
import json
import re
from pathlib import Path
from typing import Callable, Sequence
# a bar is (ts_et naive datetime, open, high, low, close, volume)
Bar = tuple
_SYM = re.compile(r"[A-Z]{1,5}")
def path_score(bars: Sequence[Bar], entry: float | None, sh: float | None, sl: float | None) -> float | None:
"""None if the tape can't produce his story; else a closeness score (lower = better).
Story: entry trades (+-2% of the day's range), then a high within 2.5% of stated_high,
then a low within 3% of stated_low."""
if not bars:
return None
lo = min(b[3] for b in bars)
hi = max(b[2] for b in bars)
if entry is not None and not (lo * 0.98 <= entry <= hi * 1.02):
return None
if sh is None:
return 0.5 if entry is not None else None
t_entry = next((i for i, b in enumerate(bars)
if entry is None or (b[3] <= entry * 1.01 and b[2] >= entry * 0.99)), None)
if t_entry is None:
return None
best = None
for i in range(t_entry, len(bars)):
err = abs(bars[i][2] - sh) / sh
if err > 0.025:
continue
e2 = err
if sl is not None:
later = [abs(b[3] - sl) / sl for b in bars[i:]]
if not later or min(later) > 0.03:
continue
e2 += min(later)
best = e2 if best is None else min(best, e2)
return best
def loose_fit(bars: Sequence[Bar], entry: float | None, sh: float | None, sl: float | None) -> bool:
"""Order-free check: every level he named traded at SOME point that day (entry inside the
day's range, a bar high within 2.5% of stated_high, a bar low within 3% of stated_low).
Used only for the ticker he NAMED -- the name is a strong prior, and he often tells the
flush-then-squeeze out of order ("squeezed to 5 ... it had come from 2.35")."""
if not bars or (entry is None and sh is None):
return False
lo, hi = min(b[3] for b in bars), max(b[2] for b in bars)
if entry is not None and not (lo * 0.98 <= entry <= hi * 1.02):
return False
if sh is not None and not any(abs(b[2] - sh) / sh <= 0.025 for b in bars):
return False
if sl is not None and not any(abs(b[3] - sl) / sl <= 0.03 for b in bars):
return False
return True
def resolve(item: dict, bars_for: Callable[[str], Sequence[Bar]], scanner_syms: Sequence[str]) -> tuple[str | None, str]:
"""(symbol, how). how: price_verified (his name fits the tape in order) | price_verified_loose
(his name has all his levels, out of order) | price_matched_scanner (a unique scanner name
fits in order) | ambiguous:A,B | no_price_match | unverified (no price given)."""
heard = (item.get("ticker_heard") or "").upper().replace(" ", "")
guess = (item.get("ticker_guess") or "").upper().replace(" ", "")
entry, sh, sl = item.get("entry_price"), item.get("stated_high"), item.get("stated_low")
cands = [c for c in dict.fromkeys([guess, heard]) if c and _SYM.fullmatch(c)]
if entry is None and sh is None:
return (cands[0] if cands else None), "unverified"
for c in cands:
if path_score(bars_for(c), entry, sh, sl) is not None:
return c, "price_verified"
for c in cands:
if loose_fit(bars_for(c), entry, sh, sl):
return c, "price_verified_loose"
scored = []
for s in dict.fromkeys(scanner_syms):
if s in cands:
continue
sc = path_score(bars_for(s), entry, sh, sl)
if sc is not None:
scored.append((sc, s))
scored.sort()
if len(scored) == 1 or (len(scored) > 1 and scored[0][0] + 0.01 < scored[1][0]):
return scored[0][1], "price_matched_scanner"
if scored:
return None, "ambiguous:" + ",".join(s for _, s in scored[:4])
return (cands[0] if cands else None), "no_price_match"
def entry_time(bars: Sequence[Bar], entry: float | None, sh: float | None) -> dt.datetime | None:
"""First minute the tape traded his entry price, before his stated high (if any)."""
if entry is None or not bars:
return None
stop = len(bars)
if sh:
hi_i = next((i for i, b in enumerate(bars) if abs(b[2] - sh) / sh <= 0.025), None)
if hi_i is not None:
stop = hi_i + 1
for b in bars[:stop]:
if b[3] <= entry * 1.005 and b[2] >= entry * 0.995:
return b[0]
return None
def entry_bucket(t: dt.datetime | None) -> str | None:
if t is None:
return None
tt = t.time()
return ("pre-7:00" if tt < dt.time(7) else "7:00-9:30" if tt < dt.time(9, 30)
else "9:30-9:45" if tt < dt.time(9, 45) else "9:45+")
def tape_stats(bars: Sequence[Bar], prev_close: float | None) -> dict:
"""Premarket/RTH summary of one symbol-day (times are ET)."""
out: dict = {"prev_close": prev_close}
if not bars:
return out
pre = [b for b in bars if b[0].time() < dt.time(9, 30)]
rth = [b for b in bars if dt.time(9, 30) <= b[0].time() < dt.time(16, 0)]
if pre:
out.update(pm_high=max(b[2] for b in pre), pm_low=min(b[3] for b in pre),
pm_volume=sum(b[5] for b in pre))
if rth:
o = rth[0][1]
hod = max(rth, key=lambda b: b[2])
out.update(open=o, hod=hod[2], hod_time=hod[0].strftime("%H:%M"), lod=min(b[3] for b in rth),
close=rth[-1][4], rth_volume=sum(b[5] for b in rth),
gap_pct=(o / prev_close - 1) if prev_close else None,
open_to_hod_pct=hod[2] / o - 1 if o else None)
top = max(bars, key=lambda b: b[2])
out["day_high"] = top[2]
out["day_high_time"] = top[0].strftime("%H:%M")
first = next((b for b in bars if prev_close and b[2] >= prev_close * 1.3), None)
out["first_30pct_time"] = first[0].strftime("%H:%M") if first else None
return out
class BarSource:
"""Complete SIP minute tape (04:00-20:00 ET) from Alpaca, cached on disk as JSON per
symbol-day (bars of a finished day never change). Falls back to our own minute_bars
when Alpaca has nothing -- ours miss the pre-discovery minutes of late-found names."""
def __init__(self, cache_dir: Path, paper_con=None):
self.cache_dir = Path(cache_dir)
self.paper_con = paper_con
self._mem: dict = {}
self._client = None
def _alpaca(self):
if self._client is None:
from alpaca_paper.client import AlpacaPaperClient
from alpaca_paper.settings import AlpacaPaperSettings
self._client = AlpacaPaperClient(AlpacaPaperSettings.from_env())
return self._client
def bars(self, sym: str, d: str) -> list[Bar]:
key = (sym, str(d))
if key in self._mem:
return self._mem[key]
f = self.cache_dir / str(d) / f"{sym}.json"
if f.exists():
rows = json.loads(f.read_text())
out = [(dt.datetime.fromisoformat(r[0]), *r[1:]) for r in rows]
self._mem[key] = out
return out
out = self._fetch(sym, str(d))
if out or dt.date.fromisoformat(str(d)) < dt.date.today():
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(json.dumps([(b[0].isoformat(), *b[1:]) for b in out]))
self._mem[key] = out
return out
def _fetch(self, sym: str, d: str) -> list[Bar]:
from zoneinfo import ZoneInfo
et = ZoneInfo("America/New_York")
day = dt.date.fromisoformat(d)
start = dt.datetime.combine(day, dt.time(4), et)
end = dt.datetime.combine(day, dt.time(20), et)
raw = []
try:
raw = self._alpaca().get_minute_bars(
[sym], start_iso=start.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
end_iso=end.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), feed="sip").get(sym) or []
except Exception: # noqa: BLE001 -- unknown/delisted symbol, network: fall back below
raw = []
out = []
for b in raw:
ts = dt.datetime.fromisoformat(b["t"].replace("Z", "+00:00")).astimezone(et).replace(tzinfo=None)
out.append((ts, float(b["o"]), float(b["h"]), float(b["l"]), float(b["c"]), float(b["v"])))
if out or self.paper_con is None:
return out
with self.paper_con.cursor() as cur: # our own bars (UTC-naive) as a fallback
cur.execute("select timestamp, open, high, low, close, volume from minute_bars "
"where symbol=%s and session_date=%s order by timestamp", (sym, d))
rows = cur.fetchall()
utc = dt.timezone.utc
return [(r[0].replace(tzinfo=utc).astimezone(et).replace(tzinfo=None), float(r[1]), float(r[2]),
float(r[3]), float(r[4]), float(r[5] or 0)) for r in rows]
+15 -4
View File
@@ -40,10 +40,15 @@ def _transcribe_whisper(wav: str, watchlist: list[str]) -> list[dict]:
def _captions_fallback(video_id: str) -> list[dict]:
"""YouTube captions via youtube-transcript-api. The API shape is chosen by feature
detection, NOT by catching an exception: the old code retried the removed pre-1.0
`get_transcript` after ANY fetch failure, so the real cause (IpBlocked / RequestBlocked
when YouTube blocks the server IP) was replaced by a misleading AttributeError. Errors now
propagate with their real type and message."""
from youtube_transcript_api import YouTubeTranscriptApi
try: # newer + older API shapes
if hasattr(YouTubeTranscriptApi, "fetch"): # >= 1.0: instance API
rows = YouTubeTranscriptApi().fetch(video_id).to_raw_data()
except Exception: # noqa: BLE001
else: # < 1.0: classmethod API
rows = YouTubeTranscriptApi.get_transcript(video_id)
return [{"start": round(x["start"], 2), "end": round(x["start"] + x.get("duration", 0), 2),
"text": x["text"]} for x in rows]
@@ -55,6 +60,7 @@ def transcribe(video_id: str, watchlist: list[str] | None = None) -> tuple[list[
watchlist = watchlist if watchlist is not None else config.watchlist()
os.makedirs(config.TRANSCRIPT_DIR, exist_ok=True)
segs, source, model = None, None, None
whisper_err = None
try:
with tempfile.TemporaryDirectory() as td:
wav = _download_audio(video_id, td)
@@ -62,9 +68,14 @@ def transcribe(video_id: str, watchlist: list[str] | None = None) -> tuple[list[
segs = _transcribe_whisper(wav, watchlist)
source, model = "whisper", config.WHISPER_MODEL
except Exception as exc: # noqa: BLE001 — fall through to captions
print(f"[transcribe] whisper path failed for {video_id}: {exc}")
whisper_err = f"{type(exc).__name__}: {exc}"
print(f"[transcribe] whisper path failed for {video_id}: {whisper_err}")
if not segs:
segs = _captions_fallback(video_id) # may raise -> worker retry/backoff
try:
segs = _captions_fallback(video_id)
except Exception as exc: # noqa: BLE001 — report BOTH causes (worker retry/backoff)
raise RuntimeError(f"whisper path: {whisper_err or 'no audio file produced'}; "
f"captions: {type(exc).__name__}: {str(exc)[:500]}") from exc
source = "captions"
path = os.path.join(config.TRANSCRIPT_DIR, f"{video_id}.json")
with open(path, "w") as f: