From e924f04db71d12e0a0c30b4f2657cc2e76ecc166 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Mon, 20 Jul 2026 01:00:43 -0700 Subject: [PATCH 1/2] feat(factors): reproduce volume-peak interval-kurtosis factor (PR-H) Reproduces the SECOND factor of the Kaiyuan market-microstructure series #27 (reportId 4957417, section 6): the kurtosis of the gaps between consecutive same-day volume peaks, pooled over the trailing 20 valid trading days. Same MACHINE as PR-F, different STATISTIC. The volume-peak identification is REUSED from data/clean/intraday_volume_prv.py rather than re-implemented, so the two factors can never drift apart. That required a behaviour-preserving additive extraction of two helpers from the existing code: - prepare_visible_minute_bars: the PIT truncation + volume guard + slot column - peak_mask_for_symbol: the same-slot strictly-prior baseline, eruptive/mild classification and the mild-neighbour peak rule compute_volume_peak_count now calls both; nothing else about it changed. The extraction is provably inert: the pre-refactor and post-refactor functions were run against the same real cached minute bars for 12 symbols (7906 factor rows, 2022-2024) and compared with assert_series_equal(check_exact=True) -- identical. The 26 existing PR-F tests pass unchanged. Two interpretations the report leaves open are PINNED and disclosed on the spec: 1. an interval is measured in TRADING MINUTES (the tradable-slot difference inside the day's visible bar sequence), so the lunch break costs nothing -- peaks at 11:29 and 13:02 are 3 apart, not 93. Measuring wall clock would inject a fixed ~90-minute spike into every straddling distribution and dominate the kurtosis. 2. kurtosis = Fisher excess, bias-corrected (the pandas .kurt() / scipy fisher=True bias=False convention; a normal sample sits at 0, not 3). A day with fewer than 2 peaks contributes 0 intervals but remains a valid day. Two honest-missing gates: fewer than 20 pooled intervals, or a zero-variance pool, yield NaN rather than a fabricated number. Pre-registered sign +1. Tests cover two hand-computed kurtosis values (intervals [2,2,2,8] -> exactly 4.0; [2,3,4,5,6,2] -> exactly -1.48125), estimator alignment with pandas .kurt() on random samples, the lunch-break trading-minute semantics, the single-peak and zero-peak day cases, both NaN gates, PIT invariance to post-14:50 and future bars, per-symbol isolation, and an anti-drift lock asserting the intervals come from exactly the peaks PR-F counts. --- data/clean/intraday_peak_interval.py | 311 ++++++++++++ data/clean/intraday_volume_prv.py | 129 +++-- factors/compute/intraday_derived.py | 110 +++++ tests/test_peak_interval_kurtosis_factor.py | 495 ++++++++++++++++++++ 4 files changed, 1013 insertions(+), 32 deletions(-) create mode 100644 data/clean/intraday_peak_interval.py create mode 100644 tests/test_peak_interval_kurtosis_factor.py diff --git a/data/clean/intraday_peak_interval.py b/data/clean/intraday_peak_interval.py new file mode 100644 index 0000000..3030ba3 --- /dev/null +++ b/data/clean/intraday_peak_interval.py @@ -0,0 +1,311 @@ +"""Volume-peak INTERVAL-KURTOSIS factor (PR-H). + +Reproduces the SECOND factor of the Kaiyuan market-microstructure series #27 (开源证券 +《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, 2025-07-20, reportId 4957417, +§6): "针对两个量峰之间的时间间隔计算统计指标,对过去 20 日同日前后两个量峰之间的时间间隔 +分布,计算其峰度". + +Same MACHINE as PR-F, different STATISTIC. The volume-peak identification is REUSED +verbatim from :mod:`data.clean.intraday_volume_prv` (``prepare_visible_minute_bars`` + +``peak_mask_for_symbol``) — same-slot strictly-prior μ+kσ eruptive/mild classification, +1-minute same-session mild-neighbour peak rule, session-boundary and missing-minute +bars are never peaks, a day is VALID iff it has enough classifiable bars. Nothing about +the taxonomy is re-implemented here, so the two factors can never drift apart; this +module only measures the GAPS between consecutive peaks and reduces them to a kurtosis. + +The report is silent on two operative details; both are deliberate, DISCLOSED choices +PINNED in the task card (task_card_pr_h_*.md §1), not tuned knobs, and both are +reproduced on the factor spec: + + 1. INTERVAL UNIT = TRADING MINUTES, i.e. the difference in TRADABLE SLOT POSITION + within the day's PIT-visible bar sequence — NOT wall-clock minutes. The lunch break + therefore costs nothing: a peak at 11:29 and a peak at 13:02 are 3 trading minutes + apart (11:29 -> 11:30 -> 13:01 -> 13:02), not 93. Measuring wall clock would inject + a fixed ~90-minute spike into every interval distribution that happens to straddle + lunch and would dominate the kurtosis. A consequence, disclosed rather than + corrected: a minute MISSING from the cache is not a tradable slot in our sequence, + so an interval spanning it is measured one shorter (the same stance PR-F takes on + gaps — it consults no exchange calendar). + 2. KURTOSIS = FISHER EXCESS, BIAS-CORRECTED — the ``pandas.Series.kurt()`` / + ``scipy.stats.kurtosis(fisher=True, bias=False)`` convention (a normal sample sits + near 0, not 3). ``excess_kurtosis`` implements it in numpy for speed and is locked + against ``pandas.kurt()`` by test. + +Factor value: ``peak_interval_kurtosis_20`` = the kurtosis of the POOLED interval +multiset over the symbol's most recent ``lookback_days`` (=20) VALID trading days +INCLUDING ``d``. A day with fewer than 2 peaks contributes ZERO intervals but is still a +valid day (it is not skipped and does not poison the window). Two NaN gates, both honest +missing rather than a fabricated number: fewer than ``min_intervals`` (=20) pooled +intervals (kurtosis is wildly unstable on small samples), or a zero-variance pool +(kurtosis is undefined). The PR-F valid-day floor is kept as well (``min_valid_days``). +Note the reused taxonomy makes two peaks 1 minute apart impossible — adjacent eruptive +minutes are RIDGES — so the smallest attainable interval is 2. + +Pre-registered sign = +1 (the report's full-market RankIC is +7.19% / RankICIR 4.63, +long-short 23.3%/yr, IR 3.39, 13/13 positive years — the most stable factor in the +report). Semantics: a peaky, fat-tailed interval distribution means informed trading +arrives in BURSTS rather than spread evenly through the session -> higher future return. +NOTE the report is a MONTHLY, market-cap + industry neutral full-market series on Wind +data; our eval cell is CSI500 daily with industry + size neutralization, so its numbers +are a LOOSE reference only (the report gives no CSI500 sub-domain figure for THIS +factor, and none is invented). Raw minute volume (cached as-is) has split-day magnitude +jumps that pollute the 20-day σ; the report (Wind) does not adjust for this either, so +it is disclosed and NOT corrected. + +The value at ``d`` uses only bars at dates <= d, so a factor value never sees a future +bar (invariant #1); it is a DAILY signal traded close-to-close from d+1. This module is +DATA-layer only: it does not fetch, does not touch factors / alpha / portfolio / +runtime, and never sees a token. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from data.clean.intraday_aggregate import DAILY_INDEX_NAMES, DEFAULT_DECISION_TIME +from data.clean.intraday_schema import SYMBOL_LEVEL, validate_intraday_bars +from data.clean.intraday_volume_prv import ( + VOLUME_PRV_BASELINE_DAYS, + VOLUME_PRV_BASELINE_MIN_OBS, + VOLUME_PRV_MIN_CLASSIFIABLE, + VOLUME_PRV_MIN_VALID_DAYS, + VOLUME_PRV_SIGMA_K, + peak_mask_for_symbol, + prepare_visible_minute_bars, +) + +# Factor DEFINITION constants (pinned interpretations of the report; NOT tuned knobs). +# The peak-identification constants are IMPORTED from PR-F, never redefined. +PEAK_INTERVAL_LOOKBACK_DAYS = 20 # trailing VALID trading-day pool window, includes d +PEAK_INTERVAL_MIN_INTERVALS = 20 # min pooled intervals for a finite kurtosis + +# Kurtosis needs at least 4 observations for the bias-corrected estimator to exist. +_KURTOSIS_MIN_N = 4 + + +def _empty_series(name: str) -> pd.Series: + """Schema-shaped empty ``MultiIndex(date, symbol)`` factor Series.""" + index = pd.MultiIndex.from_arrays( + [pd.DatetimeIndex([]), pd.Index([], dtype=object)], + names=DAILY_INDEX_NAMES, + ) + return pd.Series([], index=index, dtype=float, name=name) + + +def excess_kurtosis(x: np.ndarray) -> float: + """Fisher excess, bias-corrected kurtosis of ``x`` — the ``pandas.kurt()`` convention. + + Equivalent to ``pandas.Series(x).kurt()`` and to + ``scipy.stats.kurtosis(x, fisher=True, bias=False)`` (locked by test), computed in + numpy because the caller evaluates it once per symbol-day:: + + G2 = n(n+1)(n-1)·M4 / ((n-2)(n-3)·M2²) - 3(n-1)² / ((n-2)(n-3)) + + with ``M2 = Σ(x-x̄)²`` and ``M4 = Σ(x-x̄)⁴`` (central sums, NOT means). Central sums + are accumulated after subtracting the mean, so no catastrophic power-sum + cancellation. + + Returns: + The excess kurtosis, or NaN when it is undefined: fewer than 4 observations, or + a zero-variance sample. Never raises, never returns ±inf. + """ + n = x.size + if n < _KURTOSIS_MIN_N: + return float("nan") + d = x - x.mean() + m2 = float(np.dot(d, d)) + if not m2 > 0.0: # zero variance (or a non-finite that made it NaN) -> undefined + return float("nan") + m4 = float(np.dot(d * d, d * d)) + left = n * (n + 1.0) * (n - 1.0) * m4 / ((n - 2.0) * (n - 3.0) * m2 * m2) + adj = 3.0 * (n - 1.0) ** 2 / ((n - 2.0) * (n - 3.0)) + return left - adj + + +def peak_intervals_by_day(work: pd.DataFrame) -> pd.Series: + """Trading-minute gaps between consecutive same-day peaks, per trade date. + + ``work`` is one symbol's frame as returned by + :func:`~data.clean.intraday_volume_prv.peak_mask_for_symbol` (sorted by + ``(trade_date, bar_end)`` with a boolean ``peak`` column). + + The interval unit is the TRADABLE SLOT POSITION difference inside the day's visible + bar sequence (PINNED §1 of the module docstring): position 0, 1, 2, ... is assigned + to the day's PIT-visible bars in time order, so consecutive tradable minutes are 1 + apart REGARDLESS of the lunch break sitting between them. Intervals are never taken + ACROSS days (the positions restart every trade date). + + Returns: + Series indexed by ``trade_date`` (every day present in ``work``, in order) whose + values are integer numpy arrays of that day's peak-to-peak intervals — an EMPTY + array for a day with fewer than 2 peaks (zero intervals, still a real day). + """ + # position within the day's visible bar sequence == "trading minute" coordinate + pos = work.groupby("trade_date", sort=False).cumcount().to_numpy() + peak = work["peak"].to_numpy(dtype=bool) + dates = work["trade_date"].to_numpy() + + out_dates: list[pd.Timestamp] = [] + out_intervals: list[np.ndarray] = [] + for day, idx in pd.Series(np.arange(len(work))).groupby(dates, sort=True): + sel = idx.to_numpy() + day_pos = pos[sel][peak[sel]] + out_dates.append(pd.Timestamp(day)) + out_intervals.append(np.diff(day_pos).astype(np.int64)) + return pd.Series(out_intervals, index=pd.Index(out_dates, name="trade_date")) + + +def _kurtosis_for_symbol( + g: pd.DataFrame, + *, + baseline_days: int, + baseline_min_obs: int, + sigma_k: float, + lookback_days: int, + min_valid_days: int, + min_classifiable: int, + min_intervals: int, +) -> tuple[list[pd.Timestamp], list[float]]: + """Daily peak-interval-kurtosis values for ONE symbol from its PIT-visible bars. + + Identifies the peaks with the REUSED :func:`peak_mask_for_symbol`, measures each + valid day's peak-to-peak trading-minute intervals, then pools the trailing + ``lookback_days`` valid days and reduces the pool to its excess kurtosis. No + cross-symbol leakage (``g`` is one symbol's slice) and no lookahead (the baseline is + strictly prior, the pool is trailing). + """ + work = peak_mask_for_symbol( + g, + baseline_days=baseline_days, + baseline_min_obs=baseline_min_obs, + sigma_k=sigma_k, + ) + + classifiable_count = work.groupby("trade_date")["classifiable"].sum() + valid_days = classifiable_count.index[classifiable_count >= min_classifiable] + if len(valid_days) == 0: + return [], [] + + intervals = peak_intervals_by_day(work) + # Keep ONLY valid days, in order: a day below the classifiable floor contributes + # nothing and does not occupy a slot in the trailing window (same rule as PR-F). + valid = [pd.Timestamp(d) for d in sorted(valid_days)] + per_day = [intervals.loc[d] for d in valid] + + days: list[pd.Timestamp] = [] + values: list[float] = [] + for i, day in enumerate(valid): + window = per_day[max(0, i - lookback_days + 1) : i + 1] + days.append(day.normalize()) + if len(window) < min_valid_days: + values.append(float("nan")) + continue + pool = np.concatenate(window) if window else np.empty(0, dtype=np.int64) + # Gate 1: too few pooled intervals -> honest NaN (kurtosis is wild on small + # samples). Gate 2 (zero variance / n < 4) lives inside excess_kurtosis. + if pool.size < min_intervals: + values.append(float("nan")) + continue + values.append(excess_kurtosis(pool.astype(float))) + return days, values + + +def compute_peak_interval_kurtosis( + bars: pd.DataFrame, + *, + lookback_days: int = PEAK_INTERVAL_LOOKBACK_DAYS, + baseline_days: int = VOLUME_PRV_BASELINE_DAYS, + baseline_min_obs: int = VOLUME_PRV_BASELINE_MIN_OBS, + sigma_k: float = VOLUME_PRV_SIGMA_K, + min_valid_days: int = VOLUME_PRV_MIN_VALID_DAYS, + min_classifiable: int = VOLUME_PRV_MIN_CLASSIFIABLE, + min_intervals: int = PEAK_INTERVAL_MIN_INTERVALS, + decision_time: str = DEFAULT_DECISION_TIME, + name: str = "peak_interval_kurtosis", +) -> pd.Series: + """PIT-safe daily "volume-peak interval kurtosis" factor from 1min ``bars``. + + Takes normalized 1min ``bars``, PIT-truncates each day at ``decision_time``, + identifies the volume peaks with the REUSED PR-F taxonomy, measures the + trading-minute gaps between consecutive same-day peaks, pools the trailing + ``lookback_days`` VALID days and returns the pool's Fisher excess (bias-corrected) + kurtosis. See the module docstring for the LOCKED definition and the two pinned + interpretations (interval unit, kurtosis convention). + + Args: + bars: normalized 1min bars (:mod:`data.clean.intraday_schema`), + ``MultiIndex(time, symbol)``. May carry one or many symbols; the grouping + is strictly per symbol (no cross-symbol leakage). + lookback_days: trailing VALID trading-day window pooled for the kurtosis. + baseline_days: strictly-prior same-slot baseline window in trading days (PR-F). + baseline_min_obs: minimum same-slot observations for a classifiable bar (PR-F). + sigma_k: eruptive threshold multiplier ``k`` in ``vol > μ + k*σ`` (PR-F). + min_valid_days: minimum valid days in the trailing window for a finite value. + min_classifiable: a day is VALID iff it has at least this many classifiable bars. + min_intervals: minimum POOLED intervals for a finite kurtosis (>= 4, the point + below which the bias-corrected estimator does not exist). + decision_time: per-bar PIT cutoff time-of-day (default 14:50:00). + name: the returned Series name (the factor-panel column name). + + Returns: + ``MultiIndex(date, symbol)`` Series (midnight-normalized dates) of the daily + factor value, sorted, named ``name``. Pure: never mutates ``bars``. + """ + validate_intraday_bars(bars) + if lookback_days < 1: + raise ValueError(f"lookback_days must be >= 1; got {lookback_days!r}.") + if baseline_days < 2: + # Need >= 2 baseline observations so a ddof=1 std of the same-slot volume is + # defined. + raise ValueError(f"baseline_days must be >= 2; got {baseline_days!r}.") + if baseline_min_obs < 2: + raise ValueError(f"baseline_min_obs must be >= 2; got {baseline_min_obs!r}.") + if sigma_k < 0.0: + raise ValueError(f"sigma_k must be >= 0; got {sigma_k!r}.") + if min_valid_days < 1: + raise ValueError(f"min_valid_days must be >= 1; got {min_valid_days!r}.") + if min_classifiable < 1: + raise ValueError(f"min_classifiable must be >= 1; got {min_classifiable!r}.") + if min_intervals < _KURTOSIS_MIN_N: + raise ValueError( + f"min_intervals must be >= {_KURTOSIS_MIN_N} (the bias-corrected kurtosis " + f"does not exist below that); got {min_intervals!r}." + ) + if len(bars) == 0: + return _empty_series(name) + + visible = prepare_visible_minute_bars(bars, decision_time=decision_time) + if visible.empty: + return _empty_series(name) + + index_tuples: list[tuple] = [] + values: list[float] = [] + for sym, g in visible.groupby(SYMBOL_LEVEL, sort=True): + days, vals = _kurtosis_for_symbol( + g.reset_index(drop=True), + baseline_days=baseline_days, + baseline_min_obs=baseline_min_obs, + sigma_k=sigma_k, + lookback_days=lookback_days, + min_valid_days=min_valid_days, + min_classifiable=min_classifiable, + min_intervals=min_intervals, + ) + for day, val in zip(days, vals): + index_tuples.append((day, str(sym))) + values.append(val) + + if not index_tuples: + return _empty_series(name) + index = pd.MultiIndex.from_tuples(index_tuples, names=DAILY_INDEX_NAMES) + return pd.Series(values, index=index, name=name).sort_index() + + +__all__ = [ + "PEAK_INTERVAL_LOOKBACK_DAYS", + "PEAK_INTERVAL_MIN_INTERVALS", + "compute_peak_interval_kurtosis", + "excess_kurtosis", + "peak_intervals_by_day", +] diff --git a/data/clean/intraday_volume_prv.py b/data/clean/intraday_volume_prv.py index 779c5ca..e7fc71b 100644 --- a/data/clean/intraday_volume_prv.py +++ b/data/clean/intraday_volume_prv.py @@ -90,20 +90,75 @@ def _empty_series(name: str) -> pd.Series: return pd.Series([], index=index, dtype=float, name=name) -def _peak_count_for_symbol( +def prepare_visible_minute_bars( + bars: pd.DataFrame, *, decision_time: str = DEFAULT_DECISION_TIME +) -> pd.DataFrame: + """PIT-truncate ``bars`` at ``decision_time`` and add ``trade_date`` / ``slot``. + + The shared front half of every peak-taxonomy factor: keep only the bars whose + ``available_time`` is at or before their own ``trade_date + decision_time`` (so + history days and the signal day are truncated identically), drop non-finite / + negative volumes (invalid data that would poison the same-slot μ/σ), and add the + minute-of-day ``slot`` that aligns the SAME time-of-day across days. + + ``bars`` must ALREADY be schema-validated by the caller + (:func:`~data.clean.intraday_schema.validate_intraday_bars`) — this helper is the + post-validation preparation step, kept separate so the entry points keep their own + validate-then-check-params ordering. + + Args: + bars: normalized 1min bars, ``MultiIndex(time, symbol)``; one or many symbols. + decision_time: per-bar PIT cutoff time-of-day (default 14:50:00). + + Returns: + A fresh ``RangeIndex`` frame with columns ``symbol`` / ``bar_end`` / + ``available_time`` / ``volume`` / ``trade_date`` / ``slot`` (possibly empty). + Pure: never mutates ``bars``. + """ + work = bars.reset_index()[ + [SYMBOL_LEVEL, "bar_end", "available_time", "volume"] + ].copy() + work["trade_date"] = work["bar_end"].dt.normalize() + # PIT truncation FIRST (per-bar timestamps): each bar's cutoff is its own + # trade_date + decision_time, so every day is truncated to [open, cutoff]. + cutoff = work["trade_date"] + pd.Timedelta(decision_time) + visible = work.loc[work["available_time"] <= cutoff].copy() + if visible.empty: + return visible + + # Guard bad volume BEFORE anything else: a non-finite / negative volume is invalid + # data that would poison the same-slot μ/σ. (Split-day magnitude jumps are NOT + # corrected — disclosed, matching the report's Wind treatment.) + vol = visible["volume"].to_numpy(dtype=float) + visible = visible.loc[np.isfinite(vol) & (vol >= 0.0)].copy() + if visible.empty: + return visible + + # slot = minute-of-day (minutes since midnight); bars are minute-aligned so this is + # exact and aligns the SAME time-of-day across days for the same-slot baseline. + visible["slot"] = ( + (visible["bar_end"] - visible["trade_date"]) // pd.Timedelta(minutes=1) + ).astype(int) + return visible + + +def peak_mask_for_symbol( g: pd.DataFrame, *, - baseline_days: int, - baseline_min_obs: int, - sigma_k: float, - lookback_days: int, - min_valid_days: int, - min_classifiable: int, -) -> tuple[list[pd.Timestamp], list[float]]: - """Daily volume-peak-count values for ONE symbol from its PIT-visible bars. + baseline_days: int = VOLUME_PRV_BASELINE_DAYS, + baseline_min_obs: int = VOLUME_PRV_BASELINE_MIN_OBS, + sigma_k: float = VOLUME_PRV_SIGMA_K, +) -> pd.DataFrame: + """Per-minute PEAK / classifiable mask for ONE symbol's PIT-visible bars. + + This is THE volume-peak identification of the report (§2), shared by every factor in + the peak family so the taxonomy is defined in exactly one place: same-slot + strictly-prior μ/σ baseline -> eruptive vs mild -> a peak is an eruptive minute whose + both 1-minute same-session neighbours are mild. ``g`` holds columns ``trade_date`` / ``slot`` / ``bar_end`` / ``volume`` for a - SINGLE symbol (a fresh ``RangeIndex``). The same-slot STRICTLY-PRIOR baseline is + SINGLE symbol (a fresh ``RangeIndex``, as produced by + :func:`prepare_visible_minute_bars`). The same-slot STRICTLY-PRIOR baseline is computed on a day x slot pivot (rolling over the day axis then ``shift(1)``), so no per-``(day, slot)`` python loop is needed and the current day never enters its own baseline. Peak detection is done back in long form with a within-day 60s-gap @@ -111,6 +166,11 @@ def _peak_count_for_symbol( neighbour" at once). No cross-symbol leakage — ``g`` is one symbol's slice — and no cross-day leakage — the baseline is strictly prior and the neighbour test never crosses a day boundary (grouped by ``trade_date``). + + Returns: + ``g`` sorted by ``(trade_date, bar_end)`` on a fresh ``RangeIndex`` with the + added boolean columns ``classifiable`` (a strictly-prior baseline existed) and + ``peak``. Pure: never mutates ``g``. """ # day x slot volume matrix: rows are the symbol's trading days, columns are the # minute-of-day slots; a missing (day, slot) cell is NaN (no bar that minute). @@ -167,6 +227,30 @@ def _peak_count_for_symbol( & next_mild ) work["peak"] = peak + return work + + +def _peak_count_for_symbol( + g: pd.DataFrame, + *, + baseline_days: int, + baseline_min_obs: int, + sigma_k: float, + lookback_days: int, + min_valid_days: int, + min_classifiable: int, +) -> tuple[list[pd.Timestamp], list[float]]: + """Daily volume-peak-count values for ONE symbol from its PIT-visible bars. + + Identifies the peaks with the shared :func:`peak_mask_for_symbol` and reduces them to + the trailing-``lookback_days``-VALID-day count. + """ + work = peak_mask_for_symbol( + g, + baseline_days=baseline_days, + baseline_min_obs=baseline_min_obs, + sigma_k=sigma_k, + ) peak_count = work.groupby("trade_date")["peak"].sum() classifiable_count = work.groupby("trade_date")["classifiable"].sum() @@ -237,31 +321,10 @@ def compute_volume_peak_count( if len(bars) == 0: return _empty_series(name) - work = bars.reset_index()[ - [SYMBOL_LEVEL, "bar_end", "available_time", "volume"] - ].copy() - work["trade_date"] = work["bar_end"].dt.normalize() - # PIT truncation FIRST (per-bar timestamps): each bar's cutoff is its own - # trade_date + decision_time, so every day is truncated to [open, cutoff]. - cutoff = work["trade_date"] + pd.Timedelta(decision_time) - visible = work.loc[work["available_time"] <= cutoff].copy() - if visible.empty: - return _empty_series(name) - - # Guard bad volume BEFORE anything else: a non-finite / negative volume is invalid - # data that would poison the same-slot μ/σ. (Split-day magnitude jumps are NOT - # corrected — disclosed, matching the report's Wind treatment.) - vol = visible["volume"].to_numpy(dtype=float) - visible = visible.loc[np.isfinite(vol) & (vol >= 0.0)].copy() + visible = prepare_visible_minute_bars(bars, decision_time=decision_time) if visible.empty: return _empty_series(name) - # slot = minute-of-day (minutes since midnight); bars are minute-aligned so this is - # exact and aligns the SAME time-of-day across days for the same-slot baseline. - visible["slot"] = ( - (visible["bar_end"] - visible["trade_date"]) // pd.Timedelta(minutes=1) - ).astype(int) - index_tuples: list[tuple] = [] values: list[float] = [] for sym, g in visible.groupby(SYMBOL_LEVEL, sort=True): @@ -292,4 +355,6 @@ def compute_volume_peak_count( "VOLUME_PRV_MIN_VALID_DAYS", "VOLUME_PRV_SIGMA_K", "compute_volume_peak_count", + "peak_mask_for_symbol", + "prepare_visible_minute_bars", ] diff --git a/factors/compute/intraday_derived.py b/factors/compute/intraday_derived.py index c1ffae0..ad675cb 100644 --- a/factors/compute/intraday_derived.py +++ b/factors/compute/intraday_derived.py @@ -54,6 +54,10 @@ IDEAL_AMP_LOOKBACK_DAYS, IDEAL_AMP_MIN_MINUTES, ) +from data.clean.intraday_peak_interval import ( + PEAK_INTERVAL_LOOKBACK_DAYS, + PEAK_INTERVAL_MIN_INTERVALS, +) from data.clean.intraday_volume_prv import ( VOLUME_PRV_BASELINE_DAYS, VOLUME_PRV_BASELINE_MIN_OBS, @@ -529,10 +533,116 @@ def compute(self, panel: pd.DataFrame) -> pd.Series: return panel[self.name].rename(self.name) +class PeakIntervalKurtosisFactor(Factor): + """Volume-peak interval-kurtosis factor (daily signal, minute-derived). + + ``compute`` reads the pre-aggregated daily column the runner placed on the panel + (produced by ``data.clean.intraday_peak_interval.compute_peak_interval_kurtosis``); + it does NO minute work of its own, mirroring :class:`JumpAmountCorrFactor` / + :class:`MinuteIdealAmplitudeFactor` / :class:`AmpMarginalAnomalyVolFactor` / + :class:`VolumePeakCountFactor` / :class:`IntradayAmpCutFactor` and the value / + financial factors that surface an enriched column. + + Args: + lookback_days: trailing VALID trading-day window pooled for the kurtosis; part of + the factor DEFINITION (reproduced from the report), not a tuned knob. It only + names the column so a non-default window cannot silently mislabel it. + """ + + name: str = f"peak_interval_kurtosis_{PEAK_INTERVAL_LOOKBACK_DAYS}" + + def __init__(self, lookback_days: int = PEAK_INTERVAL_LOOKBACK_DAYS) -> None: + if not isinstance(lookback_days, int) or lookback_days < 1: + raise ValueError( + f"peak-interval-kurtosis lookback_days must be a positive integer; got " + f"{lookback_days!r}." + ) + self._lookback_days = lookback_days + self.name = f"peak_interval_kurtosis_{lookback_days}" + + @property + def lookback_days(self) -> int: + return self._lookback_days + + @property + def spec(self) -> FactorSpec: + """Evaluation contract; a property so ``factor_id`` tracks the window. + + expected_ic_sign=+1: the report's full-market RankIC is +7.19% (RankICIR 4.63, + long-short 23.3%/yr, IR 3.39, max drawdown 7.37%, 13/13 positive years — the most + stable factor in the report). A peaky, fat-tailed peak-interval distribution means + informed trading arrives in BURSTS rather than evenly through the session. The + sign is fixed BEFORE the run (a validated prototype must reproduce it). + is_intraday=False for the same reason as the siblings: minute INPUT but a DAILY + signal traded close-to-close. min_history_bars=0: the warm-up is DATA-dependent (a + value appears once enough VALID days AND enough pooled intervals accumulate), not + a fixed leading count — the honest NaN rate is reported by data_coverage. + + The description spells out the DISTINCTION FROM PR-F (``volume_peak_count``): the + SAME peak identification (reused, not re-implemented) reduced by a DIFFERENT + statistic — the shape of the gap distribution rather than the peak count — plus + the two interpretations the report leaves open (interval unit, kurtosis + convention). + """ + return FactorSpec( + factor_id=self.name, + version="1.0", + description=( + f"Volume-peak interval kurtosis (Kaiyuan microstructure series #27, " + f"SECOND factor 量峰间隔峰度). SAME peak identification as PR-F " + f"volume_peak_count (REUSED from data.clean.intraday_volume_prv, not " + f"re-implemented): 1min bars PIT-truncated at 14:50, a minute is ERUPTIVE " + f"if vol > μ + {VOLUME_PRV_SIGMA_K:g}σ of its SAME-SLOT strictly-prior " + f"{VOLUME_PRV_BASELINE_DAYS}-day baseline, and a PEAK is an eruptive " + f"minute whose both 1-minute same-session neighbours are mild. DIFFERENT " + f"STATISTIC: the gaps between consecutive same-day peaks, pooled over the " + f"trailing {self._lookback_days} VALID days (a day with < 2 peaks " + f"contributes 0 intervals but is still valid), reduced to their kurtosis. " + f"PINNED interpretations of an under-specified report: (1) an interval is " + f"measured in TRADING MINUTES — the tradable-slot difference inside the " + f"day's visible bar sequence, so the lunch break costs nothing (11:29 and " + f"13:02 peaks are 3 apart, not 93) and a wall-clock ~90-minute spike can " + f"never dominate the distribution; (2) kurtosis = FISHER excess, " + f"bias-corrected (the pandas .kurt() / scipy fisher=True bias=False " + f"convention; normal = 0). NaN unless >= " + f"{PEAK_INTERVAL_MIN_INTERVALS} intervals pooled and the pool has " + f"non-zero variance. Derived from 1min bars but a DAILY signal traded " + f"close-to-close." + ), + expected_ic_sign=1, + is_intraday=False, + forward_return_horizon=1, + return_basis="close_to_close", + # The 1min bar field the upstream aggregation is derived from. Declared for + # honest provenance disclosure (data_coverage lists it); the daily panel + # surfaces the pre-aggregated column itself. + input_fields=("volume",), + family="microstructure", + min_history_bars=0, + ) + + def compute(self, panel: pd.DataFrame) -> pd.Series: + """Select the pre-aggregated daily peak-interval-kurtosis column off ``panel``. + + The runner runs ``compute_peak_interval_kurtosis`` per symbol on the minute cache + upstream and joins the result as ``self.name``; here we only surface it, so this + factor does no temporal logic and cannot introduce lookahead. + """ + if self.name not in panel.columns: + raise ValueError( + f"PeakIntervalKurtosisFactor needs the pre-aggregated '{self.name}' " + f"column on the panel (produced upstream by " + f"compute_peak_interval_kurtosis and joined by the runner); panel has " + f"{list(panel.columns)}." + ) + return panel[self.name].rename(self.name) + + __all__ = [ "AmpMarginalAnomalyVolFactor", "IntradayAmpCutFactor", "JumpAmountCorrFactor", "MinuteIdealAmplitudeFactor", + "PeakIntervalKurtosisFactor", "VolumePeakCountFactor", ] diff --git a/tests/test_peak_interval_kurtosis_factor.py b/tests/test_peak_interval_kurtosis_factor.py new file mode 100644 index 0000000..7d04c50 --- /dev/null +++ b/tests/test_peak_interval_kurtosis_factor.py @@ -0,0 +1,495 @@ +"""PR-H: volume-peak INTERVAL-KURTOSIS factor. + +Same volume-peak identification as PR-F (REUSED, not re-implemented), different +statistic: the gaps between consecutive same-day peaks, measured in TRADING MINUTES, +pooled over the trailing N valid days, reduced to their Fisher excess (bias-corrected) +kurtosis. Sign is pre-registered +1 (peakier / fatter-tailed interval distribution = +bursty informed trading -> higher forward return). + +Hand cases build a constant BACKGROUND of prior days so the same-slot baseline μ/σ is +exact, then a test day whose eruptive / mild pattern places peaks at chosen positions, +so the pooled interval multiset -- and therefore the kurtosis -- is known exactly. +NOTE two peaks can never be 1 trading minute apart: adjacent eruptive minutes are +RIDGES under the reused taxonomy, so the smallest possible interval is 2. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from data.clean.intraday_peak_interval import ( + PEAK_INTERVAL_LOOKBACK_DAYS, + PEAK_INTERVAL_MIN_INTERVALS, + compute_peak_interval_kurtosis, + excess_kurtosis, + peak_intervals_by_day, +) +from data.clean.intraday_schema import empty_intraday_bars, normalize_intraday_bars +from data.clean.intraday_volume_prv import ( + VOLUME_PRV_BASELINE_DAYS, + VOLUME_PRV_BASELINE_MIN_OBS, + VOLUME_PRV_SIGMA_K, + compute_volume_peak_count, + peak_mask_for_symbol, + prepare_visible_minute_bars, +) +from factors.compute.intraday_derived import PeakIntervalKurtosisFactor +from factors.spec import FactorSpec + +_SYM = "000001.SZ" + + +def _bars(rows): + """rows = [(time, symbol, volume), ...] -> normalized 1min bars. + + OHLC are dummy constants (the factor reads only ``volume`` for the slot baseline); + ``amount`` mirrors volume harmlessly. ``normalize_intraday_bars`` sets + ``available_time = bar_end + 1min`` so the 14:50 PIT cutoff excludes any bar with + ``bar_end >= 14:50``. + """ + df = pd.DataFrame( + { + "time": pd.to_datetime([r[0] for r in rows]), + "symbol": [r[1] for r in rows], + "open": 100.0, + "high": 100.0, + "low": 100.0, + "close": 100.0, + "volume": [float(r[2]) for r in rows], + "amount": [float(r[2]) for r in rows], + } + ) + return normalize_intraday_bars(df, freq="1min") + + +def _session(day, vols, sym=_SYM, start="09:31:00"): + """One session of CONSECUTIVE 1-minute bars (start, start+1m, ...) with ``vols``.""" + base = pd.Timestamp(day) + pd.Timedelta(start) + return [(base + pd.Timedelta(minutes=i), sym, v) for i, v in enumerate(vols)] + + +def _background(n_days, n_slots, sym=_SYM, start_day="2021-07-01", start="09:31:00"): + """``n_days`` prior days of flat volume-100 sessions -> baseline μ=100, σ=0.""" + rows = [] + for i in range(n_days): + day = (pd.Timestamp(start_day) + pd.Timedelta(days=i)).strftime("%Y-%m-%d") + rows += _session(day, [100.0] * n_slots, sym=sym, start=start) + return rows + + +def _peaks_at(n_slots, positions): + """Volume list of ``n_slots`` mild (100) bars with 200 at each of ``positions``. + + Every position must be an interior slot >= 2 apart, so each is an isolated eruption + with mild 1-minute neighbours -> a PEAK under the reused taxonomy. + """ + vols = [100.0] * n_slots + for p in positions: + vols[p] = 200.0 + return vols + + +_BG_DAYS = 10 +_TEST_DAY = pd.Timestamp("2021-07-11") + +# Gates small enough that the single engineered test day is the only VALID day. +_KW = dict( + baseline_days=VOLUME_PRV_BASELINE_DAYS, + baseline_min_obs=VOLUME_PRV_BASELINE_MIN_OBS, + sigma_k=VOLUME_PRV_SIGMA_K, + lookback_days=PEAK_INTERVAL_LOOKBACK_DAYS, + min_valid_days=1, + min_classifiable=1, + min_intervals=4, +) + + +# --------------------------------------------------------------------------- # +# Hand-computed kurtosis values (>= 2 non-trivial cases) + pandas.kurt() alignment +# --------------------------------------------------------------------------- # +def test_hand_value_case_a_intervals_2_2_2_8(): + # 17 slots, peaks at positions 1, 3, 5, 7, 15 -> intervals [2, 2, 2, 8]. + # By hand (Fisher excess, bias-corrected, n=4): mean=3.5, M2=27, M4=425.25, + # kurt = n(n+1)(n-1)M4 / ((n-2)(n-3)M2^2) - 3(n-1)^2/((n-2)(n-3)) + # = 60*425.25/(2*729) - 13.5 = 17.5 - 13.5 = 4.0 + rows = _background(_BG_DAYS, 17) + rows += _session("2021-07-11", _peaks_at(17, [1, 3, 5, 7, 15])) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + assert out.loc[(_TEST_DAY, _SYM)] == pytest.approx(4.0) + # independent verification that our estimator IS the pandas .kurt() convention + assert pd.Series([2.0, 2.0, 2.0, 8.0]).kurt() == pytest.approx(4.0) + + +def test_hand_value_case_b_six_intervals(): + # 25 slots, peaks at 1, 3, 6, 10, 15, 21, 23 -> intervals [2, 3, 4, 5, 6, 2]. + # By hand (n=6): mean=11/3, M2=120/9, M4=3924/81, + # kurt = 210*(3924/81) / (12*(14400/81)) - 3*25/12 = 4.76875 - 6.25 = -1.48125 + rows = _background(_BG_DAYS, 25) + rows += _session("2021-07-11", _peaks_at(25, [1, 3, 6, 10, 15, 21, 23])) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + assert out.loc[(_TEST_DAY, _SYM)] == pytest.approx(-1.48125) + assert pd.Series([2.0, 3.0, 4.0, 5.0, 6.0, 2.0]).kurt() == pytest.approx(-1.48125) + + +def test_excess_kurtosis_matches_pandas_kurt_on_random_samples(): + # The estimator convention is PINNED to pandas .kurt() (== scipy kurtosis with + # fisher=True, bias=False). Verify on random samples, not just the hand cases. + rng = np.random.default_rng(11) + for n in (4, 5, 20, 137): + x = rng.integers(1, 60, size=n).astype(float) + if np.ptp(x) == 0: + continue + assert excess_kurtosis(x) == pytest.approx(pd.Series(x).kurt()) + # a normal-ish large sample sits near 0 (Fisher convention, not Pearson's 3) + big = rng.standard_normal(200_000) + assert abs(excess_kurtosis(big)) < 0.1 + + +def test_excess_kurtosis_needs_four_points_and_variance(): + assert np.isnan(excess_kurtosis(np.array([2.0, 4.0, 6.0]))) # n < 4 + assert np.isnan(excess_kurtosis(np.array([3.0, 3.0, 3.0, 3.0]))) # zero variance + + +# --------------------------------------------------------------------------- # +# Intervals are TRADING MINUTES (the lunch break is NOT 90 minutes) +# --------------------------------------------------------------------------- # +def _lunch_rows(peak_slots, sym=_SYM): + """Days spanning the lunch break: 11:26..11:30 then 13:01..13:05.""" + slots = [f"11:{m}:00" for m in range(26, 31)] + [ + f"13:0{m}:00" for m in range(1, 6) + ] + + def day(d, vols): + base = pd.Timestamp(d) + return [(base + pd.Timedelta(s), sym, v) for s, v in zip(slots, vols)] + + rows = [] + for i in range(_BG_DAYS): + d = (pd.Timestamp("2021-07-01") + pd.Timedelta(days=i)).strftime("%Y-%m-%d") + rows += day(d, [100.0] * len(slots)) + vols = [100.0] * len(slots) + for p in peak_slots: + vols[p] = 200.0 + rows += day("2021-07-11", vols) + return rows + + +def test_lunch_break_interval_is_trading_minutes_not_wall_clock(): + # Peaks at 11:29 (index 3) and 13:02 (index 6). The visible trading-minute sequence + # is 11:26,11:27,11:28,11:29,11:30,13:01,13:02,... so 11:30 -> 13:01 is ONE slot + # apart and the peak-to-peak interval is 3 TRADING minutes -- NOT the 93 minutes of + # wall clock. (PINNED interpretation; the report is silent.) + rows = _lunch_rows([3, 6]) + visible = prepare_visible_minute_bars(_bars(rows)) + g = visible[visible["symbol"] == _SYM].reset_index(drop=True) + work = peak_mask_for_symbol(g) + intervals = peak_intervals_by_day(work) + np.testing.assert_array_equal(intervals.loc[_TEST_DAY], np.array([3])) + # the wall-clock gap really is 93 minutes -- the point of the pinned choice + peaks = work.loc[work["peak"], "bar_end"].tolist() + assert (peaks[1] - peaks[0]) == pd.Timedelta(minutes=93) + + +def test_missing_minute_shrinks_interval_by_one_slot(): + # A minute absent from the cache is simply not a tradable slot in our sequence, so + # an interval spanning it is one SHORTER. Disclosed consequence of measuring in + # observed trading slots (rare in practice; the same stance PR-F takes on gaps). + full = _background(_BG_DAYS, 12) + _session("2021-07-11", _peaks_at(12, [1, 5])) + a = compute_peak_interval_kurtosis(_bars(full), **_KW) + dropped = [r for r in full if r[0] != pd.Timestamp("2021-07-11 09:34")] + visible = prepare_visible_minute_bars(_bars(dropped)) + work = peak_mask_for_symbol(visible.reset_index(drop=True)) + # peaks at 09:32 and 09:36 with 09:34 missing -> 3 slots apart, not 4 + np.testing.assert_array_equal(peak_intervals_by_day(work).loc[_TEST_DAY], [3]) + assert np.isnan(a.loc[(_TEST_DAY, _SYM)]) # single interval -> no kurtosis + + +# --------------------------------------------------------------------------- # +# A day with < 2 peaks contributes ZERO intervals but is still a VALID day +# --------------------------------------------------------------------------- # +def test_single_peak_day_contributes_no_intervals_but_stays_valid(): + # Day 1: exactly one peak -> 0 intervals (NOT NaN-poisoning, NOT skipped). + # Day 2: five peaks -> intervals [2, 2, 2, 8] -> pooled kurtosis 4.0. + rows = _background(_BG_DAYS, 17) + rows += _session("2021-07-11", _peaks_at(17, [3])) + rows += _session("2021-07-12", _peaks_at(17, [1, 3, 5, 7, 15])) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + d1, d2 = pd.Timestamp("2021-07-11"), pd.Timestamp("2021-07-12") + # day 1 IS emitted (a valid day) but has too few pooled intervals -> honest NaN + assert (d1, _SYM) in out.index + assert np.isnan(out.loc[(d1, _SYM)]) + # day 2 pools day 1 (0 intervals) + day 2 (4 intervals) -> exactly the 4 intervals + assert out.loc[(d2, _SYM)] == pytest.approx(4.0) + + +def test_zero_peak_day_is_valid_and_pools_nothing(): + rows = _background(_BG_DAYS, 17) + rows += _session("2021-07-11", [100.0] * 17) # no eruption at all + rows += _session("2021-07-12", _peaks_at(17, [1, 3, 5, 7, 15])) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + assert np.isnan(out.loc[(pd.Timestamp("2021-07-11"), _SYM)]) + assert out.loc[(pd.Timestamp("2021-07-12"), _SYM)] == pytest.approx(4.0) + + +# --------------------------------------------------------------------------- # +# The two NaN gates: too few pooled intervals / zero variance +# --------------------------------------------------------------------------- # +def test_too_few_pooled_intervals_is_nan(): + # 4 peaks -> 3 intervals, below min_intervals=4 -> NaN (kurtosis is wild on tiny + # samples; honest missing rather than a number). + rows = _background(_BG_DAYS, 17) + rows += _session("2021-07-11", _peaks_at(17, [1, 3, 5, 7])) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + assert np.isnan(out.loc[(_TEST_DAY, _SYM)]) + + +def test_zero_variance_pool_is_nan(): + # 5 evenly spaced peaks -> intervals [2, 2, 2, 2]: enough of them, but kurtosis is + # undefined with zero variance -> NaN, never a divide-by-zero inf/garbage. + rows = _background(_BG_DAYS, 12) + rows += _session("2021-07-11", _peaks_at(12, [1, 3, 5, 7, 9])) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + assert np.isnan(out.loc[(_TEST_DAY, _SYM)]) + + +def test_default_min_intervals_gate_is_twenty(): + assert PEAK_INTERVAL_MIN_INTERVALS == 20 + assert PEAK_INTERVAL_LOOKBACK_DAYS == 20 + + +# --------------------------------------------------------------------------- # +# Reuse non-drift: the peaks PR-H intervals ARE PR-F's peaks +# --------------------------------------------------------------------------- # +def test_peaks_used_are_identical_to_pr_f_peak_count(): + # Same bars through both factors: the number of intervals on a day must be exactly + # (PR-F's peak count for that day) - 1. This is the anti-drift lock on the §0 reuse: + # if PR-H ever re-implemented the taxonomy, this would break. + rows = _background(_BG_DAYS, 25) + rows += _session("2021-07-11", _peaks_at(25, [1, 3, 6, 10, 15, 21, 23])) + bars = _bars(rows) + + pr_f = compute_volume_peak_count( + bars, + baseline_days=VOLUME_PRV_BASELINE_DAYS, + baseline_min_obs=VOLUME_PRV_BASELINE_MIN_OBS, + sigma_k=VOLUME_PRV_SIGMA_K, + lookback_days=1, + min_valid_days=1, + min_classifiable=1, + ) + n_peaks = pr_f.loc[(_TEST_DAY, _SYM)] + assert n_peaks == pytest.approx(7.0) + + visible = prepare_visible_minute_bars(bars) + work = peak_mask_for_symbol(visible.reset_index(drop=True)) + intervals = peak_intervals_by_day(work).loc[_TEST_DAY] + assert len(intervals) == int(n_peaks) - 1 + np.testing.assert_array_equal(intervals, np.array([2, 3, 4, 5, 6, 2])) + + +def test_ridge_minutes_never_produce_a_one_minute_interval(): + # Adjacent eruptive minutes are RIDGES (not peaks) under the reused taxonomy, so no + # interval can ever be 1. Eruptions at 1,2 (ridge pair) and clean peaks at 5,9,13,17. + vols = [100.0] * 20 + for p in (1, 2, 5, 9, 13, 17): + vols[p] = 200.0 + rows = _background(_BG_DAYS, 20) + _session("2021-07-11", vols) + visible = prepare_visible_minute_bars(_bars(rows)) + work = peak_mask_for_symbol(visible.reset_index(drop=True)) + intervals = peak_intervals_by_day(work).loc[_TEST_DAY] + np.testing.assert_array_equal(intervals, np.array([4, 4, 4])) + assert intervals.min() >= 2 + + +# --------------------------------------------------------------------------- # +# PIT: no lookahead, no post-cutoff influence +# --------------------------------------------------------------------------- # +def test_perturbing_post_1450_bars_does_not_change_factor(): + rows = _background(_BG_DAYS, 17) + _session( + "2021-07-11", _peaks_at(17, [1, 3, 5, 7, 15]) + ) + a = compute_peak_interval_kurtosis(_bars(rows), **_KW) + late = rows + [ + (pd.Timestamp("2021-07-11 14:50"), _SYM, 9_999.0), + (pd.Timestamp("2021-07-11 14:55"), _SYM, 9_999.0), + (pd.Timestamp("2021-07-11 14:56"), _SYM, 1.0), + ] + b = compute_peak_interval_kurtosis(_bars(late), **_KW) + key = (_TEST_DAY, _SYM) + assert a.loc[key] == pytest.approx(4.0) + assert a.loc[key] == pytest.approx(b.loc[key]) + + +def test_future_day_does_not_change_earlier_factor(): + base = _background(_BG_DAYS, 17) + _session( + "2021-07-11", _peaks_at(17, [1, 3, 5, 7, 15]) + ) + a = compute_peak_interval_kurtosis(_bars(base), **_KW) + future = base + _session( + "2021-07-12", [9_999.0, 1.0, 9_999.0, 1.0] + [500.0] * 13 + ) + b = compute_peak_interval_kurtosis(_bars(future), **_KW) + key = (_TEST_DAY, _SYM) + assert np.isfinite(a.loc[key]) + assert a.loc[key] == pytest.approx(b.loc[key]) + + +# --------------------------------------------------------------------------- # +# Per-symbol isolation +# --------------------------------------------------------------------------- # +def test_per_symbol_isolation(): + rows = _background(_BG_DAYS, 25, sym="AAA.SZ") + rows += _session("2021-07-11", _peaks_at(25, [1, 3, 5, 7, 15]), sym="AAA.SZ") + rows += _background(_BG_DAYS, 25, sym="BBB.SZ") + rows += _session( + "2021-07-11", _peaks_at(25, [1, 3, 6, 10, 15, 21, 23]), sym="BBB.SZ" + ) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + assert out.loc[(_TEST_DAY, "AAA.SZ")] == pytest.approx(4.0) + assert out.loc[(_TEST_DAY, "BBB.SZ")] == pytest.approx(-1.48125) + + +# --------------------------------------------------------------------------- # +# Window mechanics +# --------------------------------------------------------------------------- # +def test_pool_is_the_trailing_lookback_valid_days(): + # 3 valid days: day1 [2,2,2,8], day2 [2,2,2,8], day3 [4,4,4] (evenly spaced). + # With lookback_days=2 the day-3 pool is days 2+3 = [2,2,2,8,4,4,4]; a lookback of 3 + # would also include day 1 and give a different value. Assert day 3 == the 2-day pool. + rows = _background(_BG_DAYS, 17) + rows += _session("2021-07-11", _peaks_at(17, [1, 3, 5, 7, 15])) + rows += _session("2021-07-12", _peaks_at(17, [1, 3, 5, 7, 15])) + rows += _session("2021-07-13", _peaks_at(17, [1, 5, 9, 13])) + out = compute_peak_interval_kurtosis( + _bars(rows), **{**_KW, "lookback_days": 2} + ) + expected = pd.Series([2.0, 2.0, 2.0, 8.0, 4.0, 4.0, 4.0]).kurt() + assert out.loc[(pd.Timestamp("2021-07-13"), _SYM)] == pytest.approx(expected) + + +def test_min_valid_days_floor_returns_nan_until_enough_valid_days(): + rows = _background(_BG_DAYS, 17) + for k in range(3): + d = (pd.Timestamp("2021-07-11") + pd.Timedelta(days=k)).strftime("%Y-%m-%d") + rows += _session(d, _peaks_at(17, [1, 3, 5, 7, 15])) + out = compute_peak_interval_kurtosis( + _bars(rows), **{**_KW, "min_valid_days": 3} + ) + assert np.isnan(out.loc[(pd.Timestamp("2021-07-11"), _SYM)]) + assert np.isnan(out.loc[(pd.Timestamp("2021-07-12"), _SYM)]) + assert np.isfinite(out.loc[(pd.Timestamp("2021-07-13"), _SYM)]) + + +def test_min_classifiable_gate_invalidates_thin_days(): + rows = _background(_BG_DAYS, 17) + rows += _session("2021-07-11", _peaks_at(17, [1, 3, 5, 7, 15])) + out = compute_peak_interval_kurtosis( + _bars(rows), **{**_KW, "min_classifiable": 100} + ) + assert out.dropna().empty + + +def test_baseline_insufficient_yields_no_value(): + # Only 9 prior days -> fewer than baseline_min_obs (=10) same-slot obs -> nothing + # classifiable -> no valid day -> no value at all. + rows = _background(9, 17) + rows += _session("2021-07-10", _peaks_at(17, [1, 3, 5, 7, 15])) + out = compute_peak_interval_kurtosis(_bars(rows), **_KW) + assert out.dropna().empty + + +# --------------------------------------------------------------------------- # +# Guards / purity +# --------------------------------------------------------------------------- # +def test_empty_bars_yield_empty_schema_series(): + out = compute_peak_interval_kurtosis(empty_intraday_bars()) + assert out.empty + assert list(out.index.names) == ["date", "symbol"] + assert out.name == "peak_interval_kurtosis" + + +def test_input_bars_not_mutated(): + bars = _bars( + _background(_BG_DAYS, 17) + _session("2021-07-11", _peaks_at(17, [1, 3, 5, 7, 15])) + ) + before = bars.copy(deep=True) + compute_peak_interval_kurtosis(bars, **_KW) + pd.testing.assert_frame_equal(bars, before) + + +def test_bad_params_raise(): + bars = _bars(_session("2021-07-01", [100.0, 100.0, 100.0])) + with pytest.raises(ValueError): + compute_peak_interval_kurtosis(bars, lookback_days=0) + with pytest.raises(ValueError): + compute_peak_interval_kurtosis(bars, baseline_days=1) + with pytest.raises(ValueError): + compute_peak_interval_kurtosis(bars, baseline_min_obs=1) + with pytest.raises(ValueError): + compute_peak_interval_kurtosis(bars, sigma_k=-0.5) + with pytest.raises(ValueError): + compute_peak_interval_kurtosis(bars, min_valid_days=0) + with pytest.raises(ValueError): + compute_peak_interval_kurtosis(bars, min_classifiable=0) + with pytest.raises(ValueError): + # kurtosis is undefined below 4 observations + compute_peak_interval_kurtosis(bars, min_intervals=3) + + +# --------------------------------------------------------------------------- # +# Spec + Factor subclass +# --------------------------------------------------------------------------- # +def test_factor_spec_is_valid_and_daily(): + spec = PeakIntervalKurtosisFactor().spec + assert isinstance(spec, FactorSpec) + assert spec.factor_id == "peak_interval_kurtosis_20" + assert spec.is_intraday is False + assert spec.expected_ic_sign == 1 # POSITIVE (report RankIC +7.19%) + assert spec.return_basis == "close_to_close" + assert spec.forward_return_horizon == 1 + assert spec.input_fields == ("volume",) + for field in ( + "decision_cutoff", "data_lag", "session_open", + "execution_model", "execution_window", + ): + assert getattr(spec, field) is None + + +def test_factor_spec_discloses_pinned_interpretations(): + # The two choices the report is silent on MUST be on the spec a reader sees. + desc = PeakIntervalKurtosisFactor().spec.description + assert "TRADING MINUTE" in desc.upper() + assert "FISHER" in desc.upper() and "bias-corrected" in desc + + +def test_factor_subclass_window_tracks_name(): + f = PeakIntervalKurtosisFactor(lookback_days=10) + assert f.name == "peak_interval_kurtosis_10" + assert f.spec.factor_id == "peak_interval_kurtosis_10" + + +def test_factor_compute_selects_preaggregated_column(): + f = PeakIntervalKurtosisFactor() + idx = pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), _SYM)], names=["date", "symbol"] + ) + panel = pd.DataFrame({f.name: [1.25]}, index=idx) + out = f.compute(panel) + assert out.loc[(pd.Timestamp("2021-07-01"), _SYM)] == 1.25 + assert out.name == f.name + + +def test_factor_compute_missing_column_raises(): + f = PeakIntervalKurtosisFactor() + idx = pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), _SYM)], names=["date", "symbol"] + ) + with pytest.raises(ValueError, match="pre-aggregated"): + f.compute(pd.DataFrame({"other": [1.0]}, index=idx)) + + +def test_factor_bad_params_raise(): + with pytest.raises(ValueError): + PeakIntervalKurtosisFactor(lookback_days=0) From 319ab2bcc5701fc31bbed6ab805a08f46e74c2c1 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Mon, 20 Jul 2026 01:01:02 -0700 Subject: [PATCH 2/2] feat(eval): peak-interval-kurtosis evaluation runner + CLI + config (PR-H) Runs the PR-H factor through the FROZEN StandardFactorEvaluator on real cached CSI500 data via a new run-eval-peak-interval-kurtosis subcommand. The eval cell is IDENTICAL to PR-C..PR-G (CSI500 PIT membership, 2021-07-01..2026-06-30, daily rebalance, OOS split 2024-01-01, book value_ep/value_bp/volatility_20, fee 0.001), so this run is directly comparable to its PR-F sibling: same peaks, different statistic. Cache-only: the per-symbol minute read goes through IntradayParquetStore, which has no fetch closure, so stk_mins live calls are provably zero; a symbol with no cached minute is disclosed as empty rather than fetched. Memory-bounded -- one symbol's minute history is aggregated to its daily series and discarded before the next. The evaluator runs twice (no book -> Incremental NOT_ASSESSED; with book -> Incremental measured). Real run: 995/996 symbols covered, stk_mins_live_calls=0, 1139746 factor rows, 462.7s. RESULT IS NEGATIVE and reported as such. Rejected on both axes: rank IC mean +0.000217, ICIR +0.006 (95% CI -0.051..+0.063), Newey-West t 0.232, win rate 52.2%, N_eff 1190; the expected sign is not consistent across the two holdout subperiods (+0.000683 / -0.000179). With the book, incremental ICIR +0.046 -- redundant. Quintile monotonicity is +0.6 (test +0.7) but the aligned Q5-Q1 spread is -11.0%/yr, i.e. the ordering is not worth anything net. The report's full-market monthly figures (RankIC +7.19%, RankICIR 4.63) do NOT reproduce on this daily CSI500 cell; the report gives no CSI500 sub-domain number for this factor and none is invented. --- config/phase_h_peak_interval_kurtosis.yaml | 142 +++++ qt/cli.py | 32 ++ qt/eval_peak_interval_kurtosis.py | 515 ++++++++++++++++++ ...test_eval_peak_interval_kurtosis_runner.py | 268 +++++++++ 4 files changed, 957 insertions(+) create mode 100644 config/phase_h_peak_interval_kurtosis.yaml create mode 100644 qt/eval_peak_interval_kurtosis.py create mode 100644 tests/test_eval_peak_interval_kurtosis_runner.py diff --git a/config/phase_h_peak_interval_kurtosis.yaml b/config/phase_h_peak_interval_kurtosis.yaml new file mode 100644 index 0000000..5287653 --- /dev/null +++ b/config/phase_h_peak_interval_kurtosis.yaml @@ -0,0 +1,142 @@ +# PR-H — Sixth real factor evaluation: volume-peak INTERVAL KURTOSIS. +# +# Reproduces the SECOND factor of the Kaiyuan market-microstructure series #27 (开源证券 +# 《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, reportId 4957417, §6) — the +# "量峰间隔峰度因子" — as a first-class PeakIntervalKurtosisFactor and runs it through the +# FROZEN StandardFactorEvaluator on REAL cached A-share data. CACHE-ONLY: the minute read +# is provably live-call-free; daily / universe / covariate endpoints go through the +# read-through cache (warm -> 0 gap fetches). +# +# SAME MACHINE as PR-F, DIFFERENT STATISTIC. The volume-peak identification is REUSED +# from data/clean/intraday_volume_prv.py (not re-implemented): PIT-truncate each day at +# 14:50, classify every visible minute against its SAME-SLOT strictly-prior 20-day +# baseline (eruptive if vol > μ + σ else mild), and a PEAK is an eruptive minute whose +# both 1-minute same-session neighbours are mild. This factor then measures the GAPS +# between consecutive same-day peaks, pools the trailing 20 VALID trading days, and takes +# the pool's kurtosis. A day with < 2 peaks contributes 0 intervals but is still a valid +# day; NaN below 20 pooled intervals or on a zero-variance pool. +# +# TWO PINNED interpretations of an under-specified report (disclosed on the factor spec): +# (1) an interval is measured in TRADING MINUTES -- the tradable-slot difference inside +# the day's visible bar sequence -- so the lunch break costs nothing (peaks at 11:29 +# and 13:02 are 3 apart, not 93) and a wall-clock ~90-minute spike can never +# dominate the distribution; +# (2) kurtosis = FISHER excess, BIAS-CORRECTED (the pandas .kurt() / scipy +# fisher=True bias=False convention; a normal sample sits at 0, not 3). +# +# Pre-registered sign = +1 (report full-market RankIC +7.19% / RankICIR 4.63, long-short +# 23.3%/yr, IR 3.39, max drawdown 7.37%, monthly win rate 82.4%, 13/13 positive years -- +# the most stable factor in the report). NOTE the report is a MONTHLY, market-cap + +# industry neutral FULL-MARKET series on Wind data -- our eval cell is CSI500 daily with +# industry + size neutral, so those numbers are a LOOSE reference only. The report gives +# NO CSI500 sub-domain figure for THIS factor and none is invented here. Raw minute volume +# (cached as-is) has split-day magnitude jumps that pollute the 20-day σ; the report +# (Wind) does not adjust for this either, so it is disclosed and NOT corrected. +# +# Eval CELL is IDENTICAL to PR-C / PR-D / PR-E / PR-F / PR-G: universe = CSI500 +# (000905.SH), PIT membership; window = 2021-07-01 .. 2026-06-30 (the project's +# minute-coverage window); daily rebalance; OOS split 2024-01-01; book = +# value_ep/value_bp/volatility_20; fee 0.001. The ONLY substantive difference is the +# subject factor, which is minute-derived and computed by the runner from the intraday +# cache -- it is NOT a config-listed daily factor. Because the cell matches PR-F exactly, +# this run is directly comparable to its sibling: same peaks, different statistic. The +# evaluator runs TWICE (see qt/eval_peak_interval_kurtosis.py): once with NO book +# (Incremental NOT_ASSESSED) and once with the confirmed book to measure whether the +# peak-interval kurtosis adds alpha BEYOND value / low-vol. + +project: + name: quantitative_trading_pr_h_peak_interval_kurtosis + timezone: Asia/Shanghai +data: + source: tushare + freq: D + start: '2021-07-01' + end: '2026-06-30' + external_secret_file: /home/shaofl/Projects/financial_projects/.config.json + tushare_token_key: tushare.token + output_name: peak_interval_kurtosis_daily + cache: + enabled: true + root_dir: artifacts/cache/tushare/v1 + refresh_recent_days: 14 + refresh_dimension_days: 30 + force_refresh: [] +universe: + type: index + index_code: 000905.SH + symbols: [] + min_listing_days: 60 + filters: + missing_close: true + suspended: false + st: false + limit_up_down: false +# factors = the confirmed BOOK used for the Incremental axis (value + low-vol). The +# SUBJECT factor (peak_interval_kurtosis_20) is minute-derived and computed by the runner +# from the intraday cache -- it is NOT a config-listed daily factor. +factors: +- name: value_ep + enabled: true +- name: value_bp + enabled: true +- name: volatility_20 + enabled: true + params: + window: 20 + price_col: close +processing: + drop_missing: true + standardize: + enabled: true + method: zscore + # winsorize is a P0 no-op in this codebase; the EvalConfig declares winsorize=None + # accordingly (nothing is clipped), so the report never overstates preprocessing. + winsorize: + enabled: false + method: mad + n: 3.0 + # Industry + market-cap neutralization (SW-L1), matching the report's neutral column + # and the EvalConfig neutralization declaration. + neutralize: + enabled: true + industry_col: industry + size_col: market_cap + industry_level: L1 +alpha: + model: equal_weight + params: {} +portfolio: + # Required by the schema but unused: this runner evaluates a factor, it does not + # build/execute a portfolio. + constructor: topn_equal_weight + top_n: 50 + long_only: true + max_weight: null + turnover_cap: null +backtest: + # Required by the schema but unused (no backtest is run). The EvalConfig uses a DAILY + # rebalance (contract default), independent of this field. + initial_nav: 1.0 + rebalance: monthly + event_order: close_to_next_period + cash_return: 0.0 +cost: + fee_rate: 0.001 + slippage_rate: 0.0 + turnover_formula: l1 +analytics: + forward_return_periods: + - 1 + quantiles: 5 + benchmark: null +# OOS split (window midpoint) so the OOS section runs and the Predictive axis can be +# assessed (sign consistency across both holdout subperiods). +oos: + split_date: '2024-01-01' +output: + root_dir: artifacts + data_dir: artifacts/data + factor_dir: artifacts/factors + report_dir: artifacts/reports + log_dir: artifacts/logs + overwrite: true diff --git a/qt/cli.py b/qt/cli.py index f0af609..09f6d7a 100644 --- a/qt/cli.py +++ b/qt/cli.py @@ -328,6 +328,31 @@ def _cmd_run_eval_intraday_amp_cut(args: argparse.Namespace) -> int: return 0 +def _cmd_run_eval_peak_interval_kurtosis(args: argparse.Namespace) -> int: + """Run the two real peak-interval-kurtosis evaluations (cache-only) + reports.""" + from qt.eval_peak_interval_kurtosis import run_eval_peak_interval_kurtosis + + try: + result = run_eval_peak_interval_kurtosis(args.config) + except (ConfigError, ValueError, FileNotFoundError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + nb, wb = result.no_book_metrics, result.with_book_metrics + print( + f"OK run-eval-peak-interval-kurtosis: covered={result.covered_symbols}/" + f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " + f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" + f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " + f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" + f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " + f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" + f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" + f"dashboards: {result.reports.no_book_dashboard} | " + f"{result.reports.with_book_dashboard}" + ) + return 0 + + def _cmd_data_update(args: argparse.Namespace) -> int: """Warm/update the tushare caches (P4-3); never runs a backtest.""" from qt.data_updater import format_summary, run_data_update @@ -494,6 +519,13 @@ def build_parser() -> argparse.ArgumentParser: p_iac.add_argument("--config", required=True, help="Path to the YAML config.") p_iac.set_defaults(func=_cmd_run_eval_intraday_amp_cut) + p_pik = sub.add_parser( + "run-eval-peak-interval-kurtosis", + help="Run the peak-interval-kurtosis factor evaluation (CSI500, cache-only).", + ) + p_pik.add_argument("--config", required=True, help="Path to the YAML config.") + p_pik.set_defaults(func=_cmd_run_eval_peak_interval_kurtosis) + for name, func, help_text in ( ("fetch-data", _cmd_fetch_data, "Run the spine, report data fetch."), ("compute-factors", _cmd_compute_factors, "Run the spine, report factor compute."), diff --git a/qt/eval_peak_interval_kurtosis.py b/qt/eval_peak_interval_kurtosis.py new file mode 100644 index 0000000..70e62d6 --- /dev/null +++ b/qt/eval_peak_interval_kurtosis.py @@ -0,0 +1,515 @@ +"""run-eval-peak-interval-kurtosis: the sixth real factor evaluation (PR-H). + +Reproduces the SECOND factor of the Kaiyuan market-microstructure series #27 (开源证券 +《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, reportId 4957417, §6) — the +"量峰间隔峰度" factor — as a first-class +:class:`~factors.compute.intraday_derived.PeakIntervalKurtosisFactor` and runs it through +the FROZEN :class:`~analytics.eval.StandardFactorEvaluator` on REAL cached A-share data +(CSI500, PIT membership) — the same contract-driven loop PR-C / PR-D / PR-E / PR-F / +PR-G used. + +The factor is a DAILY signal derived DIRECTLY from the 1min cache (see +``data.clean.intraday_peak_interval.compute_peak_interval_kurtosis``): PIT-truncate each +day at 14:50, identify the volume peaks with the taxonomy REUSED from PR-F +(``data.clean.intraday_volume_prv``: same-slot strictly-prior μ+σ eruptive test, mild +1-minute same-session neighbours), measure the gaps between consecutive same-day peaks in +TRADING MINUTES, pool the trailing 20 VALID trading days and take the pool's Fisher +excess (bias-corrected) kurtosis. It is executed CLOSE-TO-CLOSE (daily default), so +``is_intraday=False`` (the reasoning is documented on the factor's spec). The eval CELL is +identical to PR-C..PR-G, so this run is directly comparable to its PR-F sibling — same +peaks, different statistic. + +CACHE-ONLY: every input is read from the persistent tushare cache +(``artifacts/cache/tushare/v1``). The minute read is provably live-call-free (the +minute store has no fetch closure — a miss simply yields no rows); the daily / +universe / covariate endpoints go through the shared read-through cache, which on a +fully-warmed cache does zero gap fetches (disclosed via the run-log cache-stats line). +Forward returns are computed ONLY at the evaluator/analytics boundary from +``ctx.price_panel`` — the factor computation never sees a future return. + +The evaluator is run TWICE: once with NO known-factor book (the Incremental axis is +NOT_ASSESSED) and once with the project's independently-confirmed book (value_ep / +value_bp / volatility_20) so the Incremental axis measures whether the peak-interval +kurtosis adds alpha BEYOND value / low-vol. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from pathlib import Path + +import pandas as pd + +from analytics.eval import ( + EvalConfig, + EvalContext, + FactorEvalReport, + StandardFactorEvaluator, +) +from analytics.eval.figures import render_factor_dashboard +from data.cache.intraday_cache import ENDPOINT as INTRADAY_ENDPOINT +from data.cache.intraday_cache import READ_COLUMNS +from data.cache.intraday_parquet_store import IntradayParquetStore +from data.clean.intraday_peak_interval import ( + PEAK_INTERVAL_LOOKBACK_DAYS, + PEAK_INTERVAL_MIN_INTERVALS, + compute_peak_interval_kurtosis, +) +from data.clean.intraday_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars +from data.clean.intraday_volume_prv import ( + VOLUME_PRV_BASELINE_DAYS, + VOLUME_PRV_BASELINE_MIN_OBS, + VOLUME_PRV_MIN_CLASSIFIABLE, + VOLUME_PRV_MIN_VALID_DAYS, + VOLUME_PRV_SIGMA_K, +) +from data.clean.schema import CORE_COLUMNS, DATE_LEVEL +from factors.compute.intraday_derived import PeakIntervalKurtosisFactor +from factors.spec import FactorSpec +from qt.config import RootConfig, load_config +from qt.pipeline import ( + _build_cache, + _build_universe, + _load_panel, + _log_run_cache_stats, + _make_logger, + _maybe_enrich_covariates, + _maybe_enrich_value, + _process_factors, +) + +_LOGGER_NAME = "qt.eval_peak_interval_kurtosis" +_REPORT_STEM = "eval_peak_interval_kurtosis" + + +# --------------------------------------------------------------------------- # +# Minute loading (cache-only, per-symbol -> memory-bounded) +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class _PeakIntervalMinuteLoad: + """Diagnostics from the cache-only per-symbol minute read + aggregation.""" + + factor: pd.Series # MultiIndex(date, symbol) raw peak-interval kurtosis + requested: int + covered: tuple[str, ...] # symbols that produced >= 1 finite factor value + empty_symbols: tuple[str, ...] # requested but no cached minute / no value + raw_rows: int + live_calls: int # provably 0 (store read has no fetch closure) + + +def _load_peak_interval_kurtosis_panel( + cfg: RootConfig, + symbols: list[str], + spec: FactorSpec, + logger, + *, + lookback_days: int, + baseline_days: int, + baseline_min_obs: int, + sigma_k: float, + min_valid_days: int, + min_classifiable: int, + min_intervals: int, +) -> _PeakIntervalMinuteLoad: + """Compute the raw peak-interval-kurtosis panel per symbol from the minute cache. + + Memory-bounded: one symbol's minute history is read, aggregated to its daily factor + series, and discarded before the next — the multi-year all-symbol minute panel is + NEVER materialized. Read-only: :meth:`IntradayParquetStore.read_range` has no fetch + closure, so ``stk_mins`` live calls are provably zero (a symbol with no cached + minute simply yields no rows and is disclosed as empty). The peak taxonomy (reused + from PR-F) and the interval/kurtosis reduction run entirely inside + ``compute_peak_interval_kurtosis`` on 1min bars. + """ + root = cfg.data.cache.root_dir + store = IntradayParquetStore(root) + start = pd.Timestamp(cfg.data.start).normalize() + end = pd.Timestamp(cfg.data.end).normalize() + pd.Timedelta("23:59:59") + + series: list[pd.Series] = [] + covered: list[str] = [] + empty: list[str] = [] + raw_rows = 0 + for i, sym in enumerate(symbols): + part = store.read_range(INTRADAY_ENDPOINT, sym, RAW_INTRADAY_FREQ, start, end) + if part.empty: + empty.append(sym) + continue + raw_rows += len(part) + bars = normalize_intraday_bars( + part.rename(columns={"bar_end": "time"})[READ_COLUMNS], + freq=RAW_INTRADAY_FREQ, + ) + s = compute_peak_interval_kurtosis( + bars, + lookback_days=lookback_days, + baseline_days=baseline_days, + baseline_min_obs=baseline_min_obs, + sigma_k=sigma_k, + min_valid_days=min_valid_days, + min_classifiable=min_classifiable, + min_intervals=min_intervals, + name=spec.factor_id, + ) + if s.notna().any(): + series.append(s) + covered.append(sym) + else: + empty.append(sym) + if (i + 1) % 100 == 0: + logger.info( + "minute aggregation: %d/%d symbols processed (%d with a value)", + i + 1, len(symbols), len(covered), + ) + + if not series: + raise ValueError( + "run-eval-peak-interval-kurtosis blocked: no requested symbol produced a " + f"cached peak-interval-kurtosis value over [{cfg.data.start}, " + f"{cfg.data.end}]. The minute cache is required (this runner never warms " + "it); check coverage." + ) + factor = pd.concat(series).sort_index() + logger.info( + "minute aggregation (cache-only): %d/%d symbols with a value, %d raw 1min " + "rows read, %d factor rows, stk_mins_live_calls=0", + len(covered), len(symbols), raw_rows, len(factor), + ) + return _PeakIntervalMinuteLoad( + factor=factor, + requested=len(symbols), + covered=tuple(covered), + empty_symbols=tuple(empty), + raw_rows=raw_rows, + live_calls=0, + ) + + +# --------------------------------------------------------------------------- # +# Evaluation core (network-free seam: given panels, run the two evaluations) +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class _RunReports: + """The two evaluation reports (no-book / with-book) + their file paths.""" + + no_book: FactorEvalReport + with_book: FactorEvalReport + no_book_md: Path + no_book_json: Path + with_book_md: Path + with_book_json: Path + no_book_dashboard: Path + with_book_dashboard: Path + + +def evaluate_two_runs( + factor_panel: pd.Series | pd.DataFrame, + spec: FactorSpec, + eval_cfg: EvalConfig, + price_panel: pd.DataFrame, + book: pd.DataFrame, + *, + universe_symbols: tuple[str, ...], + fee_rate: float, + report_dir: Path, + stem: str = _REPORT_STEM, +) -> _RunReports: + """Run the StandardFactorEvaluator TWICE (no book / with book) and write reports. + + This is the network-free seam: given the PROCESSED factor panel, the qfq price + panel (for forward returns), and the PROCESSED known-factor book, it does the two + ``evaluate`` calls and writes ``{stem}_no_book`` / ``{stem}_with_book`` as + Markdown + JSON. Run 1 omits ``known_factors`` (Incremental NOT_ASSESSED); run 2 + supplies the book (Incremental measured). + """ + evaluator = StandardFactorEvaluator() + report_dir.mkdir(parents=True, exist_ok=True) + + ctx_no_book = EvalContext( + price_panel=price_panel, + universe_symbols=universe_symbols, + fee_rate=fee_rate, + ) + # evaluate_with_ir yields the SAME report as evaluate() plus the IR the + # research-style dashboard needs (per-period IC + quantile return series). + report_no_book, ir_no_book = evaluator.evaluate_with_ir( + factor_panel, spec, eval_cfg, ctx_no_book + ) + + ctx_with_book = EvalContext( + price_panel=price_panel, + universe_symbols=universe_symbols, + fee_rate=fee_rate, + known_factors=book, + ) + report_with_book, ir_with_book = evaluator.evaluate_with_ir( + factor_panel, spec, eval_cfg, ctx_with_book + ) + + nb_md, nb_json = _write_report(report_no_book, report_dir, f"{stem}_no_book") + wb_md, wb_json = _write_report(report_with_book, report_dir, f"{stem}_with_book") + nb_png = render_factor_dashboard( + report_no_book, ir_no_book, report_dir / f"{stem}_no_book_dashboard.png" + ) + wb_png = render_factor_dashboard( + report_with_book, ir_with_book, report_dir / f"{stem}_with_book_dashboard.png" + ) + return _RunReports( + no_book=report_no_book, + with_book=report_with_book, + no_book_md=nb_md, + no_book_json=nb_json, + with_book_md=wb_md, + with_book_json=wb_json, + no_book_dashboard=nb_png, + with_book_dashboard=wb_png, + ) + + +def _write_report(report: FactorEvalReport, report_dir: Path, stem: str) -> tuple[Path, Path]: + """Write ``report`` as deterministic Markdown + JSON; return the two paths.""" + md_path = report_dir / f"{stem}.md" + json_path = report_dir / f"{stem}.json" + md_path.write_text(report.render(), encoding="utf-8") + json_path.write_text(report.to_json(), encoding="utf-8") + return md_path, json_path + + +# --------------------------------------------------------------------------- # +# Metric extraction (for the CLI line + the handoff) +# --------------------------------------------------------------------------- # +def _section_payload(report: FactorEvalReport, name: str) -> dict: + section = report.by_name().get(name) + return dict(getattr(section, "payload", {}) or {}) + + +def extract_metrics(report: FactorEvalReport) -> dict: + """Pull the headline verdict + gated metrics out of a finished report.""" + verdict = report.require_verdict() + pred = _section_payload(report, "predictive_power") + purity = _section_payload(report, "data_coverage") + incr = _section_payload(report, "purity") + return { + "deployment": verdict.verdict, + "predictive": verdict.predictive.verdict, + "incremental": verdict.incremental.verdict, + "tradable": verdict.tradable.verdict, + "ic_mean": pred.get("ic_mean"), + "ic_ir": pred.get("ic_ir"), + "ic_ir_ci_low": pred.get("ic_ir_ci_low"), + "ic_ir_ci_high": pred.get("ic_ir_ci_high"), + "ic_ir_ci_n_eff": pred.get("ic_ir_ci_n_eff"), + "ic_win_rate": pred.get("ic_win_rate"), + "ic_nw_t": pred.get("ic_nw_t"), + "settled_rebalances": purity.get("settled_rebalances"), + "effective_samples": purity.get("effective_samples"), + "span_days": purity.get("span_days"), + "incremental_ic_ir": incr.get("incremental_ic_ir"), + "incremental_ic_ir_ci_low": incr.get("incremental_ic_ir_ci_low"), + "incremental_ic_ir_ci_high": incr.get("incremental_ic_ir_ci_high"), + "incremental_ic_ir_ci_n_eff": incr.get("incremental_ic_ir_ci_n_eff"), + "incremental_ic_mean": incr.get("incremental_ic_mean"), + } + + +# --------------------------------------------------------------------------- # +# EvalConfig construction (HONEST provenance of what the runner actually did) +# --------------------------------------------------------------------------- # +def _build_eval_config(cfg: RootConfig) -> EvalConfig: + """Build the per-run EvalConfig, declaring EXACTLY what the pipeline applied. + + The declarations must not overstate: this codebase's winsorize step is a P0 no-op, + so ``winsorize`` is declared None (nothing was clipped) even though the config may + toggle it. z-score + industry/size neutralization ARE applied, so they are + declared. ``oos_split`` (from the config's ``oos`` block) makes the OOS section run + so the Predictive axis can be assessed. ``is_exploratory=True``: this is a + reproduction on a shorter window / narrower neutralization than the report, not a + return claim (it caps the deployment label at Watch). + """ + if cfg.oos is None: + raise ValueError( + "run-eval-peak-interval-kurtosis requires an 'oos' section (split_date) so " + "the Predictive axis has an out-of-sample split to assess; add e.g. " + "oos: {split_date: '2024-01-01'}." + ) + return EvalConfig( + universe=cfg.universe.index_code or cfg.universe.type, + universe_is_pit=cfg.universe.type == "index", + start=cfg.data.start, + end=cfg.data.end, + is_exploratory=True, + post_hoc_selected=False, + rebalance="daily", + n_quantiles=int(cfg.analytics.quantiles), + cost_scenarios=(1.0, 2.0, 4.0), + oos_split=cfg.oos.split_date, + # winsorize is a P0 no-op in this codebase -> declare None (nothing clipped). + winsorize=None, + standardize="zscore" if cfg.processing.standardize.enabled else None, + neutralization=("industry", "size") if cfg.processing.neutralize.enabled else (), + industry_level=cfg.processing.neutralize.industry_level, + tuned=False, + # We evaluated ONE pre-registered factor whose sign came from the report (not a + # screen of our own); the report's own factor screen is a caveat noted in the + # run's prose, not our multiple-testing background. + n_factors_screened=1, + data_snapshot_id=cfg.data.cache.root_dir, + ) + + +# --------------------------------------------------------------------------- # +# Result container + the full glue +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class PeakIntervalKurtosisEvalResult: + """Immutable summary of one run-eval-peak-interval-kurtosis run.""" + + config: RootConfig + spec: FactorSpec + requested_symbols: int + covered_symbols: int + empty_symbols: int + factor_rows: int + minute_raw_rows: int + minute_live_calls: int + no_book_metrics: dict + with_book_metrics: dict + reports: _RunReports + log_path: Path + elapsed: float + + +def _check_preconditions(cfg: RootConfig) -> None: + """Fail readably if the config cannot drive a real, cache-only CSI500 eval.""" + if cfg.data.source != "tushare": + raise ValueError( + "run-eval-peak-interval-kurtosis needs data.source='tushare' (real cached " + f"A-share data); got {cfg.data.source!r}." + ) + if not cfg.data.cache.enabled: + raise ValueError( + "run-eval-peak-interval-kurtosis needs data.cache.enabled=true (it reads " + "the persistent tushare cache and never warms live)." + ) + if cfg.universe.type != "index": + raise ValueError( + "run-eval-peak-interval-kurtosis needs universe.type='index' (PIT " + f"membership, e.g. 000905.SH for CSI500); got {cfg.universe.type!r}." + ) + if not cfg.processing.neutralize.enabled: + raise ValueError( + "run-eval-peak-interval-kurtosis expects processing.neutralize.enabled=true " + "(industry + size neutralization, matching the report's neutral column and " + "the EvalConfig declaration)." + ) + + +def run_eval_peak_interval_kurtosis(config_path: str) -> PeakIntervalKurtosisEvalResult: + """Run the two real peak-interval-kurtosis evaluations (cache-only) + reports.""" + cfg = load_config(config_path) + _check_preconditions(cfg) + + log_path = Path(cfg.output.log_dir) / f"{_REPORT_STEM}.log" + logger = _make_logger(log_path, name=_LOGGER_NAME) + started = time.monotonic() + + factor = PeakIntervalKurtosisFactor(lookback_days=PEAK_INTERVAL_LOOKBACK_DAYS) + spec = factor.spec + eval_cfg = _build_eval_config(cfg) + logger.info( + "eval config: %s rebalance=daily oos_split=%s", eval_cfg.universe, eval_cfg.oos_split + ) + + cache = _build_cache(cfg) + universe, symbols = _build_universe(cfg, logger, cache) + panel = _load_panel(cfg, symbols, logger, cache) + + # Book (value_ep / value_bp / volatility_20): enrich, compute, process. The value + # factors need daily_basic pe/pb; volatility_20 needs close. + book_factors = _build_book_factors() + panel = _maybe_enrich_value(cfg, panel, symbols, book_factors, logger, cache) + panel = _maybe_enrich_covariates(cfg, panel, symbols, logger, cache) + _log_run_cache_stats(cache, logger) # daily/universe/covariate gap-fetches (warm -> 0) + + panel_dates = pd.Index( + pd.unique(panel.index.get_level_values(DATE_LEVEL)), name=DATE_LEVEL + ) + book_raw = pd.concat( + [f.compute(panel).rename(f.name) for f in book_factors], axis=1 + ) + book_processed = _process_factors(cfg, book_raw, panel) + + # Peak-interval-kurtosis factor: cache-only per-symbol aggregation -> raw -> process. + load = _load_peak_interval_kurtosis_panel( + cfg, symbols, spec, logger, + lookback_days=PEAK_INTERVAL_LOOKBACK_DAYS, + baseline_days=VOLUME_PRV_BASELINE_DAYS, + baseline_min_obs=VOLUME_PRV_BASELINE_MIN_OBS, + sigma_k=VOLUME_PRV_SIGMA_K, + min_valid_days=VOLUME_PRV_MIN_VALID_DAYS, + min_classifiable=VOLUME_PRV_MIN_CLASSIFIABLE, + min_intervals=PEAK_INTERVAL_MIN_INTERVALS, + ) + # A minute date with no daily bar cannot have a forward return; keep the factor on + # the daily trading grid so the analytics boundary has a price for every date. + factor_raw = load.factor[ + load.factor.index.get_level_values(DATE_LEVEL).isin(panel_dates) + ] + factor_processed = _process_factors(cfg, factor_raw.to_frame(spec.factor_id), panel) + factor_series = factor_processed[spec.factor_id] + + price_panel = panel[CORE_COLUMNS] + reports = evaluate_two_runs( + factor_series, + spec, + eval_cfg, + price_panel, + book_processed, + universe_symbols=tuple(symbols), + fee_rate=float(cfg.cost.fee_rate), + report_dir=Path(cfg.output.report_dir), + ) + + no_book_metrics = extract_metrics(reports.no_book) + with_book_metrics = extract_metrics(reports.with_book) + logger.info( + "verdict no-book: %s (predictive=%s); with-book: %s (incremental=%s)", + no_book_metrics["deployment"], no_book_metrics["predictive"], + with_book_metrics["deployment"], with_book_metrics["incremental"], + ) + + return PeakIntervalKurtosisEvalResult( + config=cfg, + spec=spec, + requested_symbols=load.requested, + covered_symbols=len(load.covered), + empty_symbols=len(load.empty_symbols), + factor_rows=int(len(factor_series)), + minute_raw_rows=load.raw_rows, + minute_live_calls=load.live_calls, + no_book_metrics=no_book_metrics, + with_book_metrics=with_book_metrics, + reports=reports, + log_path=log_path, + elapsed=time.monotonic() - started, + ) + + +def _build_book_factors() -> list: + """Instantiate the confirmed book (value_ep / value_bp / volatility_20).""" + from factors.compute.candidates import ValueFactor, VolatilityFactor + + return [ + ValueFactor("value_ep"), + ValueFactor("value_bp"), + VolatilityFactor(window=20), + ] + + +__all__ = [ + "PeakIntervalKurtosisEvalResult", + "evaluate_two_runs", + "extract_metrics", + "run_eval_peak_interval_kurtosis", +] diff --git a/tests/test_eval_peak_interval_kurtosis_runner.py b/tests/test_eval_peak_interval_kurtosis_runner.py new file mode 100644 index 0000000..db220d6 --- /dev/null +++ b/tests/test_eval_peak_interval_kurtosis_runner.py @@ -0,0 +1,268 @@ +"""PR-H runner: cache-only minute loader + the two-run evaluation core (no network).""" + +from __future__ import annotations + +import logging + +import numpy as np +import pandas as pd +import pytest + +from analytics.eval import EvalConfig, MANDATORY_SECTIONS, Section, Skipped +from analytics.eval.verdict import AXIS_NOT_ASSESSED, AXIS_VERDICTS +from data.cache.intraday_cache import ENDPOINT as INTRADAY_ENDPOINT +from data.cache.intraday_parquet_store import KEY_COLS, IntradayParquetStore +from factors.compute.intraday_derived import PeakIntervalKurtosisFactor +from qt.config import ( + AlphaCfg, + BacktestCfg, + CacheCfg, + CostCfg, + DataCfg, + FactorCfg, + OutputCfg, + PortfolioCfg, + RootConfig, + UniverseCfg, +) +from qt.eval_peak_interval_kurtosis import ( + _load_peak_interval_kurtosis_panel, + evaluate_two_runs, + extract_metrics, +) + + +# --------------------------------------------------------------------------- # +# Minute cache-only loader +# --------------------------------------------------------------------------- # +def _stored_rows(sym, day, vols): + """Build STORED_COLUMNS-shaped 1min rows for one session of CONSECUTIVE minutes. + + Bar i sits at ``09:31 + i`` minutes (all inside the 14:50 PIT window); consecutive + minutes are 60s apart so interior bars have both 1-minute neighbours. OHLC are dummy + constants (the factor reads only ``volume``); ``amount`` mirrors ``volume``. + """ + base = pd.Timestamp(day) + pd.Timedelta("09:31:00") + rows = [] + for i, v in enumerate(vols): + be = base + pd.Timedelta(minutes=i) + rows.append( + { + "symbol": sym, + "bar_end": be, + "source_trade_time": be, + "open": 100.0, + "high": 100.0, + "low": 100.0, + "close": 100.0, + "volume": float(v), + "amount": float(v), + "freq": "1min", + } + ) + return pd.DataFrame(rows) + + +def _min_config(root, start, end): + return RootConfig( + data=DataCfg( + source="tushare", + start=start, + end=end, + external_secret_file="/nonexistent.json", + cache=CacheCfg(enabled=True, root_dir=str(root)), + ), + universe=UniverseCfg(type="static", symbols=["A.SZ"]), + factors=[FactorCfg(name="momentum_20")], + alpha=AlphaCfg(), + portfolio=PortfolioCfg(top_n=1), + backtest=BacktestCfg(), + cost=CostCfg(), + output=OutputCfg(), + ) + + +# Three constant background days (baseline), then a test day whose five peaks (the +# 200-volume minutes at positions 1, 3, 5, 7, 15) give intervals [2, 2, 2, 8] -> the +# hand-computed excess kurtosis 4.0. +_DAYS = ("2021-07-01", "2021-07-02", "2021-07-03") +_TEST_DAY = "2021-07-04" +_N_SLOTS = 17 +_BG_VOLS = [100.0] * _N_SLOTS + + +def _peak_vols(): + vols = [100.0] * _N_SLOTS + for p in (1, 3, 5, 7, 15): + vols[p] = 200.0 + return vols + + +# Small gates so a 4-day cache produces a value (baseline needs >= 2 prior obs; the pool +# holds the 4 intervals of the single test day). +_LOAD_KW = dict( + lookback_days=20, baseline_days=20, baseline_min_obs=2, + sigma_k=1.0, min_valid_days=1, min_classifiable=1, min_intervals=4, +) + + +def _seed_symbol(store, sym): + for day in _DAYS: + store.upsert(INTRADAY_ENDPOINT, sym, "1min", _stored_rows(sym, day, _BG_VOLS), KEY_COLS) + store.upsert( + INTRADAY_ENDPOINT, sym, "1min", _stored_rows(sym, _TEST_DAY, _peak_vols()), KEY_COLS + ) + + +def test_minute_loader_is_cache_only_and_discloses_empty(tmp_path): + root = tmp_path / "cache" + store = IntradayParquetStore(str(root)) + # Two symbols with cached minute bars; a third has NO cached minute (must be + # disclosed as empty, never fetched). + for sym in ("AAA.SZ", "BBB.SZ"): + _seed_symbol(store, sym) + + cfg = _min_config(root, "2021-07-01", "2021-07-04") + spec = PeakIntervalKurtosisFactor().spec + logger = logging.getLogger("test.pik.loader") + load = _load_peak_interval_kurtosis_panel( + cfg, ["AAA.SZ", "BBB.SZ", "CCC.SZ"], spec, logger, **_LOAD_KW + ) + assert load.live_calls == 0 # store read has no fetch closure + assert set(load.covered) == {"AAA.SZ", "BBB.SZ"} # both produced a value + assert load.empty_symbols == ("CCC.SZ",) # uncovered disclosed, not fetched + assert load.factor.name == spec.factor_id + assert load.factor.notna().any() + # the test day carries the hand-computed kurtosis of intervals [2, 2, 2, 8] + d = pd.Timestamp(_TEST_DAY) + assert load.factor.loc[(d, "AAA.SZ")] == pytest.approx(4.0) + assert load.factor.loc[(d, "BBB.SZ")] == pytest.approx(4.0) + assert set(load.factor.index.get_level_values("symbol")) == {"AAA.SZ", "BBB.SZ"} + + +def test_minute_loader_blocks_when_nothing_cached(tmp_path): + cfg = _min_config(tmp_path / "empty", "2021-07-01", "2021-07-04") + spec = PeakIntervalKurtosisFactor().spec + with pytest.raises(ValueError, match="no requested symbol produced"): + _load_peak_interval_kurtosis_panel( + cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.pik.block"), **_LOAD_KW + ) + + +# --------------------------------------------------------------------------- # +# Evaluation core (two runs) — synthetic processed panels, no network +# --------------------------------------------------------------------------- # +def _synthetic_panels(n_days=90, n_symbols=15, seed=7): + rng = np.random.default_rng(seed) + dates = pd.bdate_range("2022-01-03", periods=n_days) + symbols = [f"{i:06d}.SZ" for i in range(n_symbols)] + idx = pd.MultiIndex.from_product([dates, symbols], names=["date", "symbol"]) + + # processed (z-scored-ish) subject factor + a 3-column book on the same grid + factor = pd.Series( + rng.standard_normal(len(idx)), index=idx, name="peak_interval_kurtosis_20" + ) + book = pd.DataFrame( + { + "value_ep": rng.standard_normal(len(idx)), + "value_bp": rng.standard_normal(len(idx)), + "volatility_20": rng.standard_normal(len(idx)), + }, + index=idx, + ) + # a qfq-style price panel with the CORE_COLUMNS the forward-return boundary needs + close = pd.Series( + 100.0 * np.exp(np.cumsum(0.001 * rng.standard_normal(len(idx)))), index=idx + ) + price = pd.DataFrame( + { + "open": close.to_numpy(), + "high": close.to_numpy() * 1.01, + "low": close.to_numpy() * 0.99, + "close": close.to_numpy(), + "volume": 1_000.0, + "amount": 100_000.0, + "adj_factor": 1.0, + }, + index=idx, + ) + return factor, book, price + + +def _eval_cfg(): + return EvalConfig( + universe="000905.SH", + universe_is_pit=True, + start="2022-01-03", + end="2022-05-10", + is_exploratory=True, + post_hoc_selected=False, + rebalance="daily", + n_quantiles=5, + oos_split="2022-03-15", + winsorize=None, + standardize="zscore", + neutralization=("industry", "size"), + industry_level="L1", + ) + + +def test_evaluate_two_runs_produces_full_reports_and_incremental_axis(tmp_path): + factor, book, price = _synthetic_panels() + spec = PeakIntervalKurtosisFactor().spec + reports = evaluate_two_runs( + factor, spec, _eval_cfg(), price, book, + universe_symbols=tuple(sorted(set(book.index.get_level_values("symbol")))), + fee_rate=0.001, + report_dir=tmp_path, + ) + + for report in (reports.no_book, reports.with_book): + by = report.by_name() + assert set(by) == set(MANDATORY_SECTIONS) # all 8 present + assert all(isinstance(s, (Section, Skipped)) for s in by.values()) + assert report.verdict is not None # a verdict was produced + + # no-book: purity Skipped (no book) -> Incremental NOT_ASSESSED + assert isinstance(reports.no_book.by_name()["purity"], Skipped) + assert reports.no_book.verdict.incremental.verdict == AXIS_NOT_ASSESSED + + # with-book: purity is a real Section that populated the Incremental facts + purity = reports.with_book.by_name()["purity"] + assert isinstance(purity, Section) + assert purity.payload["known_factors_supplied"] is True + assert "incremental_ic_ir" in purity.payload + # the axis is now assessed (not NOT_ASSESSED) and is a valid axis state + incr = reports.with_book.verdict.incremental.verdict + assert incr in AXIS_VERDICTS and incr != AXIS_NOT_ASSESSED + + # reports were written to disk (md + json), both runs + for p in (reports.no_book_md, reports.no_book_json, + reports.with_book_md, reports.with_book_json): + assert p.exists() and p.stat().st_size > 0 + + # the research-style dashboard PNG is emitted for both runs (mandatory report + # artifact alongside md/json) + for p in (reports.no_book_dashboard, reports.with_book_dashboard): + assert p.exists() and p.stat().st_size > 20_000 + with open(p, "rb") as fh: + assert fh.read(8) == b"\x89PNG\r\n\x1a\n" + + # metrics extraction surfaces the gated fields + m = extract_metrics(reports.no_book) + assert m["deployment"] in {"Adopt", "Watch", "Reject", "INSUFFICIENT-DATA"} + assert "effective_samples" in m and "ic_ir" in m + + +def test_no_book_run_never_reaches_adopt(tmp_path): + # Structural guarantee (design §6): with no book + no execution facts, at most + # Watch — regardless of the signal (exploratory cap + NOT_ASSESSED axes). + factor, book, price = _synthetic_panels(seed=3) + reports = evaluate_two_runs( + factor, PeakIntervalKurtosisFactor().spec, _eval_cfg(), price, book, + universe_symbols=(), + fee_rate=0.001, + report_dir=tmp_path, + ) + assert reports.no_book.verdict.verdict != "Adopt" + assert reports.no_book.verdict.tradable.verdict == AXIS_NOT_ASSESSED