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>
110 lines
4.2 KiB
Python
110 lines
4.2 KiB
Python
"""Float cache: FMP shares-float -> float_cache table (SHADOW DATA ONLY).
|
|
|
|
Float is the #1 selection number the source trader reads that the system has
|
|
been blind to. The 2026-06-29 audit REFUTED float as a live gate (low float is
|
|
anti-predictive after RVOL) — so this feeds shadow analysis, board display, and
|
|
future runner-grade features, and must never gate entries without clearing a
|
|
pre-registered out-of-sample bar first.
|
|
|
|
Free-plan budget: ~250 calls/day. We fetch only symbols with no row or a row
|
|
older than REFRESH_DAYS, capped at MAX_CALLS_PER_RUN per invocation (the
|
|
nightly premarket_eval calls this for the day's tracked names).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
|
|
FMP_URL = "https://financialmodelingprep.com/stable/shares-float?symbol={sym}&apikey={key}"
|
|
REFRESH_DAYS = 7
|
|
MAX_CALLS_PER_RUN = 200
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS float_cache (
|
|
symbol TEXT PRIMARY KEY,
|
|
float_shares BIGINT,
|
|
outstanding_shares BIGINT,
|
|
free_float_pct DOUBLE PRECISION,
|
|
source_date TEXT,
|
|
fetched_at TIMESTAMP NOT NULL
|
|
)
|
|
"""
|
|
|
|
|
|
def ensure_schema(con) -> None:
|
|
con.execute(_SCHEMA)
|
|
|
|
|
|
def fetch_float(symbol: str, api_key: str, timeout: float = 10.0) -> dict | None:
|
|
url = FMP_URL.format(sym=urllib.parse.quote(symbol), key=api_key)
|
|
req = urllib.request.Request(url, headers={"User-Agent": "momentum-research"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
rows = json.loads(resp.read().decode())
|
|
except Exception: # noqa: BLE001 — enrichment must never break callers
|
|
return None
|
|
if not isinstance(rows, list) or not rows:
|
|
return None
|
|
r = rows[0]
|
|
if not r.get("floatShares"):
|
|
return None
|
|
return {
|
|
"symbol": symbol.upper(),
|
|
"float_shares": int(r["floatShares"]),
|
|
"outstanding_shares": int(r.get("outstandingShares") or 0) or None,
|
|
"free_float_pct": float(r.get("freeFloat") or 0.0) or None,
|
|
"source_date": str(r.get("date") or ""),
|
|
}
|
|
|
|
|
|
def refresh_float_cache(con, symbols: list[str], api_key: str | None = None) -> dict:
|
|
"""Fetch float for symbols missing/stale in the cache. Returns a summary."""
|
|
ensure_schema(con) # apply-on-use: table must exist even on a no-key/no-op call
|
|
api_key = api_key or os.environ.get("FMP_API_KEY") or os.environ.get(
|
|
"Financial_Modeling_Prep_Api_Key")
|
|
out = {"fetched": 0, "skipped_fresh": 0, "failed": 0, "no_key": False}
|
|
if not api_key:
|
|
out["no_key"] = True
|
|
return out
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
calls = 0
|
|
for sym in dict.fromkeys(s.upper() for s in symbols if s):
|
|
if calls >= MAX_CALLS_PER_RUN:
|
|
break
|
|
row = con.execute(
|
|
"SELECT fetched_at FROM float_cache WHERE symbol=?", [sym]).fetchall()
|
|
if row and (now - row[0][0]).days < REFRESH_DAYS:
|
|
out["skipped_fresh"] += 1
|
|
continue
|
|
calls += 1
|
|
data = fetch_float(sym, api_key)
|
|
if not data:
|
|
out["failed"] += 1
|
|
continue
|
|
con.execute(
|
|
"INSERT INTO float_cache (symbol, float_shares, outstanding_shares, "
|
|
"free_float_pct, source_date, fetched_at) VALUES (?,?,?,?,?,?) "
|
|
"ON CONFLICT (symbol) DO UPDATE SET float_shares=EXCLUDED.float_shares, "
|
|
"outstanding_shares=EXCLUDED.outstanding_shares, "
|
|
"free_float_pct=EXCLUDED.free_float_pct, source_date=EXCLUDED.source_date, "
|
|
"fetched_at=EXCLUDED.fetched_at",
|
|
[data["symbol"], data["float_shares"], data["outstanding_shares"],
|
|
data["free_float_pct"], data["source_date"], now])
|
|
out["fetched"] += 1
|
|
return out
|
|
|
|
|
|
def get_float(con, symbol: str) -> int | None:
|
|
"""Cached float shares for a symbol, or None."""
|
|
try:
|
|
ensure_schema(con) # apply-on-use: readable even if nothing has written yet
|
|
r = con.execute("SELECT float_shares FROM float_cache WHERE symbol=?",
|
|
[symbol.upper()]).fetchall()
|
|
return int(r[0][0]) if r and r[0][0] else None
|
|
except Exception: # noqa: BLE001
|
|
return None
|