dilution(016): unbreak nightly EDGAR refresh + pre-register filing-veto experiment

premarket_eval's shadow enrichment call site queried session_date = current_date
(wrong on UTC-server Postgres at night, so it never matched any symbols) and
shared one try-block for float_cache + dilution filings (a float_cache failure
silently skipped the dilution refresh too). Fix: compute the ET session date
in Python, split into two independent try-blocks, lift into a testable
_refresh_shadow_enrichment(con, session_date). float_cache now applies its own
DDL unconditionally on use instead of only when an API key is present.

Adds iv_dilution_gate (additive, mirrors iv_regime_gate's keep-or-zero shape
keyed on symbol instead of session_date, fail-open on unfetched symbols) plus
its once-per-run preload, and registers the pre-registered dilution_veto_v1
experiment via the existing experiments/*.json self-registering cohort. Live
veto stays OFF; this only wires the shadow measurement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-17 09:54:49 -05:00
1 parent 92488b72bc
commit f6999c7f6a
7 files changed
+378 -11

No files matched your search

@@ -0,0 +1,150 @@
"""iv_dilution_gate (plan 016): keep-or-zero a fire on whether the symbol has
an S-1/S-3/424B* filing in the trailing lookback window, plus the registered
dilution_veto_v1 experiment running end-to-end on an isolated store. Mirrors
test_regime_gate.py's keep-or-zero shape, keyed on (symbol, session_date)
instead of session_date alone."""
import os
from datetime import date, datetime, timedelta, timezone
from research.experiments import adjudicate, registry, replay
from research.experiments.replay import Fire, iv_dilution_gate
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
SESS = date(2026, 6, 11)
def _fire(symbol="AAA", session_date=SESS, baseline=None):
return Fire(fire_id=f"f-{symbol}", symbol=symbol, session_date=session_date, fill_ts=None,
entry_price=10.0, stop_price=9.0, exit_price=None, baseline_r=baseline,
trigger_price=10.0)
# --------------------------------------------------------------------------- #
# iv_dilution_gate (pure)
# --------------------------------------------------------------------------- #
def test_dilution_gate_keeps_and_zeros():
# a filing 10 days before the session, well inside a 90-day lookback -> zeroed
history = {"AAA": [SESS - timedelta(days=10)]}
v, d = iv_dilution_gate(_fire("AAA"), 0.5, history, {"lookback_days": 90})
assert v == 0.0
assert d["kind"] == "dilution_gate" and d["flagged"] is True and d["lookback_days"] == 90
# symbol absent from dilution_events entirely (no-data case) -> KEPT, fail-open
v, d = iv_dilution_gate(_fire("OTHER"), 0.5, history, {"lookback_days": 90})
assert v == 0.5 and d["flagged"] is False
# filing OLDER than the lookback window -> kept
history_old = {"AAA": [SESS - timedelta(days=120)]}
v, d = iv_dilution_gate(_fire("AAA"), 0.5, history_old, {"lookback_days": 90})
assert v == 0.5 and d["flagged"] is False
# --------------------------------------------------------------------------- #
# expR_delta sign — proves adjudication rewards removing LOSERS, not just
# removing trades (plan 011 discipline, copied for the dilution gate).
# --------------------------------------------------------------------------- #
def test_dilution_gate_expR_delta_sign():
symbols = ["AAA", "BBB", "CCC"]
# AAA/BBB flagged (10d-old filing), CCC clean
history = {"AAA": [SESS - timedelta(days=10)], "BBB": [SESS - timedelta(days=10)]}
# flagged names are net LOSERS -> removing them raises expR_delta (positive)
baselines = [-1.0, -0.5, 2.0]
runs = []
for sym, b in zip(symbols, baselines):
v, det = iv_dilution_gate(_fire(sym), b, history, {"lookback_days": 90})
runs.append({"baseline_r": b, "variant_r": v, "detail": det})
_, metrics, _ = adjudicate.evaluate(runs, {"expR_delta_min": 0.0, "min_n": 1}, None)
assert metrics["expR_delta"] > 0, metrics
# flagged names are net WINNERS -> expR_delta negative
baselines2 = [1.0, 0.5, -2.0]
runs2 = []
for sym, b in zip(symbols, baselines2):
v, det = iv_dilution_gate(_fire(sym), b, history, {"lookback_days": 90})
runs2.append({"baseline_r": b, "variant_r": v, "detail": det})
_, metrics2, _ = adjudicate.evaluate(runs2, {"expR_delta_min": 0.0, "min_n": 1}, None)
assert metrics2["expR_delta"] < 0, metrics2
# --------------------------------------------------------------------------- #
# registered experiment, end to end on an isolated store
# --------------------------------------------------------------------------- #
def _seed_daily(store, symbol, prev_close=8.0, sess_open=10.0, vol=1_000_000):
store.con.execute(
"INSERT INTO daily_bars (symbol, trade_date, open, close, volume) VALUES (?,?,?,?,?)",
[symbol, date(2026, 6, 10), prev_close - 0.5, prev_close, vol])
store.con.execute(
"INSERT INTO daily_bars (symbol, trade_date, open, close, volume) VALUES (?,?,?,?,?)",
[symbol, SESS, sess_open, sess_open, vol])
def _seed_minutes(store, symbol, rows, start_min=30):
for i, (o, h, low, c) in enumerate(rows):
ts = datetime(2026, 6, 11, 13, start_min + i)
store.con.execute(
"INSERT INTO minute_bars (symbol, timestamp, session_date, is_premarket, "
"is_regular_hours, open, high, low, close, volume, vwap, quality_score) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
[symbol, ts, SESS, False, True, o, h, low, c, 1000, c, 1.0])
def _seed_setup(store, symbol, setup_id):
store.con.execute(
"INSERT INTO setup_events (setup_id, symbol, session_date, setup_time, setup_name, "
"setup_version, entry_reference_price, invalidation_price, gap_pct, relative_volume) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
[setup_id, symbol, SESS, datetime(2026, 6, 11, 13, 35), "orb", "v1",
10.0, 9.0, 0.12, 3.0])
def test_dilution_experiment_registers_and_runs(store):
bars = [(10 + i * 0.1, 10.2 + i * 0.1, 9.99 + i * 0.1, 10.1 + i * 0.1) for i in range(10)]
for sym in ("AAA", "BBB"):
_seed_daily(store, sym)
_seed_minutes(store, sym, bars)
_seed_setup(store, sym, f"s-{sym}-1")
# AAA has a filing 10 days before the session -> flagged; BBB has none -> clean
store.con.execute(
"INSERT INTO dilution_events (id, symbol, event_type, announced_at) VALUES (?,?,?,?)",
["acc-1", "AAA", "S-1", datetime.combine(SESS - timedelta(days=10), datetime.min.time())])
exp_id = registry.register(store.con, os.path.join(REPO, "experiments", "dilution_veto_v1.json"))
assert exp_id == "dilution_veto_v1"
n = replay.run(store)
assert n >= 2
aaa_row = store.con.execute(
"SELECT baseline_r, variant_r, detail FROM experiment_runs "
"WHERE experiment_id='dilution_veto_v1' AND fire_id='setup:s-AAA-1'").fetchone()
assert aaa_row is not None
baseline_r, variant_r, detail = aaa_row
assert variant_r == 0.0
assert detail["kind"] == "dilution_gate" and detail["flagged"] is True
bbb_row = store.con.execute(
"SELECT baseline_r, variant_r, detail FROM experiment_runs "
"WHERE experiment_id='dilution_veto_v1' AND fire_id='setup:s-BBB-1'").fetchone()
assert bbb_row is not None
bbb_baseline_r, bbb_variant_r, bbb_detail = bbb_row
assert bbb_variant_r == bbb_baseline_r # unflagged -> kept
assert bbb_detail["flagged"] is False
out = adjudicate.adjudicate(store)
verdicts = {o["experiment_id"]: o["verdict"] for o in out}
assert "dilution_veto_v1" in verdicts
assert verdicts["dilution_veto_v1"] in {"PASS", "FAIL", "INCONCLUSIVE", "KILLED"}
def test_dilution_veto_v1_is_in_the_self_registering_cohort(store):
"""The declared cohort is every experiments/*.json (registry.register_cohort);
dropping the file there is the whole registration mechanism (no separate
id-list to edit)."""
path = os.path.join(REPO, "experiments", "dilution_veto_v1.json")
assert os.path.exists(path)
newly = registry.register_cohort(store.con)
assert "dilution_veto_v1" in newly
assert registry.get(store.con, "dilution_veto_v1") is not None
+51
View File
@@ -0,0 +1,51 @@
"""research/ingestion/float_cache.py (plan 016 step 2) — the module's own
``CREATE TABLE IF NOT EXISTS float_cache`` DDL must apply on use so a fresh
schema (like a first-boot production DB) never throws UndefinedTable, even
when there's no FMP_API_KEY or no symbols to fetch. Runs against an ISOLATED
throwaway Postgres schema (EventStore ":memory:"), never production, and
makes no HTTP call (empty symbol list).
Both FMP env var names are explicitly cleared via monkeypatch before the
no-key test: an unrelated test elsewhere (test_admin_effective_config.py,
via ``import admin_api``) triggers admin_api's own module-level bare
``load_dotenv()`` during pytest COLLECTION, which walks up from cwd and can
pick up the outer repo's real .env (this worktree is nested under it) —
polluting os.environ for the rest of the session. Pre-existing, out of scope
for this plan; this test must not depend on ambient environment either way."""
from __future__ import annotations
from storage.event_store import EventStore
from research.ingestion.float_cache import get_float, refresh_float_cache
def test_refresh_creates_table_and_is_a_noop_on_empty_symbols():
s = EventStore(":memory:")
try:
out = refresh_float_cache(s.con, [], api_key="x")
assert out == {"fetched": 0, "skipped_fresh": 0, "failed": 0, "no_key": False}
rows = s.con.execute("SELECT count(*) FROM float_cache").fetchall()
assert rows[0][0] == 0
finally:
s.close()
def test_refresh_creates_table_even_without_an_api_key(monkeypatch):
monkeypatch.delenv("FMP_API_KEY", raising=False)
monkeypatch.delenv("Financial_Modeling_Prep_Api_Key", raising=False)
s = EventStore(":memory:")
try:
out = refresh_float_cache(s.con, ["AAA"], api_key=None)
assert out["no_key"] is True
rows = s.con.execute("SELECT count(*) FROM float_cache").fetchall()
assert rows[0][0] == 0
finally:
s.close()
def test_get_float_is_a_noop_on_a_fresh_schema():
s = EventStore(":memory:")
try:
assert get_float(s.con, "AAA") is None
finally:
s.close()
@@ -0,0 +1,72 @@
"""research/premarket_eval.py::_refresh_shadow_enrichment (plan 016) — the
nightly float/dilution enrichment call site was broken two ways: a
``session_date = current_date`` query (wrong on UTC-server Postgres at night)
and a shared try-block where a float_cache failure silently skipped the
dilution refresh too. This test runs against an ISOLATED throwaway Postgres
schema (EventStore ":memory:"), never the production DB (mirrors
tests/unit/experiments/conftest.py), and never makes a live HTTP call — both
enrichment functions are monkeypatched."""
from __future__ import annotations
from datetime import date, datetime
import pytest
from research.premarket_eval import _refresh_shadow_enrichment
from storage.event_store import EventStore
SESS = date(2026, 6, 11)
@pytest.fixture
def store():
s = EventStore(":memory:")
yield s
s.close()
def _seed_minute_bar(store, symbol, session_date, volume):
store.con.execute(
"INSERT INTO minute_bars (symbol, timestamp, session_date, is_premarket, "
"is_regular_hours, open, high, low, close, volume, vwap, quality_score) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
[symbol, datetime(session_date.year, session_date.month, session_date.day, 13, 35),
session_date, False, True, 10.0, 10.1, 9.9, 10.0, volume, 10.0, 1.0])
def test_dilution_still_called_when_float_raises(store, monkeypatch):
_seed_minute_bar(store, "AAA", SESS, 1_000_000)
_seed_minute_bar(store, "BBB", SESS, 500_000)
# a different session_date's bars must not leak into the query
_seed_minute_bar(store, "ZZZ", date(2026, 6, 10), 9_999_999)
def _raise_float(con, symbols):
raise RuntimeError("relation \"float_cache\" does not exist")
calls = []
def _record_dilution(con, symbols):
calls.append((con, list(symbols)))
return {"symbols": len(symbols), "filings": 0}
monkeypatch.setattr("research.ingestion.float_cache.refresh_float_cache", _raise_float)
monkeypatch.setattr("research.ingestion.sec_dilution.refresh_dilution_events", _record_dilution)
_refresh_shadow_enrichment(store.con, SESS)
assert len(calls) == 1
seen_con, seen_symbols = calls[0]
assert seen_con is store.con
# highest-volume first, only the seeded session's symbols
assert seen_symbols == ["AAA", "BBB"]
def test_no_symbols_is_a_noop(store, monkeypatch):
called = {"float": False, "dilution": False}
monkeypatch.setattr("research.ingestion.float_cache.refresh_float_cache",
lambda con, syms: called.__setitem__("float", True))
monkeypatch.setattr("research.ingestion.sec_dilution.refresh_dilution_events",
lambda con, syms: called.__setitem__("dilution", True))
_refresh_shadow_enrichment(store.con, SESS) # no minute_bars seeded
assert called == {"float": False, "dilution": False}