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:
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
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user