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:
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 (today’s 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 1–800 (senior) | **−0.035** | **−2.99** | Winner leg |
|
||||||
|
| Tier 801–1500 (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 1–800** (senior liquid) | **−0.035** | **−2.99** | 35 | 791 | true |
|
| ic_positive_pct | 22.9% |
|
||||||
| fip **tier 801–1500** (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 paper’s 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 run’s **+0.0575** still does not match the independent panel’s −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 paper’s 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
@@ -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 1–800 vs 801–1500 (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,65 +109,24 @@ 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]:
|
||||||
@@ -244,14 +141,7 @@ 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()
|
||||||
@@ -264,14 +154,9 @@ def _load_columns(conn, symbol: str) -> tuple | None:
|
|||||||
),
|
),
|
||||||
{"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 1–800 (senior liquid tier)",
|
"note": "Senior liquid ranks 1–800",
|
||||||
**_ic_from_weekly(tier_hi_fip),
|
**_ic_from_weekly(tier_hi),
|
||||||
},
|
},
|
||||||
"fip_tier_801_1500": {
|
"fip_tier_801_1500": {
|
||||||
"note": "Same-week liquid ranks 801–1500 (junior liquid tier)",
|
"note": "Junior liquid ranks 801–top_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_1 ≥ P{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 (801–1500) 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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user