Files
momentum devandClaude Fable 5 82273d9d4e feat: image version identity + DB-config parity for nightly scripts
Version: CI now tags images YYYY.MM.DD-<shortsha> alongside :latest/:sha
and passes GIT_SHA/BUILD_TAG build-args; the running app reports them via
dashboard /api/health and admin /api/status (TrueNAS custom apps only show
'custom/1.0.0', so the app self-reports what it actually runs).

Config parity: every scheduler-run script (nightly_tune, eod_replay,
premarket_eval, regime, market_context, experiments cli) now loads
config_overrides at startup like the live services do — a bare env would
replay under DEFAULT exits (the documented 4.5x-baseline landmine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:28:06 -05:00

195 lines
10 KiB
Python

"""Accumulate-then-re-validate the premarket (pm1) shadow track.
The pre-registered verdict on 2026-07-01 was PROMISING-BUT-UNDERPOWERED: the forward moves are
REAL (avg +11-14% upside, unlike the RTH levers' tight-stop artifacts) but n=21 is far too small
for the +1R Wilson bound to conclude. The live loop ingests premarket bars every trading day, so
this job rebuilds pm1 from the (growing) DB and appends a timestamped metrics row to a history
file. Run it on a schedule (systemd timer) or by hand; watch `n` climb and the Wilson LB tighten
until the locked bar is decisively cleared or failed.
python -m research.premarket_eval
"""
import os
from datetime import datetime, timezone
from dotenv import load_dotenv
from research import labeler
from research.multi_schema import open_research_db
# config parity with the live loop: DB-backed knobs (config_overrides) beat
# compose env — a bare env would replay under DEFAULT exits (the 4.5x-baseline
# landmine, orderflow-shelf-verdicts 2026-07-17)
from runtime.env_overrides import load as _load_cfg_overrides # noqa: E402
_load_cfg_overrides()
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
HIST = os.path.join(os.path.dirname(__file__), "..", "data", "premarket_eval_history.tsv")
_HEADER = "utc_ts\tn\tdays\trate_1r\twilson_lb\tmedian_up_60m\tmedian_dd_60m\tbig_gap_1r\tpassed\n"
def _refresh_shadow_enrichment(con, session_date) -> None:
"""Shadow enrichment: float cache (FMP) + SEC dilution filings for the
session's actively-traded names. SHADOW ONLY — neither gates entries
(float refuted as a gate 2026-06-29; filings veto needs a pre-registered
bar first, plan 016). Two INDEPENDENT try-blocks: float failing must
never skip dilution, and vice versa (previously they shared one
try-block, so an UndefinedTable on float_cache silently skipped the
dilution refresh too)."""
syms = [r[0] for r in con.execute(
"SELECT symbol FROM minute_bars WHERE session_date = ? "
"GROUP BY symbol ORDER BY sum(volume) DESC LIMIT 30", [session_date]).fetchall()]
if not syms:
return
try:
from research.ingestion.float_cache import refresh_float_cache
print("float cache:", refresh_float_cache(con, syms))
except Exception as exc: # noqa: BLE001 — enrichment never breaks the eval
print(f"float cache enrichment skipped: {exc}")
try:
from research.ingestion.sec_dilution import refresh_dilution_events
print("dilution filings:", refresh_dilution_events(con, syms))
except Exception as exc: # noqa: BLE001 — enrichment never breaks the eval
print(f"dilution enrichment skipped: {exc}")
def _metrics(con) -> dict:
rows = con.execute(
"SELECT l.max_upside_next_60m, l.max_drawdown_next_60m, "
"CASE WHEN l.reached_1r_before_minus_1r THEN 1 ELSE 0 END, se.gap_pct "
"FROM outcome_labels l JOIN setup_events se ON se.setup_id = l.setup_id "
"WHERE se.setup_version=?", [labeler.PREMARKET_SETUP_VERSION]).fetchall()
n = len(rows)
days = con.execute("SELECT count(DISTINCT session_date) FROM setup_events WHERE setup_version=?",
[labeler.PREMARKET_SETUP_VERSION]).fetchone()[0]
if not n:
return {"n": 0, "days": days, "rate_1r": 0.0, "wilson_lb": 0.0,
"median_up_60m": 0.0, "median_dd_60m": 0.0, "big_gap_1r": 0.0, "passed": False}
r1 = [r[2] for r in rows]
big = [r[2] for r in rows if (r[3] or 0) >= 0.5]
med_up = labeler._median([r[0] for r in rows]) or 0.0
med_dd = labeler._median([r[1] for r in rows]) or 0.0
rate_1r = sum(r1) / n
rate_big = (sum(big) / len(big)) if big else 0.0
passed = (med_up >= labeler.PREMARKET_PROMO_MEDIAN_FWDMAX
and med_dd > labeler.PREMARKET_PROMO_MEDIAN_ADVERSE
and rate_1r > labeler.PREMARKET_PROMO_MIN_1R
and rate_big > labeler.PREMARKET_PROMO_MIN_1R)
return {"n": n, "days": days, "rate_1r": rate_1r,
"wilson_lb": labeler._wilson_lower(sum(r1), n),
"median_up_60m": med_up, "median_dd_60m": med_dd,
"big_gap_1r": rate_big, "passed": passed}
def main() -> None:
con = open_research_db("market")
# upgrade today's bars to the SIP consolidated tape first (live ingest is IEX-only,
# ~2-5% of true volume) so the pm1 rebuild + every downstream signal sees real volume
try:
from alpaca_paper.client import AlpacaPaperClient
from alpaca_paper.settings import AlpacaPaperSettings
from datetime import date as _date
from research.sip_backfill import refetch_minute_session
_client = AlpacaPaperClient(AlpacaPaperSettings.from_env())
today = _date.today()
# estimator audit (pre): today's stored volumes are ratio-SCALED IEX estimates —
# snapshot them before the SIP truth overwrites, then measure the error.
est = dict(con.execute(
"SELECT symbol, sum(volume) FROM minute_bars WHERE session_date=? "
"AND source_provider='alpaca_iex_scaled' GROUP BY symbol", [today]).fetchall())
n, rows = refetch_minute_session(con, _client, today)
print(f"SIP refetch today: {n} symbols -> {rows} rows")
if est:
errs = []
for sym, ev in est.items():
tv = con.execute("SELECT sum(volume) FROM minute_bars WHERE symbol=? "
"AND session_date=?", [sym, today]).fetchone()[0]
if tv:
errs.append((sym, float(ev) / float(tv) - 1.0))
if errs:
errs.sort(key=lambda x: abs(x[1]))
med = errs[len(errs) // 2][1]
worst = max(errs, key=lambda x: abs(x[1]))
line = (f"{today}\t{len(errs)}\t{med:+.3f}\t{worst[0]}\t{worst[1]:+.3f}")
print(f"volume-estimator audit: n={len(errs)} median_err={med:+.1%} "
f"worst={worst[0]} {worst[1]:+.1%}")
with open(os.path.join(os.path.dirname(__file__), "..", "data",
"volume_estimator_audit.tsv"), "a") as f:
f.write(line + "\n")
except Exception as exc: # noqa: BLE001 — never let the refetch break the eval
print(f"SIP refetch skipped: {exc}")
# nightly DB housekeeping: observability-spam events (module ticks, per-symbol criteria
# evaluations, account snapshots) are only useful for a few days of debugging but were
# ~80% of the events table (3.5 GB before the 2026-07-04 purge). Trading-decision events
# are kept forever. Autovacuum (tuned to 2%) reclaims the space.
try:
spam = ("module_tick", "criteria_evaluated", "signal_blocked",
"account_summary_updated", "account_positions_updated", "account_orders_updated",
"entry_gate_evaluated")
ph = ",".join(["?"] * len(spam))
cur = con.execute(
f"DELETE FROM events WHERE event_type IN ({ph}) "
"AND timestamp < now() - interval '14 days'", list(spam))
print(f"events retention: purged {getattr(cur, 'rowcount', '?')} spam rows >14d")
except Exception as exc: # noqa: BLE001 — housekeeping must never break the eval
print(f"events retention skipped: {exc}")
# log rotation: systemd units append to data/*.log with no bound (live_run.log hit 12 MB
# in 3 weeks). Copy-then-truncate is safe with O_APPEND writers; keep one generation.
try:
import glob
import shutil
for lp in glob.glob(os.path.join(os.path.dirname(__file__), "..", "data", "*.log")):
if os.path.getsize(lp) > 20 * 1024 * 1024:
shutil.copy2(lp, lp + ".1")
with open(lp, "r+") as f:
f.truncate(0)
print(f"rotated {os.path.basename(lp)}")
except Exception as exc: # noqa: BLE001
print(f"log rotation skipped: {exc}")
# shadow enrichment: float cache (FMP) + SEC dilution filings for the day's
# actively-traded names. SHADOW ONLY — neither gates entries (float refuted
# as a gate 2026-06-29; filings veto needs a pre-registered bar first).
try:
from zoneinfo import ZoneInfo
session_date = datetime.now(ZoneInfo("America/New_York")).date()
_refresh_shadow_enrichment(con, session_date)
except Exception as exc: # noqa: BLE001 — enrichment never breaks the eval
print(f"float/dilution enrichment skipped: {exc}")
labeler.build_premarket(con, rebuild=True) # rebuild from the grown DB
m = _metrics(con)
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
row = (f"{ts}\t{m['n']}\t{m['days']}\t{m['rate_1r']:.3f}\t{m['wilson_lb']:.3f}\t"
f"{m['median_up_60m']:.4f}\t{m['median_dd_60m']:.4f}\t{m['big_gap_1r']:.3f}\t{m['passed']}")
new = not os.path.exists(HIST)
with open(HIST, "a") as f:
if new:
f.write(_HEADER)
f.write(row + "\n")
print("appended:", row)
labeler.validate_premarket(con)
# DUAL-WRITE (plan 006): also emit pm1's verdict through the experiment
# harness (the TSV append above stays until the owner retires it). Fully
# guarded — the harness must never break the nightly eval.
try:
from research.experiments import adjudicate, registry
from storage.event_store import EventStore
store = EventStore(os.environ.get("WATCHER_EVENT_DB_PATH", "momentum"))
try:
registry.apply_schema(store.con)
pm1_json = os.path.join(os.path.dirname(__file__), "..", "experiments", "pm1_v1.json")
if registry.get(store.con, "pm1_v1") is None and os.path.exists(pm1_json):
registry.register(store.con, pm1_json)
for v in adjudicate.adjudicate(store):
if v["experiment_id"] == "pm1_v1":
print(f"harness pm1_v1 verdict: {v['verdict']} (n={v['n']})")
finally:
store.close()
except Exception as exc: # noqa: BLE001 — harness bridge never breaks the eval
print(f"experiment harness bridge skipped: {exc}")
if __name__ == "__main__":
main()