Compare commits
43
Commits
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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."""
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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()
|
||||
@@ -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%
|
||||
@@ -0,0 +1 @@
|
||||
"""Real-time press-release / SEC filing recorder (premarket news-timing research)."""
|
||||
@@ -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()
|
||||
@@ -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%
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
))
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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%
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user