Files
signal-platform/scripts/run_fip_breadth_diagnostics.py
T
dennisthiessen ceaaadc49f research: fip breadth diagnostics + compositional read
Add lagged/tier/prod-subset/mom-conditional checks on research.sqlite.
Log: unconditional sign is a winner/bleeder tug-of-war; mom-conditional
fip stays negative and reliable; warn on high-vol tilt if universe broadens.
2026-07-18 21:40:05 +02:00

666 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Post-breadth diagnostics for fip_id (research branch only).
Same research.sqlite as the liquid-breadth IC run. No production changes.
Checks (pre-registered interpretation follow-ups)
------------------------------------------------
1. **Lagged membership** — liquid top-N ranked on *prior* week's $vol (extra lag)
so same-week liquidity explosion cannot pull a name into history.
2. **Liquidity tiers** — fip IC on ranks 1800 vs 8011500 (same-week mask).
3. **Prod-universe subset** — symbols present in prod.sqlite (~S&P-like large-cap
book) inside the same breadth weeks — compositional vs temporal flip.
4. **Momentum-conditional fip** — among weekly top 20% by mom_12_1 (or resid when
available) within the liquid top-N — the paper's actual claim and the only
version a gate could consume.
Also reports vol_6m / mom raw vs residual on the same panels for the log.
Example (Windows)
-----------------
.\\.venv\\Scripts\\python.exe scripts\\run_fip_breadth_diagnostics.py ^
--research-snapshot backtest_snapshots\\research.sqlite ^
--prod-snapshot backtest_snapshots\\prod.sqlite ^
--workers 6
"""
from __future__ import annotations
import argparse
import json
import math
import multiprocessing as mp
import sys
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import date, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
HORIZON = 30
MIN_CROSS = 20
MIN_RELIABLE = 12
LIQUID_TOP = 1500
MIN_PRICE = 5.0
MOM_WINNER_PCT = 80.0 # top 20% within liquid cross-section
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--research-snapshot",
default="backtest_snapshots/research.sqlite",
)
p.add_argument(
"--prod-snapshot",
default="backtest_snapshots/prod.sqlite",
help="Symbols here define the large-cap / prod-like subset.",
)
p.add_argument("--top-n", type=int, default=LIQUID_TOP)
p.add_argument("--min-price", type=float, default=MIN_PRICE)
p.add_argument("--workers", type=int, default=max(1, (mp.cpu_count() or 4) - 1))
p.add_argument("--out", default=None)
p.add_argument("--quiet", action="store_true")
return p.parse_args()
def _week_key(d: date) -> tuple[int, int]:
iso = d.isocalendar()
return (int(iso[0]), int(iso[1]))
def _week_ord(wk: tuple[int, int]) -> int:
return wk[0] * 53 + wk[1]
def _nonoverlap(weeks: list[tuple[int, int]], stride: int) -> list[tuple[int, int]]:
kept: list[tuple[int, int]] = []
last: int | None = None
for wk in sorted(weeks, key=_week_ord):
o = _week_ord(wk)
if last is None or o - last >= stride:
kept.append(wk)
last = o
return kept
def _rank(xs: list[float]) -> list[float]:
order = sorted(range(len(xs)), key=lambda k: xs[k])
ranks = [0.0] * len(xs)
i = 0
while i < len(xs):
j = i
while j + 1 < len(xs) and xs[order[j + 1]] == xs[order[i]]:
j += 1
avg = (i + j) / 2.0 + 1.0
for k in range(i, j + 1):
ranks[order[k]] = avg
i = j + 1
return ranks
def _pearson(a: list[float], b: list[float]) -> float | None:
n = len(a)
if n < 3:
return None
ma, mb = sum(a) / n, sum(b) / n
va = sum((x - ma) ** 2 for x in a)
vb = sum((y - mb) ** 2 for y in b)
if va <= 0 or vb <= 0:
return None
cov = sum((a[k] - ma) * (b[k] - mb) for k in range(n))
return cov / math.sqrt(va * vb)
def _spearman(xs: list[float], ys: list[float]) -> float | None:
if len(xs) < 3:
return None
return _pearson(_rank(xs), _rank(ys))
def _ic_row(pairs: list[tuple[float, float]], *, label: str) -> dict[str, Any]:
"""pairs = (signal, fwd) over non-overlapping weeks aggregated… actually
we pass per-week then aggregate outside. This helper is for multi-week IC."""
raise NotImplementedError
def _ic_from_weekly(
week_pairs: dict[tuple[int, int], list[tuple[float, float]]],
) -> dict[str, Any]:
stride = max(1, round(HORIZON / 5))
usable = [wk for wk, ps in week_pairs.items() if len(ps) >= MIN_CROSS]
kept = _nonoverlap(usable, stride)
ics: list[float] = []
sizes: list[int] = []
for wk in kept:
ps = week_pairs[wk]
if len(ps) < MIN_CROSS:
continue
ic = _spearman([p[0] for p in ps], [p[1] for p in ps])
if ic is not None:
ics.append(ic)
sizes.append(len(ps))
if not ics:
return {
"mean_ic": None,
"ic_t_stat": None,
"weeks": 0,
"avg_cross_section": None,
"ic_positive_pct": None,
"reliable": False,
}
mean_ic = sum(ics) / len(ics)
if len(ics) > 1:
var = sum((x - mean_ic) ** 2 for x in ics) / (len(ics) - 1)
std = math.sqrt(var) if var > 0 else 0.0
t_stat = mean_ic / std * math.sqrt(len(ics)) if std > 0 else None
else:
t_stat = None
return {
"mean_ic": round(mean_ic, 4),
"ic_t_stat": round(t_stat, 2) if t_stat is not None else None,
"weeks": len(ics),
"avg_cross_section": round(sum(sizes) / len(sizes), 1),
"ic_positive_pct": round(sum(1 for x in ics if x > 0) / len(ics) * 100, 1),
"reliable": len(ics) >= MIN_RELIABLE,
}
def _panel_worker(payload: tuple) -> list[dict]:
"""Build weekly observations for one ticker (picklable top-level)."""
symbol, date_ords, opens, highs, lows, closes, volumes, spy = payload
from types import SimpleNamespace
from app.services.backtest_service import (
HORIZON as H,
_median_dollar_vol_63,
_signal_values,
_weekly_asof_indices,
)
dates = [date.fromordinal(int(o)) for o in date_ords]
opens_f = [float(x) for x in opens]
highs_f = [float(x) for x in highs]
lows_f = [float(x) for x in lows]
closes_f = [float(x) for x in closes]
vols_f = [float(x) for x in volumes]
n = len(closes_f)
if n < H + 21:
return []
# Match backtest_service bar objects exactly (weekly as-of + signal_values).
bar_records = [
SimpleNamespace(
date=dates[i],
open=opens_f[i],
high=highs_f[i],
low=lows_f[i],
close=closes_f[i],
volume=vols_f[i],
)
for i in range(n)
]
out: list[dict] = []
for i in _weekly_asof_indices(bar_records):
j = i + H
if j >= n or closes_f[i] <= 0:
continue
sigs = _signal_values(dates, closes_f, highs_f, i, spy)
fip = sigs.get("fip_id")
mom = sigs.get("mom_12_1")
mom_r = sigs.get("mom_12_1_resid")
vol = sigs.get("vol_6m")
if fip is None and mom is None:
continue
dvol = _median_dollar_vol_63(closes_f, vols_f, i)
wk = _week_key(dates[i])
out.append({
"symbol": symbol,
"week": wk,
"fwd": closes_f[j] / closes_f[i] - 1.0,
"close": closes_f[i],
"dvol": dvol,
"fip_id": fip,
"mom_12_1": mom,
"mom_12_1_resid": mom_r,
"vol_6m": vol,
})
return out
def _load_spy(conn) -> dict[date, float]:
rows = conn.execute(
text("SELECT date, close FROM benchmark_prices WHERE symbol = 'SPY' ORDER BY date")
).fetchall()
out: dict[date, float] = {}
for d, c in rows:
if isinstance(d, str):
d = date.fromisoformat(d[:10])
out[d] = float(c)
return out
def _load_symbols(conn) -> list[str]:
return [
str(r[0])
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol")).fetchall()
]
def _load_columns(conn, symbol: str) -> tuple | None:
tid = conn.execute(
text("SELECT id FROM tickers WHERE symbol = :s"), {"s": symbol}
).scalar()
if tid is None:
return None
rows = conn.execute(
text(
"SELECT date, open, high, low, close, volume FROM ohlcv_records "
"WHERE ticker_id = :t ORDER BY date"
),
{"t": tid},
).fetchall()
if len(rows) < HORIZON + 60:
return None
ords: list[int] = []
opens: list[float] = []
highs: list[float] = []
lows: list[float] = []
closes: list[float] = []
vols: list[float] = []
for d, o, h, l, c, v in rows:
if isinstance(d, str):
d = date.fromisoformat(d[:10])
ords.append(d.toordinal())
opens.append(float(o))
highs.append(float(h))
lows.append(float(l))
closes.append(float(c))
vols.append(float(v or 0))
return (symbol, ords, opens, highs, lows, closes, vols)
def _liquid_members(
obs: list[dict],
*,
top_n: int,
min_price: float,
dvol_key: str = "dvol",
) -> list[dict]:
eligible = [
o
for o in obs
if o.get("close") is not None
and float(o["close"]) >= min_price
and o.get(dvol_key) is not None
and float(o[dvol_key]) > 0
]
eligible.sort(key=lambda o: float(o[dvol_key]), reverse=True)
return eligible[:top_n]
def _pairs(obs: list[dict], signal: str) -> list[tuple[float, float]]:
out: list[tuple[float, float]] = []
for o in obs:
v = o.get(signal)
if v is None:
continue
out.append((float(v), float(o["fwd"])))
return out
def main() -> None:
args = _parse_args()
research = Path(args.research_snapshot)
prod = Path(args.prod_snapshot)
if not research.exists():
raise SystemExit(f"Missing research snapshot: {research}")
research_eng = create_engine(f"sqlite:///{research.resolve().as_posix()}")
prod_symbols: set[str] = set()
if prod.exists():
prod_eng = create_engine(f"sqlite:///{prod.resolve().as_posix()}")
with prod_eng.connect() as c:
prod_symbols = {
str(r[0])
for r in c.execute(text("SELECT symbol FROM tickers")).fetchall()
}
prod_eng.dispose()
with research_eng.connect() as conn:
spy = _load_spy(conn)
symbols = _load_symbols(conn)
jobs: list[tuple] = []
for i, sym in enumerate(symbols, 1):
cols = _load_columns(conn, sym)
if cols is None:
continue
jobs.append((*cols, spy))
if not args.quiet and i % 500 == 0:
print(f" queued {i}/{len(symbols)}", flush=True)
if not args.quiet:
print(f"Building weekly panel for {len(jobs)} tickers…", flush=True)
# Panel: week -> list of obs
by_week: dict[tuple[int, int], list[dict]] = defaultdict(list)
workers = max(1, int(args.workers))
if workers == 1:
for j, job in enumerate(jobs, 1):
for row in _panel_worker(job):
by_week[tuple(row["week"])].append(row)
if not args.quiet and j % 200 == 0:
print(f" panel {j}/{len(jobs)}", flush=True)
else:
with ProcessPoolExecutor(max_workers=workers) as pool:
futs = {pool.submit(_panel_worker, job): job[0] for job in jobs}
done = 0
for fut in as_completed(futs):
done += 1
try:
rows = fut.result()
except Exception as exc:
if not args.quiet:
print(f" worker error {futs[fut]}: {exc}", flush=True)
continue
for row in rows:
by_week[tuple(row["week"])].append(row)
if not args.quiet and done % 200 == 0:
print(f" panel {done}/{len(jobs)}", flush=True)
if not args.quiet:
print(f"Weeks with data: {len(by_week)}", flush=True)
# Prior-week dvol map for lagged membership: (symbol, week) -> dvol
dvol_by_sym_week: dict[tuple[str, tuple[int, int]], float] = {}
for wk, obs in by_week.items():
for o in obs:
if o.get("dvol") is not None:
dvol_by_sym_week[(o["symbol"], wk)] = float(o["dvol"])
ordered_weeks = sorted(by_week.keys(), key=_week_ord)
prev_week: dict[tuple[int, int], tuple[int, int]] = {}
for i, wk in enumerate(ordered_weeks):
if i > 0:
prev_week[wk] = ordered_weeks[i - 1]
top_n = int(args.top_n)
min_price = float(args.min_price)
# --- Panels for each check ---
same_week_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
lag_week_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
tier_hi_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
tier_lo_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
prod_subset_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
mom_cond_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
liquid_vol: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
liquid_mom: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
liquid_mom_r: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list)
for wk, obs in by_week.items():
# Same-week liquid top-N among names that have fip (matches signal_eval mask:
# membership is ranked within each signal's observation set).
with_fip = [o for o in obs if o.get("fip_id") is not None]
liq_fip = _liquid_members(with_fip, top_n=top_n, min_price=min_price)
for rank, o in enumerate(liq_fip, 1):
same_week_fip[wk].append((float(o["fip_id"]), float(o["fwd"])))
if rank <= 800:
tier_hi_fip[wk].append((float(o["fip_id"]), float(o["fwd"])))
elif rank <= top_n:
tier_lo_fip[wk].append((float(o["fip_id"]), float(o["fwd"])))
if o["symbol"] in prod_symbols:
prod_subset_fip[wk].append((float(o["fip_id"]), float(o["fwd"])))
# Context signals: liquid among names that carry that signal
with_vol = [o for o in obs if o.get("vol_6m") is not None]
for o in _liquid_members(with_vol, top_n=top_n, min_price=min_price):
liquid_vol[wk].append((float(o["vol_6m"]), float(o["fwd"])))
with_mom_all = [o for o in obs if o.get("mom_12_1") is not None]
liq_mom = _liquid_members(with_mom_all, top_n=top_n, min_price=min_price)
for o in liq_mom:
liquid_mom[wk].append((float(o["mom_12_1"]), float(o["fwd"])))
with_mom_r = [o for o in obs if o.get("mom_12_1_resid") is not None]
for o in _liquid_members(with_mom_r, top_n=top_n, min_price=min_price):
liquid_mom_r[wk].append((float(o["mom_12_1_resid"]), float(o["fwd"])))
# Momentum-conditional: within liquid fip set, keep mom_12_1 ≥ P80
mom_key = "mom_12_1"
with_mom = [
o for o in liq_fip
if o.get(mom_key) is not None and o.get("fip_id") is not None
]
if len(with_mom) >= MIN_CROSS:
with_mom.sort(key=lambda o: float(o[mom_key]))
n = len(with_mom)
cut = int(math.floor(n * (MOM_WINNER_PCT / 100.0)))
winners = with_mom[cut:] # upper tail
for o in winners:
mom_cond_fip[wk].append((float(o["fip_id"]), float(o["fwd"])))
# Lagged membership: rank by *previous* week's dvol among fip names
pw = prev_week.get(wk)
if pw is not None:
lagged: list[dict] = []
for o in with_fip:
if o.get("close") is None or float(o["close"]) < min_price:
continue
prev_dvol = dvol_by_sym_week.get((o["symbol"], pw))
if prev_dvol is None or prev_dvol <= 0:
continue
lagged.append({**o, "lag_dvol": prev_dvol})
lagged.sort(key=lambda o: float(o["lag_dvol"]), reverse=True)
for o in lagged[:top_n]:
lag_week_fip[wk].append((float(o["fip_id"]), float(o["fwd"])))
results = {
"generated_at": datetime.now().isoformat(),
"research_snapshot": str(research.resolve()),
"prod_subset_n": len(prod_symbols),
"panel_tickers": len(jobs),
"top_n": top_n,
"min_price": min_price,
"checks": {
"fip_same_week_liquid_1500": {
"note": "Replication of main breadth run (same-week $vol mask)",
**_ic_from_weekly(same_week_fip),
},
"fip_lagged_membership_1w": {
"note": (
"Liquid top-N ranked on *prior* week's median $vol — "
"excludes same-week liquidity explosion leak"
),
**_ic_from_weekly(lag_week_fip),
},
"fip_tier_1_800": {
"note": "Same-week liquid ranks 1800 (senior liquid tier)",
**_ic_from_weekly(tier_hi_fip),
},
"fip_tier_801_1500": {
"note": "Same-week liquid ranks 8011500 (junior liquid tier)",
**_ic_from_weekly(tier_lo_fip),
},
"fip_prod_universe_subset": {
"note": (
"Symbols in prod.sqlite (~S&P-like large-cap book) inside "
"same-week liquid top-N — compositional control"
),
**_ic_from_weekly(prod_subset_fip),
},
"fip_momentum_conditional_top20pct": {
"note": (
f"Among liquid top-N, keep mom_12_1 percentile ≥ {MOM_WINNER_PCT} "
"(paper: ID modulates continuation among winners; gate-relevant)"
),
**_ic_from_weekly(mom_cond_fip),
},
"vol_6m_liquid_1500": {
"note": "Context: low-vol anomaly strength on this pool",
**_ic_from_weekly(liquid_vol),
},
"mom_12_1_liquid_1500": {
"note": "Context: raw momentum on liquid breadth",
**_ic_from_weekly(liquid_mom),
},
"mom_12_1_resid_liquid_1500": {
"note": "Context: residual momentum on liquid breadth",
**_ic_from_weekly(liquid_mom_r),
},
},
}
# Interpretations
checks = results["checks"]
lag = checks["fip_lagged_membership_1w"]
same = checks["fip_same_week_liquid_1500"]
hi = checks["fip_tier_1_800"]
lo = checks["fip_tier_801_1500"]
prod = checks["fip_prod_universe_subset"]
cond = checks["fip_momentum_conditional_top20pct"]
def _sign(x: float | None) -> str:
if x is None:
return "na"
return "neg" if x < 0 else "pos"
results["interpretation"] = {
"leak_ruled_out": (
lag.get("mean_ic") is not None
and same.get("mean_ic") is not None
and _sign(lag["mean_ic"]) == _sign(same["mean_ic"])
and abs(float(lag["mean_ic"])) >= 0.02
),
"junior_tier_drives_positive": (
lo.get("mean_ic") is not None
and float(lo["mean_ic"]) > 0
and (hi.get("mean_ic") is None or float(hi["mean_ic"]) < float(lo["mean_ic"]))
),
"prod_subset_still_negative": (
prod.get("mean_ic") is not None and float(prod["mean_ic"]) < 0
),
"mom_conditional_negative_and_reliable": (
cond.get("mean_ic") is not None
and float(cond["mean_ic"]) < 0
and abs(float(cond["mean_ic"])) >= 0.03
and bool(cond.get("reliable"))
),
"compositional_flip_story": (
"If prod subset IC is negative while full liquid-1500 is positive, "
"the sign flip is compositional (bleeders / Nasdaq junk), not a "
"temporal regime change. Unconditional fip pools continuous winners "
"(want neg IC) against continuous losers/bleeders (want pos IC)."
),
"vol_tilt_warning": (
"vol_6m large negative IC on breadth: high-vol lottery names "
"underperform. Production 80/20 high-vol tilt was validated on "
"S&P-like names; must re-validate before any universe broaden."
),
}
# Gate-relevant summary line
if results["interpretation"]["mom_conditional_negative_and_reliable"]:
results["platform_verdict"] = (
"ALIVE as breadth-book tilt candidate among momentum winners only — "
"still needs a book-level experiment; not a production wire-in."
)
else:
results["platform_verdict"] = (
"CLOSED for production use: momentum-conditional fip does not clear "
"iron rule on this liquid-Nasdaq pool. Display card remains final resting place."
)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(args.out) if args.out else Path("reports") / f"fip-breadth-diagnostics-{stamp}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(results, indent=2, default=str), encoding="utf-8")
# Append to research log
md_path = Path("docs/research/fip-breadth-ic.md")
_append_diagnostics_md(md_path, results, out)
if not args.quiet:
print(json.dumps(results["checks"], indent=2, default=str))
print()
print("interpretation:", json.dumps(results["interpretation"], indent=2))
print("platform_verdict:", results["platform_verdict"])
print(f"Wrote {out}")
print(f"Updated {md_path}")
def _append_diagnostics_md(path: Path, results: dict, artifact: Path) -> None:
checks = results["checks"]
interp = results["interpretation"]
lines = [
"",
"---",
"",
f"## Follow-up diagnostics ({results['generated_at'][:10]})",
"",
"Compositional reading of the sign flip (before any 'jumpiness premium' story):",
"",
"`fip_id = sign(PRET) × (%neg %pos)` pools two opposite continuous populations:",
"",
"- **Continuous winners** (PRET>0, mostly up days) → paper claim → **negative** IC contribution.",
"- **Continuous losers / bleeders** (PRET<0, mostly down days) → momentum continuation down → **positive** IC contribution.",
"",
"Unconditional IC is a tug-of-war weighted by universe composition. S&P-like books "
"have few steady bleeders → negative fip IC. Liquid Nasdaq has many → sign can flip "
"without contradicting Da/Gurun/Warachka (claim was always **momentum-conditional**).",
"",
"### Artifact / composition checks",
"",
"| check | mean_ic | t | weeks | avg N | reliable |",
"|---|---:|---:|---:|---:|---|",
]
order = [
"fip_same_week_liquid_1500",
"fip_lagged_membership_1w",
"fip_tier_1_800",
"fip_tier_801_1500",
"fip_prod_universe_subset",
"fip_momentum_conditional_top20pct",
"vol_6m_liquid_1500",
"mom_12_1_liquid_1500",
"mom_12_1_resid_liquid_1500",
]
for key in order:
row = checks.get(key) or {}
lines.append(
f"| {key} | {row.get('mean_ic')} | {row.get('ic_t_stat')} | "
f"{row.get('weeks')} | {row.get('avg_cross_section')} | {row.get('reliable')} |"
)
lines.extend([
"",
"### Flags",
"",
f"- Lagged mask keeps same sign / material |IC|: **{interp.get('leak_ruled_out')}**",
f"- Junior tier (8011500) drives more positive IC: **{interp.get('junior_tier_drives_positive')}**",
f"- Prod-universe subset still negative: **{interp.get('prod_subset_still_negative')}**",
f"- Mom-conditional (≥P80) negative + reliable: **{interp.get('mom_conditional_negative_and_reliable')}**",
"",
"### Platform verdict",
"",
results.get("platform_verdict", ""),
"",
"### Vol-tilt warning (any future breadth move)",
"",
interp.get("vol_tilt_warning", ""),
"",
f"Artifact: `{artifact.as_posix()}`",
"",
])
# Replace previous diagnostics section if re-run, else append
existing = path.read_text(encoding="utf-8") if path.exists() else ""
marker = "## Follow-up diagnostics"
if marker in existing:
existing = existing.split(marker)[0].rstrip() + "\n"
path.write_text(existing + "\n".join(lines), encoding="utf-8")
if __name__ == "__main__":
main()