research: single-source liquid mask; orphan +0.06 fip IC

Harness and diagnostics share _filter_liquid_breadth_week_rich. Recompute
shows unconditional liquid fip IC -0.017 (mask binds 97%); mom-conditional
-0.088/t-4.58 stands. Document +0.0575 as orphaned.
This commit is contained in:
2026-07-19 00:06:04 +02:00
parent ceaaadc49f
commit 7d60e54f5a
4 changed files with 7280 additions and 526 deletions
+96 -11
View File
@@ -942,6 +942,8 @@ def _accumulate_signal_series(
records: list, records: list,
collected: dict, collected: dict,
benchmark_closes: dict[date, float] | None = None, benchmark_closes: dict[date, float] | None = None,
*,
symbol: str | None = None,
) -> None: ) -> None:
"""For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO """For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO
week into ``collected[name][week_key]``. Forward return is close-to-close over week into ``collected[name][week_key]``. Forward return is close-to-close over
@@ -974,6 +976,7 @@ def _accumulate_signal_series(
"fwd": fwd, "fwd": fwd,
"close": closes[i], "close": closes[i],
"median_dvol_63": dvol, "median_dvol_63": dvol,
"symbol": symbol,
}) })
else: else:
collected[name][week_key].append((val, fwd)) collected[name][week_key].append((val, fwd))
@@ -1041,12 +1044,28 @@ def _filter_liquid_breadth_week(
Ranking is relative (IEX volume undercount is OK for order stats). Membership Ranking is relative (IEX volume undercount is OK for order stats). Membership
is recomputed every week from as-of bars — never frozen from today's liquidity. is recomputed every week from as-of bars — never frozen from today's liquidity.
""" """
ranked: list[tuple[float, float, float]] = [] # (-dvol, val, fwd) kept = _filter_liquid_breadth_week_rich(
recs, top_n=top_n, min_price=min_price
)
return [(float(r["val"]), float(r["fwd"])) for r in kept]
def _filter_liquid_breadth_week_rich(
recs: list,
*,
top_n: int,
min_price: float,
) -> list[dict]:
"""Same mask as ``_filter_liquid_breadth_week``, returning rich rows.
Single source for harness IC and research diagnostics. Eligible pool =
dict observations with close ≥ min_price and median_dvol_63 > 0; then
keep top_n by dollar volume (highest first). Non-dict legacy tuples are
not eligible for the liquid mask (they have no dvol).
"""
eligible: list[tuple[float, dict]] = [] # (-dvol, row)
for rec in recs: for rec in recs:
if not isinstance(rec, dict): if not isinstance(rec, dict):
pair = _obs_val_fwd(rec)
if pair is not None:
ranked.append((0.0, pair[0], pair[1]))
continue continue
close = rec.get("close") close = rec.get("close")
dvol = rec.get("median_dvol_63") dvol = rec.get("median_dvol_63")
@@ -1057,10 +1076,50 @@ def _filter_liquid_breadth_week(
pair = _obs_val_fwd(rec) pair = _obs_val_fwd(rec)
if pair is None: if pair is None:
continue continue
ranked.append((-float(dvol), pair[0], pair[1])) row = {
ranked.sort(key=lambda row: row[0]) "val": pair[0],
kept = ranked[:top_n] "fwd": pair[1],
return [(val, fwd) for _, val, fwd in kept] "close": float(close),
"median_dvol_63": float(dvol),
"symbol": rec.get("symbol"),
}
# Preserve optional research fields for mom-conditional diagnostics.
for key in ("mom_12_1", "mom_12_1_resid", "vol_6m", "fip_id"):
if key in rec and rec[key] is not None:
row[key] = rec[key]
eligible.append((-float(dvol), row))
eligible.sort(key=lambda item: item[0])
return [row for _, row in eligible[:top_n]]
def _liquid_breadth_week_stats(
recs: list,
*,
top_n: int,
min_price: float,
) -> dict[str, int | bool]:
"""Pre/post mask counts for reconciling avg_cross_section semantics."""
raw = len(recs)
eligible = 0
for rec in recs:
if not isinstance(rec, dict):
continue
close = rec.get("close")
dvol = rec.get("median_dvol_63")
if close is None or float(close) < min_price:
continue
if dvol is None or float(dvol) <= 0:
continue
if _obs_val_fwd(rec) is None:
continue
eligible += 1
post = min(eligible, top_n) if top_n > 0 else eligible
return {
"raw_pool": raw,
"eligible_pre_mask": eligible,
"post_mask": post,
"mask_binds": bool(top_n > 0 and eligible > top_n),
}
def _quintile_spread(pairs: list[tuple[float, float]]) -> float | None: def _quintile_spread(pairs: list[tuple[float, float]]) -> float | None:
@@ -1126,9 +1185,18 @@ def _signal_evaluation(collected: dict) -> list[dict]:
ics: list[float] = [] ics: list[float] = []
spreads: list[float] = [] spreads: list[float] = []
sizes: list[int] = [] sizes: list[int] = []
raw_sizes: list[int] = []
eligible_sizes: list[int] = []
bind_flags: list[bool] = []
for wk in kept: for wk in kept:
recs = weeks_map[wk] recs = weeks_map[wk]
if top_n > 0: if top_n > 0:
stats = _liquid_breadth_week_stats(
recs, top_n=top_n, min_price=min_price
)
raw_sizes.append(int(stats["raw_pool"]))
eligible_sizes.append(int(stats["eligible_pre_mask"]))
bind_flags.append(bool(stats["mask_binds"]))
pairs = _filter_liquid_breadth_week( pairs = _filter_liquid_breadth_week(
recs, top_n=top_n, min_price=min_price recs, top_n=top_n, min_price=min_price
) )
@@ -1146,6 +1214,7 @@ def _signal_evaluation(collected: dict) -> list[dict]:
spread = _quintile_spread(pairs) spread = _quintile_spread(pairs)
if spread is not None: if spread is not None:
spreads.append(spread) spreads.append(spread)
# avg_cross_section is ALWAYS post-mask pair count (the IC sample).
sizes.append(len(pairs)) sizes.append(len(pairs))
if not ics: if not ics:
continue continue
@@ -1168,16 +1237,32 @@ def _signal_evaluation(collected: dict) -> list[dict]:
if top_n > 0: if top_n > 0:
row["liquid_breadth_top_n"] = top_n row["liquid_breadth_top_n"] = top_n
row["liquid_min_price"] = min_price row["liquid_min_price"] = min_price
# Explicit pre/post mask diagnostics (reconcile "did top-N bind?").
if raw_sizes:
row["avg_raw_pool"] = round(sum(raw_sizes) / len(raw_sizes), 1)
if eligible_sizes:
row["avg_eligible_pre_mask"] = round(
sum(eligible_sizes) / len(eligible_sizes), 1
)
if bind_flags:
row["mask_binds_pct"] = round(
sum(1 for b in bind_flags if b) / len(bind_flags) * 100, 1
)
rows.append(row) rows.append(row)
rows.sort(key=lambda r: r["mean_ic"], reverse=True) rows.sort(key=lambda r: r["mean_ic"], reverse=True)
return rows return rows
def _signal_series(records: list, benchmark_closes: dict[date, float] | None = None) -> dict: def _signal_series(
records: list,
benchmark_closes: dict[date, float] | None = None,
*,
symbol: str | None = None,
) -> dict:
"""Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict """Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict
— no defaultdict/lambda — so it can cross a process boundary.""" — no defaultdict/lambda — so it can cross a process boundary."""
tmp: dict = defaultdict(lambda: defaultdict(list)) tmp: dict = defaultdict(lambda: defaultdict(list))
_accumulate_signal_series(records, tmp, benchmark_closes) _accumulate_signal_series(records, tmp, benchmark_closes, symbol=symbol)
return {name: dict(weeks) for name, weeks in tmp.items()} return {name: dict(weeks) for name, weeks in tmp.items()}
@@ -1218,7 +1303,7 @@ def _replay_and_signals(
) )
return ( return (
candidates, candidates,
_signal_series(bars, benchmark_closes), _signal_series(bars, benchmark_closes, symbol=symbol),
) )
+81 -87
View File
@@ -1,151 +1,145 @@
# Broad-universe fip_id IC research (Phase B) # Broad-universe fip_id IC research (Phase B)
**Status:** research complete enough for a platform decision on *unconditional* fip. **Status:** unconditional fip closed; mom-conditional lead confirmed on single-sourced path.
**Production impact:** none. Display card remains context-only. **Production impact:** none. Display card remains context-only.
Generated: 2026-07-18 (breadth run + diagnostics same day).
## Scope ## Scope
- **Research only** — production universe, gate, scanner, schedule unchanged. - Research only — production universe, gate, scanner, schedule unchanged.
- Price-only signal harness; no sentiment/fundamentals on the broad tier. - Snapshot: `research.sqlite` (~4,650 tickers = prod + nasdaq_all extend).
- Snapshot: `research.sqlite` — ~4,650 tickers (prod + nasdaq_all extend). - Liquid mask: top **1,500** by point-in-time 63d median $vol, price ≥ **$5**/week.
- IC mask: top **1,500** by point-in-time 63d median $vol, price ≥ **$5**, per week.
## Caveats ## Caveats
- **Survivorship bias** — today's constituents, history backfilled (worse in small caps). - Survivorship bias (todays constituents, history backfilled).
- **IEX volume undercount** — relative $vol rank only, not absolute floors. - IEX volume undercount relative $vol rank only.
- **Pool skew** — nasdaq_all partial SPX seed tilts tech/biotech; missing pure NYSE mid-caps. - Pool skew: Nasdaq-heavy; missing pure NYSE mid-caps.
- **Do not** compare full multi-signal tables across universe baselines; only compare `fip_id` to its 505-name fingerprint. - Do not mix multi-signal tables across universe baselines.
--- ---
## Fingerprint (505-name prod snapshot) ## Fingerprint (505-name prod)
| | Expected | Observed | | | Expected | Observed |
|---|---:|---:| |---|---:|---:|
| mean IC | 0.045 | **0.045** | | mean IC | 0.045 | **0.045** |
| t-stat | 2.9 | **2.91** | | t-stat | 2.9 | **2.91** |
| weeks | ≥12 | 35 | | weeks / N / reliable | ≥12 / ~500 / true | 35 / 497.7 / true |
| avg N | ~500 | 497.7 |
| reliable | true | **true** |
**Pass.** Pipeline and formula are trustworthy. **Pass.** Formula + pipeline trustworthy.
Artifacts: `reports/fip-breadth-20260718-211440-fingerprint.json`
--- ---
## First breadth harness run (pre-registered iron rule) ## Discrepancy (must not be papered over)
Unconditional `fip_id` on liquid top-1500 (runner `run_fip_breadth_research.py`): | Source | fip IC (liquid ~1500) | t |
|---|---:|---:|
| Report `fip-breadth-20260718-211440-breadth.json` | **+0.0575** | **+5.12** |
| Single-sourced recompute (2026-07-19) | **0.0168** | **1.85** |
That is a **sign disagreement** on the same intended quantity. Method rule: the number you cannot reconcile is the number you cannot use.
### What we did
1. **Single-sourced the mask** — diagnostics call harness `_signal_series` + `_filter_liquid_breadth_week_rich` only (no parallel mask).
2. **Documented avg_cross_section semantics** — always **post-mask** IC sample size.
3. **Logged pre-mask stats** so “did top-N bind?” is answerable.
### Authoritative unconditional liquid fip (post-reconciliation)
| metric | value | | metric | value |
|---|---:| |---|---:|
| mean_ic | **+0.0575** | | mean_ic | **0.0168** |
| ic_t_stat | **+5.12** | | ic_t_stat | **1.85** |
| ic_positive_pct | 88.6% |
| weeks | 35 | | weeks | 35 |
| avg_cross_section | 1471.2 | | avg_cross_section (**post-mask**) | 1471.2 |
| avg_raw_pool | 3214.4 |
| avg_eligible_pre_mask | **2338.4** |
| mask_binds_pct | **97.1%** |
| reliable | true | | reliable | true |
**Iron rule as written (need negative sign):** **not green.** **Mask binds hard** (eligible ≫ 1500). The hypothesis that “1471 meant the mask never bound / unmasked +5σ” is **false**.
Honest call: no production change from that screen alone.
Artifact: `reports/fip-breadth-20260718-211440-breadth.json` Harness `_signal_evaluation` vs manual IC through the same filter: **exact match** (0.0168 / 1.85).
### Verdict on the orphan
The **+0.0575 / t +5.12** row is **orphaned**. Do not cite it. Root cause of that single run is not fully forensic-reconstructed (no dual dump from the original process remains), but every single-sourced recompute on this snapshot lands near **0.017**, and the tier blend (≈800×−0.035 + ≈670×+0.014)/1471 ≈ **0.013** is internally consistent with that number—not with +0.058.
**Iron rule unconditional:** still **not green** (|IC| 0.017 < 0.03), and now with the correct mild-negative sign.
Artifact: `reports/fip-reconcile-20260719-000520.json`
--- ---
## Why “+IC on Nasdaq” is not a jumpiness-premium story ## Compositional story (supported)
`fip_id = sign(PRET) × (%neg %pos)` **pools two opposite continuous populations:** `fip_id = sign(PRET)×(%neg%pos)` pools:
| Leg | Formation | Continuation intuition | IC contribution | - **Continuous winners** → want **negative** IC
|---|---|---|---| - **Continuous bleeders** → want **positive** IC
| **Continuous winners** | PRET>0, mostly up days (smooth climbers) | Paper: keep going up | **negative** |
| **Continuous losers / bleeders** | PRET<0, mostly down days (grind-down biotechs, SPACs, etc.) | Momentum: keep going down | **positive** |
Unconditional IC is a **tug-of-war weighted by universe composition**: | check | IC | t | read |
|---|---:|---:|---|
| Prod-universe subset inside liquid | **0.044** | **2.88** | Matches fingerprint → compositional, not regime change |
| Tier 1800 (senior) | **0.035** | **2.99** | Winner leg |
| Tier 8011500 (junior) | **+0.014** | +1.25 | More bleeder / junk weight |
| Lagged membership (prior-week $vol) | 0.010 | 0.93 | Same sign as same-week; not a +5σ leak artifact |
- **S&P-like book** ≈ few steady bleeders → winner leg dominates → IC **0.045**. **Do not log “on Nasdaq, jumpy paths outperform.”** That would mythologize an orphaned +0.06.
- **Liquid Nasdaq pool** ≈ many bleeders / junk-lottery names → loser leg can flip the **aggregate** sign **without contradicting Da/Gurun/Warachka**, whose claim was always **momentum-conditional** (ID modulates continuation *among winners*), not an unconditional sort.
First-run context rows (same breadth harness) fit that reading: strong **vol_6m** underperformance and **high_52w** effects flag a large junk segment — exactly the population that can flip unconditional fip.
**Do not write “on Nasdaq, jumpy paths outperform” into the log as a collectible premium** until the diagnostics below are read.
--- ---
## Follow-up diagnostics (same snapshot, independent panel) ## Platform-relevant test: momentum-conditional fip
Script: `scripts/run_fip_breadth_diagnostics.py` Among liquid top-1500, keep **mom_12_1 ≥ P80** (~294 names/week):
Artifact: `reports/fip-breadth-diagnostics-20260718-213908.json`
| check | mean_ic | t | weeks | avg N | reliable | | metric | value |
|---|---:|---:|---:|---:|---| |---|---:|
| fip same-week liquid 1500 (panel) | 0.017 | 1.85 | 35 | 1471 | true | | mean_ic | **0.0879** |
| fip **lagged membership** (prior-week $vol) | 0.010 | 0.93 | 35 | 1471 | true | | ic_t_stat | **4.58** |
| fip **tier 1800** (senior liquid) | **0.035** | **2.99** | 35 | 791 | true | | ic_positive_pct | 22.9% |
| fip **tier 8011500** (junior liquid) | **+0.014** | +1.25 | 35 | 700 | true | | weeks | 35 |
| fip **prod-universe subset** inside liquid | **0.044** | **2.88** | 35 | 498 | true | | reliable | **true** |
| fip **mom-conditional** (top 20% mom_12_1) | **0.088** | **4.58** | 35 | 294 | true |
| vol_6m liquid 1500 (panel) | 0.047 | 1.3 | 35 | 1471 | true |
| mom_12_1 liquid 1500 | +0.046 | +1.91 | 35 | 1471 | true |
| mom_12_1_resid liquid 1500 | +0.029 | +1.33 | 35 | 1471 | true |
### What the checks settle Computed on the **same single-sourced path** as the authoritative 0.017. This is the papers claim and the only version a gate could consume.
1. **Lagged membership** — same sign as same-week panel (mildly negative); does **not** recreate a large positive IC. Not a clean “liquidity explosion leak manufactures +0.06” story for the panel path. (The first harness runs **+0.0575** still does not match the independent panels 0.017 — treat the **+0.0575 as a contested unconditional figure**; do not build a premium narrative on it.) | Decision | |
2. **Tier split** — senior liquid **negative** and reliable; junior liquid **mildly positive** / weak. Bias and bleeder weight are stronger in the junior tier.
3. **Prod-universe subset** — IC **0.044 / t 2.88**, ~498 names/week — matches the fingerprint. **Sign flip is compositional**, not “the whole market regime flipped.”
4. **Momentum-conditional fip (the platform test)** — IC **0.088 / t 4.58**, reliable, ~294 winners/week. **Negative sign, |IC| ≳ 0.03.** This is the papers claim and the only version a gate could consume.
### Platform verdict
| Question | Answer |
|---|---| |---|---|
| Unconditional fip iron rule (negative on liquid-1500) | **Not green** (first harness +0.06 fails sign; panel mild neg fails magnitude) | | Unconditional fip | **Closed** for production |
| Production change now? | **No** | | Mom-conditional fip | **Alive as book-tilt candidate only** — book sim before any gate talk |
| Is fip “dead forever”? | **No****alive only as a momentum-conditional tilt candidate** on breadth | | Display card | Stays |
| Next real step if pursued | Book-level experiment: among qualified residual-momentum names, tilt/filter by lower fip — **not** an unconditional fip sort | | Production change | **None** |
| Display card | Stays; still the right home until a book test wins |
--- ---
## Buried headline: vol tilt / residual mom on breadth ## Vol-tilt / residual-mom warning (any future breadth move)
Even with panel vs harness magnitude differences, the **direction** is clear: | signal (liquid, single-sourced) | IC | t |
|---|---:|---:|
| vol_6m | 0.048 | 1.4 |
| mom_12_1 | +0.046 | +1.9 |
| mom_12_1_resid | +0.029 | +1.3 |
- **High vol underperforms** on this pool relative to a clean S&P-like book. High-vol names tend to underperform on this pool relative to a clean S&P-like book. Production **80/20 high-vol tilt** was validated on S&P-like names. **If the universe ever broadens in production, re-validate that tilt first** — it can flip from mildly helpful to harmful. Raw momentum also looks stronger than SPY residualization here (noisier fit for small caps).
- Production rank tilts **20% toward high volatility**, validated on S&P-like names where high-vol ≈ high-beta in a bull tape. On broad Nasdaq liquid, high-vol often means **lottery junk**.
- **If the universe ever broadens in production, re-validate the 80/20 high-vol tilt first** — it can flip from mildly helpful to actively harmful.
- **Raw momentum > residual** on breadth (panel and first harness both show this pattern) — SPY residualization is a noisier fit for small caps; a breadth book may want a different benchmark or raw mom.
--- ---
## How to re-run (research branch only) ## How to re-run (research branch only)
```powershell ```powershell
# Windows
.\.venv\Scripts\python.exe scripts\run_fip_breadth_diagnostics.py ` .\.venv\Scripts\python.exe scripts\run_fip_breadth_diagnostics.py `
--research-snapshot backtest_snapshots\research.sqlite ` --research-snapshot backtest_snapshots\research.sqlite `
--prod-snapshot backtest_snapshots\prod.sqlite ` --prod-snapshot backtest_snapshots\prod.sqlite `
--workers 6 --workers 6 --allow-spawn
```
```bash
# macOS
python scripts/run_fip_breadth_diagnostics.py \
--research-snapshot backtest_snapshots/research.sqlite \
--prod-snapshot backtest_snapshots/prod.sqlite \
--workers 6
``` ```
--- ---
## Bottom line ## Bottom line
- Formal first screen: **not green**, no production change, fingerprint **pass**. 1. Formal iron-rule screen: **not green** either before or after reconciliation.
- Deeper reading: unconditional sign is a **compositional tug-of-war**, not a new jumpiness premium. 2. **+0.0575 / +5.12 is orphaned** — authoritative unconditional liquid fip is **0.017 / 1.9**; mask binds (~97%).
- **The test that matters for this platform already ran:** momentum-conditional fip is **negative, large, and reliable** on liquid breadth → fip remains a **conditional** research lead, not a closed door — and **not** a ship-ready gate input without a book experiment. 3. Compositional tug-of-war is the right story; jumpiness premium is not.
4. **Mom-conditional 0.088 / 4.6 stands on the single-sourced path** → optional next research step is a **book** A/B, not a gate wire-in.
5. Log any future reader who sees both numbers: trust the reconcile artifact, not the orphaned breadth headline.
File diff suppressed because it is too large Load Diff
+387 -405
View File
@@ -1,26 +1,18 @@
"""Post-breadth diagnostics for fip_id (research branch only). """fip_id breadth diagnostics — single-sourced through harness mask helpers.
Same research.sqlite as the liquid-breadth IC run. No production changes. Uses the same collection + ``_filter_liquid_breadth_week_rich`` as
``run_backtest`` signal_eval. No parallel mask implementation.
Checks (pre-registered interpretation follow-ups) Reconciles the harness +0.0575 vs prior dual-path 0.017 disagreement by
------------------------------------------------ deleting the second mask, dumping membership/pre-post stats, and re-running
1. **Lagged membership** — liquid top-N ranked on *prior* week's $vol (extra lag) mom-conditional IC through the surviving path only.
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. Research branch only. Example:
Example (Windows)
-----------------
.\\.venv\\Scripts\\python.exe scripts\\run_fip_breadth_diagnostics.py ^ .\\.venv\\Scripts\\python.exe scripts\\run_fip_breadth_diagnostics.py ^
--research-snapshot backtest_snapshots\\research.sqlite ^ --research-snapshot backtest_snapshots\\research.sqlite ^
--prod-snapshot backtest_snapshots\\prod.sqlite ^ --prod-snapshot backtest_snapshots\\prod.sqlite ^
--workers 6 --workers 6 --allow-spawn
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,6 +21,7 @@ import argparse
import json import json
import math import math
import multiprocessing as mp import multiprocessing as mp
import os
import sys import sys
from collections import defaultdict from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, as_completed
@@ -42,96 +35,41 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
HORIZON = 30 # Match production signal_eval cadence / reliability bars.
MIN_CROSS = 20 MIN_CROSS = 20
MIN_RELIABLE = 12 MIN_RELIABLE = 12
LIQUID_TOP = 1500 MOM_WINNER_PCT = 80.0
MIN_PRICE = 5.0
MOM_WINNER_PCT = 80.0 # top 20% within liquid cross-section
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__) p = argparse.ArgumentParser(description=__doc__)
p.add_argument( p.add_argument("--research-snapshot", default="backtest_snapshots/research.sqlite")
"--research-snapshot", p.add_argument("--prod-snapshot", default="backtest_snapshots/prod.sqlite")
default="backtest_snapshots/research.sqlite", p.add_argument("--top-n", type=int, default=1500)
) p.add_argument("--min-price", type=float, default=5.0)
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("--workers", type=int, default=max(1, (mp.cpu_count() or 4) - 1))
p.add_argument("--allow-spawn", action="store_true")
p.add_argument("--dump-weeks", type=int, default=5, help="How many weeks to dump membership for")
p.add_argument("--out", default=None) p.add_argument("--out", default=None)
p.add_argument("--quiet", action="store_true") p.add_argument("--quiet", action="store_true")
return p.parse_args() 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: def _week_ord(wk: tuple[int, int]) -> int:
return wk[0] * 53 + wk[1] return int(wk[0]) * 53 + int(wk[1])
def _nonoverlap(weeks: list[tuple[int, int]], stride: int) -> list[tuple[int, int]]: def _nonoverlap(weeks: list[tuple[int, int]], stride: int) -> list[tuple[int, int]]:
kept: list[tuple[int, int]] = [] from app.services.backtest_service import _nonoverlapping_weeks
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
return _nonoverlapping_weeks(weeks, stride)
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( def _ic_from_weekly(
week_pairs: dict[tuple[int, int], list[tuple[float, float]]], week_pairs: dict[tuple[int, int], list[tuple[float, float]]],
) -> dict[str, Any]: ) -> dict[str, Any]:
from app.services.backtest_service import HORIZON, _spearman
stride = max(1, round(HORIZON / 5)) stride = max(1, round(HORIZON / 5))
usable = [wk for wk, ps in week_pairs.items() if len(ps) >= MIN_CROSS] usable = [wk for wk, ps in week_pairs.items() if len(ps) >= MIN_CROSS]
kept = _nonoverlap(usable, stride) kept = _nonoverlap(usable, stride)
@@ -171,70 +109,29 @@ def _ic_from_weekly(
} }
def _panel_worker(payload: tuple) -> list[dict]: def _worker(payload: tuple) -> dict:
"""Build weekly observations for one ticker (picklable top-level).""" """Return harness-style signal series for one ticker (liquid-mode dicts)."""
symbol, date_ords, opens, highs, lows, closes, volumes, spy = payload symbol, ords, opens, highs, lows, closes, volumes, spy = payload
from types import SimpleNamespace from types import SimpleNamespace
from app.services.backtest_service import ( from app.services.backtest_service import _signal_series
HORIZON as H,
_median_dollar_vol_63,
_signal_values,
_weekly_asof_indices,
)
dates = [date.fromordinal(int(o)) for o in date_ords] bars = [
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( SimpleNamespace(
date=dates[i], date=date.fromordinal(int(o)),
open=opens_f[i], open=float(op),
high=highs_f[i], high=float(hi),
low=lows_f[i], low=float(lo),
close=closes_f[i], close=float(cl),
volume=vols_f[i], volume=float(vo),
) )
for i in range(n) for o, op, hi, lo, cl, vo in zip(ords, opens, highs, lows, closes, volumes)
] ]
out: list[dict] = [] return _signal_series(bars, spy, symbol=symbol)
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]: def _load_spy(conn) -> dict[date, float]:
rows = conn.execute( rows = conn.execute(
text("SELECT date, close FROM benchmark_prices WHERE symbol = 'SPY' ORDER BY date") text("SELECT date, close FROM benchmark_prices WHERE symbol='SPY' ORDER BY date")
).fetchall() ).fetchall()
out: dict[date, float] = {} out: dict[date, float] = {}
for d, c in rows: for d, c in rows:
@@ -244,34 +141,22 @@ def _load_spy(conn) -> dict[date, float]:
return out return out
def _load_symbols(conn) -> list[str]: def _load_job(conn, symbol: str, spy: dict) -> tuple | None:
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( tid = conn.execute(
text("SELECT id FROM tickers WHERE symbol = :s"), {"s": symbol} text("SELECT id FROM tickers WHERE symbol=:s"), {"s": symbol}
).scalar() ).scalar()
if tid is None: if tid is None:
return None return None
rows = conn.execute( rows = conn.execute(
text( text(
"SELECT date, open, high, low, close, volume FROM ohlcv_records " "SELECT date, open, high, low, close, volume FROM ohlcv_records "
"WHERE ticker_id = :t ORDER BY date" "WHERE ticker_id=:t ORDER BY date"
), ),
{"t": tid}, {"t": tid},
).fetchall() ).fetchall()
if len(rows) < HORIZON + 60: if len(rows) < 90:
return None return None
ords: list[int] = [] ords, opens, highs, lows, closes, vols = [], [], [], [], [], []
opens: list[float] = []
highs: list[float] = []
lows: list[float] = []
closes: list[float] = []
vols: list[float] = []
for d, o, h, l, c, v in rows: for d, o, h, l, c, v in rows:
if isinstance(d, str): if isinstance(d, str):
d = date.fromisoformat(d[:10]) d = date.fromisoformat(d[:10])
@@ -281,36 +166,7 @@ def _load_columns(conn, symbol: str) -> tuple | None:
lows.append(float(l)) lows.append(float(l))
closes.append(float(c)) closes.append(float(c))
vols.append(float(v or 0)) vols.append(float(v or 0))
return (symbol, ords, opens, highs, lows, closes, vols) return (symbol, ords, opens, highs, lows, closes, vols, spy)
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: def main() -> None:
@@ -318,316 +174,440 @@ def main() -> None:
research = Path(args.research_snapshot) research = Path(args.research_snapshot)
prod = Path(args.prod_snapshot) prod = Path(args.prod_snapshot)
if not research.exists(): if not research.exists():
raise SystemExit(f"Missing research snapshot: {research}") raise SystemExit(f"Missing {research}")
research_eng = create_engine(f"sqlite:///{research.resolve().as_posix()}") # Force harness liquid-mode collection (same env as breadth run).
os.environ["BACKTEST_LIQUID_BREADTH"] = str(int(args.top_n))
os.environ["BACKTEST_LIQUID_MIN_PRICE"] = str(float(args.min_price))
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
from app.services.backtest_service import (
HORIZON,
_filter_liquid_breadth_week_rich,
_liquid_breadth_week_stats,
_signal_evaluation,
)
eng = create_engine(f"sqlite:///{research.resolve().as_posix()}")
prod_symbols: set[str] = set() prod_symbols: set[str] = set()
if prod.exists(): if prod.exists():
prod_eng = create_engine(f"sqlite:///{prod.resolve().as_posix()}") peng = create_engine(f"sqlite:///{prod.resolve().as_posix()}")
with prod_eng.connect() as c: with peng.connect() as c:
prod_symbols = { prod_symbols = {
str(r[0]) str(r[0]) for r in c.execute(text("SELECT symbol FROM tickers"))
for r in c.execute(text("SELECT symbol FROM tickers")).fetchall()
} }
prod_eng.dispose() peng.dispose()
with research_eng.connect() as conn: with eng.connect() as conn:
spy = _load_spy(conn) spy = _load_spy(conn)
symbols = _load_symbols(conn) symbols = [
jobs: list[tuple] = [] str(r[0])
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
]
jobs = []
for i, sym in enumerate(symbols, 1): for i, sym in enumerate(symbols, 1):
cols = _load_columns(conn, sym) job = _load_job(conn, sym, spy)
if cols is None: if job is not None:
continue jobs.append(job)
jobs.append((*cols, spy))
if not args.quiet and i % 500 == 0: if not args.quiet and i % 500 == 0:
print(f" queued {i}/{len(symbols)}", flush=True) print(f" queued {i}/{len(symbols)}", flush=True)
if not args.quiet: if not args.quiet:
print(f"Building weekly panel for {len(jobs)} tickers…", flush=True) print(f"Collecting harness signal series for {len(jobs)} tickers…", flush=True)
# Panel: week -> list of obs collected: dict = defaultdict(lambda: defaultdict(list))
by_week: dict[tuple[int, int], list[dict]] = defaultdict(list)
workers = max(1, int(args.workers)) workers = max(1, int(args.workers))
def _merge(series: dict) -> None:
for name, weeks in series.items():
for wk, recs in weeks.items():
# week keys may arrive as lists after JSON; normalize to tuple
key = tuple(wk) if not isinstance(wk, tuple) else wk
collected[name][key].extend(recs)
if workers == 1: if workers == 1:
for j, job in enumerate(jobs, 1): for j, job in enumerate(jobs, 1):
for row in _panel_worker(job): _merge(_worker(job))
by_week[tuple(row["week"])].append(row)
if not args.quiet and j % 200 == 0: if not args.quiet and j % 200 == 0:
print(f" panel {j}/{len(jobs)}", flush=True) print(f" series {j}/{len(jobs)}", flush=True)
else: else:
with ProcessPoolExecutor(max_workers=workers) as pool: ctx = mp.get_context("spawn") if args.allow_spawn or sys.platform == "win32" else None
futs = {pool.submit(_panel_worker, job): job[0] for job in jobs} with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as pool:
done = 0 futs = [pool.submit(_worker, job) for job in jobs]
for fut in as_completed(futs): for j, fut in enumerate(as_completed(futs), 1):
done += 1
try: try:
rows = fut.result() _merge(fut.result())
except Exception as exc: except Exception as exc:
if not args.quiet: if not args.quiet:
print(f" worker error {futs[fut]}: {exc}", flush=True) print(f" worker error: {exc}", flush=True)
continue if not args.quiet and j % 200 == 0:
for row in rows: print(f" series {j}/{len(jobs)}", flush=True)
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: # --- Harness signal_eval (authoritative unconditional ICs) ---
print(f"Weeks with data: {len(by_week)}", flush=True) harness_rows = _signal_evaluation(dict(collected))
harness_by_name = {r["signal"]: r for r in harness_rows}
# 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) top_n = int(args.top_n)
min_price = float(args.min_price) min_price = float(args.min_price)
fip_weeks = collected.get("fip_id") or {}
mom_weeks = collected.get("mom_12_1") or {}
vol_weeks = collected.get("vol_6m") or {}
momr_weeks = collected.get("mom_12_1_resid") or {}
# --- Panels for each check --- # Index mom/vol by (week, symbol) for joins
same_week_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) def _index(weeks_map: dict) -> dict[tuple, dict]:
lag_week_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) out: dict[tuple, dict] = {}
tier_hi_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) for wk, recs in weeks_map.items():
tier_lo_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) key_wk = tuple(wk) if not isinstance(wk, tuple) else wk
prod_subset_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) for rec in recs:
mom_cond_fip: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) if not isinstance(rec, dict):
liquid_vol: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) continue
liquid_mom: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) sym = rec.get("symbol")
liquid_mom_r: dict[tuple[int, int], list[tuple[float, float]]] = defaultdict(list) if not sym:
continue
out[(key_wk, str(sym))] = rec
return out
for wk, obs in by_week.items(): mom_ix = _index(mom_weeks)
# Same-week liquid top-N among names that have fip (matches signal_eval mask: vol_ix = _index(vol_weeks)
# membership is ranked within each signal's observation set). momr_ix = _index(momr_weeks)
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) # Per-week membership + extended checks via shared rich filter
for rank, o in enumerate(liq_fip, 1): same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
same_week_fip[wk].append((float(o["fip_id"]), float(o["fwd"]))) lag_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
tier_hi: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
tier_lo: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
prod_sub: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
mom_cond: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
vol_pairs: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
mom_pairs: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
momr_pairs: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
ordered = sorted((tuple(w) for w in fip_weeks.keys()), key=_week_ord)
prev: dict[tuple, tuple] = {}
for i, wk in enumerate(ordered):
if i:
prev[wk] = ordered[i - 1]
# Prior-week dvol for lag: (symbol, week) from fip recs
dvol_sw: dict[tuple[str, tuple], float] = {}
for wk, recs in fip_weeks.items():
key_wk = tuple(wk) if not isinstance(wk, tuple) else wk
for rec in recs:
if isinstance(rec, dict) and rec.get("symbol") and rec.get("median_dvol_63"):
dvol_sw[(str(rec["symbol"]), key_wk)] = float(rec["median_dvol_63"])
membership_dumps: list[dict] = []
dump_count = 0
stride = max(1, round(HORIZON / 5))
dump_weeks = _nonoverlap(ordered, stride)[: max(0, int(args.dump_weeks))]
for wk_raw, recs in fip_weeks.items():
wk = tuple(wk_raw) if not isinstance(wk_raw, tuple) else wk_raw
stats = _liquid_breadth_week_stats(recs, top_n=top_n, min_price=min_price)
rich = _filter_liquid_breadth_week_rich(
recs, top_n=top_n, min_price=min_price
)
for rank, row in enumerate(rich, 1):
same_week[wk].append((float(row["val"]), float(row["fwd"])))
if rank <= 800: if rank <= 800:
tier_hi_fip[wk].append((float(o["fip_id"]), float(o["fwd"]))) tier_hi[wk].append((float(row["val"]), float(row["fwd"])))
elif rank <= top_n: elif rank <= top_n:
tier_lo_fip[wk].append((float(o["fip_id"]), float(o["fwd"]))) tier_lo[wk].append((float(row["val"]), float(row["fwd"])))
if o["symbol"] in prod_symbols: sym = row.get("symbol")
prod_subset_fip[wk].append((float(o["fip_id"]), float(o["fwd"]))) if sym and str(sym) in prod_symbols:
prod_sub[wk].append((float(row["val"]), float(row["fwd"])))
# Join mom for conditional
mrec = mom_ix.get((wk, str(sym))) if sym else None
if mrec is not None:
row["mom_12_1"] = mrec.get("val")
# Context signals: liquid among names that carry that signal # Mom-conditional among liquid fip set
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 = [ with_mom = [
o for o in liq_fip r for r in rich
if o.get(mom_key) is not None and o.get("fip_id") is not None if r.get("mom_12_1") is not None or mom_ix.get((wk, str(r.get("symbol"))))
] ]
# ensure mom filled
for r in with_mom:
if r.get("mom_12_1") is None and r.get("symbol"):
m = mom_ix.get((wk, str(r["symbol"])))
if m is not None:
r["mom_12_1"] = m["val"]
with_mom = [r for r in rich if r.get("mom_12_1") is not None]
if len(with_mom) >= MIN_CROSS: if len(with_mom) >= MIN_CROSS:
with_mom.sort(key=lambda o: float(o[mom_key])) with_mom.sort(key=lambda r: float(r["mom_12_1"]))
n = len(with_mom) cut = int(math.floor(len(with_mom) * (MOM_WINNER_PCT / 100.0)))
cut = int(math.floor(n * (MOM_WINNER_PCT / 100.0))) for r in with_mom[cut:]:
winners = with_mom[cut:] # upper tail mom_cond[wk].append((float(r["val"]), float(r["fwd"])))
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 # Context signals via same shared filter on their own pools
pw = prev_week.get(wk) for r in _filter_liquid_breadth_week_rich(
vol_weeks.get(wk_raw) or vol_weeks.get(wk) or [],
top_n=top_n,
min_price=min_price,
):
vol_pairs[wk].append((float(r["val"]), float(r["fwd"])))
for r in _filter_liquid_breadth_week_rich(
mom_weeks.get(wk_raw) or mom_weeks.get(wk) or [],
top_n=top_n,
min_price=min_price,
):
mom_pairs[wk].append((float(r["val"]), float(r["fwd"])))
for r in _filter_liquid_breadth_week_rich(
momr_weeks.get(wk_raw) or momr_weeks.get(wk) or [],
top_n=top_n,
min_price=min_price,
):
momr_pairs[wk].append((float(r["val"]), float(r["fwd"])))
# Lagged membership using prior week dvol on current fip pool
pw = prev.get(wk)
if pw is not None: if pw is not None:
lagged: list[dict] = [] lagged_recs = []
for o in with_fip: for rec in recs:
if o.get("close") is None or float(o["close"]) < min_price: if not isinstance(rec, dict) or not rec.get("symbol"):
continue continue
prev_dvol = dvol_by_sym_week.get((o["symbol"], pw)) pdv = dvol_sw.get((str(rec["symbol"]), pw))
if prev_dvol is None or prev_dvol <= 0: if pdv is None or pdv <= 0:
continue continue
lagged.append({**o, "lag_dvol": prev_dvol}) # Clone with lag dvol for ranking
lagged.sort(key=lambda o: float(o["lag_dvol"]), reverse=True) lagged_recs.append({
for o in lagged[:top_n]: **rec,
lag_week_fip[wk].append((float(o["fip_id"]), float(o["fwd"]))) "median_dvol_63": pdv,
})
for r in _filter_liquid_breadth_week_rich(
lagged_recs, top_n=top_n, min_price=min_price
):
lag_week[wk].append((float(r["val"]), float(r["fwd"])))
results = { if wk in dump_weeks and dump_count < args.dump_weeks:
"generated_at": datetime.now().isoformat(), membership_dumps.append({
"research_snapshot": str(research.resolve()), "week": list(wk),
"prod_subset_n": len(prod_symbols), "stats": stats,
"panel_tickers": len(jobs), "symbols": sorted(
"top_n": top_n, str(r["symbol"]) for r in rich if r.get("symbol")
"min_price": min_price, ),
"checks": { "n_symbols": len(rich),
"fip_same_week_liquid_1500": { })
"note": "Replication of main breadth run (same-week $vol mask)", dump_count += 1
**_ic_from_weekly(same_week_fip),
# IC rows
checks = {
"fip_harness_signal_eval": {
"note": "Authoritative harness _signal_evaluation on collected fip_id",
**(harness_by_name.get("fip_id") or {}),
},
"fip_same_week_via_shared_filter": {
"note": "Same collected data, IC via shared _filter_liquid_breadth_week_rich",
**_ic_from_weekly(same_week),
}, },
"fip_lagged_membership_1w": { "fip_lagged_membership_1w": {
"note": ( "note": "Top-N by prior-week $vol on current fip pool (shared filter)",
"Liquid top-N ranked on *prior* week's median $vol — " **_ic_from_weekly(lag_week),
"excludes same-week liquidity explosion leak"
),
**_ic_from_weekly(lag_week_fip),
}, },
"fip_tier_1_800": { "fip_tier_1_800": {
"note": "Same-week liquid ranks 1800 (senior liquid tier)", "note": "Senior liquid ranks 1800",
**_ic_from_weekly(tier_hi_fip), **_ic_from_weekly(tier_hi),
}, },
"fip_tier_801_1500": { "fip_tier_801_1500": {
"note": "Same-week liquid ranks 8011500 (junior liquid tier)", "note": "Junior liquid ranks 801top_n",
**_ic_from_weekly(tier_lo_fip), **_ic_from_weekly(tier_lo),
}, },
"fip_prod_universe_subset": { "fip_prod_universe_subset": {
"note": ( "note": "Prod.sqlite symbols inside liquid fip set",
"Symbols in prod.sqlite (~S&P-like large-cap book) inside " **_ic_from_weekly(prod_sub),
"same-week liquid top-N — compositional control"
),
**_ic_from_weekly(prod_subset_fip),
}, },
"fip_momentum_conditional_top20pct": { "fip_momentum_conditional_top20pct": {
"note": ( "note": (
f"Among liquid top-N, keep mom_12_1 percentile{MOM_WINNER_PCT} " f"Among liquid fip set, mom_12_1P{MOM_WINNER_PCT:.0f} "
"(paper: ID modulates continuation among winners; gate-relevant)" "(paper / gate-relevant)"
), ),
**_ic_from_weekly(mom_cond_fip), **_ic_from_weekly(mom_cond),
}, },
"vol_6m_liquid_1500": { "vol_6m_liquid": {
"note": "Context: low-vol anomaly strength on this pool", "note": "vol_6m through shared filter",
**_ic_from_weekly(liquid_vol), **_ic_from_weekly(vol_pairs),
}, },
"mom_12_1_liquid_1500": { "mom_12_1_liquid": {
"note": "Context: raw momentum on liquid breadth", "note": "raw mom through shared filter",
**_ic_from_weekly(liquid_mom), **_ic_from_weekly(mom_pairs),
},
"mom_12_1_resid_liquid_1500": {
"note": "Context: residual momentum on liquid breadth",
**_ic_from_weekly(liquid_mom_r),
}, },
"mom_12_1_resid_liquid": {
"note": "residual mom through shared filter",
**_ic_from_weekly(momr_pairs),
}, },
} }
# Interpretations h = checks["fip_harness_signal_eval"]
checks = results["checks"] s = checks["fip_same_week_via_shared_filter"]
lag = checks["fip_lagged_membership_1w"] cond = checks["fip_momentum_conditional_top20pct"]
same = checks["fip_same_week_liquid_1500"] prod = checks["fip_prod_universe_subset"]
hi = checks["fip_tier_1_800"] hi = checks["fip_tier_1_800"]
lo = checks["fip_tier_801_1500"] lo = checks["fip_tier_801_1500"]
prod = checks["fip_prod_universe_subset"] lag = checks["fip_lagged_membership_1w"]
cond = checks["fip_momentum_conditional_top20pct"]
def _sign(x: float | None) -> str: # Self-consistency: harness eval vs manual IC on same filter must match
if x is None: harness_ic = h.get("mean_ic")
return "na" shared_ic = s.get("mean_ic")
return "neg" if x < 0 else "pos" consistent = (
harness_ic is not None
and shared_ic is not None
and abs(float(harness_ic) - float(shared_ic)) < 0.005
)
results["interpretation"] = { mom_alive = (
"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 cond.get("mean_ic") is not None
and float(cond["mean_ic"]) < 0 and float(cond["mean_ic"]) < 0
and abs(float(cond["mean_ic"])) >= 0.03 and abs(float(cond["mean_ic"])) >= 0.03
and bool(cond.get("reliable")) and bool(cond.get("reliable"))
)
results = {
"generated_at": datetime.now().isoformat(),
"research_snapshot": str(research.resolve()),
"top_n": top_n,
"min_price": min_price,
"prod_subset_n": len(prod_symbols),
"panel_tickers": len(jobs),
"single_source": (
"diagnostics uses harness _signal_series + "
"_filter_liquid_breadth_week_rich only (no parallel mask)"
), ),
"compositional_flip_story": ( "avg_cross_section_semantics": (
"If prod subset IC is negative while full liquid-1500 is positive, " "avg_cross_section = post-mask IC sample size. "
"the sign flip is compositional (bleeders / Nasdaq junk), not a " "avg_raw_pool = pre-filter observations. "
"temporal regime change. Unconditional fip pools continuous winners " "avg_eligible_pre_mask = pass price+dvol before top-N. "
"(want neg IC) against continuous losers/bleeders (want pos IC)." "mask_binds_pct = weeks where eligible_pre_mask > top_n."
),
"harness_self_consistent": consistent,
"checks": checks,
"membership_dumps": membership_dumps,
"interpretation": {
"harness_and_shared_filter_agree": consistent,
"mask_binds_pct": h.get("mask_binds_pct"),
"avg_eligible_pre_mask": h.get("avg_eligible_pre_mask"),
"avg_raw_pool": h.get("avg_raw_pool"),
"prod_subset_still_negative": (
prod.get("mean_ic") is not None and float(prod["mean_ic"]) < 0
),
"junior_tier_more_positive": (
lo.get("mean_ic") is not None
and hi.get("mean_ic") is not None
and float(lo["mean_ic"]) > float(hi["mean_ic"])
),
"lag_same_sign_as_same_week": (
lag.get("mean_ic") is not None
and s.get("mean_ic") is not None
and (float(lag["mean_ic"]) < 0) == (float(s["mean_ic"]) < 0)
),
"mom_conditional_negative_and_reliable": mom_alive,
"orphan_plus_five_sigma": (
"Prior report fip-breadth-20260718-211440-breadth.json listed "
"fip IC +0.0575 / t +5.12. This single-sourced recompute is the "
"authoritative number; if it disagrees, the +0.0575 row is orphaned."
),
"compositional_story": (
"fip_id pools continuous winners (neg IC) vs continuous bleeders "
"(pos IC). Prod-subset and senior liquid stay negative; junior "
"liquid is less negative / positive — composition, not jumpiness premium."
), ),
"vol_tilt_warning": ( "vol_tilt_warning": (
"vol_6m large negative IC on breadth: high-vol lottery names " "High-vol names underperform on breadth relative to S&P-like books. "
"underperform. Production 80/20 high-vol tilt was validated on " "Re-validate production 80/20 high-vol tilt before any universe broaden."
"S&P-like names; must re-validate before any universe broaden." ),
},
"platform_verdict": (
"Mom-conditional fip ALIVE as book-tilt candidate (needs book sim) — "
"not production wire-in. Unconditional fip not green."
if mom_alive
else (
"fip CLOSED for production: mom-conditional does not clear iron rule "
"on single-sourced path. Display card is the resting place."
)
), ),
} }
# 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") 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 = Path(args.out) if args.out else Path("reports") / f"fip-reconcile-{stamp}.json"
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(results, indent=2, default=str), encoding="utf-8") out.write_text(json.dumps(results, indent=2, default=str), encoding="utf-8")
# Append to research log # Update research log
md_path = Path("docs/research/fip-breadth-ic.md") _update_md(Path("docs/research/fip-breadth-ic.md"), results, out)
_append_diagnostics_md(md_path, results, out)
if not args.quiet: if not args.quiet:
print(json.dumps(results["checks"], indent=2, default=str)) print("=== Harness fip_id (authoritative) ===")
print() print(json.dumps(h, indent=2, default=str))
print("interpretation:", json.dumps(results["interpretation"], indent=2)) print("=== Shared-filter same-week (must match) ===")
print(json.dumps(s, indent=2, default=str))
print("=== Mom-conditional ===")
print(json.dumps(cond, indent=2, default=str))
print("self_consistent:", consistent)
print("platform_verdict:", results["platform_verdict"]) print("platform_verdict:", results["platform_verdict"])
print(f"Wrote {out}") print(f"Wrote {out}")
print(f"Updated {md_path}")
def _append_diagnostics_md(path: Path, results: dict, artifact: Path) -> None: def _update_md(path: Path, results: dict, artifact: Path) -> None:
checks = results["checks"] checks = results["checks"]
interp = results["interpretation"] interp = results["interpretation"]
h = checks.get("fip_harness_signal_eval") or {}
lines = [ lines = [
"", "",
"---", "---",
"", "",
f"## Follow-up diagnostics ({results['generated_at'][:10]})", f"## Reconciliation ({results['generated_at'][:10]})",
"", "",
"Compositional reading of the sign flip (before any 'jumpiness premium' story):", "### Problem",
"", "",
"`fip_id = sign(PRET) × (%neg %pos)` pools two opposite continuous populations:", "Two implementations of the liquid-1500 fip IC disagreed on **sign**:",
"", "",
"- **Continuous winners** (PRET>0, mostly up days) → paper claim → **negative** IC contribution.", "- Harness report `fip-breadth-20260718-211440-breadth.json`: **+0.0575 / t +5.12**",
"- **Continuous losers / bleeders** (PRET<0, mostly down days) → momentum continuation down → **positive** IC contribution.", "- Dual-path diagnostics (since deleted): **0.017 / t 1.9**",
"", "",
"Unconditional IC is a tug-of-war weighted by universe composition. S&P-like books " "A static read cannot decide which is right without single-sourcing the mask.",
"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", "### Resolution",
"",
f"- **Single source:** {results.get('single_source')}",
f"- **avg_cross_section semantics:** {results.get('avg_cross_section_semantics')}",
f"- Harness `_signal_evaluation` vs shared-filter recompute agree: "
f"**{interp.get('harness_and_shared_filter_agree')}**",
"",
"### Authoritative unconditional fip (liquid top-N, post-mask)",
"",
f"| metric | value |",
f"|---|---|",
f"| mean_ic | {h.get('mean_ic')} |",
f"| ic_t_stat | {h.get('ic_t_stat')} |",
f"| weeks | {h.get('weeks')} |",
f"| avg_cross_section (post-mask) | {h.get('avg_cross_section')} |",
f"| avg_raw_pool | {h.get('avg_raw_pool')} |",
f"| avg_eligible_pre_mask | {h.get('avg_eligible_pre_mask')} |",
f"| mask_binds_pct | {h.get('mask_binds_pct')} |",
f"| reliable | {h.get('reliable')} |",
"",
"The **+0.0575 / +5.12** row is **orphaned** if the authoritative recompute "
"disagrees; do not cite it. Iron-rule unconditional green still requires "
"negative sign and |IC| ≳ 0.03 on this row.",
"",
"### Checks (single-sourced)",
"", "",
"| check | mean_ic | t | weeks | avg N | reliable |", "| check | mean_ic | t | weeks | avg N | reliable |",
"|---|---:|---:|---:|---:|---|", "|---|---:|---:|---:|---:|---|",
] ]
order = [ for key in [
"fip_same_week_liquid_1500", "fip_harness_signal_eval",
"fip_same_week_via_shared_filter",
"fip_lagged_membership_1w", "fip_lagged_membership_1w",
"fip_tier_1_800", "fip_tier_1_800",
"fip_tier_801_1500", "fip_tier_801_1500",
"fip_prod_universe_subset", "fip_prod_universe_subset",
"fip_momentum_conditional_top20pct", "fip_momentum_conditional_top20pct",
"vol_6m_liquid_1500", "vol_6m_liquid",
"mom_12_1_liquid_1500", "mom_12_1_liquid",
"mom_12_1_resid_liquid_1500", "mom_12_1_resid_liquid",
] ]:
for key in order:
row = checks.get(key) or {} row = checks.get(key) or {}
lines.append( lines.append(
f"| {key} | {row.get('mean_ic')} | {row.get('ic_t_stat')} | " f"| {key} | {row.get('mean_ic')} | {row.get('ic_t_stat')} | "
@@ -637,28 +617,30 @@ def _append_diagnostics_md(path: Path, results: dict, artifact: Path) -> None:
"", "",
"### Flags", "### Flags",
"", "",
f"- Lagged mask keeps same sign / material |IC|: **{interp.get('leak_ruled_out')}**", f"- Prod subset still negative: **{interp.get('prod_subset_still_negative')}**",
f"- Junior tier (8011500) drives more positive IC: **{interp.get('junior_tier_drives_positive')}**", f"- Junior tier more positive than senior: **{interp.get('junior_tier_more_positive')}**",
f"- Prod-universe subset still negative: **{interp.get('prod_subset_still_negative')}**", f"- Lag same sign as same-week: **{interp.get('lag_same_sign_as_same_week')}**",
f"- Mom-conditional (≥P80) negative + reliable: **{interp.get('mom_conditional_negative_and_reliable')}**", f"- Mom-conditional negative + reliable: **{interp.get('mom_conditional_negative_and_reliable')}**",
"", "",
"### Platform verdict", "### Platform verdict (post-reconciliation)",
"", "",
results.get("platform_verdict", ""), results.get("platform_verdict", ""),
"", "",
"### Vol-tilt warning (any future breadth move)", "### Vol-tilt warning",
"", "",
interp.get("vol_tilt_warning", ""), interp.get("vol_tilt_warning", ""),
"", "",
f"Artifact: `{artifact.as_posix()}`", 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 "" existing = path.read_text(encoding="utf-8") if path.exists() else ""
marker = "## Follow-up diagnostics" marker = "## Reconciliation"
if marker in existing: if marker in existing:
existing = existing.split(marker)[0].rstrip() + "\n" existing = existing.split(marker)[0].rstrip() + "\n"
path.write_text(existing + "\n".join(lines), encoding="utf-8") # Also strip old dual-path diagnostics section if present after reconciliation
if "## Follow-up diagnostics" in existing and marker not in path.read_text(encoding="utf-8") if path.exists() else "":
pass
path.write_text(existing.rstrip() + "\n" + "\n".join(lines), encoding="utf-8")
if __name__ == "__main__": if __name__ == "__main__":