"""READ-ONLY falsification study of crypto ON-CHAIN / FUNDAMENTAL signals — a data class we have never mined
(every prior probe was price / funding / derivatives). Two independent angles, each with its own PASS/FAIL
verdict, both NET of measured cost, both walk-forward (OOS), both checked vs the 4 live edges.

Angle ① — STABLECOIN-SUPPLY LIQUIDITY TIMING (macro overlay)
-----------------------------------------------------------
The aggregate stablecoin circulating supply (DefiLlama `stablecoincharts/all`) is a genuine DAILY time series.
Thesis: rising supply = fiat entering crypto = bullish the broad market. Signal at day t (strictly causal): the
trailing z-score of the daily supply CHANGE. A long/flat TIMING book on the crypto market (BTC, or the liquid
equal-weight mean): position formed at t earns the market's t→t+1 return. The threshold AND the sign are chosen
on TRAIN only and scored on the held-out OOS TEST (guards the after-the-fact calibration). HONEST CAVEAT baked
in: macro-timing on ~one crypto cycle overfits trivially — we demand strict OOS, a non-overlapping t-stat, and
report how FEW independent observations the supply series really contains (N/A when too thin).

Angle ② — FUNDAMENTAL VALUE FACTOR (cross-sectional)
---------------------------------------------------
Per-protocol fundamentals (DefiLlama TVL `/protocols` + fees `/overview/fees`) ÷ market cap (CoinGecko) give a
cross-sectional VALUE ratio (fees/mcap, revenue/mcap or TVL/mcap). Long CHEAP (high ratio) / short EXPENSIVE,
market-neutral, no-lookahead (rank at t, forward return t+1), net of the MEASURED per-coin Corwin–Schultz cost,
checked for correlation to tstrend (value should be ~orthogonal to momentum) and the other 3 edges.

CRITICAL POINT-IN-TIME CAVEAT: the cheap DefiLlama/CoinGecko endpoints return a CURRENT SNAPSHOT (TVL/fees/mcap
"now"), NOT a survivorship-free historical panel. A static present-day ranking applied to PAST returns is
lookahead-contaminated, and an OOS split does NOT cleanse a static signal. So when the fundamentals are a
snapshot (`point_in_time=False`, the live case) the verdict is forced to N/A with that reason — the diagnostic
numbers are still reported, but a real value-factor CONFIRM requires a historical point-in-time fundamentals
panel we do not have for free. The fixtures inject a genuine multi-day panel (`point_in_time=True`) to exercise
the CONFIRM/FALSIFY logic (proving it is data-driven, not hard-coded).

Reuse (no engine reimplemented)
-------------------------------
* `forward_returns_from_close` (below) for the no-lookahead t→t+1 forward-return panel (relocated here,
  Phase 0b venue consolidation, from the retired `funding_dispersion_eval` — this module's only consumer);
* `bybit_carry_revalidate._curve_metrics` for Sharpe / CAGR / maxDD / total-return;
* `bybit_liquidity_sweep.corwin_schultz_spread_bps` + `coin_cost_bps` for the MEASURED per-coin cost;
* `bybit_book_eval.sleeve_returns_from_store` for the 4 live edges' return series (the correlation targets);
* `bybit_book_eval._UniverseRestrictedStore` for the memory-bounded reads.

READ-ONLY + memory-bounded: no write/persist call is ever made (a WriteTripwire test proves it); reads are
restricted to the bounded universe.
"""
from __future__ import annotations

import datetime as dt
import math
import statistics as st
from typing import Any, Protocol

from fxhnt.application.bybit_book_eval import _UniverseRestrictedStore, sleeve_returns_from_store
from fxhnt.application.bybit_carry_revalidate import _curve_metrics
from fxhnt.application.bybit_liquidity_sweep import coin_cost_bps, corwin_schultz_spread_bps

_PERIODS_PER_YEAR = 365
_TAKER_FEE_BPS = 5.5

# The 4 live edges a new signal must add value BEYOND (correlation target series).
_LIVE_EDGES = ("tstrend", "unlock", "xsfunding", "positioning")

# --- verdict thresholds (module constants, documented) ------------------------------------------
_MIN_OOS_SHARPE = 0.5          # NET out-of-sample Sharpe must clear this to count as "meaningfully positive"
_MIN_TSTAT = 2.0               # non-overlapping daily-return t-stat must clear this (honest significance)
_MAX_CORR_TO_EDGE = 0.6        # |corr| to EVERY live edge must be below this (a diversifying addition)
_LARGE_ANN_RETURN = 0.05       # LARGE: |annualised return| >= 5%/yr (economically meaningful, not noise)
_MIN_ACTIVE_FRACTION = 0.05    # FREQUENT (timing): position active on >= this fraction of days
_MIN_VALUE_COINS = 8           # CONCENTRATED/breadth floor for the cross-sectional value book
_MIN_TIMING_DAYS = 365         # Angle ①: fewer supply-series trading days than this → too thin → N/A
_MIN_REGIME_SWITCHES = 6       # Angle ①: fewer position switches than this → not enough independent bets → N/A


def _epoch_day_to_iso(day: int) -> str:
    return (dt.date(1970, 1, 1) + dt.timedelta(days=int(day))).isoformat()


def forward_returns_from_close(close_panel: dict[str, dict[int, float]], *,
                               max_gap: int = 4) -> dict[int, dict[str, float]]:
    """`{day: {coin: ret_{t→next}}}` — each coin's NEXT-DAY perp return attributed to day t (no-lookahead: a
    position FORMED at t earns the t→t+1 return realized at t+1). For each coin, the return on day t is
    `close[t']/close[t] − 1` where t' is the nearest FUTURE day with data within `max_gap` (handles sparse
    bars). The last day of a coin (no future close) yields no forward return. Pure, deterministic."""
    out: dict[int, dict[str, float]] = {}
    for coin, series in close_panel.items():
        days = sorted(series)
        for i, t in enumerate(days):
            nxt = None
            for t2 in days[i + 1:]:
                if t2 - t > max_gap:
                    break
                nxt = t2
                break
            if nxt is None:
                continue
            c0, c1 = series[t], series[nxt]
            if c0 > 0.0 and c1 > 0.0:
                out.setdefault(int(t), {})[coin] = c1 / c0 - 1.0
    return out


# --- shared metric / stats helpers (mirror funding_dispersion_eval) ------------------------------

def _sharpe(series: dict[int, float]) -> float:
    rets = [series[d] for d in sorted(series)]
    if len(rets) < 2:
        return 0.0
    sd = st.pstdev(rets)
    return (st.mean(rets) / sd * math.sqrt(_PERIODS_PER_YEAR)) if sd > 0 else 0.0


def _metrics(series: dict[int, float]) -> dict[str, Any]:
    daily = [(d, series[d]) for d in sorted(series)]
    meta = {"symbols": 0, "first_day": _epoch_day_to_iso(daily[0][0]) if daily else None,
            "last_day": _epoch_day_to_iso(daily[-1][0]) if daily else None}
    return _curve_metrics(daily, days_meta=meta)


def _tstat(series: dict[int, float]) -> float:
    """Non-overlapping daily-return t-stat: mean / (sample-stdev / √n). Each daily return is one independent
    observation (positions formed at t earn the strictly-next return t→t+1, no overlap). 0.0 when <2 obs or
    zero variance. The honest significance check — overlapping-sample t-stats are worthless (memory:
    pearl_intraday_edge_existence_falsified)."""
    rets = [series[d] for d in sorted(series)]
    n = len(rets)
    if n < 2:
        return 0.0
    sd = st.stdev(rets)
    if sd <= 0.0:
        return 0.0
    return st.mean(rets) / (sd / math.sqrt(n))


def _split_day(days: list[int], train_frac: float) -> int:
    """First TEST day (inclusive) at `train_frac` of the date axis."""
    if len(days) < 4:
        return (days[-1] + 1) if days else 1
    idx = max(1, min(len(days) - 1, int(len(days) * train_frac)))
    return days[idx]


def _window(series: dict[int, float], *, day_lo: int | None = None,
            day_hi: int | None = None) -> dict[int, float]:
    return {d: r for d, r in series.items()
            if (day_lo is None or d >= day_lo) and (day_hi is None or d <= day_hi)}


def _correlation(a_series: dict[int, float], b_series: dict[int, float]) -> float | None:
    common = sorted(set(a_series) & set(b_series))
    if len(common) < 2:
        return None
    a = [a_series[d] for d in common]
    b = [b_series[d] for d in common]
    sa, sb = st.pstdev(a), st.pstdev(b)
    if sa <= 0.0 or sb <= 0.0:
        return None
    ma, mb = st.mean(a), st.mean(b)
    cov = sum((x - ma) * (y - mb) for x, y in zip(a, b, strict=True)) / len(common)
    return cov / (sa * sb)


def _max_abs_corr(series: dict[int, float], edges: dict[str, dict[int, float]]) -> tuple[float | None, str | None]:
    """The MOST correlated live edge (|corr|) — the diversification bar: a new signal must be below
    `_MAX_CORR_TO_EDGE` against EVERY live edge. Returns (max|corr|, edge_name) or (None, None) when no edge
    series overlaps (correlation uncomputable)."""
    best: float | None = None
    best_edge: str | None = None
    for name, es in edges.items():
        c = _correlation(series, es)
        if c is None:
            continue
        if best is None or abs(c) > best:
            best, best_edge = abs(c), name
    return best, best_edge


# --- ANGLE ①: STABLECOIN-SUPPLY LIQUIDITY TIMING -------------------------------------------------

def supply_growth_zscore(supply: dict[int, float], *, lookback: int = 30) -> dict[int, float]:
    """`{day: z}` — the trailing z-score of the daily supply CHANGE, STRICTLY CAUSAL (uses only data ≤ t).

    For each day t (in ascending order), Δ_t = supply_t − supply_{prev observed day}. The z-score at t uses
    the trailing window of the LAST `lookback` changes up to AND INCLUDING t: `z_t = (Δ_t − mean) / stdev`.
    A window with <2 changes or zero stdev yields no z (skipped). No future change ever enters z_t — adding a
    later day cannot alter an earlier z (a tested invariant). Pure, deterministic."""
    days = sorted(supply)
    changes: list[tuple[int, float]] = []
    for i in range(1, len(days)):
        changes.append((days[i], supply[days[i]] - supply[days[i - 1]]))
    out: dict[int, float] = {}
    for j in range(len(changes)):
        window = [c for _, c in changes[max(0, j - lookback + 1): j + 1]]
        if len(window) < 2:
            continue
        sd = st.pstdev(window)
        if sd <= 0.0:
            continue
        day = changes[j][0]
        out[day] = (changes[j][1] - st.mean(window)) / sd
    return out


def market_forward_returns(close: dict[int, float], *, max_gap: int = 4) -> dict[int, float]:
    """`{day: ret_{t→next}}` — the single-asset (market) next-day return attributed to day t (no-lookahead:
    a position formed at t earns t→t+1). Mirrors `forward_returns_from_close` for one series. Pure."""
    one = forward_returns_from_close({"_MKT": close}, max_gap=max_gap)
    return {d: row["_MKT"] for d, row in one.items() if "_MKT" in row}


def timing_book(z: dict[int, float], market_fwd: dict[int, float], *, threshold: float = 0.0,
                direction: str = "long_high", cost_bps: float = 5.5) -> dict[str, Any]:
    """Long/flat market-TIMING book from the supply-growth z-score.

    `direction`: `long_high` → go LONG (position 1.0) when z_t > `threshold`, else FLAT (rising supply =
    bullish); `long_low` is the sign-flip (long when −z_t > threshold = supply CONTRACTING). The position
    formed at t earns `market_fwd[t]` (the t→t+1 market return — no-lookahead). Turnover = |pos − prev_pos|;
    cost = turnover·`cost_bps`/1e4 (the broad-market overlay trades one liquid instrument). Returns
    `{net, gross, turnover, position, n_active}`. Pure, deterministic."""
    if direction not in ("long_high", "long_low"):
        raise ValueError(f"direction must be 'long_high' or 'long_low', got {direction!r}")
    sign = 1.0 if direction == "long_high" else -1.0
    net: dict[int, float] = {}
    gross_s: dict[int, float] = {}
    turnover_s: dict[int, float] = {}
    position: dict[int, float] = {}
    prev_pos = 0.0
    n_active = 0
    for day in sorted(z):
        if day not in market_fwd:
            continue
        pos = 1.0 if sign * z[day] > threshold else 0.0
        turnover = abs(pos - prev_pos)
        gross = pos * market_fwd[day]
        gross_s[day] = gross
        net[day] = gross - turnover * cost_bps / 1e4
        turnover_s[day] = turnover
        position[day] = pos
        n_active += int(pos > 0.0)
        prev_pos = pos
    return {"net": net, "gross": gross_s, "turnover": turnover_s, "position": position, "n_active": n_active}


def _regime_switches(position: dict[int, float]) -> int:
    """Count position changes (entries + exits) — the number of independent timing bets the overlay makes."""
    days = sorted(position)
    prev = 0.0
    n = 0
    for d in days:
        if position[d] != prev:
            n += 1
            prev = position[d]
    return n


# --- ANGLE ②: FUNDAMENTAL VALUE FACTOR (cross-sectional) -----------------------------------------

def value_ratio_by_coin(fundamentals: dict[str, dict[str, float]], *, ratio_kind: str = "fees_mcap",
                        ) -> dict[str, float]:
    """`{coin: ratio}` value score from per-coin fundamentals. `ratio_kind` ∈ {fees_mcap, revenue_mcap,
    tvl_mcap}: numerator / market cap (high ratio = CHEAP = a lot of fundamental per $ of cap). A coin missing
    the numerator OR with mcap ≤ 0 is EXCLUDED (no fundamentals → not in the value universe). Pure."""
    num_key = {"fees_mcap": "fees", "revenue_mcap": "revenue", "tvl_mcap": "tvl"}.get(ratio_kind)
    if num_key is None:
        raise ValueError(f"ratio_kind must be fees_mcap|revenue_mcap|tvl_mcap, got {ratio_kind!r}")
    out: dict[str, float] = {}
    for coin, f in fundamentals.items():
        mcap = float(f.get("mcap", 0.0) or 0.0)
        num = f.get(num_key)
        if mcap <= 0.0 or num is None:
            continue
        out[coin] = float(num) / mcap
    return out


def value_factor_weights(value_by_coin: dict[str, float]) -> dict[str, float]:
    """Market-neutral, unit-gross cross-sectional value weights: LONG the high-ratio (CHEAP) coins (+demeaned)
    / SHORT the low-ratio (EXPENSIVE). Σw ≈ 0, Σ|w| = 1. <2 coins or zero gross → {}. Pure."""
    if len(value_by_coin) < 2:
        return {}
    mean = st.mean(value_by_coin.values())
    raw = {c: v - mean for c, v in value_by_coin.items()}
    gross = sum(abs(w) for w in raw.values())
    if gross <= 0.0:
        return {}
    return {c: w / gross for c, w in raw.items()}


def value_factor_book(value_panel: dict[int, dict[str, float]],
                      forward_returns: dict[int, dict[str, float]], *,
                      cost_by_coin: dict[str, float] | None = None,
                      cost_bps: float = 5.5) -> dict[str, Any]:
    """Cross-sectional value-factor book: per-day value weights applied to the NEXT-DAY forward returns.

    Each day t: `w = value_factor_weights(value_panel[t])` (long cheap / short expensive), earning the t→t+1
    forward perp return `forward_returns[t]`, minus a per-turnover MEASURED cost (`Σ|Δw_c|·cost_by_coin[c]/1e4`,
    falling back to `cost_bps` for a coin without a measured cost). No-lookahead by construction (weights from
    t, returns from t+1). A STATIC snapshot panel (same ratios every day) ⇒ turnover ≈ 0 after entry. Returns
    `{net, gross, turnover}`. Pure, deterministic."""
    cost_by_coin = cost_by_coin or {}
    net: dict[int, float] = {}
    gross_s: dict[int, float] = {}
    turnover_s: dict[int, float] = {}
    prev_w: dict[str, float] = {}
    for day in sorted(value_panel):
        w = value_factor_weights(value_panel[day])
        if not w:
            prev_w = {}
            continue
        fr = forward_returns.get(day, {})
        gross = sum(w.get(c, 0.0) * fr.get(c, 0.0) for c in w)
        cost = 0.0
        for c in set(w) | set(prev_w):
            dwt = abs(w.get(c, 0.0) - prev_w.get(c, 0.0))
            cost += dwt * cost_by_coin.get(c, cost_bps) / 1e4
        turnover = sum(abs(w.get(c, 0.0) - prev_w.get(c, 0.0)) for c in set(w) | set(prev_w))
        if math.isfinite(gross):
            gross_s[day] = gross
            net[day] = gross - cost
            turnover_s[day] = turnover
        prev_w = w
    return {"net": net, "gross": gross_s, "turnover": turnover_s}


# --- LARGE + FREQUENT + CONCENTRATED gate --------------------------------------------------------

def _large_frequent_concentrated(net: dict[int, float], *, frequent: bool, concentrated: bool,
                                 ) -> dict[str, Any]:
    """The LARGE+FREQUENT+CONCENTRATED heuristic (memory: small_capital_fund_playbook). LARGE = |annualised
    return| ≥ `_LARGE_ANN_RETURN`; FREQUENT and CONCENTRATED are angle-specific booleans computed by the
    caller (active-day fraction / breadth). Returns the three flags + a combined `pass`."""
    m = _metrics(net)
    large = abs(m["ann_return"]) >= _LARGE_ANN_RETURN
    return {"large": large, "frequent": frequent, "concentrated": concentrated,
            "pass": bool(large and frequent and concentrated)}


# --- store-backed loaders (READ-ONLY, memory-bounded) -------------------------------------------

class _FeatureStore(Protocol):
    def crypto_close_panel(self, *, suffix: str = "USDT") -> dict[str, dict[int, float]]: ...
    def crypto_funding_panel(self, *, suffix: str = "USDT") -> dict[str, dict[int, float]]: ...
    def crypto_spot_panel(self, *, suffix: str = "USDT") -> dict[str, dict[int, float]]: ...
    def read_panel(self, symbols: list[str], feature: str) -> dict[str, dict[int, float]]: ...


def _live_edge_series(store: _FeatureStore, *, universe: set[str] | None, cost_bps: float,
                      ) -> dict[str, dict[int, float]]:
    """The 4 live edges' return series over the same store (the correlation targets). An uncomputable edge
    (e.g. unlock without an injected calendar, or a tiny fixture) contributes an EMPTY series, dropped here."""
    out: dict[str, dict[int, float]] = {}
    for edge in _LIVE_EDGES:
        try:
            s = sleeve_returns_from_store(store, edge, universe=universe, cost_bps=cost_bps)
        except Exception:  # noqa: BLE001 — an uncomputable edge contributes no correlation target
            s = {}
        if len(s) >= 2:
            out[edge] = s
    return out


def _market_close_series(store: _FeatureStore, *, market: str, universe: set[str] | None) -> dict[int, float]:
    """The broad-market close series for the timing overlay. `market='btc'` → BTCUSDT close; `market='ew'` →
    the equal-weight mean of the (universe-restricted) close panel's per-day cross-section. Empty when absent."""
    if market == "btc":
        panel = store.read_panel(["BTCUSDT"], "close")
        return dict(panel.get("BTCUSDT", {}))
    if market == "ew":
        bounded = _UniverseRestrictedStore(store, universe) if universe is not None else store
        panel = bounded.crypto_close_panel()
        by_day: dict[int, list[float]] = {}
        for series in panel.values():
            for d, px in series.items():
                if px > 0.0:
                    by_day.setdefault(int(d), []).append(px)
        # Equal-weight mean LEVEL per day → a synthetic market index whose returns are the cross-sectional mean.
        return {d: (sum(v) / len(v)) for d, v in by_day.items() if v}
    raise ValueError(f"market must be 'btc' or 'ew', got {market!r}")


def _measured_cost_by_coin(store: _FeatureStore, coins: list[str], *, taker_fee_bps: float = _TAKER_FEE_BPS,
                           ) -> dict[str, float]:
    """Per-coin MEASURED single-venue cost (bps) = taker + 0.5·Corwin–Schultz spread, from the coin's daily
    high/low (read bounded). A coin missing high/low measures 0 spread → pays only the taker floor."""
    if not coins:
        return {}
    syms = sorted(coins)
    high = store.read_panel(syms, "high")
    low = store.read_panel(syms, "low")
    out: dict[str, float] = {}
    for s in syms:
        hi, lo = high.get(s, {}), low.get(s, {})
        common = sorted(set(hi) & set(lo))
        spread = corwin_schultz_spread_bps([hi[d] for d in common], [lo[d] for d in common]) \
            if len(common) >= 2 else 0.0
        out[s] = coin_cost_bps(effective_spread_bps=spread, taker_fee_bps=taker_fee_bps)
    return out


# --- ANGLE ① verdict -----------------------------------------------------------------------------

def _angle1_verdict(store: _FeatureStore, supply: dict[int, float], *, market: str,
                    universe: set[str] | None, edges: dict[str, dict[int, float]], cost_bps: float,
                    lookback: int, threshold_grid: tuple[float, ...], train_frac: float) -> dict[str, Any]:
    close = _market_close_series(store, market=market, universe=universe)
    fwd = market_forward_returns(close)
    z = supply_growth_zscore(supply, lookback=lookback)
    common_days = sorted(set(z) & set(fwd))
    n_days = len(common_days)

    # Too few independent observations → N/A (honest: macro-timing on ~1 cycle overfits trivially).
    if n_days < _MIN_TIMING_DAYS:
        return {"available": False, "n_days": n_days, "market": market,
                "reason": f"supply/market overlap has only {n_days} trading days (< {_MIN_TIMING_DAYS}) — too "
                          "thin to conclude a macro-timing edge on ~one crypto cycle"}

    # Choose (direction, threshold) on TRAIN Sharpe; score the winner OOS (guards the calibration choice).
    test_lo = _split_day(common_days, train_frac)
    best: tuple[float, str, float] | None = None      # (train_sharpe, direction, threshold)
    books: dict[tuple[str, float], dict[str, Any]] = {}
    for direction in ("long_high", "long_low"):
        for thr in threshold_grid:
            bd = timing_book(z, fwd, threshold=thr, direction=direction, cost_bps=cost_bps)
            books[(direction, thr)] = bd
            train_sh = _sharpe(_window(bd["net"], day_hi=test_lo - 1))
            if best is None or train_sh > best[0]:
                best = (train_sh, direction, thr)
    assert best is not None
    _, chosen_dir, chosen_thr = best
    chosen = books[(chosen_dir, chosen_thr)]
    net = chosen["net"]

    net_full = _metrics(net)
    net_test = _metrics(_window(net, day_lo=test_lo))
    tstat_full = _tstat(net)
    tstat_oos = _tstat(_window(net, day_lo=test_lo))
    n_switch = _regime_switches(chosen["position"])
    active_frac = (chosen["n_active"] / len(chosen["position"])) if chosen["position"] else 0.0
    max_corr, max_corr_edge = _max_abs_corr(net, edges)

    # LARGE + FREQUENT + CONCENTRATED. FREQUENT = active on >= _MIN_ACTIVE_FRACTION of days AND >=
    # _MIN_REGIME_SWITCHES bets; CONCENTRATED = trivially true (a single broad-market instrument — a
    # capacity-deep overlay, not an over-diversified alt basket).
    frequent = active_frac >= _MIN_ACTIVE_FRACTION and n_switch >= _MIN_REGIME_SWITCHES
    lfc = _large_frequent_concentrated(net, frequent=frequent, concentrated=True)

    oos_ok = net_test["sharpe"] >= _MIN_OOS_SHARPE
    # The (direction, threshold) grid is searched on TRAIN, so we demand significance in BOTH windows — a
    # single full-sample t-stat is cheap to clear by the best-of-grid selection on ~one cycle of noise (the
    # macro-timing overfit trap). Requiring the OOS window to ALSO be significant is the honest bar.
    tstat_ok = abs(tstat_full) >= _MIN_TSTAT and abs(tstat_oos) >= _MIN_TSTAT
    uncorrelated = max_corr is None or max_corr < _MAX_CORR_TO_EDGE
    too_few_bets = n_switch < _MIN_REGIME_SWITCHES
    confirm = oos_ok and tstat_ok and uncorrelated and lfc["pass"] and not too_few_bets

    return {
        "available": True, "n_days": n_days, "market": market,
        "chosen_direction": chosen_dir, "chosen_threshold": chosen_thr,
        "net_full": net_full, "net_test_oos": net_test,
        "tstat_full": tstat_full, "tstat_oos": tstat_oos,
        "n_regime_switches": n_switch, "active_fraction": active_frac,
        "max_abs_corr_edge": max_corr, "max_corr_edge_name": max_corr_edge,
        "lfc": lfc,
        "verdict": "CONFIRM" if confirm else "FALSIFY",
        "reason": (
            f"the supply-growth {chosen_dir} timing overlay clears NET-OOS Sharpe {net_test['sharpe']:.2f} "
            f"(>= {_MIN_OOS_SHARPE}), non-overlapping t-stat {tstat_full:.2f} (>= {_MIN_TSTAT}), is "
            f"uncorrelated with the live edges, and is LARGE+FREQUENT+CONCENTRATED — a real macro-timing edge"
            if confirm else
            f"FALSIFIED: NET-OOS Sharpe {net_test['sharpe']:.2f}, non-overlapping t-stat {tstat_full:.2f} over "
            f"only {n_switch} independent bets — stablecoin-supply growth carries no robust market-timing edge "
            "(as expected: ~one cycle of data, the macro-timing overfit trap)"),
    }


# --- ANGLE ② verdict -----------------------------------------------------------------------------

def _angle2_verdict(store: _FeatureStore, value_panel: dict[int, dict[str, float]], *,
                    point_in_time: bool, value_universe: set[str], edges: dict[str, dict[int, float]],
                    cost_bps: float, train_frac: float) -> dict[str, Any]:
    n_coins = len(value_universe)
    if n_coins < _MIN_VALUE_COINS:
        return {"available": False, "n_value_coins": n_coins, "point_in_time": point_in_time,
                "reason": f"only {n_coins} Bybit-tradeable coins HAVE fundamentals (< {_MIN_VALUE_COINS}) — the "
                          "value universe is too thin to sort cross-sectionally"}

    bounded = _UniverseRestrictedStore(store, value_universe)
    close = bounded.crypto_close_panel()
    fwd = forward_returns_from_close(close)
    cost_by_coin = _measured_cost_by_coin(store, sorted(value_universe))

    net = value_factor_book(value_panel, fwd, cost_by_coin=cost_by_coin, cost_bps=cost_bps)["net"]
    days = sorted(net)
    if len(days) < 2:
        return {"available": False, "n_value_coins": n_coins, "point_in_time": point_in_time,
                "reason": "no forward-return days for the value universe (no overlapping price history)"}
    test_lo = _split_day(days, train_frac)
    net_full = _metrics(net)
    net_test = _metrics(_window(net, day_lo=test_lo))
    tstat_full = _tstat(net)
    tstat_oos = _tstat(_window(net, day_lo=test_lo))
    max_corr, max_corr_edge = _max_abs_corr(net, edges)
    corr_tstrend = _correlation(net, edges.get("tstrend", {}))

    frequent = len(days) >= 2          # the book trades every day it has a cross-section
    concentrated = n_coins >= _MIN_VALUE_COINS
    lfc = _large_frequent_concentrated(net, frequent=frequent, concentrated=concentrated)

    oos_ok = net_test["sharpe"] >= _MIN_OOS_SHARPE
    tstat_ok = abs(tstat_full) >= _MIN_TSTAT and abs(tstat_oos) >= _MIN_TSTAT
    uncorrelated = max_corr is None or max_corr < _MAX_CORR_TO_EDGE

    # POINT-IN-TIME GATE: a static present-day snapshot applied to past returns is lookahead-contaminated and
    # OOS does NOT cleanse it — so a snapshot can NEVER be CONFIRM. The verdict is forced to N/A with the
    # reason; the diagnostic numbers are reported regardless so the result is transparent.
    if not point_in_time:
        return {
            "available": True, "verdict": "N/A", "point_in_time": False, "n_value_coins": n_coins,
            "net_full": net_full, "net_test_oos": net_test, "tstat_full": tstat_full, "tstat_oos": tstat_oos,
            "max_abs_corr_edge": max_corr, "max_corr_edge_name": max_corr_edge, "corr_tstrend": corr_tstrend,
            "lfc": lfc,
            "reason": "N/A — the DefiLlama/CoinGecko fundamentals are a CURRENT SNAPSHOT, not a point-in-time "
                      "historical panel; a static present-day value ranking applied to past returns is "
                      "lookahead-contaminated and an OOS split cannot cleanse a static signal. A real value "
                      "factor needs historical (survivorship-free) fundamentals we do not have for free. "
                      f"(snapshot diagnostic only: full Sharpe {net_full['sharpe']:.2f}, OOS "
                      f"{net_test['sharpe']:.2f}, t {tstat_full:.2f})",
        }

    confirm = oos_ok and tstat_ok and uncorrelated and lfc["pass"]
    return {
        "available": True, "verdict": "CONFIRM" if confirm else "FALSIFY", "point_in_time": True,
        "n_value_coins": n_coins,
        "net_full": net_full, "net_test_oos": net_test, "tstat_full": tstat_full, "tstat_oos": tstat_oos,
        "max_abs_corr_edge": max_corr, "max_corr_edge_name": max_corr_edge, "corr_tstrend": corr_tstrend,
        "lfc": lfc,
        "reason": (
            f"the cross-sectional value factor clears NET-OOS Sharpe {net_test['sharpe']:.2f} "
            f"(>= {_MIN_OOS_SHARPE}), non-overlapping t-stat {tstat_full:.2f} (>= {_MIN_TSTAT}), is "
            f"uncorrelated with the live edges (incl. tstrend), and is LARGE+FREQUENT+CONCENTRATED"
            if confirm else
            f"FALSIFIED: NET-OOS Sharpe {net_test['sharpe']:.2f}, non-overlapping t-stat {tstat_full:.2f}, "
            f"max|corr| to edges {('N/A' if max_corr is None else round(max_corr, 2))} — the fundamental value "
            "ranking carries no NET cross-sectional edge beyond the live book"),
    }


# --- THE STUDY -----------------------------------------------------------------------------------

def onchain_fundamental_study(store: _FeatureStore, *, supply_series: dict[int, float],
                              fundamentals_by_coin: dict[str, dict[str, float]],
                              fundamentals_point_in_time: bool = False,
                              universe: set[str] | None = None, market: str = "btc",
                              ratio_kind: str = "fees_mcap", cost_bps: float = 5.5,
                              lookback: int = 30,
                              threshold_grid: tuple[float, ...] = (-0.5, 0.0, 0.5, 1.0),
                              train_frac: float = 0.6) -> dict[str, Any]:
    """READ-ONLY, memory-bounded falsification study of crypto on-chain / fundamental signals — both angles,
    each with its own verdict, both net of cost, both checked vs the 4 live edges.

    `supply_series` ({epoch_day: total_stablecoin_usd}) and `fundamentals_by_coin` ({base: {tvl, fees,
    revenue, mcap}}) are INJECTED (the live CLI supplies them from DefiLlama/CoinGecko; tests inject fixtures
    → no network). `fundamentals_point_in_time` flags whether the fundamentals are a genuine historical panel
    (True → angle ② can CONFIRM) or a current snapshot (False, the live case → angle ② forced to N/A).
    `universe` bounds the crypto symbol set; `market` ∈ {btc, ew} selects the timing index. Writes NOTHING."""
    edges = _live_edge_series(store, universe=universe, cost_bps=cost_bps)

    angle1 = _angle1_verdict(store, supply_series, market=market, universe=universe, edges=edges,
                             cost_bps=cost_bps, lookback=lookback, threshold_grid=threshold_grid,
                             train_frac=train_frac)

    # The value universe = Bybit-tradeable bases (∩ universe) that HAVE a usable value ratio.
    value_by_coin = value_ratio_by_coin(fundamentals_by_coin, ratio_kind=ratio_kind)
    tradeable_bases = {s[:-4] for s in store.crypto_close_panel() if s.endswith("USDT")}
    if universe is not None:
        tradeable_bases &= {s[:-4] for s in universe if s.endswith("USDT")}
    value_universe_bases = set(value_by_coin) & tradeable_bases
    value_universe_syms = {b + "USDT" for b in value_universe_bases}
    # Map the per-base value to the perp symbol and broadcast across the price days (snapshot) so the book has a
    # cross-section every day. With a real historical panel the loader would pass a per-day panel instead.
    value_sym_by_coin = {b + "USDT": value_by_coin[b] for b in value_universe_bases}
    if value_universe_syms:
        close = _UniverseRestrictedStore(store, value_universe_syms).crypto_close_panel()
        price_days = sorted({d for series in close.values() for d in series})
    else:
        price_days = []
    value_panel = {d: dict(value_sym_by_coin) for d in price_days}

    angle2 = _angle2_verdict(store, value_panel, point_in_time=fundamentals_point_in_time,
                             value_universe=value_universe_syms, edges=edges, cost_bps=cost_bps,
                             train_frac=train_frac)

    return {
        "supply_series_days": len(supply_series),
        "supply_first_day": _epoch_day_to_iso(min(supply_series)) if supply_series else None,
        "supply_last_day": _epoch_day_to_iso(max(supply_series)) if supply_series else None,
        "n_fundamentals_coins": len(fundamentals_by_coin),
        "n_value_universe": len(value_universe_syms),
        "n_live_edges_for_corr": len(edges),
        "ratio_kind": ratio_kind,
        "angle1": angle1,
        "angle2": angle2,
    }
