From 92798e6647f8e398992b2607df26a268df6ed381 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 09:24:31 -0700 Subject: [PATCH 1/6] refactor(qt): delete the eleven legacy eval runners and their tests C6 part two. The unified runner (D5 C4) replaced them, the CLI converged on run-factor-eval, and the verify-only conversion cut the last import edge, so these eleven modules and their ten test files have no remaining reader. Reachability checked in both forms before deleting, not just the static one: no module outside the runners and their own tests names qt.eval_*; every importlib/__import__ site in the repo resolves to factors.* / data.clean.* or imports the `qt` package itself, never a submodule; no module name is built from a string; nothing globs qt/. The only path-based reference anywhere is the R16 guard's own glob over tests/test_eval_*_runner.py, which was written to go vacuous exactly here. Deleted: 11 qt/eval_*.py, 10 tests/test_eval_*_runner.py. NOT deleted and verified present: tests/test_eval_contract_v1.py and tests/test_eval_figures.py, which are not runner tests. peak_ridge_amount_ratio never had a runner test file (catalogue BUG 3) -- a pre-existing gap, not a deletion. Node-ID differential: 2701 -> 2642, removed 59, added 0, and every one of the 59 matches tests/test_eval_*_runner.py::. Zero collateral: nothing outside the deleted files stopped collecting. On what those 59 were: they are GONE WITH THE CAPABILITY, and this commit claims no replacement for them as tests. What the map records -- and what R16 measures -- is that the PROPERTIES they asserted are pinned elsewhere in the new suite: 7 bodies moved verbatim, 50 covered by named new tests, 2 carriers retired with the reason on file (R1). That claim is checked, not asserted: all 56 new-suite node-IDs the map cites still resolve after the deletion, all three family wildcards still have the claimed size, and every citation still resolves to exactly one file. Both R16 runs are now in the map, side by side, because the admission ticket was earned on the pre-deletion tree. The audit script exits 1 afterwards -- its first two sections assert the legacy files exist, which is the pre-deletion framing and cannot hold now. That is NOT a coverage drop; section 3, the one that measures coverage, is clean. The script was deliberately left unfixed so the distinction stays visible, with both raw outputs kept. The durable guard behaves as designed: 6 passed, 1 skipped. Neither the vacuous-by-design check nor the count check goes red, because a guard that failed on the deletion it exists to authorise would be a booby trap. Also fixes the review's NIT-1: hand_anchors_d2's docstring still said the four daily factors "are compared by" the companion. They are not; the companion's rebuild was retired, and those rows now stay pending. --- .../factors/d5_property_test_migration_map.md | 28 + qt/eval_amp_marginal_anomaly_vol.py | 515 -------------- qt/eval_intraday_amp_cut.py | 538 --------------- qt/eval_jump_amount_corr.py | 502 -------------- qt/eval_minute_ideal_amplitude.py | 508 -------------- qt/eval_peak_interval_kurtosis.py | 534 --------------- qt/eval_peak_ridge_amount_ratio.py | 609 ----------------- qt/eval_ridge_minute_return.py | 607 ----------------- qt/eval_valley_price_quantile.py | 640 ------------------ qt/eval_valley_relative_vwap.py | 540 --------------- qt/eval_valley_ridge_vwap_ratio.py | 573 ---------------- qt/eval_volume_peak_count.py | 522 -------------- qt/hand_anchors_d2.py | 10 +- ...st_eval_amp_marginal_anomaly_vol_runner.py | 244 ------- tests/test_eval_intraday_amp_cut_runner.py | 258 ------- tests/test_eval_jump_amount_corr_runner.py | 232 ------- ...test_eval_minute_ideal_amplitude_runner.py | 239 ------- ...test_eval_peak_interval_kurtosis_runner.py | 268 -------- tests/test_eval_ridge_minute_return_runner.py | 435 ------------ .../test_eval_valley_price_quantile_runner.py | 509 -------------- .../test_eval_valley_relative_vwap_runner.py | 288 -------- ...est_eval_valley_ridge_vwap_ratio_runner.py | 365 ---------- tests/test_eval_volume_peak_count_runner.py | 258 ------- 23 files changed, 34 insertions(+), 9188 deletions(-) delete mode 100644 qt/eval_amp_marginal_anomaly_vol.py delete mode 100644 qt/eval_intraday_amp_cut.py delete mode 100644 qt/eval_jump_amount_corr.py delete mode 100644 qt/eval_minute_ideal_amplitude.py delete mode 100644 qt/eval_peak_interval_kurtosis.py delete mode 100644 qt/eval_peak_ridge_amount_ratio.py delete mode 100644 qt/eval_ridge_minute_return.py delete mode 100644 qt/eval_valley_price_quantile.py delete mode 100644 qt/eval_valley_relative_vwap.py delete mode 100644 qt/eval_valley_ridge_vwap_ratio.py delete mode 100644 qt/eval_volume_peak_count.py delete mode 100644 tests/test_eval_amp_marginal_anomaly_vol_runner.py delete mode 100644 tests/test_eval_intraday_amp_cut_runner.py delete mode 100644 tests/test_eval_jump_amount_corr_runner.py delete mode 100644 tests/test_eval_minute_ideal_amplitude_runner.py delete mode 100644 tests/test_eval_peak_interval_kurtosis_runner.py delete mode 100644 tests/test_eval_ridge_minute_return_runner.py delete mode 100644 tests/test_eval_valley_price_quantile_runner.py delete mode 100644 tests/test_eval_valley_relative_vwap_runner.py delete mode 100644 tests/test_eval_valley_ridge_vwap_ratio_runner.py delete mode 100644 tests/test_eval_volume_peak_count_runner.py diff --git a/docs/factors/d5_property_test_migration_map.md b/docs/factors/d5_property_test_migration_map.md index f94fe22..a334507 100644 --- a/docs/factors/d5_property_test_migration_map.md +++ b/docs/factors/d5_property_test_migration_map.md @@ -219,3 +219,31 @@ R16 的门是"删旧 runner 测试文件之前,59 条逐条有映射"。本节 真引用、判定"引用了但不存在"。占位符已改成不含双冒号的描述。**这不是误报**——本表的引用格式 就是双冒号加测试名,任何写成那个形状的东西都应该指向一个真实测试。写这一段本身又踩了一次 (复述时把占位符原样抄了回来),第二次才改对。 + +### 删除后复核(C6 删除 PR,2026-07-31) + +改造 PR 拿到的准入证是在**删除前**的树上跑的。删完**又跑了一次**,两次结果并列如下——R16 的门是 +「覆盖数非降」,所以承重的是**第三行**,不是收集总数。 + +| | 删除前(改造 PR 末) | 删除后(本 PR) | +|---|---|---| +| 旧套件文件 / 测试函数 | 10 / **59** | **0 / 0**(已删) | +| 59 条逐条有映射 | **59 / 59** | 不适用(左列已不存在) | +| **本表引用的新套件 node-ID 仍可收集** | **56 / 56** | **56 / 56** ✅ | +| 家族通配数目属实 | 3 / 3 | **3 / 3** ✅ | +| 引用唯一解析 | 全部 | **全部** ✅ | +| 收集总数(背景,非主张) | 2,701 | 2,642 | + +**结论:覆盖数非降成立。** 新套件这一侧**一条没少**——56 条引用删除后仍全部命中,家族通配数目 +不变,无歧义。少掉的 **59** 条全部是被删掉的那 10 个文件里的测试,**没有一条落在别处** +(node-ID 差分:removed 59 / added 0,且 59 条全部匹配 `tests/test_eval_*_runner.py::`)。 + +⚠️ **审计脚本删除后 rc=1,这不是覆盖下降**:它的 [1]/[2] 两节断言「旧套件应有 10 文件 59 条」, +那是**删除前**的口径,删完必然不成立。**判定覆盖是否下降的是 [3]**,它 0 problems。 +把脚本改成"删完也 rc=0"会掩盖这个区分,所以**没有改**——两份原始输出留在 +`tmp/context/cc_c6_verify_only/r16_{BEFORE,AFTER}_delete.txt`。 + +**耐久守卫 `tests/test_property_test_migration_map.py` 的表现符合设计**:删除后 **6 passed + +1 skipped**——`test_no_legacy_runner_test_is_missing_from_the_map` 空过(旧文件已不存在, +它的职责是"只要旧文件还在就不许有没被映射的"),`test_the_claimed_total_matches_the_legacy_files_while_they_exist` +显式 skip。**没有一条因为删除而变红**——一个会因删除变红的守卫,对本表要授权的这个 PR 就是陷阱。 diff --git a/qt/eval_amp_marginal_anomaly_vol.py b/qt/eval_amp_marginal_anomaly_vol.py deleted file mode 100644 index 0914789..0000000 --- a/qt/eval_amp_marginal_anomaly_vol.py +++ /dev/null @@ -1,515 +0,0 @@ -"""run-eval-amp-marginal-anomaly-vol: the third real factor evaluation (PR-E). - -Reproduces the Changjiang high-frequency-factor series #19 (长江证券《高频因子(十九)》, -reportId 5462994) "amplitude marginal-anomaly relative-volatility" factor as a -first-class :class:`~factors.compute.intraday_derived.AmpMarginalAnomalyVolFactor` 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 used. - -The factor is a DAILY signal derived from 5min bars (themselves DERIVED from the 1min -cache; see ``data.clean.intraday_amp_anomaly.compute_amp_marginal_anomaly_vol``): pool -the trailing 20 trading days of PIT-truncated (14:50) 5min bars, form within-day -``(|Δamp|, r)`` pairs, select the bars whose ``|Δamp| > μ + σ`` of the pooled -``|Δamp|``, and take the ddof=1 std of the RETURNS on those selected bars. It is -executed CLOSE-TO-CLOSE (daily default), so ``is_intraday=False`` (the reasoning is -documented on the factor's spec). - -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 anomaly-vol -factor 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_amp_anomaly import ( - AMP_ANOMALY_LOOKBACK_DAYS, - AMP_ANOMALY_MIN_POOL, - AMP_ANOMALY_MIN_SELECTED, - AMP_ANOMALY_SIGMA_K, - compute_amp_marginal_anomaly_vol, -) -from data.clean.intraday_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from data.clean.schema import CORE_COLUMNS, DATE_LEVEL -from factors.compute.intraday_derived import AmpMarginalAnomalyVolFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -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_amp_marginal_anomaly_vol" -_REPORT_STEM = "eval_amp_marginal_anomaly_vol" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _AnomalyVolMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw amp-marginal-anomaly-vol - 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_amp_anomaly_vol_panel( - cfg: RootConfig, - symbols: list[str], - spec: FactorSpec, - logger, - *, - lookback_days: int, - min_pool: int, - min_selected: int, - sigma_k: float, -) -> _AnomalyVolMinuteLoad: - """Compute the raw amp-marginal-anomaly-vol 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 1min -> 5min derivation - happens inside ``compute_amp_marginal_anomaly_vol``. - """ - 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_amp_marginal_anomaly_vol( - bars, - lookback_days=lookback_days, - min_pool=min_pool, - min_selected=min_selected, - sigma_k=sigma_k, - 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-amp-marginal-anomaly-vol blocked: no requested symbol produced a " - f"cached amp-marginal-anomaly-vol 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 _AnomalyVolMinuteLoad( - 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-amp-marginal-anomaly-vol 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 AmpAnomalyVolEvalResult: - """Immutable summary of one run-eval-amp-marginal-anomaly-vol 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 - exec_basis: ExecBasisEvaluation - 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-amp-marginal-anomaly-vol 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-amp-marginal-anomaly-vol needs data.cache.enabled=true (it reads " - "the persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-amp-marginal-anomaly-vol 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-amp-marginal-anomaly-vol expects processing.neutralize.enabled=" - "true (industry + size neutralization, matching the report's neutral column " - "and the EvalConfig declaration)." - ) - - -def run_eval_amp_marginal_anomaly_vol(config_path: str) -> AmpAnomalyVolEvalResult: - """Run the two real amp-marginal-anomaly-vol 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 = AmpMarginalAnomalyVolFactor(lookback_days=AMP_ANOMALY_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) - - # Amp-marginal-anomaly-vol factor: cache-only per-symbol aggregation -> raw -> process. - load = _load_amp_anomaly_vol_panel( - cfg, symbols, spec, logger, - lookback_days=AMP_ANOMALY_LOOKBACK_DAYS, - min_pool=AMP_ANOMALY_MIN_POOL, - min_selected=AMP_ANOMALY_MIN_SELECTED, - sigma_k=AMP_ANOMALY_SIGMA_K, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 AmpAnomalyVolEvalResult( - 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, - exec_basis=exec_basis, - 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__ = [ - "AmpAnomalyVolEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_amp_marginal_anomaly_vol", -] diff --git a/qt/eval_intraday_amp_cut.py b/qt/eval_intraday_amp_cut.py deleted file mode 100644 index eb12140..0000000 --- a/qt/eval_intraday_amp_cut.py +++ /dev/null @@ -1,538 +0,0 @@ -"""run-eval-intraday-amp-cut: the fifth real factor evaluation (PR-G). - -Reproduces the SECOND factor of the Kaiyuan market-microstructure series #30 (开源证券 -《高频振幅因子的内部切割——市场微观结构系列(30)》, reportId 4988549) — the "日内振幅切割因子" -(§3, thought (2)) — as a first-class -:class:`~factors.compute.intraday_derived.IntradayAmpCutFactor` 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 used. - -DISTINCT FROM PR-D (``minute_ideal_amp``, the report's flagship, thought (1)) which pools -the trailing 10 days' minutes into ONE set and cuts by minute CLOSE PRICE: this factor -cuts EACH DAY independently by the 1-MINUTE RETURN, produces a daily ``V_day`` series, -takes its trailing-10-valid-day ``V_mean`` / ``V_std``, and combines them CROSS- -SECTIONALLY (z-score each per date, average). See ``data.clean.intraday_amp_cut``. - -The factor is a DAILY signal executed CLOSE-TO-CLOSE (daily default), so -``is_intraday=False`` (the reasoning is documented on the factor's spec). - -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 cross-sectional standardization (report step 4) needs EVERY symbol's ``(V_mean, -V_std)`` present before it can z-score a date. The loader therefore streams one symbol at -a time through ``compute_amp_cut_stats`` (memory-bounded), assembles the full-universe -two-column stats panel, then calls ``combine_amp_cut_cross_section`` ONCE — the CSI500 -covered set is the cross-section (PINNED, disclosed on the factor spec). - -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 amplitude-cut -factor 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_amp_cut import ( - AMP_CUT_LAMBDA, - AMP_CUT_LOOKBACK_DAYS, - AMP_CUT_MIN_CROSS_SECTION, - AMP_CUT_MIN_DAY_MINUTES, - AMP_CUT_MIN_VALID_DAYS, - V_MEAN_COL, - V_STD_COL, - combine_amp_cut_cross_section, - compute_amp_cut_stats, -) -from data.clean.intraday_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from data.clean.schema import CORE_COLUMNS, DATE_LEVEL -from factors.compute.intraday_derived import IntradayAmpCutFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -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_intraday_amp_cut" -_REPORT_STEM = "eval_intraday_amp_cut" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol stats -> ONE cross-sectional combine) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _AmpCutMinuteLoad: - """Diagnostics from the cache-only per-symbol stats read + cross-sectional combine.""" - - factor: pd.Series # MultiIndex(date, symbol) final combined intraday_amp_cut - stats_rows: int # rows in the assembled (V_mean, V_std) panel - requested: int - covered: tuple[str, ...] # symbols that produced >= 1 finite (V_mean, V_std) pair - empty_symbols: tuple[str, ...] # requested but no cached minute / no finite stat - raw_rows: int - live_calls: int # provably 0 (store read has no fetch closure) - - -def _load_amp_cut_panel( - cfg: RootConfig, - symbols: list[str], - spec: FactorSpec, - logger, - *, - lookback_days: int, - lam: float, - min_day_minutes: int, - min_valid_days: int, - min_cross_section: int, -) -> _AmpCutMinuteLoad: - """Compute the final amp-cut factor from the minute cache (stats loop + combine). - - Memory-bounded: one symbol's minute history is read, aggregated to its daily - ``(V_mean, V_std)`` stats, 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 per-day cut and - trailing aggregation run inside ``compute_amp_cut_stats``; the CROSS-SECTIONAL z-score - combine (report step 4) is applied ONCE to the assembled full-universe stats panel. - """ - 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") - - stats_frames: list[pd.DataFrame] = [] - 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, - ) - stats = compute_amp_cut_stats( - bars, - lookback_days=lookback_days, - lam=lam, - min_day_minutes=min_day_minutes, - min_valid_days=min_valid_days, - ) - # A symbol counts as covered iff it produced >= 1 finite (V_mean, V_std) pair - # (those are the only rows that can enter a date's cross-section). - if not stats.empty and stats[[V_MEAN_COL, V_STD_COL]].notna().all(axis=1).any(): - stats_frames.append(stats) - 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 stats_frames: - raise ValueError( - "run-eval-intraday-amp-cut blocked: no requested symbol produced a cached " - f"intraday-amp-cut stat over [{cfg.data.start}, {cfg.data.end}]. The minute " - "cache is required (this runner never warms it); check coverage." - ) - stats = pd.concat(stats_frames).sort_index() - # ONE cross-sectional combine over the full covered universe (report step 4); the - # CSI500 covered set is the cross-section (PINNED, disclosed on the spec). - factor = combine_amp_cut_cross_section( - stats, min_cross_section=min_cross_section, name=spec.factor_id - ) - logger.info( - "minute aggregation (cache-only): %d/%d symbols with a stat, %d raw 1min rows " - "read, %d stats rows, %d factor rows, stk_mins_live_calls=0", - len(covered), len(symbols), raw_rows, len(stats), len(factor), - ) - return _AmpCutMinuteLoad( - factor=factor, - stats_rows=int(len(stats)), - 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-intraday-amp-cut 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 AmpCutEvalResult: - """Immutable summary of one run-eval-intraday-amp-cut run.""" - - config: RootConfig - spec: FactorSpec - requested_symbols: int - covered_symbols: int - empty_symbols: int - factor_rows: int - stats_rows: int - minute_raw_rows: int - minute_live_calls: int - no_book_metrics: dict - with_book_metrics: dict - reports: _RunReports - exec_basis: ExecBasisEvaluation - 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-intraday-amp-cut needs data.source='tushare' (real cached A-share " - f"data); got {cfg.data.source!r}." - ) - if not cfg.data.cache.enabled: - raise ValueError( - "run-eval-intraday-amp-cut needs data.cache.enabled=true (it reads the " - "persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-intraday-amp-cut needs universe.type='index' (PIT membership, " - f"e.g. 000905.SH for CSI500); got {cfg.universe.type!r}." - ) - if not cfg.processing.neutralize.enabled: - raise ValueError( - "run-eval-intraday-amp-cut expects processing.neutralize.enabled=true " - "(industry + size neutralization, matching the report's neutral column and " - "the EvalConfig declaration)." - ) - - -def run_eval_intraday_amp_cut(config_path: str) -> AmpCutEvalResult: - """Run the two real intraday-amp-cut 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 = IntradayAmpCutFactor(lookback_days=AMP_CUT_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) - - # Amp-cut factor: cache-only per-symbol stats + ONE cross-sectional combine -> process. - load = _load_amp_cut_panel( - cfg, symbols, spec, logger, - lookback_days=AMP_CUT_LOOKBACK_DAYS, - lam=AMP_CUT_LAMBDA, - min_day_minutes=AMP_CUT_MIN_DAY_MINUTES, - min_valid_days=AMP_CUT_MIN_VALID_DAYS, - min_cross_section=AMP_CUT_MIN_CROSS_SECTION, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 AmpCutEvalResult( - 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)), - stats_rows=load.stats_rows, - 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, - exec_basis=exec_basis, - 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__ = [ - "AmpCutEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_intraday_amp_cut", -] diff --git a/qt/eval_jump_amount_corr.py b/qt/eval_jump_amount_corr.py deleted file mode 100644 index 3acd572..0000000 --- a/qt/eval_jump_amount_corr.py +++ /dev/null @@ -1,502 +0,0 @@ -"""run-eval-jump-amount-corr: the FIRST real factor evaluation (PR-C). - -Reproduces the Kaiyuan report §6 "price-jump turnover correlation" factor as a -first-class :class:`~factors.compute.intraday_derived.JumpAmountCorrFactor` and -runs it through the FROZEN :class:`~analytics.eval.StandardFactorEvaluator` on REAL -cached A-share data (CSI500, PIT membership), closing the contract-driven loop. - -The factor is a DAILY signal derived from 1min bars (see -``data.clean.intraday_aggregate.compute_jump_amount_corr``): the trailing-20- -trading-day lagged correlation between the amount at price-jump minutes and the -amount at the strictly-next minute. It is executed CLOSE-TO-CLOSE (daily default), -so ``is_intraday=False`` (the reasoning is documented on the factor's spec). - -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 -jump-amount-corr 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_aggregate import ( - JUMP_LOOKBACK_DAYS, - JUMP_MIN_PAIRS, - compute_jump_amount_corr, -) -from data.clean.intraday_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from data.clean.schema import CORE_COLUMNS, DATE_LEVEL -from factors.compute.intraday_derived import JumpAmountCorrFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -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_jump_amount_corr" -_REPORT_STEM = "eval_jump_amount_corr" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _JumpMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw jump-amount-corr - 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_jump_factor_panel( - cfg: RootConfig, - symbols: list[str], - spec: FactorSpec, - logger, - *, - lookback_days: int, - min_pairs: int, -) -> _JumpMinuteLoad: - """Compute the raw jump-amount-corr 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). - """ - root = cfg.data.cache.root_dir - store = IntradayParquetStore(root) - start = pd.Timestamp(cfg.data.start).normalize() - # Whole-day READ window; the 14:50 PIT truncation is applied inside - # ``compute_jump_amount_corr`` (as in every sibling minute factor), so the - # post-cutoff bars this read returns are dropped before any pooling. This - # read is NOT the cutoff — a reader who assumes it is will conclude the - # factor is truncated when the compute is not, which is exactly how the - # missing truncation survived review here. - 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_jump_amount_corr( - bars, lookback_days=lookback_days, min_pairs=min_pairs, 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-jump-amount-corr blocked: no requested symbol produced a " - f"cached minute jump-amount-corr 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 _JumpMinuteLoad( - 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-jump-amount-corr 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 17-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 JumpEvalResult: - """Immutable summary of one run-eval-jump-amount-corr 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 - exec_basis: ExecBasisEvaluation - 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-jump-amount-corr 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-jump-amount-corr needs data.cache.enabled=true (it reads the " - "persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-jump-amount-corr needs universe.type='index' (PIT membership, " - f"e.g. 000905.SH for CSI500); got {cfg.universe.type!r}." - ) - if not cfg.processing.neutralize.enabled: - raise ValueError( - "run-eval-jump-amount-corr expects processing.neutralize.enabled=true " - "(industry + size neutralization, matching the report's neutral column " - "and the EvalConfig declaration)." - ) - - -def run_eval_jump_amount_corr(config_path: str) -> JumpEvalResult: - """Run the two real jump-amount-corr evaluations (cache-only) and write 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 = JumpAmountCorrFactor(lookback_days=JUMP_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) - - # Jump factor: cache-only per-symbol minute aggregation -> raw -> process. - load = _load_jump_factor_panel( - cfg, symbols, spec, logger, - lookback_days=JUMP_LOOKBACK_DAYS, min_pairs=JUMP_MIN_PAIRS, - ) - # 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. - jump_raw = load.factor[ - load.factor.index.get_level_values(DATE_LEVEL).isin(panel_dates) - ] - jump_processed = _process_factors(cfg, jump_raw.to_frame(spec.factor_id), panel) - factor_series = jump_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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 JumpEvalResult( - 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, - exec_basis=exec_basis, - 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__ = [ - "JumpEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_jump_amount_corr", -] diff --git a/qt/eval_minute_ideal_amplitude.py b/qt/eval_minute_ideal_amplitude.py deleted file mode 100644 index 9759b4a..0000000 --- a/qt/eval_minute_ideal_amplitude.py +++ /dev/null @@ -1,508 +0,0 @@ -"""run-eval-minute-ideal-amplitude: the second real factor evaluation (PR-D). - -Reproduces the Kaiyuan report §30 "minute ideal amplitude" factor -(市场微观结构系列 30) as a first-class -:class:`~factors.compute.intraday_derived.MinuteIdealAmplitudeFactor` 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 used. - -The factor is a DAILY signal derived from 1min bars (see -``data.clean.intraday_amplitude.compute_minute_ideal_amplitude``): pool the trailing -10 trading days of PIT-truncated (14:50) minutes, rank them by RAW close, and take -the mean-amplitude spread between the top / bottom 25% by close. It is executed -CLOSE-TO-CLOSE (daily default), so ``is_intraday=False`` (the reasoning is -documented on the factor's spec). - -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 minute ideal -amplitude 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_amplitude import ( - IDEAL_AMP_LAMBDA, - IDEAL_AMP_LOOKBACK_DAYS, - IDEAL_AMP_MIN_MINUTES, - compute_minute_ideal_amplitude, -) -from data.clean.intraday_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from data.clean.schema import CORE_COLUMNS, DATE_LEVEL -from factors.compute.intraday_derived import MinuteIdealAmplitudeFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -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_minute_ideal_amplitude" -_REPORT_STEM = "eval_minute_ideal_amplitude" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _AmpMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw minute-ideal-amplitude - 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_minute_ideal_amp_panel( - cfg: RootConfig, - symbols: list[str], - spec: FactorSpec, - logger, - *, - lookback_days: int, - lam: float, - min_minutes: int, -) -> _AmpMinuteLoad: - """Compute the raw minute-ideal-amplitude 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). - """ - 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_minute_ideal_amplitude( - bars, - lookback_days=lookback_days, - lam=lam, - min_minutes=min_minutes, - 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-minute-ideal-amplitude blocked: no requested symbol produced a " - f"cached minute ideal-amplitude 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 _AmpMinuteLoad( - 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-minute-ideal-amplitude 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 MinuteIdealAmpEvalResult: - """Immutable summary of one run-eval-minute-ideal-amplitude 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 - exec_basis: ExecBasisEvaluation - 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-minute-ideal-amplitude 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-minute-ideal-amplitude needs data.cache.enabled=true (it reads " - "the persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-minute-ideal-amplitude 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-minute-ideal-amplitude expects processing.neutralize.enabled=" - "true (industry + size neutralization, matching the report's neutral " - "column and the EvalConfig declaration)." - ) - - -def run_eval_minute_ideal_amplitude(config_path: str) -> MinuteIdealAmpEvalResult: - """Run the two real minute-ideal-amplitude 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 = MinuteIdealAmplitudeFactor(lookback_days=IDEAL_AMP_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) - - # Minute ideal-amplitude factor: cache-only per-symbol aggregation -> raw -> process. - load = _load_minute_ideal_amp_panel( - cfg, symbols, spec, logger, - lookback_days=IDEAL_AMP_LOOKBACK_DAYS, - lam=IDEAL_AMP_LAMBDA, - min_minutes=IDEAL_AMP_MIN_MINUTES, - ) - # 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. - amp_raw = load.factor[ - load.factor.index.get_level_values(DATE_LEVEL).isin(panel_dates) - ] - amp_processed = _process_factors(cfg, amp_raw.to_frame(spec.factor_id), panel) - factor_series = amp_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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 MinuteIdealAmpEvalResult( - 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, - exec_basis=exec_basis, - 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__ = [ - "MinuteIdealAmpEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_minute_ideal_amplitude", -] diff --git a/qt/eval_peak_interval_kurtosis.py b/qt/eval_peak_interval_kurtosis.py deleted file mode 100644 index b21a08f..0000000 --- a/qt/eval_peak_interval_kurtosis.py +++ /dev/null @@ -1,534 +0,0 @@ -"""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.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -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 - exec_basis: ExecBasisEvaluation - 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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, - exec_basis=exec_basis, - 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/qt/eval_peak_ridge_amount_ratio.py b/qt/eval_peak_ridge_amount_ratio.py deleted file mode 100644 index 799bd5c..0000000 --- a/qt/eval_peak_ridge_amount_ratio.py +++ /dev/null @@ -1,609 +0,0 @@ -"""run-eval-peak-ridge-amount-ratio: the eleventh real factor evaluation (PR-M). - -Reproduces the SEVENTH factor of the Kaiyuan market-microstructure series #27 (开源证券 -《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, reportId 4957417, §7.2) — the -"峰岭成交比" factor — as a first-class -:class:`~factors.compute.intraday_derived.PeakRidgeAmountRatioFactor` 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-L used. - -THIS IS THE CLOSING FACTOR of the reproduction loop, and it is the one that closes the -FAMILY MAP. The nine prior reproductions covered a COUNT (PR-F volume_peak_count, weak), a -TIMING moment (PR-H peak_interval_kurtosis, null), two price RATIOS (PR-I -valley_relative_vwap, PR-J valley_ridge_vwap_ratio, both PASSING), a price POSITION (PR-L -valley_price_quantile, PASSING and strongest) and a RETURN (PR-K ridge_minute_return, sign -transferred but Reject). Every signal found so far has been a PRICE signal. This factor -carries NO price information whatsoever — both legs are pure traded VALUE — so it is the -one test that distinguishes "only price information survives in this taxonomy" from "the -peak/ridge behavioural split itself carries alpha". A PASS widens the finding to the -taxonomy; a REJECT sharpens it to prices. Either outcome is a legitimate result and is -reported as such. - -The factor is a DAILY signal derived DIRECTLY from the 1min cache (see -``data.clean.intraday_amount_ratio.compute_peak_ridge_amount_ratio``): PIT-truncate each -day at 14:50, classify every visible minute with the taxonomy REUSED from PR-F -(``data.clean.intraday_volume_prv``: same-slot strictly-prior μ+σ eruptive test; a PEAK is -an ISOLATED eruption, a RIDGE is an eruptive minute that is not isolated), total each valid -day's peak and ridge traded amount, and divide the trailing-20-VALID-day SUM of the peak -leg by that of the ridge leg. NOTE THE AGGREGATION: the report specifies a RATIO OF SUMS -here ("计算 20 日量峰总成交额与量岭总成交额,二者做比"), NOT the mean of daily ratios that -§7.1 (PR-J) specifies — the two forms are followed as written in each section. It is -executed CLOSE-TO-CLOSE (daily default), so ``is_intraday=False``. The eval CELL is -identical to PR-C..PR-L, so this run is directly comparable to its siblings. - -PEAK SCARCITY IS MEASURED, NOT ASSUMED — and the asymmetry RUNS THE OTHER WAY THAN PR-J's. -There, ridges were the scarce leg; here PEAKS are, because a peak must erupt AND be -ISOLATED. The valid-day gate therefore asks for >= 5 tradable peak bars against the ridge -leg's >= 10. The runner collects the REALIZED per-day peak-bar distribution, the day -validity rate across the whole universe, and the COUNTERFACTUAL valid-day count at a peak -floor of 10, so the cost of the lowered threshold is a number rather than an assumption. - -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 ratio adds alpha -BEYOND value / low-vol. The with-book run carries a different weight here than for the -price factors: a traded-VALUE mix has no obvious kinship with a value or low-vol bet, so an -Incremental PASS would be a genuinely new exposure rather than a repackaged one — and an -Incremental failure would be the more surprising outcome. -""" - -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_amount_ratio import ( - PEAK_RIDGE_LOOKBACK_DAYS, - PEAK_RIDGE_MIN_PEAK_BARS, - PEAK_RIDGE_MIN_RIDGE_BARS, - compute_peak_ridge_amount_ratio, -) -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 PeakRidgeAmountRatioFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -# The peak-scarcity disclosure MOVED to qt.factor_eval_disclosures (the single -# home, D5 C4); re-exported here so historical import paths keep working. -from qt.factor_eval_disclosures import PeakCoverage, summarize_peak_coverage -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_ridge_amount_ratio" -_REPORT_STEM = "eval_peak_ridge_amount_ratio" - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _PeakRidgeMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw peak/ridge amount ratio - 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) - peak_coverage: PeakCoverage - - -def _load_peak_ridge_amount_ratio_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_peak_bars: int, - min_ridge_bars: int, -) -> _PeakRidgeMinuteLoad: - """Compute the raw peak/ridge amount-ratio 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 classification (reused from PR-F) and - the amount-ratio reduction run entirely inside ``compute_peak_ridge_amount_ratio`` on - 1min bars, which read ``volume`` and ``amount``. The per-day bar-count diagnostics are - collected alongside so the peak scarcity can be REPORTED, not assumed; collecting them - does not change the factor. - """ - 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] = [] - diagnostics: list[pd.DataFrame] = [] - 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_ridge_amount_ratio( - 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_peak_bars=min_peak_bars, - min_ridge_bars=min_ridge_bars, - name=spec.factor_id, - diagnostics_out=diagnostics, - ) - 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), - ) - - coverage = summarize_peak_coverage( - diagnostics, - min_peak_bars=min_peak_bars, - min_ridge_bars=min_ridge_bars, - min_classifiable=min_classifiable, - ) - if not series: - raise ValueError( - "run-eval-peak-ridge-amount-ratio blocked: no requested symbol produced a " - f"cached peak/ridge amount-ratio 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), - ) - logger.info("%s", coverage.render()) - return _PeakRidgeMinuteLoad( - factor=factor, - requested=len(symbols), - covered=tuple(covered), - empty_symbols=tuple(empty), - raw_rows=raw_rows, - live_calls=0, - peak_coverage=coverage, - ) - - -# --------------------------------------------------------------------------- # -# 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. - - Surfaces the same comparison quantities PR-K / PR-L extracted (turnover, net long-short - by cost scenario, lag-1 rank autocorrelation + half-life, cross-section size) so PR-M - sits in ONE table with all nine siblings — which matters more for this run than any - other, since the closing deliverable of the loop is the full ten-factor comparison. - ``ic_pearson_mean`` rides alongside the rank ``ic_mean`` to keep testing the PR-K - review's regularity, that a divergence between the two predicts the monotonicity gate - failing. - - ``aligned_spread_*`` is NOT unreliable here: the frozen layer's cost-sign defect only - mis-signs negative-sign factors, and this factor's pre-registered sign is +1. - ``net_long_short_by_cost`` is still surfaced as the primary read, for comparability. - """ - verdict = report.require_verdict() - pred = _section_payload(report, "predictive_power") - coverage = _section_payload(report, "data_coverage") - incr = _section_payload(report, "purity") - ret_risk = _section_payload(report, "return_risk") - stability = _section_payload(report, "stability_cost") - autocorr = dict(stability.get("factor_rank_autocorr_by_lag", {}) or {}) - return { - "deployment": verdict.verdict, - "predictive": verdict.predictive.verdict, - "incremental": verdict.incremental.verdict, - "tradable": verdict.tradable.verdict, - "ic_mean": pred.get("ic_mean"), - "ic_pearson_mean": pred.get("ic_pearson_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": coverage.get("settled_rebalances"), - "effective_samples": coverage.get("effective_samples"), - "span_days": coverage.get("span_days"), - "cross_section_size_mean": coverage.get("cross_section_size_mean"), - "cross_section_size_median": coverage.get("cross_section_size_median"), - "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"), - "monotonicity_spearman": ret_risk.get("monotonicity_spearman"), - "gross_long_short_mean": ret_risk.get("gross_long_short_mean"), - "net_long_short_by_cost": dict(ret_risk.get("net_long_short_by_cost", {}) or {}), - # sign=+1 -> the aligned spreads are NOT mis-signed for this factor. - "aligned_spread_by_cost": dict(ret_risk.get("aligned_spread_by_cost", {}) or {}), - "long_short_turnover": stability.get("turnover_mean_long_short_legs"), - "rank_autocorr_lag1": autocorr.get(1), - "half_life_periods": stability.get("half_life_periods"), - } - - -# --------------------------------------------------------------------------- # -# 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-ridge-amount-ratio 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 PeakRidgeAmountRatioEvalResult: - """Immutable summary of one run-eval-peak-ridge-amount-ratio 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 - peak_coverage: PeakCoverage - no_book_metrics: dict - with_book_metrics: dict - reports: _RunReports - exec_basis: ExecBasisEvaluation - 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-ridge-amount-ratio 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-ridge-amount-ratio 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-ridge-amount-ratio 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-ridge-amount-ratio expects processing.neutralize.enabled=true " - "(industry + size neutralization, matching the report's neutral column and " - "the EvalConfig declaration)." - ) - - -def run_eval_peak_ridge_amount_ratio(config_path: str) -> PeakRidgeAmountRatioEvalResult: - """Run the two real peak/ridge amount-ratio 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 = PeakRidgeAmountRatioFactor(lookback_days=PEAK_RIDGE_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/ridge amount-ratio factor: cache-only per-symbol aggregation -> raw -> process. - load = _load_peak_ridge_amount_ratio_panel( - cfg, symbols, spec, logger, - lookback_days=PEAK_RIDGE_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_peak_bars=PEAK_RIDGE_MIN_PEAK_BARS, - min_ridge_bars=PEAK_RIDGE_MIN_RIDGE_BARS, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 PeakRidgeAmountRatioEvalResult( - 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, - peak_coverage=load.peak_coverage, - no_book_metrics=no_book_metrics, - with_book_metrics=with_book_metrics, - reports=reports, - exec_basis=exec_basis, - 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__ = [ - "PeakCoverage", - "PeakRidgeAmountRatioEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_peak_ridge_amount_ratio", - "summarize_peak_coverage", -] diff --git a/qt/eval_ridge_minute_return.py b/qt/eval_ridge_minute_return.py deleted file mode 100644 index 7c7e151..0000000 --- a/qt/eval_ridge_minute_return.py +++ /dev/null @@ -1,607 +0,0 @@ -"""run-eval-ridge-minute-return: the ninth real factor evaluation (PR-K). - -Reproduces the FIFTH factor of the Kaiyuan market-microstructure series #27 (开源证券 -《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, reportId 4957417, §3) — the -"量岭分钟收益" factor — as a first-class -:class:`~factors.compute.intraday_derived.RidgeMinuteReturnFactor` 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-J used. - -This is the FIFTH reproduction from the same report and it opens a FOURTH statistic -family. The four prior ones covered a COUNT (PR-F, weak), a TIMING moment (PR-H, null) and -two PRICE LEVELS (PR-I / PR-J, both passed). This factor is a RETURN — and it is the -report's ONLY NEGATIVE peak/ridge/valley factor, so the run also tests whether the SIGN -transfers along with the family, not just the magnitude. - -The factor is a DAILY signal derived DIRECTLY from the 1min cache (see -``data.clean.intraday_ridge_return.compute_ridge_minute_return``): PIT-truncate each day at -14:50, classify every visible minute with the taxonomy REUSED from PR-F -(``data.clean.intraday_volume_prv``: same-slot strictly-prior μ+σ eruptive test; a RIDGE is -an eruptive minute that is not an isolated peak), form each minute's return against the -previous VISIBLE bar of the SAME day, sum those returns over the day's ridge bars, and sum -the daily sums over the trailing 20 VALID trading days. It is executed CLOSE-TO-CLOSE -(daily default), so ``is_intraday=False``. The eval CELL is identical to PR-C..PR-J, so -this run is directly comparable to its siblings — same classification, different statistic. - -RIDGE SCARCITY IS MEASURED, NOT ASSUMED, exactly as in PR-J. Ridge bars are structurally -rare (a minute must erupt AND fail the isolation test), and this factor narrows them -further: only ridge bars that CARRY A VALID RETURN count, so the day's first visible bar -drops out even when it is a ridge. The runner therefore collects the realized per-day -distribution of BOTH counts plus the day-validity rate across the whole universe and logs -them, so the return-guard attrition and any coverage regression against PR-J are visible as -numbers rather than hidden behind a threshold. - -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 accumulated -ridge-minute return adds alpha BEYOND value / low-vol. That check matters here because a -trailing sum of returns is a plausible cousin of a REVERSAL signal, and the with-book run -is the one that says whether this is a genuinely new bet. - -⚠️ THIS FACTOR'S PRE-REGISTERED SIGN IS -1, which triggers a KNOWN DEFECT in the frozen -evaluation layer: its ``aligned_spread_*`` fields compute ``sign * (gross - cost)``, which -for a negative sign becomes ``-gross + cost`` — i.e. the trading cost is ADDED BACK rather -than deducted. The frozen layer is deliberately NOT patched here. Any reading of this run's -tradability must use ``net_long_short_by_cost`` (which is sign-agnostic and correct) and -must treat ``aligned_spread_*`` as unreliable. :func:`extract_metrics` therefore surfaces -the cost-scenario net spreads explicitly. -""" - -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_ridge_return import ( - RIDGE_RETURN_LOOKBACK_DAYS, - RIDGE_RETURN_MIN_RIDGE_BARS, - compute_ridge_minute_return, -) -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 RidgeMinuteReturnFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -# The ridge-scarcity disclosure MOVED to qt.factor_eval_disclosures (the single -# home, D5 C4); re-exported here so historical import paths keep working. -from qt.factor_eval_disclosures import ( - RidgeReturnCoverage, - summarize_ridge_return_coverage, -) -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_ridge_minute_return" -_REPORT_STEM = "eval_ridge_minute_return" - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _RidgeReturnMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw ridge-minute-return sum - 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) - ridge_coverage: RidgeReturnCoverage - - -def _load_ridge_minute_return_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_ridge_bars: int, -) -> _RidgeReturnMinuteLoad: - """Compute the raw ridge-minute-return 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 classification (reused from PR-F) and - the return reduction run entirely inside ``compute_ridge_minute_return`` on 1min bars, - which read ``volume`` and ``close``. The per-day bar-count diagnostics are collected - alongside so the ridge scarcity can be REPORTED, not assumed; collecting them does not - change the factor. - """ - 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] = [] - diagnostics: list[pd.DataFrame] = [] - 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_ridge_minute_return( - 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_ridge_bars=min_ridge_bars, - name=spec.factor_id, - diagnostics_out=diagnostics, - ) - 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), - ) - - coverage = summarize_ridge_return_coverage( - diagnostics, - min_ridge_bars=min_ridge_bars, - min_classifiable=min_classifiable, - ) - if not series: - raise ValueError( - "run-eval-ridge-minute-return blocked: no requested symbol produced a cached " - f"ridge-minute-return value over [{cfg.data.start}, {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), - ) - logger.info("%s", coverage.render()) - return _RidgeReturnMinuteLoad( - factor=factor, - requested=len(symbols), - covered=tuple(covered), - empty_symbols=tuple(empty), - raw_rows=raw_rows, - live_calls=0, - ridge_coverage=coverage, - ) - - -# --------------------------------------------------------------------------- # -# 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. - - Beyond the fields PR-C..PR-J surfaced, this also extracts the TRADABILITY comparison - quantities the PR-K analysis needs alongside PR-I / PR-J: the long-short leg turnover, - the net long-short spread at EACH cost scenario, the lag-1 rank autocorrelation with - its implied half-life, and the cross-section size. - - ⚠️ ``net_long_short_by_cost`` is deliberately the tradability field surfaced here. The - frozen layer's ``aligned_spread_*`` computes ``sign * (gross - cost)``, which for this - factor's pre-registered sign of -1 becomes ``-gross + cost`` — costs are added back - rather than deducted, so those fields are UNRELIABLE for a negative-sign factor. The - frozen layer is not patched; the correct, sign-agnostic net spreads are reported - instead. - """ - verdict = report.require_verdict() - pred = _section_payload(report, "predictive_power") - coverage = _section_payload(report, "data_coverage") - incr = _section_payload(report, "purity") - ret_risk = _section_payload(report, "return_risk") - stability = _section_payload(report, "stability_cost") - autocorr = dict(stability.get("factor_rank_autocorr_by_lag", {}) or {}) - 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": coverage.get("settled_rebalances"), - "effective_samples": coverage.get("effective_samples"), - "span_days": coverage.get("span_days"), - "cross_section_size_mean": coverage.get("cross_section_size_mean"), - "cross_section_size_median": coverage.get("cross_section_size_median"), - "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"), - "monotonicity_spearman": ret_risk.get("monotonicity_spearman"), - "gross_long_short_mean": ret_risk.get("gross_long_short_mean"), - # The correct tradability read for a negative-sign factor (see the docstring). - "net_long_short_by_cost": dict(ret_risk.get("net_long_short_by_cost", {}) or {}), - "long_short_turnover": stability.get("turnover_mean_long_short_legs"), - "rank_autocorr_lag1": autocorr.get(1), - "half_life_periods": stability.get("half_life_periods"), - } - - -# --------------------------------------------------------------------------- # -# 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-ridge-minute-return 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 RidgeMinuteReturnEvalResult: - """Immutable summary of one run-eval-ridge-minute-return 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 - ridge_coverage: RidgeReturnCoverage - no_book_metrics: dict - with_book_metrics: dict - reports: _RunReports - exec_basis: ExecBasisEvaluation - 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-ridge-minute-return 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-ridge-minute-return needs data.cache.enabled=true (it reads the " - "persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-ridge-minute-return needs universe.type='index' (PIT membership, " - f"e.g. 000905.SH for CSI500); got {cfg.universe.type!r}." - ) - if not cfg.processing.neutralize.enabled: - raise ValueError( - "run-eval-ridge-minute-return expects processing.neutralize.enabled=true " - "(industry + size neutralization, matching the report's neutral column and " - "the EvalConfig declaration)." - ) - - -def run_eval_ridge_minute_return(config_path: str) -> RidgeMinuteReturnEvalResult: - """Run the two real ridge-minute-return 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 = RidgeMinuteReturnFactor(lookback_days=RIDGE_RETURN_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) - - # Ridge-minute-return factor: cache-only per-symbol aggregation -> raw -> process. - load = _load_ridge_minute_return_panel( - cfg, symbols, spec, logger, - lookback_days=RIDGE_RETURN_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_ridge_bars=RIDGE_RETURN_MIN_RIDGE_BARS, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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"], - ) - # The tradability read for a NEGATIVE-sign factor: aligned_spread_* is unreliable here - # (frozen-layer defect, see the module docstring), so log the net spreads explicitly. - logger.info( - "net long-short by cost (aligned_spread_* is UNRELIABLE for sign=-1): %s", - no_book_metrics["net_long_short_by_cost"], - ) - - return RidgeMinuteReturnEvalResult( - 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, - ridge_coverage=load.ridge_coverage, - no_book_metrics=no_book_metrics, - with_book_metrics=with_book_metrics, - reports=reports, - exec_basis=exec_basis, - 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__ = [ - "RidgeMinuteReturnEvalResult", - "RidgeReturnCoverage", - "evaluate_two_runs", - "extract_metrics", - "run_eval_ridge_minute_return", - "summarize_ridge_return_coverage", -] diff --git a/qt/eval_valley_price_quantile.py b/qt/eval_valley_price_quantile.py deleted file mode 100644 index 4703e7c..0000000 --- a/qt/eval_valley_price_quantile.py +++ /dev/null @@ -1,640 +0,0 @@ -"""run-eval-valley-price-quantile: the tenth real factor evaluation (PR-L). - -Reproduces the SIXTH factor of the Kaiyuan market-microstructure series #27 (开源证券 -《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, reportId 4957417, §5) — the -"量谷加权价格分位点" factor — as a first-class -:class:`~factors.compute.intraday_derived.ValleyPriceQuantileFactor` 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-K used. - -WHY THIS FACTOR, NOW: the five prior reproductions from this report covered a COUNT (PR-F -volume_peak_count, weak), a TIMING moment (PR-H peak_interval_kurtosis, null), two price -RATIOS (PR-I valley_relative_vwap and PR-J valley_ridge_vwap_ratio, both passing) and a -RETURN (PR-K ridge_minute_return, sign transferred but Reject). This factor is a price -POSITION — where in the day's range the valley VWAP sits — so it tests whether PR-I/PR-J -succeeded because of the ratio FORM or because of price-level INFORMATION generally. - -SAME MACHINE as PR-F .. PR-K: the minute classification is REUSED from -``data/clean/intraday_volume_prv.py`` (not re-implemented, and not modified by this PR), -so a different verdict here cannot be an artefact of a differently implemented taxonomy. - -TWO STRUCTURAL DIFFERENCES from its siblings, both of which this runner is built around: - - 1. THE PRICE RANGE READS THE PREVIOUS DAY'S CLOSE. Pinned to the last VISIBLE (<=14:50) - raw close of the previous trading day, so every price in the factor comes from ONE - source under ONE visibility rule. That is a DISCLOSED DEVIATION from the report, - which uses the true previous daily close. - 2. THE FACTOR IS REVERSAL-NEUTRALIZED, which makes it CROSS-SECTIONAL. Like PR-G, the - runner streams one symbol at a time through ``compute_valley_price_quantile_stats`` - (memory-bounded), assembles the full-universe raw panel, and then residualizes ONCE - against the 20-day reversal. The reversal is taken at T-1 — - ``rev20 = -(close_{d-1}/close_{d-21} - 1)`` — because the report's naive ``close_d`` - form is 15:00 information and WOULD be a lookahead at our 14:50 decision. Those - closes come from the FRONT-ADJUSTED daily panel this runner already loads; NO new - data source is introduced. - -The runner REPORTS the before/after-neutralization IC exposure and the day-validity / -cross-section coverage, so a neutralization that silently ate the signal (or a -cross-section too thin to regress) shows up as a number rather than being hidden. - -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 price quantile adds -alpha BEYOND value / low-vol. That check matters more than usual here: a within-range -price POSITION is a plausible cousin of both a value signal and a reversal signal, and -although the factor is already reversal-neutralized, the with-book run is what says -whether it is a genuinely new bet. - -⚠️ SIGN = +1, so this factor is NOT affected by the frozen layer's aligned-spread cost -defect (which mis-signs costs only for negative-sign factors). The ``aligned_spread_*`` -fields may be read directly here — unlike PR-K — though ``net_long_short_by_cost`` remains -the primary tradability read for comparability with its siblings. -""" - -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_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from data.clean.intraday_valley_quantile import ( - VALLEY_QUANTILE_LOOKBACK_DAYS, - VALLEY_QUANTILE_MIN_CROSS_SECTION, - VALLEY_QUANTILE_MIN_VALLEY_BARS, - VALLEY_QUANTILE_REVERSAL_DAYS, - compute_valley_price_quantile_stats, - residualize_on_reversal, - reversal_20, -) -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 ValleyPriceQuantileFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -# The neutralization disclosure MOVED to qt.factor_eval_disclosures (the single -# home, D5 C4); re-exported here so historical import paths keep working. -from qt.factor_eval_disclosures import ( - NeutralizationCoverage, - summarize_neutralization, -) -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_valley_price_quantile" -_REPORT_STEM = "eval_valley_price_quantile" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol stats -> ONE cross-sectional residualization) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _ValleyQuantileMinuteLoad: - """Diagnostics from the cache-only per-symbol read + the neutralization.""" - - factor: pd.Series # MultiIndex(date, symbol) RESIDUALIZED factor (the shipped one) - raw: pd.Series # MultiIndex(date, symbol) RAW trailing-mean quantile (pre-neutral) - requested: int - covered: tuple[str, ...] # symbols that produced >= 1 finite RAW 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) - coverage: NeutralizationCoverage - - -def _load_valley_price_quantile_panel( - cfg: RootConfig, - symbols: list[str], - spec: FactorSpec, - panel: pd.DataFrame, - logger, - *, - lookback_days: int, - baseline_days: int, - baseline_min_obs: int, - sigma_k: float, - min_valid_days: int, - min_classifiable: int, - min_valley_bars: int, - min_cross_section: int, - reversal_days: int, -) -> _ValleyQuantileMinuteLoad: - """Compute the factor from the minute cache: per-symbol stats then ONE residualization. - - Memory-bounded: one symbol's minute history is read, aggregated to its daily raw - quantile 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 REVERSAL comes from ``panel['close']``, which :func:`qt.pipeline._load_panel` has - already FRONT-ADJUSTED — required, because the ratio spans 20 trading days. Taking it - at T-1 is what keeps day d's 15:00 close out of day d's factor value. - """ - 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_valley_price_quantile_stats( - 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_valley_bars=min_valley_bars, - ) - 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-valley-price-quantile blocked: no requested symbol produced a " - f"cached valley-price-quantile value over [{cfg.data.start}, {cfg.data.end}]. " - "The minute cache is required (this runner never warms it); check coverage." - ) - raw = pd.concat(series).sort_index() - - # ONE cross-sectional reversal neutralization over the assembled universe panel. - # The closes are the panel's FRONT-ADJUSTED closes -- no new data source. - rev = reversal_20(panel[["close"]], days=reversal_days) - factor = residualize_on_reversal( - raw, rev, min_cross_section=min_cross_section, name=spec.factor_id - ) - coverage = summarize_neutralization( - raw, rev, factor, min_cross_section=min_cross_section - ) - logger.info( - "minute aggregation (cache-only): %d/%d symbols with a value, %d raw 1min rows " - "read, %d raw factor rows, stk_mins_live_calls=0", - len(covered), len(symbols), raw_rows, len(raw), - ) - logger.info( - "reversal neutralization (T-1 basis, %d-day span): raw=%d rev-paired=%d " - "residual=%d rows; dates %d/%d residualized; cross-section min/median/max=" - "%d/%.1f/%d; mean per-date Spearman(raw, rev20)=%.6f", - reversal_days, coverage.raw_rows, coverage.rev_rows, coverage.residual_rows, - coverage.dates_residualized, coverage.dates_total, - coverage.cross_section_min, coverage.cross_section_median, - coverage.cross_section_max, coverage.raw_rev_spearman_mean, - ) - return _ValleyQuantileMinuteLoad( - factor=factor, - raw=raw, - requested=len(symbols), - covered=tuple(covered), - empty_symbols=tuple(empty), - raw_rows=raw_rows, - live_calls=0, - coverage=coverage, - ) - - -# --------------------------------------------------------------------------- # -# 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. - - Surfaces the same comparison quantities PR-K extracted (turnover, net long-short by - cost scenario, lag-1 rank autocorrelation + half-life, cross-section size) so PR-L - sits in one table with PR-I / PR-J / PR-K, PLUS ``ic_pearson_mean`` alongside the rank - ``ic_mean``: the PR-K review found that a divergence between the two predicts the - monotonicity gate failing, and this run is the first test of that regularity on a - POSITIVE-sign factor (the four prior cases were all sign=-1). - - Unlike PR-K, ``aligned_spread_*`` is NOT unreliable here: the frozen layer's - cost-sign defect only mis-signs negative-sign factors, and this factor's pre-registered - sign is +1. ``net_long_short_by_cost`` is still surfaced as the primary read, for - sibling comparability. - """ - verdict = report.require_verdict() - pred = _section_payload(report, "predictive_power") - coverage = _section_payload(report, "data_coverage") - incr = _section_payload(report, "purity") - ret_risk = _section_payload(report, "return_risk") - stability = _section_payload(report, "stability_cost") - autocorr = dict(stability.get("factor_rank_autocorr_by_lag", {}) or {}) - return { - "deployment": verdict.verdict, - "predictive": verdict.predictive.verdict, - "incremental": verdict.incremental.verdict, - "tradable": verdict.tradable.verdict, - "ic_mean": pred.get("ic_mean"), - "ic_pearson_mean": pred.get("ic_pearson_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": coverage.get("settled_rebalances"), - "effective_samples": coverage.get("effective_samples"), - "span_days": coverage.get("span_days"), - "cross_section_size_mean": coverage.get("cross_section_size_mean"), - "cross_section_size_median": coverage.get("cross_section_size_median"), - "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"), - "monotonicity_spearman": ret_risk.get("monotonicity_spearman"), - "gross_long_short_mean": ret_risk.get("gross_long_short_mean"), - "net_long_short_by_cost": dict(ret_risk.get("net_long_short_by_cost", {}) or {}), - # sign=+1 -> the aligned spreads are NOT mis-signed for this factor. - "aligned_spread_by_cost": dict(ret_risk.get("aligned_spread_by_cost", {}) or {}), - "long_short_turnover": stability.get("turnover_mean_long_short_legs"), - "rank_autocorr_lag1": autocorr.get(1), - "half_life_periods": stability.get("half_life_periods"), - } - - -# --------------------------------------------------------------------------- # -# 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-valley-price-quantile 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 ValleyPriceQuantileEvalResult: - """Immutable summary of one run-eval-valley-price-quantile 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 - neutralization: NeutralizationCoverage - no_book_metrics: dict - with_book_metrics: dict - reports: _RunReports - exec_basis: ExecBasisEvaluation - 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-valley-price-quantile 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-valley-price-quantile needs data.cache.enabled=true (it reads the " - "persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-valley-price-quantile needs universe.type='index' (PIT membership, " - f"e.g. 000905.SH for CSI500); got {cfg.universe.type!r}." - ) - if not cfg.processing.neutralize.enabled: - raise ValueError( - "run-eval-valley-price-quantile expects processing.neutralize.enabled=true " - "(industry + size neutralization, matching the report's neutral column and " - "the EvalConfig declaration)." - ) - - -def run_eval_valley_price_quantile(config_path: str) -> ValleyPriceQuantileEvalResult: - """Run the two real valley-price-quantile 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 = ValleyPriceQuantileFactor(lookback_days=VALLEY_QUANTILE_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) - - # Valley-price-quantile: cache-only per-symbol stats + ONE reversal neutralization. - load = _load_valley_price_quantile_panel( - cfg, symbols, spec, panel, logger, - lookback_days=VALLEY_QUANTILE_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_valley_bars=VALLEY_QUANTILE_MIN_VALLEY_BARS, - min_cross_section=VALLEY_QUANTILE_MIN_CROSS_SECTION, - reversal_days=VALLEY_QUANTILE_REVERSAL_DAYS, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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"], - ) - logger.info( - "net long-short by cost: %s", no_book_metrics["net_long_short_by_cost"] - ) - # The PR-K review's regularity, tested here on a POSITIVE-sign factor for the first - # time: a divergence between the rank IC and the Pearson IC predicted the monotonicity - # gate failing on all four prior (negative-sign) factors. - logger.info( - "ic_mean(rank)=%s vs ic_pearson_mean=%s; monotonicity_spearman=%s", - no_book_metrics["ic_mean"], no_book_metrics["ic_pearson_mean"], - no_book_metrics["monotonicity_spearman"], - ) - - return ValleyPriceQuantileEvalResult( - 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, - neutralization=load.coverage, - no_book_metrics=no_book_metrics, - with_book_metrics=with_book_metrics, - reports=reports, - exec_basis=exec_basis, - 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__ = [ - "NeutralizationCoverage", - "ValleyPriceQuantileEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_valley_price_quantile", - "summarize_neutralization", -] diff --git a/qt/eval_valley_relative_vwap.py b/qt/eval_valley_relative_vwap.py deleted file mode 100644 index 01ba042..0000000 --- a/qt/eval_valley_relative_vwap.py +++ /dev/null @@ -1,540 +0,0 @@ -"""run-eval-valley-relative-vwap: the seventh real factor evaluation (PR-I). - -Reproduces the THIRD factor of the Kaiyuan market-microstructure series #27 (开源证券 -《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, reportId 4957417, §4) — the -"量谷相对加权价格" factor — as a first-class -:class:`~factors.compute.intraday_derived.ValleyRelativeVwapFactor` 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-H used. - -This is the THIRD reproduction from the same report, and the first from a DIFFERENT -FAMILY: PR-F counted volume peaks and PR-H measured their timing (both null results); -this factor prices the VALLEYS. The minute classification is REUSED verbatim, so a -different verdict here cannot be an artefact of a differently-implemented taxonomy. - -The factor is a DAILY signal derived DIRECTLY from the 1min cache (see -``data.clean.intraday_valley_vwap.compute_valley_relative_vwap``): PIT-truncate each day -at 14:50, classify every visible minute with the taxonomy REUSED from PR-F -(``data.clean.intraday_volume_prv``: same-slot strictly-prior μ+σ eruptive test; a VALLEY -is a classifiable non-eruptive minute), take each valid day's ratio of the valley VWAP to -the WHOLE VISIBLE DAY's VWAP (both via the Σamount/Σvolume identity), and average that -ratio over the trailing 20 VALID trading days. 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-H, so this run is directly comparable to its -PR-F / PR-H siblings — same classification, different statistic and different family. - -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 valley-relative -VWAP adds alpha BEYOND value / low-vol. That check matters more than usual here: a -relative PRICE LEVEL is a plausible cousin of a value signal, so the with-book run is -the one that says whether this is a new bet. -""" - -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_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from data.clean.intraday_valley_vwap import ( - VALLEY_VWAP_LOOKBACK_DAYS, - VALLEY_VWAP_MIN_VALLEY_BARS, - compute_valley_relative_vwap, -) -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 ValleyRelativeVwapFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -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_valley_relative_vwap" -_REPORT_STEM = "eval_valley_relative_vwap" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _ValleyVwapMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw valley-relative VWAP - 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_valley_relative_vwap_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_valley_bars: int, -) -> _ValleyVwapMinuteLoad: - """Compute the raw valley-relative-VWAP 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 classification (reused - from PR-F) and the VWAP-ratio reduction run entirely inside - ``compute_valley_relative_vwap`` on 1min bars, which read ``volume`` and ``amount``. - """ - 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_valley_relative_vwap( - 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_valley_bars=min_valley_bars, - 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-valley-relative-vwap blocked: no requested symbol produced a " - f"cached valley-relative-VWAP 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 _ValleyVwapMinuteLoad( - 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-valley-relative-vwap 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 ValleyRelativeVwapEvalResult: - """Immutable summary of one run-eval-valley-relative-vwap 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 - exec_basis: ExecBasisEvaluation - 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-valley-relative-vwap 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-valley-relative-vwap needs data.cache.enabled=true (it reads " - "the persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-valley-relative-vwap 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-valley-relative-vwap expects processing.neutralize.enabled=true " - "(industry + size neutralization, matching the report's neutral column and " - "the EvalConfig declaration)." - ) - - -def run_eval_valley_relative_vwap(config_path: str) -> ValleyRelativeVwapEvalResult: - """Run the two real valley-relative-VWAP 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 = ValleyRelativeVwapFactor(lookback_days=VALLEY_VWAP_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) - - # Valley-relative-VWAP factor: cache-only per-symbol aggregation -> raw -> process. - load = _load_valley_relative_vwap_panel( - cfg, symbols, spec, logger, - lookback_days=VALLEY_VWAP_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_valley_bars=VALLEY_VWAP_MIN_VALLEY_BARS, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 ValleyRelativeVwapEvalResult( - 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, - exec_basis=exec_basis, - 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__ = [ - "ValleyRelativeVwapEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_valley_relative_vwap", -] diff --git a/qt/eval_valley_ridge_vwap_ratio.py b/qt/eval_valley_ridge_vwap_ratio.py deleted file mode 100644 index 6cecce7..0000000 --- a/qt/eval_valley_ridge_vwap_ratio.py +++ /dev/null @@ -1,573 +0,0 @@ -"""run-eval-valley-ridge-vwap-ratio: the eighth real factor evaluation (PR-J). - -Reproduces the FOURTH factor of the Kaiyuan market-microstructure series #27 (开源证券 -《高频成交量的峰、岭、谷信息——市场微观结构研究系列(27)》, reportId 4957417, §7.1) — the -"谷岭加权价格比" factor — as a first-class -:class:`~factors.compute.intraday_derived.ValleyRidgeVwapRatioFactor` 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-I used. - -This is the FOURTH reproduction from the same report and the direct ROBUSTNESS TEST of -PR-I. PR-I (valley VWAP / whole-visible-day VWAP) is the strongest factor of this loop — -both assessed axes PASS. PR-J keeps everything else fixed and swaps only the DENOMINATOR -to the RIDGE VWAP, contrasting the two behavioural groups head-on. If the price-level -signal survives the swap it is a property of the FAMILY; if it does not, PR-I depended on -its specific denominator. Either outcome is a legitimate result and is reported as such. - -The factor is a DAILY signal derived DIRECTLY from the 1min cache (see -``data.clean.intraday_valley_ridge_vwap.compute_valley_ridge_vwap_ratio``): PIT-truncate -each day at 14:50, classify every visible minute with the taxonomy REUSED from PR-F -(``data.clean.intraday_volume_prv``: same-slot strictly-prior μ+σ eruptive test; a VALLEY -is a classifiable non-eruptive minute, a RIDGE is an eruptive minute that is not an -isolated peak), take each valid day's ratio of the valley VWAP to the ridge VWAP (both -via the Σamount/Σvolume identity), and average that ratio over the trailing 20 VALID -trading days. It is executed CLOSE-TO-CLOSE (daily default), so ``is_intraday=False``. -The eval CELL is identical to PR-C..PR-I, so this run is directly comparable to its -siblings — same classification, same VWAP identity, different denominator. - -RIDGE SCARCITY IS MEASURED, NOT ASSUMED. Ridge bars are structurally far rarer than -valley bars (a minute must erupt AND fail the isolation test), which is why the valid-day -gate asks for >= 10 tradable ridge bars against the valley leg's >= 20. The runner -therefore collects the REALIZED per-day ridge-bar distribution and the day-validity rate -across the whole universe and logs them, so a coverage regression against PR-I is visible -as a number rather than hidden behind a lower threshold. - -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 ratio adds alpha -BEYOND value / low-vol. As with PR-I that check matters more than usual: a relative PRICE -LEVEL is a plausible cousin of a value signal, so the with-book run is the one that says -whether this is a new bet. -""" - -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_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from data.clean.intraday_valley_ridge_vwap import ( - VALLEY_RIDGE_LOOKBACK_DAYS, - VALLEY_RIDGE_MIN_RIDGE_BARS, - VALLEY_RIDGE_MIN_VALLEY_BARS, - compute_valley_ridge_vwap_ratio, -) -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 ValleyRidgeVwapRatioFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -# The ridge-scarcity disclosure MOVED to qt.factor_eval_disclosures (the single -# home, D5 C4); re-exported here so historical import paths keep working. -from qt.factor_eval_disclosures import RidgeCoverage, summarize_ridge_coverage -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_valley_ridge_vwap_ratio" -_REPORT_STEM = "eval_valley_ridge_vwap_ratio" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _ValleyRidgeMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw valley/ridge VWAP ratio - 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) - ridge_coverage: RidgeCoverage - - -def _load_valley_ridge_vwap_ratio_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_valley_bars: int, - min_ridge_bars: int, -) -> _ValleyRidgeMinuteLoad: - """Compute the raw valley/ridge VWAP-ratio 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 classification (reused - from PR-F) and the VWAP-ratio reduction run entirely inside - ``compute_valley_ridge_vwap_ratio`` on 1min bars, which read ``volume`` and - ``amount``. The per-day bar-count diagnostics are collected alongside so the ridge - scarcity can be REPORTED, not assumed; collecting them does not change the factor. - """ - 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] = [] - diagnostics: list[pd.DataFrame] = [] - 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_valley_ridge_vwap_ratio( - 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_valley_bars=min_valley_bars, - min_ridge_bars=min_ridge_bars, - name=spec.factor_id, - diagnostics_out=diagnostics, - ) - 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), - ) - - coverage = summarize_ridge_coverage( - diagnostics, - min_ridge_bars=min_ridge_bars, - min_valley_bars=min_valley_bars, - min_classifiable=min_classifiable, - ) - if not series: - raise ValueError( - "run-eval-valley-ridge-vwap-ratio blocked: no requested symbol produced a " - f"cached valley/ridge VWAP-ratio 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), - ) - logger.info("%s", coverage.render()) - return _ValleyRidgeMinuteLoad( - factor=factor, - requested=len(symbols), - covered=tuple(covered), - empty_symbols=tuple(empty), - raw_rows=raw_rows, - live_calls=0, - ridge_coverage=coverage, - ) - - -# --------------------------------------------------------------------------- # -# 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-valley-ridge-vwap-ratio 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 ValleyRidgeVwapRatioEvalResult: - """Immutable summary of one run-eval-valley-ridge-vwap-ratio 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 - ridge_coverage: RidgeCoverage - no_book_metrics: dict - with_book_metrics: dict - reports: _RunReports - exec_basis: ExecBasisEvaluation - 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-valley-ridge-vwap-ratio 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-valley-ridge-vwap-ratio needs data.cache.enabled=true (it reads " - "the persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-valley-ridge-vwap-ratio 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-valley-ridge-vwap-ratio expects " - "processing.neutralize.enabled=true (industry + size neutralization, " - "matching the report's neutral column and the EvalConfig declaration)." - ) - - -def run_eval_valley_ridge_vwap_ratio(config_path: str) -> ValleyRidgeVwapRatioEvalResult: - """Run the two real valley/ridge VWAP-ratio 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 = ValleyRidgeVwapRatioFactor(lookback_days=VALLEY_RIDGE_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) - - # Valley/ridge VWAP-ratio factor: cache-only per-symbol aggregation -> raw -> process. - load = _load_valley_ridge_vwap_ratio_panel( - cfg, symbols, spec, logger, - lookback_days=VALLEY_RIDGE_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_valley_bars=VALLEY_RIDGE_MIN_VALLEY_BARS, - min_ridge_bars=VALLEY_RIDGE_MIN_RIDGE_BARS, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 ValleyRidgeVwapRatioEvalResult( - 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, - ridge_coverage=load.ridge_coverage, - no_book_metrics=no_book_metrics, - with_book_metrics=with_book_metrics, - reports=reports, - exec_basis=exec_basis, - 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__ = [ - "RidgeCoverage", - "ValleyRidgeVwapRatioEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_valley_ridge_vwap_ratio", - "summarize_ridge_coverage", -] diff --git a/qt/eval_volume_peak_count.py b/qt/eval_volume_peak_count.py deleted file mode 100644 index 43ab2fb..0000000 --- a/qt/eval_volume_peak_count.py +++ /dev/null @@ -1,522 +0,0 @@ -"""run-eval-volume-peak-count: the fourth real factor evaluation (PR-F). - -Reproduces the Kaiyuan market-microstructure series #27 (开源证券《高频成交量的峰、岭、 -谷信息——市场微观结构研究系列(27)》, reportId 4957417) flagship "volume-peak-minute-count" -factor as a first-class -:class:`~factors.compute.intraday_derived.VolumePeakCountFactor` 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 used. - -The factor is a DAILY signal derived DIRECTLY from the 1min cache (no coarser resample; -see ``data.clean.intraday_volume_prv.compute_volume_peak_count``): 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), mark the eruptive minutes whose both -1-minute same-session neighbours are mild as PEAKS, and count the peak minutes over the -trailing 20 VALID trading days. It is executed CLOSE-TO-CLOSE (daily default), so -``is_intraday=False`` (the reasoning is documented on the factor's spec). - -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 volume-peak-count -factor 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_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_LOOKBACK_DAYS, - VOLUME_PRV_MIN_CLASSIFIABLE, - VOLUME_PRV_MIN_VALID_DAYS, - VOLUME_PRV_SIGMA_K, - compute_volume_peak_count, -) -from data.clean.schema import CORE_COLUMNS, DATE_LEVEL -from factors.compute.intraday_derived import VolumePeakCountFactor -from factors.spec import FactorSpec -from qt.config import RootConfig, load_config -from qt.exec_basis_eval import ExecBasisEvaluation, run_exec_basis_evaluation -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_volume_peak_count" -_REPORT_STEM = "eval_volume_peak_count" - - -# --------------------------------------------------------------------------- # -# Minute loading (cache-only, per-symbol -> memory-bounded) -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class _VolumePeakMinuteLoad: - """Diagnostics from the cache-only per-symbol minute read + aggregation.""" - - factor: pd.Series # MultiIndex(date, symbol) raw volume-peak-count - 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_volume_peak_count_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, -) -> _VolumePeakMinuteLoad: - """Compute the raw volume-peak-count 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 same-slot baseline / - peak taxonomy runs entirely inside ``compute_volume_peak_count`` 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_volume_peak_count( - 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, - 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-volume-peak-count blocked: no requested symbol produced a cached " - f"volume-peak-count value over [{cfg.data.start}, {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 _VolumePeakMinuteLoad( - 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-volume-peak-count 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 VolumePeakCountEvalResult: - """Immutable summary of one run-eval-volume-peak-count 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 - exec_basis: ExecBasisEvaluation - 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-volume-peak-count 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-volume-peak-count needs data.cache.enabled=true (it reads the " - "persistent tushare cache and never warms live)." - ) - if cfg.universe.type != "index": - raise ValueError( - "run-eval-volume-peak-count needs universe.type='index' (PIT membership, " - f"e.g. 000905.SH for CSI500); got {cfg.universe.type!r}." - ) - if not cfg.processing.neutralize.enabled: - raise ValueError( - "run-eval-volume-peak-count expects processing.neutralize.enabled=true " - "(industry + size neutralization, matching the report's neutral column and " - "the EvalConfig declaration)." - ) - - -def run_eval_volume_peak_count(config_path: str) -> VolumePeakCountEvalResult: - """Run the two real volume-peak-count 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 = VolumePeakCountFactor(lookback_days=VOLUME_PRV_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) - - # Volume-peak-count factor: cache-only per-symbol aggregation -> raw -> process. - load = _load_volume_peak_count_panel( - cfg, symbols, spec, logger, - lookback_days=VOLUME_PRV_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, - ) - # 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), - ) - - # Second evaluation basis (task card §2.3): the SAME factor values scored on - # the 14:51 VWAP exec-to-exec return instead of close-to-close. The reports - # above are the close_to_close control and are left untouched. - exec_basis = run_exec_basis_evaluation( - factor_series, - spec, - eval_cfg, - book_processed, - cfg=cfg, - panel=panel, - symbols=symbols, - logger=logger, - report_dir=Path(cfg.output.report_dir), - stem=_REPORT_STEM, - ) - - 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 VolumePeakCountEvalResult( - 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, - exec_basis=exec_basis, - 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__ = [ - "VolumePeakCountEvalResult", - "evaluate_two_runs", - "extract_metrics", - "run_eval_volume_peak_count", -] diff --git a/qt/hand_anchors_d2.py b/qt/hand_anchors_d2.py index 8e5732f..65c2246 100644 --- a/qt/hand_anchors_d2.py +++ b/qt/hand_anchors_d2.py @@ -6,11 +6,13 @@ that imports the engine inherits the engine's bugs); a runtime guard at the bottom of the module raises if any such module is loaded. The engine side of each comparison is read FROM DISK (the ``panels_d2`` parquet files the D2 -reconciliation wrote) for the 14 frozen factors; the four ops-rewritten daily +reconciliation wrote) for the 14 frozen factors. The four ops-rewritten daily factors without a frozen panel (momentum/reversal/liquidity/overnight_mom) are -compared by the companion ``qt.hand_anchors_engine_values`` (which may import -the engine — the INDEPENDENCE requirement binds the hand computation, not the -comparer). +selected and hand-computed here, but are NO LONGER COMPARED: the companion that +did it, ``qt.hand_anchors_engine_values``, had its engine-side rebuild retired +in D5 C6, so those rows stay in ``daily_pending_engine``. The independence rule +that let the companion import the engine still reads correctly — it binds the +hand computation, not the comparer — it simply has nothing left to license. Stratified sampling (R12): per factor, one row from each APPLICABLE boundary class — (a) the warm-up END (the symbol's first finite value; verified by hand diff --git a/tests/test_eval_amp_marginal_anomaly_vol_runner.py b/tests/test_eval_amp_marginal_anomaly_vol_runner.py deleted file mode 100644 index 63acabe..0000000 --- a/tests/test_eval_amp_marginal_anomaly_vol_runner.py +++ /dev/null @@ -1,244 +0,0 @@ -"""PR-E 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 AmpMarginalAnomalyVolFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_amp_marginal_anomaly_vol import ( - _load_amp_anomaly_vol_panel, - evaluate_two_runs, - extract_metrics, -) - - -# --------------------------------------------------------------------------- # -# Minute cache-only loader -# --------------------------------------------------------------------------- # -def _stored_rows(sym, day, closes, amps): - """Build STORED_COLUMNS-shaped 1min rows for one session, ON the 5min grid. - - Bar i sits at ``09:35 + 5*i`` minutes (an on-grid time), so the runner's internal - 1min -> 5min resample gives one clean 5min bar per input bar with - ``high=100*(1+amp_i)``, ``low=100`` (amplitude ``amp_i`` exactly) and - ``close=close_i`` (the within-day return). All bars sit inside the 14:50 PIT window. - """ - base = pd.Timestamp(day) + pd.Timedelta("09:35:00") - rows = [] - for i, (c, a) in enumerate(zip(closes, amps)): - be = base + pd.Timedelta(minutes=5 * i) - rows.append( - { - "symbol": sym, - "bar_end": be, - "source_trade_time": be, - "open": c, - "high": 100.0 * (1.0 + a), - "low": 100.0, - "close": c, - "volume": 1.0, - "amount": 1.0, - "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(), - ) - - -# closes/amps designed so each day has 5 within-day pairs and exactly 2 anomalies -# (the two 0.20 |Δamp| bars) -> a finite value with small gates. -_CLOSES = [100.0, 100.0, 100.0, 110.0, 100.0, 105.0] -_AMPS = [0.02, 0.03, 0.04, 0.24, 0.04, 0.05] - - -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 across two trading days; a third symbol has - # NO cached minute at all (must be disclosed as empty, never fetched). - for sym in ("AAA.SZ", "BBB.SZ"): - for day in ("2021-07-01", "2021-07-02"): - store.upsert(INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, day, _CLOSES, _AMPS), KEY_COLS) - - cfg = _min_config(root, "2021-07-01", "2021-07-02") - spec = AmpMarginalAnomalyVolFactor().spec - logger = logging.getLogger("test.amav.loader") - load = _load_amp_anomaly_vol_panel( - cfg, ["AAA.SZ", "BBB.SZ", "CCC.SZ"], spec, logger, - lookback_days=20, min_pool=4, min_selected=2, sigma_k=1.0, - ) - 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() - # values live only on the covered symbols - 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-02") - spec = AmpMarginalAnomalyVolFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_amp_anomaly_vol_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.amav.block"), - lookback_days=20, min_pool=4, min_selected=2, sigma_k=1.0, - ) - - -# --------------------------------------------------------------------------- # -# 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="amp_marginal_anomaly_vol_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 = AmpMarginalAnomalyVolFactor().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, AmpMarginalAnomalyVolFactor().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 diff --git a/tests/test_eval_intraday_amp_cut_runner.py b/tests/test_eval_intraday_amp_cut_runner.py deleted file mode 100644 index 1d724fd..0000000 --- a/tests/test_eval_intraday_amp_cut_runner.py +++ /dev/null @@ -1,258 +0,0 @@ -"""PR-G runner: cache-only per-symbol stats loop + cross-sectional combine + two-run eval. - -Network-free: the minute loader is seeded through a temp ``IntradayParquetStore`` (no -fetch closure, so ``stk_mins`` live calls are provably 0), and the two-run evaluation core -uses synthetic processed panels. The distinguishing PR-G behaviour is that the loader -assembles the FULL-UNIVERSE ``(V_mean, V_std)`` panel across the per-symbol loop and then -applies the cross-sectional z-score combine ONCE. -""" - -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 IntradayAmpCutFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_intraday_amp_cut import ( - _load_amp_cut_panel, - evaluate_two_runs, - extract_metrics, -) - -# Within-day close chain for a 6-bar day: bar 0 has no return, bars 1..5 have returns -# [+0.05, -0.02, +0.03, -0.04, +0.01]; TOP-return bar is index 1, BOTTOM is index 4. With -# lam=0.20 and 5 valid bars (k=1), V_day = amp[1] - amp[4]. -_CLOSE_CHAIN = [100.0, 105.0, 102.9, 105.987, 101.74752, 102.7649952] - - -def _stored_amp_rows(sym, day, amps): - """STORED_COLUMNS-shaped 1min rows: low=100, high=100*(1+amp), close on _CLOSE_CHAIN.""" - base = pd.Timestamp(day) + pd.Timedelta("09:31:00") - rows = [] - for i, (a, c) in enumerate(zip(amps, _CLOSE_CHAIN)): - be = base + pd.Timedelta(minutes=i) - rows.append( - { - "symbol": sym, - "bar_end": be, - "source_trade_time": be, - "open": c, - "high": 100.0 * (1.0 + a), - "low": 100.0, - "close": c, - "volume": 1.0, - "amount": 1.0, - "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(), - ) - - -_DAY1 = "2021-07-01" -_DAY2 = "2021-07-02" - -# Each symbol has two valid days with DISTINCT V_day (bottom-bar amp differs day1->day2), -# so V_std > 0, and DISTINCT (V_mean, V_std) across symbols so the day-2 cross-section is -# non-degenerate. V_day = 0.02 - amps[4]. -_SYM_AMPS = { - "AAA.SZ": ([0.10, 0.02, 0.03, 0.04, 0.05, 0.06], [0.10, 0.02, 0.03, 0.04, 0.07, 0.06]), - "BBB.SZ": ([0.10, 0.02, 0.03, 0.04, 0.06, 0.06], [0.10, 0.02, 0.03, 0.04, 0.10, 0.06]), - "CCC.SZ": ([0.10, 0.02, 0.03, 0.04, 0.04, 0.06], [0.10, 0.02, 0.03, 0.04, 0.05, 0.06]), -} - -# Small gates so a 2-day cache produces a finite (V_mean, V_std) pair on day 2 and a -# 3-symbol cross-section is enough to z-score. -_LOAD_KW = dict( - lookback_days=10, lam=0.20, min_day_minutes=5, min_valid_days=2, min_cross_section=3 -) - - -def _seed_symbol(store, sym): - a1, a2 = _SYM_AMPS[sym] - store.upsert(INTRADAY_ENDPOINT, sym, "1min", _stored_amp_rows(sym, _DAY1, a1), KEY_COLS) - store.upsert(INTRADAY_ENDPOINT, sym, "1min", _stored_amp_rows(sym, _DAY2, a2), KEY_COLS) - - -# --------------------------------------------------------------------------- # -# Minute cache-only loader (per-symbol stats -> cross-sectional combine) -# --------------------------------------------------------------------------- # -def test_amp_cut_loader_is_cache_only_and_combines_cross_section(tmp_path): - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - for sym in ("AAA.SZ", "BBB.SZ", "CCC.SZ"): - _seed_symbol(store, sym) - - cfg = _min_config(root, _DAY1, _DAY2) - spec = IntradayAmpCutFactor().spec - logger = logging.getLogger("test.amp_cut.loader") - load = _load_amp_cut_panel( - cfg, ["AAA.SZ", "BBB.SZ", "CCC.SZ", "DDD.SZ"], spec, logger, **_LOAD_KW - ) - assert load.live_calls == 0 # store read has no fetch closure - assert set(load.covered) == {"AAA.SZ", "BBB.SZ", "CCC.SZ"} - assert load.empty_symbols == ("DDD.SZ",) # uncovered disclosed, not fetched - assert load.factor.name == spec.factor_id - # only day 2 carries a finite (V_mean, V_std) pair (min_valid_days=2) - d2 = pd.Timestamp(_DAY2) - assert set(load.factor.index.get_level_values("date")) == {d2} - assert load.factor.notna().all() - # the cross-sectional z-scores centre at 0 on the date - assert load.factor.loc[d2].sum() == pytest.approx(0.0, abs=1e-9) - # the assembled stats panel had day1 (NaN) + day2 rows for all three symbols - assert load.stats_rows == 6 - - -def test_amp_cut_loader_blocks_when_nothing_cached(tmp_path): - cfg = _min_config(tmp_path / "empty", _DAY1, _DAY2) - spec = IntradayAmpCutFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_amp_cut_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.amp_cut.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"]) - - factor = pd.Series( - rng.standard_normal(len(idx)), index=idx, name="intraday_amp_cut_10" - ) - 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, - ) - 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_amp_cut_evaluate_two_runs_produces_full_reports_and_incremental_axis(tmp_path): - factor, book, price = _synthetic_panels() - spec = IntradayAmpCutFactor().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 - - assert isinstance(reports.no_book.by_name()["purity"], Skipped) - assert reports.no_book.verdict.incremental.verdict == AXIS_NOT_ASSESSED - - 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 - incr = reports.with_book.verdict.incremental.verdict - assert incr in AXIS_VERDICTS and incr != AXIS_NOT_ASSESSED - - 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 - - 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" - - 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_amp_cut_no_book_run_never_reaches_adopt(tmp_path): - # Structural guarantee: with no book + no execution facts, at most Watch. - factor, book, price = _synthetic_panels(seed=3) - reports = evaluate_two_runs( - factor, IntradayAmpCutFactor().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 diff --git a/tests/test_eval_jump_amount_corr_runner.py b/tests/test_eval_jump_amount_corr_runner.py deleted file mode 100644 index 270f75c..0000000 --- a/tests/test_eval_jump_amount_corr_runner.py +++ /dev/null @@ -1,232 +0,0 @@ -"""PR-C 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 JumpAmountCorrFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_jump_amount_corr import ( - _load_jump_factor_panel, - evaluate_two_runs, - extract_metrics, -) - - -# --------------------------------------------------------------------------- # -# Minute cache-only loader -# --------------------------------------------------------------------------- # -def _stored_rows(sym, day, amps, amounts): - """Build STORED_COLUMNS-shaped 1min rows (open=100, amplitude a) for one session.""" - base = pd.Timestamp(day) + pd.Timedelta("09:31:00") - rows = [] - for i, (a, amt) in enumerate(zip(amps, amounts)): - be = base + pd.Timedelta(minutes=i) - rows.append( - { - "symbol": sym, - "bar_end": be, - "source_trade_time": be, - "open": 100.0, - "high": 100.0 + a * 50.0, - "low": 100.0 - a * 50.0, - "close": 100.0, - "volume": 1.0, - "amount": amt, - "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(), - ) - - -def test_minute_loader_is_cache_only_and_discloses_empty(tmp_path): - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - amps = [0.001, 0.02, 0.001, 0.001, 0.02, 0.001] - amts = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0] - # Two symbols with cached minute bars across two trading days; a third symbol - # has NO cached minute at all (must be disclosed as empty, never fetched). - for sym in ("AAA.SZ", "BBB.SZ"): - for day in ("2021-07-01", "2021-07-02"): - store.upsert(INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, day, amps, amts), KEY_COLS) - - cfg = _min_config(root, "2021-07-01", "2021-07-02") - spec = JumpAmountCorrFactor().spec - logger = logging.getLogger("test.jac.loader") - load = _load_jump_factor_panel( - cfg, ["AAA.SZ", "BBB.SZ", "CCC.SZ"], spec, logger, - lookback_days=20, min_pairs=2, - ) - 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() - # values live only on the covered symbols - 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-02") - spec = JumpAmountCorrFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_jump_factor_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.jac.block"), - lookback_days=20, min_pairs=2, - ) - - -# --------------------------------------------------------------------------- # -# 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="jump_amount_corr_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 = JumpAmountCorrFactor().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, JumpAmountCorrFactor().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 diff --git a/tests/test_eval_minute_ideal_amplitude_runner.py b/tests/test_eval_minute_ideal_amplitude_runner.py deleted file mode 100644 index 81bae7e..0000000 --- a/tests/test_eval_minute_ideal_amplitude_runner.py +++ /dev/null @@ -1,239 +0,0 @@ -"""PR-D 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 MinuteIdealAmplitudeFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_minute_ideal_amplitude import ( - _load_minute_ideal_amp_panel, - evaluate_two_runs, - extract_metrics, -) - - -# --------------------------------------------------------------------------- # -# Minute cache-only loader -# --------------------------------------------------------------------------- # -def _stored_rows(sym, day, closes, amps): - """Build STORED_COLUMNS-shaped 1min rows for one session. - - Minute i: ``low=100``, ``high=100*(1+amp_i)`` (amplitude ``amp_i`` exactly), - ``close=close_i`` (controls the price rank). Bars start at 09:31 so all sit - inside the 14:50 PIT window. - """ - base = pd.Timestamp(day) + pd.Timedelta("09:31:00") - rows = [] - for i, (c, a) in enumerate(zip(closes, amps)): - be = base + pd.Timedelta(minutes=i) - rows.append( - { - "symbol": sym, - "bar_end": be, - "source_trade_time": be, - "open": c, - "high": 100.0 * (1.0 + a), - "low": 100.0, - "close": c, - "volume": 1.0, - "amount": 1.0, - "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(), - ) - - -def test_minute_loader_is_cache_only_and_discloses_empty(tmp_path): - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - closes = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0] - amps = [0.01, 0.02, 0.03, 0.04, 0.05, 0.06] - # Two symbols with cached minute bars across two trading days; a third symbol has - # NO cached minute at all (must be disclosed as empty, never fetched). - for sym in ("AAA.SZ", "BBB.SZ"): - for day in ("2021-07-01", "2021-07-02"): - store.upsert(INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, day, closes, amps), KEY_COLS) - - cfg = _min_config(root, "2021-07-01", "2021-07-02") - spec = MinuteIdealAmplitudeFactor().spec - logger = logging.getLogger("test.mia.loader") - load = _load_minute_ideal_amp_panel( - cfg, ["AAA.SZ", "BBB.SZ", "CCC.SZ"], spec, logger, - lookback_days=20, lam=0.25, min_minutes=4, - ) - 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() - # values live only on the covered symbols - 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-02") - spec = MinuteIdealAmplitudeFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_minute_ideal_amp_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.mia.block"), - lookback_days=20, lam=0.25, min_minutes=4, - ) - - -# --------------------------------------------------------------------------- # -# 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="minute_ideal_amp_10" - ) - 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 = MinuteIdealAmplitudeFactor().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, MinuteIdealAmplitudeFactor().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 diff --git a/tests/test_eval_peak_interval_kurtosis_runner.py b/tests/test_eval_peak_interval_kurtosis_runner.py deleted file mode 100644 index db220d6..0000000 --- a/tests/test_eval_peak_interval_kurtosis_runner.py +++ /dev/null @@ -1,268 +0,0 @@ -"""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 diff --git a/tests/test_eval_ridge_minute_return_runner.py b/tests/test_eval_ridge_minute_return_runner.py deleted file mode 100644 index e68a30c..0000000 --- a/tests/test_eval_ridge_minute_return_runner.py +++ /dev/null @@ -1,435 +0,0 @@ -"""PR-K runner: cache-only minute loader, ridge coverage, two-run eval (no net).""" - -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 data.clean.intraday_ridge_return import RIDGE_RETURN_MIN_RIDGE_BARS -from factors.compute.intraday_derived import RidgeMinuteReturnFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_ridge_minute_return import ( - _load_ridge_minute_return_panel, - evaluate_two_runs, - extract_metrics, - summarize_ridge_return_coverage, -) - - -# --------------------------------------------------------------------------- # -# Minute cache-only loader -# --------------------------------------------------------------------------- # -def _stored_rows(sym, day, closes, 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). ``volume`` - drives the classification and ``close`` -- set INDEPENDENTLY -- drives the returns. - """ - base = pd.Timestamp(day) + pd.Timedelta("09:31:00") - rows = [] - for i, (c, v) in enumerate(zip(closes, vols)): - be = base + pd.Timedelta(minutes=i) - rows.append( - { - "symbol": sym, - "bar_end": be, - "source_trade_time": be, - "open": float(c), - "high": float(c), - "low": float(c), - "close": float(c), - "volume": float(v), - "amount": float(c) * 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 mu=100, sigma=0 -> eruptive threshold 100), then -# the hand-computed CASE A test day: 16 slots, ridge runs at (3,4) and (8,9) with volume -# 200, an ISOLATED peak at slot 12 (volume 300, return +9.0, excluded), and closes chosen so -# the four ridge returns are +0.5 / -0.5 / +3.0 / -0.5 -> daily SUM = +2.5 (a compounded -# reading would give -0.5 instead). -_DAYS = ("2021-07-01", "2021-07-02", "2021-07-03") -_TEST_DAY = "2021-07-04" -_N_SLOTS = 16 -_RIDGES = (3, 4, 8, 9) -_PEAK = 12 -_CASE_A_SUM = 2.5 -_CASE_A_CLOSES = [ - 100.0, 100.0, 100.0, 150.0, 75.0, 75.0, 75.0, 75.0, - 300.0, 150.0, 150.0, 150.0, 1500.0, 150.0, 150.0, 150.0, -] -_BG_CLOSES = [100.0] * _N_SLOTS -_BG_VOLS = [100.0] * _N_SLOTS - - -def _case_a(): - vols = [100.0] * _N_SLOTS - for s in _RIDGES: - vols[s] = 200.0 - vols[_PEAK] = 300.0 - return list(_CASE_A_CLOSES), vols - - -# Small gates so a 4-day cache produces a value (baseline needs >= 2 prior obs; the test day -# carries 4 return-bearing ridge bars). lookback_days=1 so the test day's value IS its own -# daily sum -- the background days have no eruption at all, hence no ridge, hence no valid -# day, so they cannot accumulate in. -_LOAD_KW = dict( - lookback_days=1, baseline_days=20, baseline_min_obs=2, - sigma_k=1.0, min_valid_days=1, min_classifiable=1, min_ridge_bars=1, -) - - -def _seed_symbol(store, sym): - for day in _DAYS: - store.upsert( - INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, day, _BG_CLOSES, _BG_VOLS), KEY_COLS, - ) - store.upsert( - INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, _TEST_DAY, *_case_a()), 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 = RidgeMinuteReturnFactor().spec - logger = logging.getLogger("test.rmr.loader") - load = _load_ridge_minute_return_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 summed ridge-minute return - d = pd.Timestamp(_TEST_DAY) - assert load.factor.loc[(d, "AAA.SZ")] == pytest.approx(_CASE_A_SUM) - assert load.factor.loc[(d, "BBB.SZ")] == pytest.approx(_CASE_A_SUM) - assert set(load.factor.index.get_level_values("symbol")) == {"AAA.SZ", "BBB.SZ"} - # a day where NOTHING erupts has no ridge at all -> invalid -> no value emitted - bg = pd.Timestamp(_DAYS[-1]) - assert (bg, "AAA.SZ") not in load.factor.index - - -def test_minute_loader_reports_the_ridge_scarcity_distribution(tmp_path): - """The ridge distribution + validity rate must be MEASURED on the real days.""" - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - _seed_symbol(store, "AAA.SZ") - cfg = _min_config(root, "2021-07-01", "2021-07-04") - load = _load_ridge_minute_return_panel( - cfg, ["AAA.SZ"], RidgeMinuteReturnFactor().spec, - logging.getLogger("test.rmr.cov"), **_LOAD_KW, - ) - cov = load.ridge_coverage - # four symbol-days seen, but the first two have no same-slot baseline yet (it needs 2 - # prior observations) -> only two are CLASSIFIABLE, and of those only the test day has a - # ridge at all -> validity rate 1/2 over the days that had a fair chance. - assert cov.symbol_days == 4 - assert cov.classifiable_days == 2 - assert cov.days_below_classifiable_gate == 2 # the warm-up days, counted separately - assert cov.valid_days == 1 - assert cov.validity_rate == pytest.approx(0.5) - # the flat background day has zero ridge bars, the test day has four; warm-up days are - # excluded from the distribution so they cannot drag it towards zero - assert dict(cov.ridge_return_percentiles)[100] == pytest.approx(4.0) - assert dict(cov.ridge_return_percentiles)[0] == pytest.approx(0.0) - assert cov.ridge_return_mean == pytest.approx(2.0) - # every ridge here sits past the day's first bar, so nothing is lost to the guard - assert cov.ridge_bars_mean == pytest.approx(2.0) - assert cov.return_guard_attrition == pytest.approx(0.0) - # the floors reported are the ones this RUN applied (_LOAD_KW uses 1, not the module - # defaults) -- otherwise the disclosure would describe gates nobody enforced - assert cov.min_ridge_bars == 1 - assert cov.days_below_ridge_gate == 1 # only the flat day (0 ridges) is below 1 - line = cov.render() - assert "ridge scarcity" in line and "valid_days=1" in line - assert "below_ridge_gate(1)" in line - - -def test_coverage_reports_the_return_guard_attrition(): - """Ridge bars lost to the within-day lag must be VISIBLE, not silently absorbed.""" - diag = pd.DataFrame( - { - "classifiable_bars": [240, 240], - "ridge_bars": [10, 30], - # one ridge on each day is the day's first visible bar -> no return - "ridge_return_bars": [9, 29], - "valid": [False, True], - }, - index=pd.DatetimeIndex(pd.bdate_range("2022-01-03", periods=2), name="trade_date"), - ) - cov = summarize_ridge_return_coverage([diag]) - assert cov.ridge_bars_mean == pytest.approx(20.0) - assert cov.ridge_return_mean == pytest.approx(19.0) - assert cov.return_guard_attrition == pytest.approx(1.0 - 19.0 / 20.0) - # the gate is applied to the RETURN-carrying count, so the 9-ridge day falls below 10 - assert cov.days_below_ridge_gate == 1 - assert "return_guard_attrition" in cov.render() - - -def test_summarize_ridge_coverage_counterfactual_at_the_comparison_floor(): - """The disclosure quantifies exactly what the scarcity floor buys (vs PR-J's 20).""" - diag = pd.DataFrame( - { - "classifiable_bars": [240, 240, 240, 240], - "ridge_bars": [4, 12, 25, 30], - "ridge_return_bars": [4, 12, 25, 30], - # as the factor would mark them under the default floor of 10 - "valid": [False, True, True, True], - }, - index=pd.DatetimeIndex(pd.bdate_range("2022-01-03", periods=4), name="trade_date"), - ) - cov = summarize_ridge_return_coverage([diag]) - assert cov.symbol_days == 4 - assert cov.classifiable_days == 4 # every day clears PR-F's classifiable floor - assert cov.valid_days == 3 - # raising the ridge floor to PR-J's 20 would keep only the 25 / 30 days - assert cov.valid_days_at_comparison_floor == 2 - assert cov.days_below_ridge_gate == 1 - assert cov.ridge_return_mean == pytest.approx((4 + 12 + 25 + 30) / 4) - # defaults are the PINNED production floors when the caller does not override - assert cov.min_ridge_bars == RIDGE_RETURN_MIN_RIDGE_BARS == 10 - assert f"below_ridge_gate({RIDGE_RETURN_MIN_RIDGE_BARS})" in cov.render() - - -def test_summarize_ridge_coverage_handles_no_frames(): - cov = summarize_ridge_return_coverage([]) - assert cov.symbol_days == 0 - assert cov.classifiable_days == 0 - assert cov.valid_days == 0 - assert np.isnan(cov.validity_rate) - assert np.isnan(cov.return_guard_attrition) - assert cov.render() # renders without dividing by zero - - -def test_minute_loader_blocks_when_nothing_cached(tmp_path): - cfg = _min_config(tmp_path / "empty", "2021-07-01", "2021-07-04") - spec = RidgeMinuteReturnFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_ridge_minute_return_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.rmr.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="ridge_minute_return_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 = RidgeMinuteReturnFactor().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 - 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_extract_metrics_surfaces_the_pr_k_comparison_quantities(tmp_path): - """PR-K's analysis needs turnover / net-by-cost / autocorr / cross-section size. - - These are the quantities compared head-on with PR-I's and PR-J's for the same cell, so - the runner must extract them rather than leaving them buried in the JSON. - """ - factor, book, price = _synthetic_panels() - reports = evaluate_two_runs( - factor, RidgeMinuteReturnFactor().spec, _eval_cfg(), price, book, - universe_symbols=tuple(sorted(set(book.index.get_level_values("symbol")))), - fee_rate=0.001, - report_dir=tmp_path, - ) - m = extract_metrics(reports.no_book) - for key in ( - "long_short_turnover", "rank_autocorr_lag1", "half_life_periods", - "cross_section_size_mean", "cross_section_size_median", - "monotonicity_spearman", "gross_long_short_mean", - ): - assert key in m, key - assert m[key] is not None, key - # the cost gradient is a per-scenario mapping covering the declared scenarios - net = m["net_long_short_by_cost"] - assert isinstance(net, dict) and net - assert {1.0, 2.0, 4.0} <= {float(k) for k in net} - # higher costs can only reduce a gross-positive spread; the scenarios must not be equal - assert len({round(float(v), 12) for v in net.values()}) > 1 - - -def test_absent_stability_metrics_render_as_na_not_a_crash(): - """A Skipped ``stability_cost`` section must not blow up the CLI summary line. - - The section is Skipped when no period carries a quantile label, which the - scarcity-gated ridge family can plausibly reach on a thin universe. The metrics then - come back as None, and the summary is printed AFTER the reports are already on disk - and OUTSIDE the command's error handling -- so an unguarded ``:.4f`` would surface as - a raw TypeError on an otherwise successful run. - """ - from qt.cli import _fmt_metric - - assert _fmt_metric(None) == "n/a" - assert _fmt_metric(None, ".2f") == "n/a" - assert _fmt_metric(2.108441) == "2.1084" - assert _fmt_metric(8.061448, ".2f") == "8.06" - # the exact expression the summary builds, with every stability field absent - absent = {"long_short_turnover": None, "rank_autocorr_lag1": None, - "half_life_periods": None} - line = ( - f"turnover={_fmt_metric(absent['long_short_turnover'])} " - f"rank_autocorr_lag1={_fmt_metric(absent['rank_autocorr_lag1'])} " - f"half_life={_fmt_metric(absent['half_life_periods'], '.2f')}" - ) - assert line == "turnover=n/a rank_autocorr_lag1=n/a half_life=n/a" - - -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, RidgeMinuteReturnFactor().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 diff --git a/tests/test_eval_valley_price_quantile_runner.py b/tests/test_eval_valley_price_quantile_runner.py deleted file mode 100644 index fe25272..0000000 --- a/tests/test_eval_valley_price_quantile_runner.py +++ /dev/null @@ -1,509 +0,0 @@ -"""PR-L runner: cache-only minute loader + reversal neutralization + the two evaluations. - -Network-free throughout: the minute "cache" is a real ``IntradayParquetStore`` seeded in a -tmp_path, and the two-run evaluation seam is exercised on synthetic processed panels. - -The structural novelty this file has to pin down, beyond PR-I..PR-K's loader contract, is -that the loader RESIDUALIZES: it streams per-symbol raw quantiles, then applies ONE -cross-sectional reversal neutralization against the T-1 reversal built from the daily -panel's FRONT-ADJUSTED closes. Tests cover that the shipped factor is the residual (not -the raw quantile), that the daily closes actually reach it, and that the neutralization -coverage is measured rather than assumed. -""" - -from __future__ import annotations - -import logging - -import numpy as np -import pandas as pd -import pytest - -from analytics.eval import MANDATORY_SECTIONS, EvalConfig, 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 data.clean.intraday_valley_quantile import ( - VALLEY_QUANTILE_MIN_VALLEY_BARS, - reversal_20, -) -from factors.compute.intraday_derived import ValleyPriceQuantileFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_valley_price_quantile import ( - _load_valley_price_quantile_panel, - evaluate_two_runs, - extract_metrics, - summarize_neutralization, -) - - -# --------------------------------------------------------------------------- # -# Minute cache fixtures -# --------------------------------------------------------------------------- # -_N_SLOTS = 16 -_ERUPT = (3, 7) -_HIGH_SLOT, _LOW_SLOT = 5, 9 -_BG_DAYS = ("2021-07-01", "2021-07-02", "2021-07-05") -_TEST_DAY = "2021-07-06" -# Range on the test day: intraday [80, 120], prev_close 100 (inside) -> hi=120, lo=80. -_HI, _LO = 120.0, 80.0 -_PREV_CLOSE = 100.0 -# Per-symbol valley VWAP -> q = (vwap - 80) / 40. -_SYM_VWAP = {"AAA.SZ": 90.0, "BBB.SZ": 100.0, "CCC.SZ": 110.0} -_SYM_Q = {s: (v - _LO) / (_HI - _LO) for s, v in _SYM_VWAP.items()} - - -def _stored_rows(sym, day, *, vols, amts, highs, lows, closes): - """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). ``amount`` is - set INDEPENDENTLY of ``volume`` (the per-bar price is amount/volume) and high / low / - close are explicit, because this factor reads the day's range and the previous day's - last visible close alongside the valley VWAP. - """ - base = pd.Timestamp(day) + pd.Timedelta("09:31:00") - rows = [] - for i in range(len(vols)): - be = base + pd.Timedelta(minutes=i) - rows.append( - { - "symbol": sym, - "bar_end": be, - "source_trade_time": be, - "open": float(closes[i]), - "high": float(highs[i]), - "low": float(lows[i]), - "close": float(closes[i]), - "volume": float(vols[i]), - "amount": float(amts[i]), - "freq": "1min", - } - ) - return pd.DataFrame(rows) - - -def _background_day(sym, day): - """Flat volume-100 session at price 100 -> baseline mu=100, sigma=0; hi == lo.""" - n = _N_SLOTS - return _stored_rows( - sym, day, - vols=[100.0] * n, amts=[100.0 * _PREV_CLOSE] * n, - highs=[_PREV_CLOSE] * n, lows=[_PREV_CLOSE] * n, closes=[_PREV_CLOSE] * n, - ) - - -def _test_day(sym): - """Engineered day whose valley VWAP is ``_SYM_VWAP[sym]`` inside the range [80, 120].""" - n = _N_SLOTS - vwap = _SYM_VWAP[sym] - vols = [100.0] * n - amts = [100.0 * vwap] * n - for s in _ERUPT: - vols[s] = 200.0 - amts[s] = 200.0 * 500.0 # eruptive bars traded elsewhere; excluded from the VWAP - highs = [110.0] * n - lows = [95.0] * n - highs[_HIGH_SLOT] = _HI - lows[_LOW_SLOT] = _LO - return _stored_rows( - sym, _TEST_DAY, - vols=vols, amts=amts, highs=highs, lows=lows, closes=[100.0] * n, - ) - - -def _seed_symbol(store, sym): - for day in _BG_DAYS: - store.upsert(INTRADAY_ENDPOINT, sym, "1min", _background_day(sym, day), KEY_COLS) - store.upsert(INTRADAY_ENDPOINT, sym, "1min", _test_day(sym), KEY_COLS) - - -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(), - ) - - -def _daily_panel(symbols, *, n_days=25, end=_TEST_DAY, seed=5): - """Daily FRONT-ADJUSTED close panel spanning enough history for a T-1 20-day reversal. - - The panel is INDEPENDENT of the minute cache (it is the runner's own daily panel) and - must reach back >= 22 business days before the signal day so ``rev20`` is finite there. - """ - dates = pd.bdate_range(end=pd.Timestamp(end), periods=n_days) - rng = np.random.default_rng(seed) - idx = pd.MultiIndex.from_product([dates, symbols], names=["date", "symbol"]) - close = 100.0 * np.exp( - np.cumsum(rng.normal(0, 0.02, size=(len(dates), len(symbols))), axis=0) - ) - return pd.DataFrame({"close": close.reshape(-1)}, index=idx) - - -# Small gates so a 4-day cache produces a value. lookback_days=1 so the test day's value IS -# its own daily quantile; the flat background days have hi == lo, hence no valid day, so -# they cannot accumulate in. -_LOAD_KW = dict( - lookback_days=1, baseline_days=20, baseline_min_obs=2, - sigma_k=1.0, min_valid_days=1, min_classifiable=1, min_valley_bars=1, - min_cross_section=3, reversal_days=20, -) - - -# --------------------------------------------------------------------------- # -# Cache-only loader -# --------------------------------------------------------------------------- # -def test_minute_loader_is_cache_only_and_discloses_empty(tmp_path): - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - for sym in _SYM_VWAP: - _seed_symbol(store, sym) - - syms = sorted(_SYM_VWAP) - cfg = _min_config(root, "2021-07-01", _TEST_DAY) - spec = ValleyPriceQuantileFactor().spec - load = _load_valley_price_quantile_panel( - cfg, syms + ["ZZZ.SZ"], spec, _daily_panel(syms + ["ZZZ.SZ"]), - logging.getLogger("test.vpq.loader"), **_LOAD_KW, - ) - assert load.live_calls == 0 # store read has no fetch closure - assert set(load.covered) == set(syms) # all three produced a raw value - assert load.empty_symbols == ("ZZZ.SZ",) # uncovered disclosed, never fetched - assert load.factor.name == spec.factor_id - - # The RAW panel carries the hand-computed daily quantiles. - d = pd.Timestamp(_TEST_DAY) - for sym, q in _SYM_Q.items(): - assert load.raw.loc[(d, sym)] == pytest.approx(q) - - # A flat background day (hi == lo) is invalid -> no value emitted. - assert (pd.Timestamp(_BG_DAYS[-1]), "AAA.SZ") not in load.raw.index - - -def test_loader_ships_the_residual_not_the_raw_quantile(tmp_path): - """The factor the loader returns is the REVERSAL-NEUTRALIZED panel. - - Guards the wiring bug where the neutralization is computed and then discarded. The - residuals of a 3-name cross-section also sum to ~0 by construction of an intercept - OLS fit, which the raw quantiles (0.25 / 0.50 / 0.75) do not. - """ - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - for sym in _SYM_VWAP: - _seed_symbol(store, sym) - syms = sorted(_SYM_VWAP) - - load = _load_valley_price_quantile_panel( - _min_config(root, "2021-07-01", _TEST_DAY), syms, - ValleyPriceQuantileFactor().spec, _daily_panel(syms), - logging.getLogger("test.vpq.resid"), **_LOAD_KW, - ) - d = pd.Timestamp(_TEST_DAY) - shipped = np.array([float(load.factor.loc[(d, s)]) for s in syms]) - raw = np.array([float(load.raw.loc[(d, s)]) for s in syms]) - assert np.isfinite(shipped).all() - assert not np.allclose(shipped, raw) - assert shipped.sum() == pytest.approx(0.0, abs=1e-12) # intercept OLS residual - - -def test_loader_actually_consumes_the_daily_closes(tmp_path): - """A different daily close panel must produce a different factor. - - Without this, the reversal could be silently absent and every test above would still - pass on the raw quantiles. - """ - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - for sym in _SYM_VWAP: - _seed_symbol(store, sym) - syms = sorted(_SYM_VWAP) - cfg = _min_config(root, "2021-07-01", _TEST_DAY) - spec = ValleyPriceQuantileFactor().spec - - a = _load_valley_price_quantile_panel( - cfg, syms, spec, _daily_panel(syms, seed=5), - logging.getLogger("test.vpq.px.a"), **_LOAD_KW, - ) - b = _load_valley_price_quantile_panel( - cfg, syms, spec, _daily_panel(syms, seed=99), - logging.getLogger("test.vpq.px.b"), **_LOAD_KW, - ) - d = pd.Timestamp(_TEST_DAY) - va = [float(a.factor.loc[(d, s)]) for s in syms] - vb = [float(b.factor.loc[(d, s)]) for s in syms] - assert not np.allclose(va, vb) - # ... while the RAW quantiles, which never see a daily close, are identical. - assert [float(a.raw.loc[(d, s)]) for s in syms] == [ - float(b.raw.loc[(d, s)]) for s in syms - ] - - -def test_loader_reports_the_neutralization_coverage(tmp_path): - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - for sym in _SYM_VWAP: - _seed_symbol(store, sym) - syms = sorted(_SYM_VWAP) - - load = _load_valley_price_quantile_panel( - _min_config(root, "2021-07-01", _TEST_DAY), syms, - ValleyPriceQuantileFactor().spec, _daily_panel(syms), - logging.getLogger("test.vpq.cov"), **_LOAD_KW, - ) - cov = load.coverage - assert cov.raw_rows == 3 # one valid day x three symbols - assert cov.rev_rows == 3 # all three had a finite T-1 reversal - assert cov.residual_rows == 3 # ... and all three were residualized - assert cov.dates_total == 1 - assert cov.dates_residualized == 1 - assert cov.cross_section_min == 3 and cov.cross_section_max == 3 - assert np.isfinite(cov.raw_rev_spearman_mean) - - -def test_loader_blocks_when_nothing_cached(tmp_path): - cfg = _min_config(tmp_path / "empty", "2021-07-01", _TEST_DAY) - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_valley_price_quantile_panel( - cfg, ["ZZZ.SZ"], ValleyPriceQuantileFactor().spec, - _daily_panel(["ZZZ.SZ"]), logging.getLogger("test.vpq.block"), **_LOAD_KW, - ) - - -def test_thin_cross_section_leaves_the_factor_nan(tmp_path): - """Below ``min_cross_section`` the date cannot be residualized -> NaN, never raw.""" - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - for sym in _SYM_VWAP: - _seed_symbol(store, sym) - syms = sorted(_SYM_VWAP) - - load = _load_valley_price_quantile_panel( - _min_config(root, "2021-07-01", _TEST_DAY), syms, - ValleyPriceQuantileFactor().spec, _daily_panel(syms), - logging.getLogger("test.vpq.thin"), - **dict(_LOAD_KW, min_cross_section=4), - ) - assert not np.isfinite(load.factor.to_numpy(dtype=float)).any() - assert load.raw.notna().any() # the raw panel is unaffected - assert load.coverage.residual_rows == 0 - - -# --------------------------------------------------------------------------- # -# summarize_neutralization -# --------------------------------------------------------------------------- # -def _panel(dates, syms, values, name): - idx = pd.MultiIndex.from_product([dates, syms], names=["date", "symbol"]) - return pd.Series(np.asarray(values, dtype=float).reshape(-1), index=idx, name=name) - - -def test_summarize_neutralization_counts_missing_reversal_rows(): - dates = pd.bdate_range("2023-01-02", periods=2) - syms = ["A", "B", "C"] - raw = _panel(dates, syms, [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], "q") - rev = _panel(dates, syms, [[1.0, 2.0, 3.0], [1.0, np.nan, np.nan]], "r") - resid = _panel(dates, syms, [[0.0, 0.0, 0.0], [np.nan] * 3], "f") - cov = summarize_neutralization(raw, rev, resid, min_cross_section=3) - assert cov.raw_rows == 6 - assert cov.rev_rows == 4 # only four rows had BOTH - assert cov.residual_rows == 3 - assert cov.dates_total == 2 - assert cov.dates_residualized == 1 - assert cov.cross_section_min == 1 and cov.cross_section_max == 3 - - -def test_summarize_neutralization_handles_an_all_missing_reversal(): - dates = pd.bdate_range("2023-01-02", periods=1) - syms = ["A", "B"] - raw = _panel(dates, syms, [[0.1, 0.2]], "q") - rev = _panel(dates, syms, [[np.nan, np.nan]], "r") - resid = _panel(dates, syms, [[np.nan, np.nan]], "f") - cov = summarize_neutralization(raw, rev, resid, min_cross_section=3) - assert cov.rev_rows == 0 - assert cov.residual_rows == 0 - assert cov.dates_residualized == 0 - assert not np.isfinite(cov.raw_rev_spearman_mean) # no exposure is measurable - - -def test_reversal_from_a_daily_panel_is_the_t_minus_1_ratio(): - """The runner's reversal input is the panel's close column, read at T-1.""" - dates = pd.bdate_range("2023-01-02", periods=25) - panel = pd.DataFrame( - {"close": np.tile(np.arange(100.0, 125.0), 1)}, - index=pd.MultiIndex.from_product([dates, ["A"]], names=["date", "symbol"]), - ) - rev = reversal_20(panel[["close"]], days=20) - d = dates[22] - assert float(rev.loc[(d, "A")]) == pytest.approx(-((121.0 / 101.0) - 1.0)) - - -# --------------------------------------------------------------------------- # -# 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"]) - - factor = pd.Series( - rng.standard_normal(len(idx)), index=idx, name="valley_price_quantile_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, - ) - 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 = ValleyPriceQuantileFactor().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) - assert all(isinstance(s, (Section, Skipped)) for s in by.values()) - assert report.verdict is not None - - assert isinstance(reports.no_book.by_name()["purity"], Skipped) - assert reports.no_book.verdict.incremental.verdict == AXIS_NOT_ASSESSED - - 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 - incr = reports.with_book.verdict.incremental.verdict - assert incr in AXIS_VERDICTS and incr != AXIS_NOT_ASSESSED - - 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 - - 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" - - 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_extract_metrics_surfaces_the_pr_l_comparison_quantities(tmp_path): - """PR-L is compared head-on with PR-I / PR-J / PR-K on the same cell. - - Beyond the shared tradability quantities, this run needs ``ic_pearson_mean`` next to - the rank ``ic_mean``: the PR-K review found their divergence predicted the - monotonicity gate failing, and PR-L is the first test of that on a sign=+1 factor. - """ - factor, book, price = _synthetic_panels() - reports = evaluate_two_runs( - factor, ValleyPriceQuantileFactor().spec, _eval_cfg(), price, book, - universe_symbols=tuple(sorted(set(book.index.get_level_values("symbol")))), - fee_rate=0.001, - report_dir=tmp_path, - ) - m = extract_metrics(reports.no_book) - for key in ( - "long_short_turnover", "rank_autocorr_lag1", "half_life_periods", - "cross_section_size_mean", "cross_section_size_median", - "monotonicity_spearman", "gross_long_short_mean", - "ic_mean", "ic_pearson_mean", - ): - assert key in m, key - assert m[key] is not None, key - net = m["net_long_short_by_cost"] - assert isinstance(net, dict) and net - assert {1.0, 2.0, 4.0} <= {float(k) for k in net} - assert len({round(float(v), 12) for v in net.values()}) > 1 - # sign=+1 -> the aligned spreads are NOT mis-signed for this factor and are surfaced. - assert isinstance(m["aligned_spread_by_cost"], dict) - - -def test_spec_sign_is_positive_so_aligned_spread_is_not_mis_signed(): - """The frozen layer's cost-sign defect only bites at sign=-1; PR-L is +1.""" - assert ValleyPriceQuantileFactor().spec.expected_ic_sign == 1 - - -def test_no_book_run_never_reaches_adopt(tmp_path): - factor, book, price = _synthetic_panels(seed=3) - reports = evaluate_two_runs( - factor, ValleyPriceQuantileFactor().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 - - -def test_min_valley_bars_default_matches_the_pr_i_floor(): - """PR-L reuses PR-I's valley floor so the two runs' coverage is comparable.""" - from data.clean.intraday_valley_vwap import VALLEY_VWAP_MIN_VALLEY_BARS - - assert VALLEY_QUANTILE_MIN_VALLEY_BARS == VALLEY_VWAP_MIN_VALLEY_BARS diff --git a/tests/test_eval_valley_relative_vwap_runner.py b/tests/test_eval_valley_relative_vwap_runner.py deleted file mode 100644 index d4b0958..0000000 --- a/tests/test_eval_valley_relative_vwap_runner.py +++ /dev/null @@ -1,288 +0,0 @@ -"""PR-I 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 ValleyRelativeVwapFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_valley_relative_vwap import ( - _load_valley_relative_vwap_panel, - evaluate_two_runs, - extract_metrics, -) - - -# --------------------------------------------------------------------------- # -# Minute cache-only loader -# --------------------------------------------------------------------------- # -def _stored_rows(sym, day, vols, amts): - """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). OHLC are dummy - constants; ``volume`` drives the classification and ``amount`` -- set INDEPENDENTLY -- - drives the VWAPs. - """ - base = pd.Timestamp(day) + pd.Timedelta("09:31:00") - rows = [] - for i, (v, a) in enumerate(zip(vols, amts)): - 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(a), - "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 mu=100, sigma=0 -> eruptive threshold 100), -# then the hand-computed CASE A test day: 12 slots, eruptions at 3 and 7 (volume 200 / -# amount 4000 -> price 20); the ten valley bars carry volume 100 with amount 1000 (x5) -# and 1200 (x5) -> valley VWAP 11.0, day VWAP 19000/1400, ratio 77/95. -_DAYS = ("2021-07-01", "2021-07-02", "2021-07-03") -_TEST_DAY = "2021-07-04" -_N_SLOTS = 12 -_ERUPT = (3, 7) -_CASE_A_RATIO = 77.0 / 95.0 -_BG_VOLS = [100.0] * _N_SLOTS -_BG_AMTS = [1000.0] * _N_SLOTS - - -def _case_a(): - vols = [100.0] * _N_SLOTS - amts = [0.0] * _N_SLOTS - valley_slots = [s for s in range(_N_SLOTS) if s not in _ERUPT] - for i, s in enumerate(valley_slots): - amts[s] = 1000.0 if i < 5 else 1200.0 - for s in _ERUPT: - vols[s] = 200.0 - amts[s] = 4000.0 - return vols, amts - - -# Small gates so a 4-day cache produces a value (baseline needs >= 2 prior obs; the test -# day carries 10 tradable valley bars). lookback_days=1 so the test day's value IS its own -# ratio: with these tiny gates the last background day also becomes valid, and a day where -# NOTHING erupts has ratio exactly 1.0 (every bar is a valley, so the two VWAPs coincide), -# which would otherwise average into the assertion. -_LOAD_KW = dict( - lookback_days=1, baseline_days=20, baseline_min_obs=2, - sigma_k=1.0, min_valid_days=1, min_classifiable=1, min_valley_bars=1, -) - - -def _seed_symbol(store, sym): - for day in _DAYS: - store.upsert( - INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, day, _BG_VOLS, _BG_AMTS), KEY_COLS, - ) - store.upsert( - INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, _TEST_DAY, *_case_a()), 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 = ValleyRelativeVwapFactor().spec - logger = logging.getLogger("test.vrv.loader") - load = _load_valley_relative_vwap_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 valley/day VWAP ratio - d = pd.Timestamp(_TEST_DAY) - assert load.factor.loc[(d, "AAA.SZ")] == pytest.approx(_CASE_A_RATIO) - assert load.factor.loc[(d, "BBB.SZ")] == pytest.approx(_CASE_A_RATIO) - assert set(load.factor.index.get_level_values("symbol")) == {"AAA.SZ", "BBB.SZ"} - # the last background day (nothing erupts -> every bar is a valley) has ratio - # exactly 1.0: the two VWAPs are sums over the SAME bar set - bg = pd.Timestamp(_DAYS[-1]) - assert load.factor.loc[(bg, "AAA.SZ")] == pytest.approx(1.0) - - -def test_minute_loader_blocks_when_nothing_cached(tmp_path): - cfg = _min_config(tmp_path / "empty", "2021-07-01", "2021-07-04") - spec = ValleyRelativeVwapFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_valley_relative_vwap_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.vrv.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="valley_relative_vwap_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 = ValleyRelativeVwapFactor().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, ValleyRelativeVwapFactor().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 diff --git a/tests/test_eval_valley_ridge_vwap_ratio_runner.py b/tests/test_eval_valley_ridge_vwap_ratio_runner.py deleted file mode 100644 index 79097ad..0000000 --- a/tests/test_eval_valley_ridge_vwap_ratio_runner.py +++ /dev/null @@ -1,365 +0,0 @@ -"""PR-J runner: cache-only minute loader, ridge-scarcity coverage, two-run eval (no net).""" - -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 data.clean.intraday_valley_ridge_vwap import ( - VALLEY_RIDGE_MIN_RIDGE_BARS, - VALLEY_RIDGE_MIN_VALLEY_BARS, -) -from factors.compute.intraday_derived import ValleyRidgeVwapRatioFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_valley_ridge_vwap_ratio import ( - _load_valley_ridge_vwap_ratio_panel, - evaluate_two_runs, - extract_metrics, - summarize_ridge_coverage, -) - - -# --------------------------------------------------------------------------- # -# Minute cache-only loader -# --------------------------------------------------------------------------- # -def _stored_rows(sym, day, vols, amts): - """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). OHLC are dummy - constants; ``volume`` drives the classification and ``amount`` -- set INDEPENDENTLY -- - drives the VWAPs. - """ - base = pd.Timestamp(day) + pd.Timedelta("09:31:00") - rows = [] - for i, (v, a) in enumerate(zip(vols, amts)): - 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(a), - "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 mu=100, sigma=0 -> eruptive threshold 100), -# then the hand-computed CASE A test day: 16 slots, ridge runs at (3,4) and (8,9) with -# volume 200 / amount 4000 (price 20), an ISOLATED peak at slot 12 (volume 300 / amount -# 9000 -> price 30, excluded from BOTH legs), and 11 valley bars at volume 100 carrying -# amount 1000 (x6) and 1200 (x5) -> valley VWAP 12000/1100, ridge VWAP 20, ratio 6/11. -_DAYS = ("2021-07-01", "2021-07-02", "2021-07-03") -_TEST_DAY = "2021-07-04" -_N_SLOTS = 16 -_RIDGES = (3, 4, 8, 9) -_PEAK = 12 -_CASE_A_RATIO = 6.0 / 11.0 -_BG_VOLS = [100.0] * _N_SLOTS -_BG_AMTS = [1000.0] * _N_SLOTS - - -def _case_a(): - vols = [100.0] * _N_SLOTS - amts = [0.0] * _N_SLOTS - for s in _RIDGES: - vols[s] = 200.0 - amts[s] = 4000.0 - vols[_PEAK] = 300.0 - amts[_PEAK] = 9000.0 - valley_slots = [s for s in range(_N_SLOTS) if s not in _RIDGES and s != _PEAK] - for i, s in enumerate(valley_slots): - amts[s] = 1000.0 if i < 6 else 1200.0 - return vols, amts - - -# Small gates so a 4-day cache produces a value (baseline needs >= 2 prior obs; the test -# day carries 4 tradable ridge bars and 11 tradable valley bars). lookback_days=1 so the -# test day's value IS its own ratio -- the background days have no eruption at all, hence -# no ridge, hence no valid day, so they cannot average in. -_LOAD_KW = dict( - lookback_days=1, baseline_days=20, baseline_min_obs=2, - sigma_k=1.0, min_valid_days=1, min_classifiable=1, - min_valley_bars=1, min_ridge_bars=1, -) - - -def _seed_symbol(store, sym): - for day in _DAYS: - store.upsert( - INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, day, _BG_VOLS, _BG_AMTS), KEY_COLS, - ) - store.upsert( - INTRADAY_ENDPOINT, sym, "1min", - _stored_rows(sym, _TEST_DAY, *_case_a()), 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 = ValleyRidgeVwapRatioFactor().spec - logger = logging.getLogger("test.vrr.loader") - load = _load_valley_ridge_vwap_ratio_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 valley/ridge VWAP ratio - d = pd.Timestamp(_TEST_DAY) - assert load.factor.loc[(d, "AAA.SZ")] == pytest.approx(_CASE_A_RATIO) - assert load.factor.loc[(d, "BBB.SZ")] == pytest.approx(_CASE_A_RATIO) - assert set(load.factor.index.get_level_values("symbol")) == {"AAA.SZ", "BBB.SZ"} - # a day where NOTHING erupts has no ridge leg at all -> invalid -> no value emitted - bg = pd.Timestamp(_DAYS[-1]) - assert (bg, "AAA.SZ") not in load.factor.index - - -def test_minute_loader_reports_the_ridge_scarcity_distribution(tmp_path): - """The ridge-bar distribution + validity rate must be MEASURED on the real days.""" - root = tmp_path / "cache" - store = IntradayParquetStore(str(root)) - _seed_symbol(store, "AAA.SZ") - cfg = _min_config(root, "2021-07-01", "2021-07-04") - load = _load_valley_ridge_vwap_ratio_panel( - cfg, ["AAA.SZ"], ValleyRidgeVwapRatioFactor().spec, - logging.getLogger("test.vrr.cov"), **_LOAD_KW, - ) - cov = load.ridge_coverage - # four symbol-days seen, but the first two have no same-slot baseline yet (it needs - # 2 prior observations) -> only two are CLASSIFIABLE, and of those only the test day - # has a ridge at all -> validity rate 1/2 over the days that had a fair chance. - assert cov.symbol_days == 4 - assert cov.classifiable_days == 2 - assert cov.days_below_classifiable_gate == 2 # the warm-up days, counted separately - assert cov.valid_days == 1 - assert cov.validity_rate == pytest.approx(0.5) - # the flat background day has zero ridge bars, the test day has four; warm-up days - # are excluded from the distribution so they cannot drag it towards zero - assert dict(cov.ridge_percentiles)[100] == pytest.approx(4.0) - assert dict(cov.ridge_percentiles)[0] == pytest.approx(0.0) - assert cov.ridge_mean == pytest.approx(2.0) - # the floors reported are the ones this RUN applied (_LOAD_KW uses 1, not the module - # defaults) -- otherwise the disclosure would describe gates nobody enforced - assert cov.min_ridge_bars == 1 - assert cov.min_valley_bars == 1 - assert cov.days_below_ridge_gate == 1 # only the flat day (0 ridges) is below 1 - line = cov.render() - assert "ridge scarcity" in line and "valid_days=1" in line - assert "below_ridge_gate(1)" in line and "below_valley_gate(1)" in line - - -def test_summarize_ridge_coverage_counterfactual_at_the_valley_floor(): - """The disclosure quantifies exactly what the LOWERED ridge floor buys.""" - diag = pd.DataFrame( - { - "classifiable_bars": [240, 240, 240, 240], - "valley_bars": [200, 200, 200, 200], - "ridge_bars": [4, 12, 25, 30], - # as the factor would mark them under the default floor of 10 - "valid": [False, True, True, True], - }, - index=pd.DatetimeIndex(pd.bdate_range("2022-01-03", periods=4), name="trade_date"), - ) - cov = summarize_ridge_coverage([diag]) - assert cov.symbol_days == 4 - assert cov.classifiable_days == 4 # every day clears PR-F's classifiable floor - assert cov.valid_days == 3 - # holding the ridge leg to the VALLEY floor (20) would keep only the 25 / 30 days - assert cov.valid_days_at_valley_floor == 2 - assert cov.days_below_ridge_gate == 1 - assert cov.days_below_valley_gate == 0 - assert cov.ridge_mean == pytest.approx((4 + 12 + 25 + 30) / 4) - # defaults are the PINNED production floors when the caller does not override - assert cov.min_ridge_bars == VALLEY_RIDGE_MIN_RIDGE_BARS == 10 - assert cov.min_valley_bars == VALLEY_RIDGE_MIN_VALLEY_BARS == 20 - assert f"below_ridge_gate({VALLEY_RIDGE_MIN_RIDGE_BARS})" in cov.render() - - -def test_summarize_ridge_coverage_handles_no_frames(): - cov = summarize_ridge_coverage([]) - assert cov.symbol_days == 0 - assert cov.classifiable_days == 0 - assert cov.valid_days == 0 - assert np.isnan(cov.validity_rate) - assert cov.render() # renders without dividing by zero - - -def test_minute_loader_blocks_when_nothing_cached(tmp_path): - cfg = _min_config(tmp_path / "empty", "2021-07-01", "2021-07-04") - spec = ValleyRidgeVwapRatioFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_valley_ridge_vwap_ratio_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.vrr.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="valley_ridge_vwap_ratio_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 = ValleyRidgeVwapRatioFactor().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 - 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, ValleyRidgeVwapRatioFactor().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 diff --git a/tests/test_eval_volume_peak_count_runner.py b/tests/test_eval_volume_peak_count_runner.py deleted file mode 100644 index 7a7e8de..0000000 --- a/tests/test_eval_volume_peak_count_runner.py +++ /dev/null @@ -1,258 +0,0 @@ -"""PR-F 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 VolumePeakCountFactor -from qt.config import ( - AlphaCfg, - BacktestCfg, - CacheCfg, - CostCfg, - DataCfg, - FactorCfg, - OutputCfg, - PortfolioCfg, - RootConfig, - UniverseCfg, -) -from qt.eval_volume_peak_count import ( - _load_volume_peak_count_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 with two clean peaks (the -# 200-volume minutes at 09:32 and 09:35, both with mild neighbours). -_DAYS = ("2021-07-01", "2021-07-02", "2021-07-03") -_TEST_DAY = "2021-07-04" -_BG_VOLS = [100.0] * 6 -_PEAK_VOLS = [100.0, 200.0, 100.0, 100.0, 200.0, 100.0] - -# Small gates so a 4-day cache produces a value (baseline needs >= 2 prior obs). -_LOAD_KW = dict( - lookback_days=20, baseline_days=20, baseline_min_obs=2, - sigma_k=1.0, min_valid_days=1, min_classifiable=1, -) - - -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 = VolumePeakCountFactor().spec - logger = logging.getLogger("test.vpc.loader") - load = _load_volume_peak_count_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 two-peak count (=2) on both covered symbols - d = pd.Timestamp(_TEST_DAY) - assert load.factor.loc[(d, "AAA.SZ")] == pytest.approx(2.0) - assert load.factor.loc[(d, "BBB.SZ")] == pytest.approx(2.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 = VolumePeakCountFactor().spec - with pytest.raises(ValueError, match="no requested symbol produced"): - _load_volume_peak_count_panel( - cfg, ["ZZZ.SZ"], spec, logging.getLogger("test.vpc.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="volume_peak_count_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 = VolumePeakCountFactor().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, VolumePeakCountFactor().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 From a91f5b9a0d677c4ea82c197694e628c05dc370de Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 09:44:29 -0700 Subject: [PATCH 2/6] feat(factors): verify the frozen inputs the reconciliation harness reads Three read sites opened frozen artifacts with no integrity check at all, while the directory holding them is demonstrably writable -- one file in it was overwritten on 2026-07-25. "The harness reconciled against whatever was on disk" was never hypothetical, so C5's four legs went green against inputs nothing had authenticated. (They are intact today: verify passes 15/15 panels and 14/14 cells, so no C5 conclusion is in question. Both facts are true and neither alone is the whole story.) A/B -- the two frozen-panel reads. `verified_frozen_panel_path` is now THE only gateway: `frozen_panel_path` is called nowhere else in the module, enforced by AST, because checking "is this read verified?" per site makes it a per-site question and the site that forgets is the one that will not announce itself. The anchors leg's tolerance for an absent panel became `required=False` rather than a second bare call, which is what collapsed the two callers into one. Per-panel, not a whole-tree sweep: the harness runs per factor, and verifying fifteen panels to read one (3.0s vs 0.2s) makes the check something people switch off, and a check that is disabled because it is slow protects nothing. The jump fork is followed off the PATH, not re-derived from the factor id: both trees hold a jump_amount_corr_20.parquet and they are DIFFERENT definitions (v1.0 untruncated, v1.1 truncated), so re-deriving would eventually drift from what frozen_panel_path actually picked. Written in a code comment, not only in the review thread. C-1 -- the hand-anchor record. It was the one file under refactor_baseline/ that no manifest covered and the one that actually got overwritten. Its sha256 now lives in git (docs/factors/d5_hand_anchor_record_manifest.json) while the record stays gitignored -- the same split as everywhere else, so an overwrite cannot move its own expectation without appearing in git diff. Pinned rather than regenerated on purpose: the C5 anchors leg closed 11/11 against THESE bytes, and the stronger provenance form needs a rerun, which would overwrite them. That would leave a C5 leg verified against a record that no longer exists. The manifest states the record's inventory AND the inventory is checked, not merely written down -- because the pinned record is INCOMPLETE: daily_engine_compared is 0 rows and cannot be refilled. Pinning an incomplete record is fine; letting a later reader take "pinned" for "complete" is not. A test asserts the manifest says so, including that the D2 conclusion itself survives elsewhere ("88 hand anchors, 0 mismatches", PR #89). The inventory check also catches what a sha alone cannot: a rerun that drops rows and has its sha refreshed to match. D-1 -- the write side. hand_anchor_rows now refuses to overwrite an existing record without --allow-overwrite, naming the keys this run would drop, computed by DIFFERENCE against what it produces so a future key needs nobody to remember a list. The check runs BEFORE the ~7 minutes of hand computation: a refusal that arrives at the end of a long run teaches people to pass the override by reflex. PRODUCED_RECORD_KEYS is asserted against the actual payload at write time, so the set the guard reasons about cannot drift from what is written. D-3 (carrying the old daily_engine_compared forward) stays rejected and is recorded as rejected: it would splice a comparison made against older engine values onto a freshly recomputed frozen14 -- internally inconsistent, worse than honestly missing. Writing down the tempting option that lost is more useful than only writing down the one that won. Two things found while building this, both fixed: - the CLI shim's `except RecordOverwriteRefused` did not catch. Under `python -m qt.hand_anchors_d2` the module is loaded twice, as __main__ and as qt.hand_anchors_d2, so the class raised and the class caught were different objects and the guard surfaced as a traceback with its handler right there. - the first mutation aimed at A/B had NO teeth: reverting the read site to the bare path broke nothing, because no test pinned the wiring -- only the routing. That is what prompted the single-gateway restructure; the property is now checkable and the mutation is red. 25/25 mutations have teeth. C-2 (record the panels_d2 hashes inside the record) and D-4 (move it out of a directory documented as un-regenerable, which it is not) are registered in the catalogue for a follow-up that must also re-verify the anchors leg. They are one piece of work, and this PR's job is deletion. --- .../d5_hand_anchor_record_manifest.json | 23 ++ .../factors/d5_runner_difference_catalogue.md | 57 ++- qt/factor_eval_reconcile.py | 57 ++- qt/hand_anchor_manifest.py | 132 +++++++ qt/hand_anchor_rows.py | 14 +- qt/hand_anchors_d2.py | 100 +++++- qt/panel_freeze.py | 62 ++++ tests/test_frozen_input_verification.py | 333 ++++++++++++++++++ 8 files changed, 769 insertions(+), 9 deletions(-) create mode 100644 docs/factors/d5_hand_anchor_record_manifest.json create mode 100644 qt/hand_anchor_manifest.py create mode 100644 tests/test_frozen_input_verification.py diff --git a/docs/factors/d5_hand_anchor_record_manifest.json b/docs/factors/d5_hand_anchor_record_manifest.json new file mode 100644 index 0000000..27f581c --- /dev/null +++ b/docs/factors/d5_hand_anchor_record_manifest.json @@ -0,0 +1,23 @@ +{ + "schema": "d5-hand-anchor-record/1", + "path": "artifacts/refactor_baseline/hand_anchors_d2.json", + "sha256": "6e779a3685c7e1edda237bd9a5297aa6601d634b9a27c3c4dd1b0d903f67b6f4", + "size_bytes": 23525, + "why_pinned": "The C5 anchors leg closed 11/11 against THESE bytes. The record carries no provenance of its own (it records no panels_d2 hash and no git SHA), and it lives in a directory documented as never-overwrite while being, unlike the panels, still regenerable -- qt.hand_anchor_rows rewrites it wholesale, which is how the engine comparison was lost on 2026-07-25. Pinning the sha in git is what makes a further overwrite loud on the READ side, and makes changing the expectation a reviewed action visible in git diff.", + "contents": { + "frozen14_rows": 70, + "daily_pending_engine_rows": 20, + "daily_engine_compared_rows": 0, + "all_ok_frozen14": false, + "top_level_keys": [ + "all_ok_frozen14", + "daily_pending_engine", + "elapsed_seconds", + "frozen14", + "seed", + "tolerance" + ] + }, + "what_this_record_does_NOT_contain": "daily_engine_compared is EMPTY (0 rows) and all_ok_daily is absent. The four ops-rewritten daily factors are selected and hand-computed but NOT compared: the companion that compared them was retired in D5 C6, so those 20 rows stay pending and cannot be completed from this tree. Pinning an INCOMPLETE record is fine; reading 'it is pinned' as 'it is complete' is not. The D2 hand-anchor conclusion itself is not lost -- '88 hand anchors, 0 mismatches' is on file in PR #89 and docs/progress/07_factor_layer_refactor.md. What was lost is the ability to re-derive it.", + "all_ok_frozen14_is_false_on_purpose": "all_ok_frozen14 is false: 5 jump_amount_corr_20 rows mismatch because the hand side was truncated to 14:50 by PR #103 while the frozen panels_d2 panel is the pre-truncation one, held unchanged by convention. See docs/factors/pr_c_cutoff_fix_reference_panel.md section 6-4." +} diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index bbeb8a9..f4c688d 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -937,9 +937,7 @@ anchors 的结论不受影响,**只重跑 reports 腿**。 manifest 行、**15/15 通过**,`panel_reconcile --verify` 14/14 `max_rel=0.0` ⇒ **面板此刻完好,C5 的结论不受影响**。 -**处置**:修在**下一个 PR(删除 PR)**——那个 PR 的主题正是「让 frozen-forever 名副其实」, -把 `qt.panel_freeze.verify_frozen_panels` 接进 C5 harness 的**三个**读入点正属于它。 -**本 PR 不动 C5 harness。** +**处置:三处已在删除 PR(C6 part 2)接上,见下 §六之四。** ### (3之二) `hand_anchor_rows` 的 payload 只写 pending、不写 compared —— 已知后果,本 PR 不改行为 @@ -967,3 +965,56 @@ C6 退役的三个工具对 11 个旧 runner 的依赖**不是一个量级**: **裁定维持**(owner 2026-07-28 的裁定是「退役重生成能力、基线 frozen-forever」,那是**目的**, 不是从「依赖 11 个 runner」推出来的推论;依赖强弱不改变裁定)。但如实记下: **第三条退役真的放弃了一个未必注定失效的能力**,这一点已单独提给 owner。 + + +## 六之四、C6 删除 PR 的收口(2026-07-31) + +### 已做:三个裸读点全部接上校验 + +| 读入点 | 接了什么 | 失败形态 | +|---|---|---| +| `run_panels_mode` | `verified_frozen_panel_path()` → 按 **git 里的**哈希核该面板 | `ReconciliationError`,点名因子与两个哈希 | +| `run_anchors_mode` | 同上(warmup 网格那次读) | 同上 | +| `load_anchor_rows` | `verify_anchor_record()` → 核 `hand_anchors_d2.json` 的 **sha + 内容清单** | `ReconciliationError` | + +**按因子校验,不是整棵扫**:harness 本来就按因子跑,为读 1 个面板而验 15 个(3.0s vs 0.2s), +是把校验变成大家会去关掉的东西;**一个因为太慢而被关掉的校验等于没有**。 +**jump 的分叉写在代码注释里**:两棵树都有 `jump_amount_corr_20.parquet` 而它们是**两个不同定义** +(D1 是 v1.0 未截断、pr_c 是 v1.1 已截断),`frozen_panel_path()` 把 jump 路由到 pr_c, +所以校验**按路径**跟着同一个分叉走(不按 factor_id 重新推导,两者因此不会漂)。 + +### 已做:`hand_anchors_d2.json` 用 git 里的 manifest 钉住(方案 C-1) + +新 `docs/factors/d5_hand_anchor_record_manifest.json`(**入 git**,记录本身仍 gitignored)。 +**为什么是 C-1 而不是更强的 C-2**:C5 的 anchors 腿刚刚以 **11/11** 收口,比的正是盘上这份字节; +C-2 需要重跑,**而重跑就会覆盖它** ⇒ C5 的一条腿会变成「对着一份已不存在的记录验过」。 +**所以先钉住 C5 实际跑过的那份**。 + +⚠️ **manifest 明写这份被钉住的记录「有什么、没有什么」,并且清单是被程序核对的、不只是散文**: +`daily_engine_compared` **为 0 行**、`all_ok_daily` 缺席。**钉住一份残缺记录可以,把它说成完整的不行。** +(D2 手算锚的**结论**没丢——「88 行分层手算锚 0 失配」在 PR #89 与 `docs/progress/07` 在案; +丢的是**重新验一次**的能力。)另记:`all_ok_frozen14` 为 false 是**预期**——5 行 jump 失配源于 +手算侧已按 PR #103 截断而冻结的 panels_d2 按约定保持未截断,见 +`pr_c_cutoff_fix_reference_panel.md` §六之四。 + +### 已做:`hand_anchor_rows` 覆盖需显式表态(方案 D-1) + +目标已存在 → **拒绝**,除非 `--allow-overwrite`(或 `--selection` 写别处)。拒绝信息按**差集** +算出「这次跑不会重新产生的键」,所以将来新增键**不靠谁记得**去维护清单;并说明 +`daily_engine_compared` 无法重建、以及覆盖后必须同 commit 更新 manifest(否则读侧会拒新字节)。 +**检查放在 ~7 分钟计算之前**,因为在长跑结束时才说"拒绝",教出来的是反射性加 `--allow-overwrite`; +`PRODUCED_RECORD_KEYS` 在写出时被断言等于实际 payload 键集,所以它不会与现实漂移。 + +**D-3(把旧 `daily_engine_compared` carry forward)已否决,禁翻烧饼**:它会把**对着旧引擎值算出的** +比较结果拼进**刚重算的** `frozen14` ⇒ **一份内部不自洽的记录,比诚实的缺失更糟**。 +记在案是因为**它很诱人**——把否决过的诱人选项写进案卷,比只写选中的那个有用。 + +### 未做,留后续 PR(C-2 + D-4,是同一件事) + +1. **C-2 provenance**:让 `hand_anchor_rows` 把它读过的 `panels_d2` canonical hash 写进记录, + 读侧核「锚是对着现在要比的这批面板算的」——**把记录绑到它所描述的东西**,而不只是「自钉住以来没变」。 +2. **D-4 搬家**:把该记录移出 `artifacts/refactor_baseline/`。它**可再生**(`hand_anchor_rows` 至今可跑), + 而那个目录写着「无法再生、绝不覆盖」——**一条对 1/113 文件为假的规则**,正是本仓一直在猎的那类缺陷。 + +两件必须一起做,且那个 PR **要连 anchors 腿一起重验**(C-2 要求重跑,重跑即覆盖 C5 验过的字节)。 +本 PR 主职是删除(已 −9,188 行),再叠文件搬迁 + 路径改 + 文档改,是在最不该出错的 PR 里加出错面。 diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index 8c8b1a5..2258559 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -203,6 +203,12 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells FACTORS as REPORT_FACTOR_NAMES, FrozenExecBaseline, ) +from qt.hand_anchor_manifest import ( + DEFAULT_MANIFEST as ANCHOR_RECORD_MANIFEST, + AnchorRecordMismatch, + verify_anchor_record, +) +from qt.panel_freeze import FrozenPanelMismatch, verify_frozen_panel_file # --------------------------------------------------------------------------- # # Constants (each pre-registered in the handoff §3 / catalogue §七/§七之二) @@ -1392,6 +1398,39 @@ def frozen_panel_path(factor_id: str, repo_root: Path) -> Path: return Path(repo_root) / FROZEN_PANELS_DIR / f"{factor_id}.parquet" +def verified_frozen_panel_path( + factor_id: str, repo_root: Path, *, required: bool = True +) -> Path | None: + """``frozen_panel_path`` + the git-tracked hash check, as THE only gateway. + + Every read of a frozen panel in this module goes through here, so "did we + check this one?" cannot differ between read sites. Until D5 C6 nothing + checked them at all: the harness read the parquet directly, and the frozen + directory is demonstrably writable (one file in it was overwritten on + 2026-07-25), so a drifted baseline would have reconciled silently. + + The mismatch is re-raised as ``ReconciliationError`` because that is what the + CLI's error handling knows how to print; a bare RuntimeError would surface + as a traceback. + + THE ONLY GATEWAY: ``frozen_panel_path`` must not be called anywhere else in + this module, and a test enforces that by AST. Otherwise "did we verify this + read?" becomes a per-site question, and the site that forgets is exactly the + one that will not announce itself. + """ + path = frozen_panel_path(factor_id, repo_root) + if not path.exists() and not required: + # The anchors leg tolerates an absent panel (only bounded factors have a + # warmup grid to read). Absence is not drift; a missing file that WAS + # required is caught inside the verifier. + return None + try: + verify_frozen_panel_file(path, factor_id, repo_root=repo_root) + except FrozenPanelMismatch as exc: + raise ReconciliationError(str(exc)) from exc + return path + + # --------------------------------------------------------------------------- # # anchors mode: the service path vs the hand-computed anchor rows # --------------------------------------------------------------------------- # @@ -1463,10 +1502,20 @@ def classify_anchor_row( def load_anchor_rows(factor_id: str, repo_root: Path) -> list[dict]: - """The factor's hand-anchor rows (placeholder rows carry no date/symbol).""" + """The factor's hand-anchor rows (placeholder rows carry no date/symbol). + + The record is verified against its git-tracked manifest BEFORE it is parsed. + It is the one file under ``artifacts/refactor_baseline/`` that no manifest + covered, and also the one that actually got overwritten (2026-07-25), so + "the anchors leg read whatever was on disk" was not hypothetical. + """ path = Path(repo_root) / ANCHORS_JSON if not path.exists(): raise ReconciliationError(f"hand anchors not found: {path}") + try: + verify_anchor_record(path, Path(repo_root) / ANCHOR_RECORD_MANIFEST) + except AnchorRecordMismatch as exc: + raise ReconciliationError(str(exc)) from exc payload = json.loads(path.read_text(encoding="utf-8")) return [ row @@ -1539,7 +1588,7 @@ def run_panels_mode(config_path: str, factor_id: str, repo_root: Path) -> PanelD f"cache-only violated: stk_mins_live_calls={live_calls}. ABORT." ) factor = factor_registry.build(factor_id) - frozen = pd.read_parquet(frozen_panel_path(factor_id, repo_root)) + frozen = pd.read_parquet(verified_frozen_panel_path(factor_id, repo_root)) from factors.materialize import stores_intermediate is_cross = stores_intermediate(factor) @@ -1704,8 +1753,8 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho if is_minute and not pooled: # Bounded factors: the same warmup boundary as the panels leg — the # frozen grid's first (lookback_depth - 1) trading dates. - panel_path = frozen_panel_path(factor_id, repo_root) - if panel_path.exists(): + panel_path = verified_frozen_panel_path(factor_id, repo_root, required=False) + if panel_path is not None: grid = pd.Index( pd.unique(pd.to_datetime(pd.read_parquet(panel_path)["date"])) ).sort_values() diff --git a/qt/hand_anchor_manifest.py b/qt/hand_anchor_manifest.py new file mode 100644 index 0000000..59da0c1 --- /dev/null +++ b/qt/hand_anchor_manifest.py @@ -0,0 +1,132 @@ +"""Integrity check for the D2 hand-anchor record (``hand_anchors_d2.json``). + +The record is the anchors leg's input: ``factor_eval_reconcile.load_anchor_rows`` +reads its ``frozen14`` rows and compares service values against the hand-computed +ones. Until D5 C6 nothing checked it at all, and it is the ONE file in +``artifacts/refactor_baseline/`` that no manifest covered -- while also being the +one file in there that demonstrably got overwritten (2026-07-25, by a +``qt.hand_anchor_rows`` rerun, which is how the engine comparison was lost). + +Why a plain sha256 rather than the panels' canonical-content hash: the panels are +parquet, where writer metadata makes byte equality too strict to be the +authority. This is JSON written by one writer; its bytes ARE canonical, exactly +as for the frozen exec artifacts. + +Where the expectation lives +--------------------------- +``docs/factors/d5_hand_anchor_record_manifest.json`` -- in git, while the record +itself is gitignored. Same split, same reason, as everywhere else here: whoever +overwrites the record cannot also move the expectation without it appearing in +``git diff``. + +The manifest also states the record's INVENTORY, and the inventory is checked, +not merely written down. That matters because the pinned record is incomplete: +``daily_engine_compared`` is empty and cannot be refilled (the companion was +retired in C6). Pinning an incomplete record is fine. Letting a later reader take +"it is pinned" for "it is complete" is not, so the shortfall is a field in the +manifest and an assertion here rather than a sentence someone might not read. + +A stronger form exists and is deliberately NOT built yet: have +``hand_anchor_rows`` record the ``panels_d2`` canonical hashes it computed +against, so the record is tied to the panels it describes rather than merely +being unchanged. That needs a rerun, and a rerun would overwrite the very bytes +the C5 anchors leg was verified against. It belongs with the follow-up that +re-verifies that leg (design note: C-2/D-4). +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path + +#: Git-tracked expectation for the record. +DEFAULT_MANIFEST = "docs/factors/d5_hand_anchor_record_manifest.json" +#: Manifest schema tag; bumping it invalidates every reader, intentionally. +MANIFEST_SCHEMA = "d5-hand-anchor-record/1" +#: Inventory fields the manifest states and this module re-derives and compares. +INVENTORY_FIELDS = ( + "frozen14_rows", + "daily_pending_engine_rows", + "daily_engine_compared_rows", + "all_ok_frozen14", +) + + +class AnchorRecordMismatch(RuntimeError): + """The hand-anchor record is not the one the git-tracked manifest describes.""" + + +@dataclass(frozen=True) +class AnchorRecordCheck: + """What was verified (no secrets; counts and a hash only).""" + + path: Path + sha256: str + inventory: dict + + +def record_inventory(payload: dict) -> dict: + """The inventory the manifest states, re-derived from the record itself.""" + return { + "frozen14_rows": len(payload.get("frozen14", []) or []), + "daily_pending_engine_rows": len(payload.get("daily_pending_engine", []) or []), + "daily_engine_compared_rows": len(payload.get("daily_engine_compared", []) or []), + "all_ok_frozen14": payload.get("all_ok_frozen14"), + } + + +def verify_anchor_record( + record_path: Path | str, + manifest_path: Path | str, +) -> AnchorRecordCheck: + """Verify the record's bytes AND its inventory against the git-tracked manifest. + + Raises :class:`AnchorRecordMismatch` on any disagreement. Both halves matter: + the sha catches an edit, and the inventory catches a manifest that was + updated to match a new file without anyone noticing what changed inside it + (in particular, a ``daily_engine_compared`` that silently went to zero). + """ + record_path = Path(record_path) + manifest_path = Path(manifest_path) + if not manifest_path.exists(): + raise AnchorRecordMismatch( + f"no hand-anchor manifest at {manifest_path}; the record cannot be " + "verified, and an unverified anchors input is how a drifted record " + "reconciles silently." + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("schema") != MANIFEST_SCHEMA: + raise AnchorRecordMismatch( + f"manifest schema {manifest.get('schema')!r} != {MANIFEST_SCHEMA!r}" + ) + if not record_path.exists(): + raise AnchorRecordMismatch(f"hand-anchor record not found: {record_path}") + + payload_bytes = record_path.read_bytes() + actual = hashlib.sha256(payload_bytes).hexdigest() + expected = str(manifest.get("sha256", "")) + if actual != expected: + raise AnchorRecordMismatch( + f"{record_path.name}: sha256 {actual[:16]} != the git-tracked " + f"{expected[:16]}. The record changed since it was pinned. If a " + "`qt.hand_anchor_rows` rerun was intended, update " + f"{manifest_path.name} in the same commit so the change is reviewed " + "-- and note that a rerun does NOT restore daily_engine_compared." + ) + + inventory = record_inventory(json.loads(payload_bytes.decode("utf-8"))) + stated = manifest.get("contents", {}) + for field in INVENTORY_FIELDS: + if field not in stated: + raise AnchorRecordMismatch( + f"{manifest_path.name} states no {field!r}; the inventory is what " + "keeps 'pinned' from being read as 'complete'." + ) + if stated[field] != inventory[field]: + raise AnchorRecordMismatch( + f"{record_path.name}: {field} is {inventory[field]!r} but " + f"{manifest_path.name} states {stated[field]!r}." + ) + return AnchorRecordCheck(path=record_path, sha256=actual, inventory=inventory) diff --git a/qt/hand_anchor_rows.py b/qt/hand_anchor_rows.py index 725cb04..0af20fc 100644 --- a/qt/hand_anchor_rows.py +++ b/qt/hand_anchor_rows.py @@ -151,7 +151,12 @@ def provider(sym: str) -> pd.Series | None: return provider -def run_hand_anchors(out_path: Path) -> int: +def run_hand_anchors(out_path: Path, *, allow_overwrite: bool = False) -> int: + # BEFORE the work, not after it: this run takes minutes, and a refusal that + # arrives at the end teaches people to pass --allow-overwrite by reflex. + H.check_overwrite_allowed( + out_path, H.PRODUCED_RECORD_KEYS, allow_overwrite=allow_overwrite + ) started = time.monotonic() rng = np.random.RandomState(SEED) results: list[dict] = [] @@ -454,6 +459,13 @@ def compare(factor_id, cls, key, hand, note=""): "daily_pending_engine": daily_rows, "all_ok_frozen14": all(r["ok"] for r in results), } + if frozenset(payload) != H.PRODUCED_RECORD_KEYS: + raise RuntimeError( + "payload keys drifted from PRODUCED_RECORD_KEYS " + f"({sorted(frozenset(payload) ^ H.PRODUCED_RECORD_KEYS)}); the " + "overwrite guard reasons about that set BEFORE this point, so a new " + "key must be declared there or it is protected by nothing." + ) out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") n_bad = sum(1 for r in results if not r["ok"]) print(f"hand anchors (frozen 14): {len(results)} rows, {n_bad} mismatches -> {out_path}") diff --git a/qt/hand_anchors_d2.py b/qt/hand_anchors_d2.py index 65c2246..e839389 100644 --- a/qt/hand_anchors_d2.py +++ b/qt/hand_anchors_d2.py @@ -47,6 +47,7 @@ class — (a) the warm-up END (the symbol's first finite value; verified by hand from __future__ import annotations import argparse +import json import sys from pathlib import Path @@ -77,6 +78,82 @@ class — (a) the warm-up END (the symbol's first finite value; verified by hand ) +#: The keys ``qt.hand_anchor_rows`` writes. Declared here so the overwrite +#: refusal can run BEFORE the ~7 minutes of hand computation instead of after +#: it -- being told "refused" at the end of a long run is how people learn to +#: pass the override reflexively. The writer asserts its payload equals this +#: set, so the constant cannot drift from what is actually produced. +PRODUCED_RECORD_KEYS = frozenset({ + "seed", + "tolerance", + "elapsed_seconds", + "frozen14", + "daily_pending_engine", + "all_ok_frozen14", +}) + + +class RecordOverwriteRefused(RuntimeError): + """Refusing to overwrite an existing hand-anchor record without intent.""" + + +def check_overwrite_allowed( + out_path: Path, produced_keys: frozenset[str], *, allow_overwrite: bool +) -> None: + """Refuse to clobber an existing record unless the caller says so. + + This tool rewrites the record WHOLESALE, and the keys it produces are not + all the keys the file can hold. On 2026-07-25 that is exactly how the + engine-side comparison was destroyed: a rerun replaced a file containing + ``daily_engine_compared`` with one that does not, silently, and the + companion that could refill it has since been retired. + + So the refusal is not "you may not write" -- the tool must be able to do its + job -- it is "say so, and here is what this run will not put back". The keys + at risk are computed by DIFFERENCE against what this run actually produces, + not from a hard-coded list, so a future key gets the same protection without + anyone remembering to add it. + + Deliberately NOT a merge: carrying the old ``daily_engine_compared`` forward + onto a freshly recomputed ``frozen14`` would splice a comparison made against + older engine values into a record describing new ones -- internally + inconsistent, which is worse than honestly missing (lead ruling, D-3 + rejected). + """ + if allow_overwrite or not out_path.exists(): + return + try: + existing = json.loads(out_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + existing = {} + at_risk = sorted(k for k in existing if k not in produced_keys) + if at_risk: + detail = ( + f"it holds {at_risk}, which this run does NOT produce and would " + "therefore drop" + ) + if "daily_engine_compared" in at_risk: + detail += ( + " -- and `daily_engine_compared` CANNOT be rebuilt, because the " + "companion that produced it was retired in D5 C6" + ) + else: + detail = ( + "it holds no key this run would drop, but it is still a full rewrite " + "of a file the anchors reconciliation leg reads. (Its " + "`daily_engine_compared` is ALREADY empty and this run will not " + "restore it; that companion was retired in D5 C6.)" + ) + raise RecordOverwriteRefused( + f"refusing to overwrite the existing hand-anchor record at {out_path}: " + f"{detail}. Re-run with --allow-overwrite if that is intended, or pass " + "--selection to write elsewhere; after an intended " + "overwrite, update docs/factors/d5_hand_anchor_record_manifest.json in " + "the same commit, or the anchors reconciliation leg will refuse the new " + "bytes." + ) + + def pending_engine_line(n_pending: int) -> str: """The run-summary line about daily rows awaiting an engine comparison.""" return f"daily rows pending engine comparison: {n_pending} — {ENGINE_COMPARISON_POINTER}" @@ -720,11 +797,32 @@ def assert_no_engine_imports() -> None: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--selection", default=str(OUT_JSON)) + parser.add_argument( + "--allow-overwrite", + action="store_true", + help="overwrite an existing record (it is rewritten wholesale; keys " + "this run does not produce are lost and daily_engine_compared cannot " + "be rebuilt)", + ) args = parser.parse_args(argv) assert_no_engine_imports() from qt.hand_anchor_rows import run_hand_anchors # heavy driver, engine-free - return run_hand_anchors(Path(args.selection)) + # Catch the CANONICAL class, not this module's. Under `python -m + # qt.hand_anchors_d2` this file is executed as ``__main__`` AND imported + # again as ``qt.hand_anchors_d2`` (hand_anchor_rows imports it), so there are + # two distinct class objects with the same name and a bare + # ``except RecordOverwriteRefused`` here catches the wrong one -- the guard + # then surfaced as a raw traceback despite the handler being right there. + from qt.hand_anchors_d2 import RecordOverwriteRefused as _Refused + + try: + return run_hand_anchors( + Path(args.selection), allow_overwrite=args.allow_overwrite + ) + except _Refused as exc: # CLI-003: readable, not a traceback + print(f"ERROR: {exc}", file=sys.stderr) + return 1 if __name__ == "__main__": # pragma: no cover - thin CLI shim diff --git a/qt/panel_freeze.py b/qt/panel_freeze.py index 88ed60f..9ec71c4 100644 --- a/qt/panel_freeze.py +++ b/qt/panel_freeze.py @@ -636,6 +636,68 @@ def _verify_one_panel( ) +class FrozenPanelMismatch(RuntimeError): + """A frozen panel on disk is not the one the git-tracked manifest describes.""" + + +def doc_for_frozen_panel(path: Path | str, repo_root: Path | str | None = None) -> Path: + """Which git-tracked manifest holds the expectation for THIS panel file. + + Two frozen trees carry a panel named ``jump_amount_corr_20.parquet`` and they + are DIFFERENT factor definitions: the D1 baseline holds v1.0 (no 14:50 + truncation) and ``pr_c_cutoff_fix/`` holds v1.1 (truncated). The reconciler's + ``frozen_panel_path()`` routes jump to the PR-C tree, so verification has to + follow the same fork or it would check a panel against the other panel's + hash and fail for the wrong reason. The routing decision is read off the + PATH rather than re-derived from the factor id, so the two cannot drift. + """ + path = Path(path) + root = Path(repo_root) if repo_root is not None else repo_root_for(path.parent) + in_pr_c = PR_C_SUBDIR in path.parts + return root / (PR_C_MANIFEST_DOC if in_pr_c else D1_MANIFEST_DOC) + + +def verify_frozen_panel_file( + path: Path | str, + factor_id: str, + *, + repo_root: Path | str | None = None, + doc_path: Path | str | None = None, +) -> str: + """Check ONE frozen panel against the hash authored in git; return that hash. + + Raises :class:`FrozenPanelMismatch` on any disagreement — a caller cannot + accidentally ignore the result the way it could ignore a returned list. + + ``doc_path`` overrides which manifest supplies the expectation; it exists so + the tests can build a small frozen tree with its own document instead of + being forced through the fourteen real factors. + + Deliberately per-panel rather than a whole-tree sweep. The reconciliation + harness runs per factor, and making it verify fifteen panels to read one + turns the check into something people switch off; a check that is disabled + because it is slow protects nothing. + """ + path = Path(path) + if not path.exists(): + raise FrozenPanelMismatch(f"frozen panel missing from disk: {path}") + doc = Path(doc_path) if doc_path is not None else doc_for_frozen_panel(path, repo_root) + expected = parse_doc_manifest(doc).get(factor_id) + if expected is None: + raise FrozenPanelMismatch( + f"{doc.name} carries no expectation for {factor_id!r}; refusing to " + "reconcile against an unverified panel." + ) + actual = canonical_content_hash(read_frozen_panel(path, factor_id)) + if actual != expected.canonical_sha256: + raise FrozenPanelMismatch( + f"{path.name}: canonical content hash {actual} != the git-tracked " + f"{expected.canonical_sha256} (from {doc.name}). These are NOT the " + "frozen bytes; a reconciliation against them would be meaningless." + ) + return actual + + def verify_all( baseline_root: Path | str = DEFAULT_OUTPUT_ROOT, *, diff --git a/tests/test_frozen_input_verification.py b/tests/test_frozen_input_verification.py new file mode 100644 index 0000000..9c59a3b --- /dev/null +++ b/tests/test_frozen_input_verification.py @@ -0,0 +1,333 @@ +"""The C5 harness's frozen inputs are now checked before they are believed. + +Three read sites used to open frozen artifacts with no integrity check at all, +while the directory holding them is demonstrably writable — one file in it was +overwritten on 2026-07-25. So "the harness reconciled against whatever was on +disk" was never hypothetical, and these tests pin the checks that close it: + +* ``verify_frozen_panel_file`` — one panel against the hash authored in git, + following the same jump -> PR-C fork the reconciler's path helper takes; +* ``verify_anchor_record`` — the hand-anchor record's bytes AND its inventory; +* ``check_overwrite_allowed`` — the write side, refusing to clobber that record + without intent. + +Every conviction test starts from a state that verifies GREEN, so a red result +is attributable to the tampering rather than to a check that is red on +everything. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from qt.hand_anchor_manifest import ( + MANIFEST_SCHEMA, + AnchorRecordMismatch, + record_inventory, + verify_anchor_record, +) +from qt.hand_anchors_d2 import ( + PRODUCED_RECORD_KEYS, + RecordOverwriteRefused, + check_overwrite_allowed, +) +from qt.panel_freeze import ( + D1_MANIFEST_DOC, + PR_C_MANIFEST_DOC, + PR_C_SUBDIR, + FrozenPanelMismatch, + canonical_content_hash, + doc_for_frozen_panel, + verify_frozen_panel_file, +) +from tests.fixtures.frozen_baseline import build_frozen_tree, make_panel, rewrite_panel + +REPO = Path(__file__).resolve().parents[1] + + +# --------------------------------------------------------------------------- # +# A/B — per-panel verification at the harness read sites +# --------------------------------------------------------------------------- # +def test_a_frozen_panel_that_matches_git_verifies(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + panel_path = tmp_path / "panels" / "alpha_20.parquet" + expected = canonical_content_hash(make_panel("alpha_20")) + assert verify_frozen_panel_file(panel_path, "alpha_20", doc_path=doc) == expected + + +def test_a_drifted_panel_is_refused_at_the_read_site(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + panel_path = tmp_path / "panels" / "alpha_20.parquet" + assert verify_frozen_panel_file(panel_path, "alpha_20", doc_path=doc) # green + + panel = make_panel("alpha_20") + panel.iloc[0] = float(panel.iloc[0]) + 1.0 + rewrite_panel(tmp_path, "alpha_20", panel) + with pytest.raises(FrozenPanelMismatch, match="canonical content hash"): + verify_frozen_panel_file(panel_path, "alpha_20", doc_path=doc) + + +def test_a_missing_panel_is_refused_rather_than_skipped(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + (tmp_path / "panels" / "alpha_20.parquet").unlink() + with pytest.raises(FrozenPanelMismatch, match="missing from disk"): + verify_frozen_panel_file( + tmp_path / "panels" / "alpha_20.parquet", "alpha_20", doc_path=doc + ) + + +def test_a_factor_absent_from_the_document_is_refused(tmp_path: Path): + """No expectation in git means nothing to verify against — which must be a + refusal, not a pass.""" + doc = build_frozen_tree(tmp_path) + from qt.panel_freeze import atomic_write_parquet + + atomic_write_parquet(make_panel("stranger_20"), tmp_path / "panels" / "stranger_20.parquet") + with pytest.raises(FrozenPanelMismatch, match="no expectation"): + verify_frozen_panel_file( + tmp_path / "panels" / "stranger_20.parquet", "stranger_20", doc_path=doc + ) + + +def test_the_jump_fork_is_read_off_the_path_not_the_factor_id(): + """Both trees hold a ``jump_amount_corr_20.parquet`` and they are DIFFERENT + factor definitions (v1.0 untruncated vs v1.1 truncated). Verification has to + follow the same fork the reconciler's path helper takes, or it checks a panel + against the other panel's hash.""" + d1 = doc_for_frozen_panel(REPO / "artifacts/refactor_baseline/panels/x.parquet", REPO) + prc = doc_for_frozen_panel( + REPO / f"artifacts/refactor_baseline/{PR_C_SUBDIR}/panels/x.parquet", REPO + ) + assert d1 == REPO / D1_MANIFEST_DOC + assert prc == REPO / PR_C_MANIFEST_DOC + assert d1 != prc + + +def test_the_reconciler_routes_jump_the_same_way_verification_does(): + """Coupling: if frozen_panel_path ever stops routing jump to the PR-C tree, + or verification stops following it, the pair must not drift silently.""" + from qt.factor_eval_reconcile import frozen_panel_path + + jump = frozen_panel_path("jump_amount_corr_20", REPO) + other = frozen_panel_path("volume_peak_count_20", REPO) + assert doc_for_frozen_panel(jump, REPO) == REPO / PR_C_MANIFEST_DOC + assert doc_for_frozen_panel(other, REPO) == REPO / D1_MANIFEST_DOC + + +# --------------------------------------------------------------------------- # +# C-1 — the hand-anchor record +# --------------------------------------------------------------------------- # +def _record(tmp_path: Path, **overrides) -> tuple[Path, Path]: + payload = { + "seed": 1, + "tolerance": 1e-12, + "frozen14": [{"factor_id": "a", "ok": True}], + "daily_pending_engine": [{"factor_id": "momentum_20"}], + "all_ok_frozen14": True, + } + payload.update(overrides) + record = tmp_path / "hand_anchors_d2.json" + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + import hashlib + + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema": MANIFEST_SCHEMA, + "sha256": hashlib.sha256(record.read_bytes()).hexdigest(), + "contents": record_inventory(payload), + } + ), + encoding="utf-8", + ) + return record, manifest + + +def test_an_untouched_record_verifies(tmp_path: Path): + record, manifest = _record(tmp_path) + check = verify_anchor_record(record, manifest) + assert check.inventory["frozen14_rows"] == 1 + assert check.inventory["daily_engine_compared_rows"] == 0 + + +def test_an_edited_record_is_refused(tmp_path: Path): + record, manifest = _record(tmp_path) + assert verify_anchor_record(record, manifest) # green control + payload = json.loads(record.read_text()) + payload["frozen14"][0]["ok"] = False # flip a verdict + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + with pytest.raises(AnchorRecordMismatch, match="sha256"): + verify_anchor_record(record, manifest) + + +def test_a_manifest_updated_without_noticing_the_contents_change_is_refused(tmp_path: Path): + """The sha alone cannot see this: someone reruns the tool, updates the sha to + match, and the record silently loses rows. The inventory is what catches it, + which is why it is asserted rather than merely written down.""" + record, manifest = _record(tmp_path, daily_engine_compared=[{"ok": True}]) + assert verify_anchor_record(record, manifest) # green, 1 compared row + + payload = json.loads(record.read_text()) + payload["daily_engine_compared"] = [] # the 2026-07-25 shape + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + import hashlib + + stale = json.loads(manifest.read_text()) + stale["sha256"] = hashlib.sha256(record.read_bytes()).hexdigest() # sha "fixed" + manifest.write_text(json.dumps(stale), encoding="utf-8") + + with pytest.raises(AnchorRecordMismatch, match="daily_engine_compared_rows"): + verify_anchor_record(record, manifest) + + +def test_a_missing_manifest_is_refused_not_skipped(tmp_path: Path): + record, _ = _record(tmp_path) + with pytest.raises(AnchorRecordMismatch, match="cannot be verified"): + verify_anchor_record(record, tmp_path / "nope.json") + + +def test_the_real_record_matches_its_committed_manifest(): + """Coupling to reality: the pinned sha must be the bytes actually on disk, or + every anchors run fails.""" + record = REPO / "artifacts/refactor_baseline/hand_anchors_d2.json" + manifest = REPO / "docs/factors/d5_hand_anchor_record_manifest.json" + if not record.exists(): # gitignored bulk tree + pytest.skip("frozen baseline not present in this checkout") + check = verify_anchor_record(record, manifest) + assert check.inventory["frozen14_rows"] == 70 + assert check.inventory["daily_pending_engine_rows"] == 20 + + +def test_the_committed_manifest_states_the_record_is_incomplete(): + """Pinning an incomplete record is fine; letting a later reader take "pinned" + for "complete" is not. The shortfall must be stated in the manifest.""" + manifest = json.loads( + (REPO / "docs/factors/d5_hand_anchor_record_manifest.json").read_text(encoding="utf-8") + ) + assert manifest["contents"]["daily_engine_compared_rows"] == 0 + prose = manifest["what_this_record_does_NOT_contain"] + assert "daily_engine_compared" in prose and "EMPTY" in prose + assert "cannot be completed" in prose + # and it must say the D2 conclusion itself survived + assert "88 hand anchors" in prose + + +# --------------------------------------------------------------------------- # +# D-1 — the write side +# --------------------------------------------------------------------------- # +def test_writing_a_fresh_record_is_not_refused(tmp_path: Path): + """The tool must still be able to do its job.""" + check_overwrite_allowed( + tmp_path / "absent.json", PRODUCED_RECORD_KEYS, allow_overwrite=False + ) + + +def test_overwriting_an_existing_record_is_refused_by_default(tmp_path: Path): + target = tmp_path / "rec.json" + target.write_text(json.dumps({k: 1 for k in PRODUCED_RECORD_KEYS}), encoding="utf-8") + with pytest.raises(RecordOverwriteRefused, match="refusing to overwrite"): + check_overwrite_allowed(target, PRODUCED_RECORD_KEYS, allow_overwrite=False) + + +def test_the_refusal_names_the_keys_this_run_would_drop(tmp_path: Path): + """Computed by DIFFERENCE against what the run produces, so a future key is + protected without anyone remembering to add it to a list.""" + target = tmp_path / "rec.json" + payload = {k: 1 for k in PRODUCED_RECORD_KEYS} + payload["daily_engine_compared"] = [{"ok": True}] + payload["some_future_key"] = 1 + target.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(RecordOverwriteRefused) as caught: + check_overwrite_allowed(target, PRODUCED_RECORD_KEYS, allow_overwrite=False) + message = str(caught.value) + assert "daily_engine_compared" in message and "some_future_key" in message + assert "CANNOT be rebuilt" in message + assert "d5_hand_anchor_record_manifest.json" in message # what to do after + + +def test_explicit_intent_is_honoured(tmp_path: Path): + target = tmp_path / "rec.json" + target.write_text(json.dumps({"daily_engine_compared": []}), encoding="utf-8") + check_overwrite_allowed(target, PRODUCED_RECORD_KEYS, allow_overwrite=True) + + +def test_an_unreadable_existing_record_still_refuses(tmp_path: Path): + """Corrupt JSON must not be read as "nothing at risk, go ahead".""" + target = tmp_path / "rec.json" + target.write_text("{not json", encoding="utf-8") + with pytest.raises(RecordOverwriteRefused): + check_overwrite_allowed(target, PRODUCED_RECORD_KEYS, allow_overwrite=False) + + +def test_the_declared_key_set_is_what_the_writer_actually_produces(): + """The guard reasons about PRODUCED_RECORD_KEYS before the work runs, so the + writer asserts its payload equals that set. Pin both halves here.""" + source = (REPO / "qt" / "hand_anchor_rows.py").read_text(encoding="utf-8") + assert "PRODUCED_RECORD_KEYS" in source + assert "payload keys drifted" in source + assert PRODUCED_RECORD_KEYS == frozenset( + {"seed", "tolerance", "elapsed_seconds", "frozen14", + "daily_pending_engine", "all_ok_frozen14"} + ) + + +def test_the_verified_path_is_the_only_gateway_to_a_frozen_panel(): + """AST, not grep: every call to ``frozen_panel_path`` in the reconciler must + sit inside ``verified_frozen_panel_path``. + + This is the property that makes A/B real. Checking "the read site calls + something verified" per site turns "did we verify this read?" into a + per-site question, and the site that forgets is precisely the one that will + not announce itself. With one gateway there is nothing to forget. + + Range: it proves no OTHER site resolves a frozen panel path, not that the + verifier's own body is correct — the tests above cover that. + """ + import ast + + source = (REPO / "qt" / "factor_eval_reconcile.py").read_text(encoding="utf-8") + tree = ast.parse(source) + gateway = "verified_frozen_panel_path" + + def enclosing(node: ast.AST) -> str | None: + for func in ast.walk(tree): + if isinstance(func, ast.FunctionDef) and any( + inner is node for inner in ast.walk(func) + ): + return func.name + return None + + offenders = [ + enclosing(call) + for call in ast.walk(tree) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "frozen_panel_path" + and enclosing(call) != gateway + ] + assert not offenders, ( + "these resolve a frozen panel path without the verifying gateway: " + f"{offenders}" + ) + # anti-vacuity: the gateway itself must actually make the call + assert f"def {gateway}" in source and "frozen_panel_path(factor_id, repo_root)" in source + + +def test_both_frozen_panel_reads_go_through_the_gateway(): + """Companion to the above: the two read sites must still READ something, so + the gateway is not merely unused.""" + import ast + + source = (REPO / "qt" / "factor_eval_reconcile.py").read_text(encoding="utf-8") + tree = ast.parse(source) + for name in ("run_panels_mode", "run_anchors_mode"): + func = next( + n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == name + ) + body = ast.get_source_segment(source, func) or "" + assert "read_parquet" in body, f"{name} no longer reads a panel" + assert "verified_frozen_panel_path" in body, f"{name} bypasses the gateway" From 69872022beab7365309e6c86773a88a3854f024a Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 09:57:06 -0700 Subject: [PATCH 3/6] chore: make the ignore rule match what it was always meant to mean `artifacts/` -- with a trailing slash -- matches only a directory, so it never matched the `artifacts` SYMLINK the git worktrees carry (pointing at the main checkout). That left it untracked-but-addable, and a `git add -A` in this PR duly committed a machine-specific absolute-path symlink plus two generated phase0 outputs. This belongs in this PR rather than a later one: the PR exists to make "frozen bytes stay out of git" true in fact, and the root cause was that the rule was never correctly expressed. Two lines remove the whole class. The symlink was caught by test_frozen_bytes_are_untracked_while_the_manifest_is_tracked, which guards the split every tamper check here rests on -- if the bytes and their hashes both lived in git, one commit could move them together and the checks would be decorative. It fired at exactly the right moment. `artifacts_local_phase0/` needs its own line: `artifacts` does not match it, and NO test guards it, so the identical accident there would have been silent. The guard is deliberately not extended to cover it -- it is not load-bearing for the frozen design, and widening a guard's range inside a deletion PR is how range creeps. Evidence, before and after, with the change asserted to have landed (.gitignore sha e1f17c45 -> 9791dd9b): before: git add -A --dry-run -> artifacts artifacts_local_phase0/logs/run_phase0.log artifacts_local_phase0/reports/phase0_summary.md after : git add -A --dry-run -> .gitignore (this change only) git check-ignore -v -> .gitignore:21:artifacts artifacts .gitignore:24:artifacts_local_phase0/ artifacts_local_phase0 No tracked path contains "artifacts", so the broader pattern cannot mask anything already in the repository. --- .gitignore | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1d4c3f6..ce3160d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,18 @@ venv/ .env # Data artifacts (quant: keep large data out of git) -artifacts/ +# +# NO TRAILING SLASH on purpose. `artifacts/` matches only a DIRECTORY, so it did +# not match the `artifacts` SYMLINK the git worktrees carry (pointing at the main +# checkout), leaving it untracked-but-addable -- and a `git add -A` duly committed +# a machine-specific absolute-path symlink. Caught by +# test_frozen_bytes_are_untracked_while_the_manifest_is_tracked, which guards the +# split this repo's tamper checks rest on: if the frozen bytes and their hashes +# both lived in git, one commit could move them together. +artifacts +# Local phase0 output. Named separately because `artifacts` does not match it, +# and NO test guards it -- the same accident here would be silent. +artifacts_local_phase0/ *.parquet *.feather *.h5 From 239ca91799a38117496fe3d2f52b268759c6d65b Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 10:21:48 -0700 Subject: [PATCH 4/6] test(factors): pin that the anchors read site CALLS its check, not just that it works Review MEDIUM-1. `load_anchor_rows`'s call to `verify_anchor_record` was pinned by nothing: the reviewer replaced it with `pass` and the whole suite stayed green -- 2661 passed, rc=0, identical to the unmutated tree. This is my own failure form, surviving in the third read site after I fixed it in the other two. The first A/B mutation was empty for exactly this reason: six tests exercised the verifier thoroughly, and none said "the read site must call it". Fixing the panels side by collapsing to a single gateway did nothing for the anchors side, because a lesson applied only where it was learned is not applied. Behavioural, not AST, and the reason is the failure it has to catch: an AST assertion proves the call is written down. A call with the wrong argument, or one whose exception is swallowed, satisfies AST and stops nothing. So the test builds a miniature repo_root, tampers the record AFTER its manifest is written (bytes drift, expectation does not -- the real shape), and demands a refusal. A second mutation proves the distinction is not theoretical: swallowing the exception while keeping the call is now red too, and AST would have passed it. A green control comes first -- an intact record must return its rows -- because "it raised" means nothing from a function that raises at everything. The reviewer made the same point about its own R5: the control must be ACCEPTED first or every subsequent REFUSED is empty. Also pinned: the check runs BEFORE parsing. An unparseable tampered record must fail as an integrity mismatch, not as a JSON error that never names the real problem. Review LOW-1. `top_level_keys` was stated in the manifest and never compared -- INVENTORY_FIELDS held four count-shaped fields. The reviewer added `all_ok_daily: true`, refreshed the sha, and it was ACCEPTED, while the manifest's most prominent prose says precisely that key is absent. So "pinned is not complete" was resting on prose, and prose stops nothing. Counts cannot see a key appear or vanish; the key SET can. It is now compared like every other inventory field. Note this makes the catalogue's existing claim -- that the inventory is machine-checked rather than prose -- true of all five fields, where it had been true of four. Mutations: 28/28 with teeth, three new. #23 is the reviewer's own, reproduced verbatim. NIT-1 (the FAMILY regex not matching a parenthetical) is left alone as ruled: pre-existing, byte-identical to main, and no actual exposure -- both members of that family are cited by name elsewhere and remain covered by the exists-and-is- unique check. --- qt/hand_anchor_manifest.py | 7 ++ tests/test_frozen_input_verification.py | 115 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/qt/hand_anchor_manifest.py b/qt/hand_anchor_manifest.py index 59da0c1..8a2d3aa 100644 --- a/qt/hand_anchor_manifest.py +++ b/qt/hand_anchor_manifest.py @@ -51,6 +51,12 @@ "daily_pending_engine_rows", "daily_engine_compared_rows", "all_ok_frozen14", + # ``top_level_keys`` was STATED in the manifest but never compared, so a + # record could grow a key -- ``all_ok_daily``, say -- and be accepted with a + # refreshed sha, while the manifest's most prominent prose says that key is + # ABSENT. "Pinned is not complete" was resting on prose, and prose stops + # nothing. Counts alone cannot see a key appear or vanish; the key SET can. + "top_level_keys", ) @@ -74,6 +80,7 @@ def record_inventory(payload: dict) -> dict: "daily_pending_engine_rows": len(payload.get("daily_pending_engine", []) or []), "daily_engine_compared_rows": len(payload.get("daily_engine_compared", []) or []), "all_ok_frozen14": payload.get("all_ok_frozen14"), + "top_level_keys": sorted(payload), } diff --git a/tests/test_frozen_input_verification.py b/tests/test_frozen_input_verification.py index 9c59a3b..65adc83 100644 --- a/tests/test_frozen_input_verification.py +++ b/tests/test_frozen_input_verification.py @@ -331,3 +331,118 @@ def test_both_frozen_panel_reads_go_through_the_gateway(): body = ast.get_source_segment(source, func) or "" assert "read_parquet" in body, f"{name} no longer reads a panel" assert "verified_frozen_panel_path" in body, f"{name} bypasses the gateway" + + +# --------------------------------------------------------------------------- # +# The anchors read site: that the check is WIRED, not merely that it works +# --------------------------------------------------------------------------- # +def _anchor_repo(tmp_path: Path, *, tamper: bool) -> Path: + """A miniature repo_root holding the record and its git-tracked manifest. + + Built through the production inventory helper, so the manifest states what + the record actually contains — then optionally tampered AFTER the manifest + is written, which is the real-world shape (bytes drift, expectation does + not). + """ + import hashlib + + from qt.factor_eval_reconcile import ANCHORS_JSON + from qt.hand_anchor_manifest import DEFAULT_MANIFEST + + payload = { + "seed": 1, + "tolerance": 1e-12, + "frozen14": [ + {"factor_id": "alpha_20", "date": "2024-01-02", "symbol": "000001.SZ", + "hand": 1.0, "engine": 1.0, "rel_diff": 0.0, "ok": True}, + ], + "daily_pending_engine": [], + "all_ok_frozen14": True, + } + record = tmp_path / ANCHORS_JSON + record.parent.mkdir(parents=True, exist_ok=True) + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + manifest = tmp_path / DEFAULT_MANIFEST + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps( + { + "schema": MANIFEST_SCHEMA, + "sha256": hashlib.sha256(record.read_bytes()).hexdigest(), + "contents": record_inventory(payload), + } + ), + encoding="utf-8", + ) + if tamper: + payload["frozen14"][0]["ok"] = False # flip a verdict after pinning + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return tmp_path + + +def test_the_anchors_read_site_returns_rows_when_the_record_is_intact(tmp_path: Path): + """GREEN CONTROL. Without it, the refusal test below could pass simply + because load_anchor_rows raises on everything.""" + from qt.factor_eval_reconcile import load_anchor_rows + + rows = load_anchor_rows("alpha_20", _anchor_repo(tmp_path, tamper=False)) + assert len(rows) == 1 and rows[0]["symbol"] == "000001.SZ" + + +def test_the_anchors_read_site_refuses_a_record_that_disagrees_with_its_manifest( + tmp_path: Path, +): + """BEHAVIOURAL, on purpose — this pins the WIRING, which is what was missing. + + The verifier itself was thoroughly tested while nothing asserted that + ``load_anchor_rows`` calls it: replacing that call with ``pass`` left the + whole suite green. That is the identical shape as the first A/B mutation + (function pinned, wiring not), surviving in the third read site after the + other two were fixed. + + An AST assertion would only show the call is written down. A call with the + wrong argument, or one whose exception is swallowed, passes AST and fails + here — so the load-bearing test is the one that actually feeds the function + a bad record and demands a refusal. + """ + from qt.factor_eval_reconcile import ReconciliationError, load_anchor_rows + + with pytest.raises(ReconciliationError, match="sha256"): + load_anchor_rows("alpha_20", _anchor_repo(tmp_path, tamper=True)) + + +def test_the_anchors_read_site_refuses_before_it_parses(tmp_path: Path): + """Order matters: a corrupt record must be refused for the RIGHT reason. + If parsing came first, a tampered-and-unparseable record would surface as a + JSON error and the integrity failure would never be named.""" + from qt.factor_eval_reconcile import ANCHORS_JSON, ReconciliationError, load_anchor_rows + + repo = _anchor_repo(tmp_path, tamper=False) + (repo / ANCHORS_JSON).write_text("{ not json at all", encoding="utf-8") + with pytest.raises(ReconciliationError, match="sha256"): # not a JSONDecodeError + load_anchor_rows("alpha_20", repo) + + +def test_a_new_key_cannot_appear_without_the_manifest_noticing(tmp_path: Path): + """LOW-1: counts alone cannot see a key APPEAR. + + The reviewer added ``all_ok_daily: true`` to the record, refreshed the sha, + and it was accepted — while the manifest's most prominent prose says exactly + that key is absent. The key SET is now compared, so "pinned is not complete" + no longer rests on prose. + """ + import hashlib + + record, manifest = _record(tmp_path) + assert verify_anchor_record(record, manifest) # green control + + payload = json.loads(record.read_text()) + payload["all_ok_daily"] = True # the key the manifest says is ABSENT + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + refreshed = json.loads(manifest.read_text()) + refreshed["sha256"] = hashlib.sha256(record.read_bytes()).hexdigest() + manifest.write_text(json.dumps(refreshed), encoding="utf-8") + + with pytest.raises(AnchorRecordMismatch, match="top_level_keys"): + verify_anchor_record(record, manifest) From 0d911641a3cb4804944ccfc8fea7051e9662f9ec Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 10:31:31 -0700 Subject: [PATCH 5/6] docs(factors): narrow the anchor-record guard's claim to what it actually blocks Review finding, and the same defect class this repository keeps catching: a guard whose DECLARED range is wider than what it blocks. Precedent for the fix is `_complete_grid_bars`, which states its exposure per caller rather than asserting a general property. The function docstring promised the inventory catches "a manifest updated to match a new file without anyone noticing what changed inside it (in particular, a daily_engine_compared that silently went to zero)". From the CURRENTLY PINNED record that case cannot occur: the loss already happened, what is pinned is the state after it, and the field is already at zero. Measured at the real pinned shape (70 / 20 / 0 / False) rather than reasoned: untouched -> ACCEPTED (control) rerun, sha NOT refreshed -> REFUSED (the sha catches it) rerun, sha honestly refreshed -> ACCEPTED <- inventory contributes nothing daily_engine_compared 0 -> 20 -> REFUSED <- inventory does bite here A plain rerun keeps every count and the key set identical -- same seed, same stratified selection -- while the values move. So on that path the protection is the human reading the git diff, not this check. Adding top_level_keys did not change this: a rerun does not move the key set either. Both halves are now in the docstring, because either alone misleads: "the inventory is checked" invites more confidence than it earns on the rerun path, and "the inventory is useless" is false in the other direction. Two tests measure the range instead of restating it -- one asserting the LIMITATION (rerun + refreshed sha is accepted) and one the residual teeth (a shape change is refused). The limitation test is deliberately shaped so that strengthening the check later makes it fail: that is the signal to update the RANGE section in the same commit, so the claim cannot drift wider than the code again. A third test pins the docstring itself, whitespace-normalised -- these sentences wrap, and a raw substring match would be testing line breaks rather than the claim (the same wrapped-string trap already hit once in this suite). Mutations 30/30, two new: restoring the unreachable promise, and stating the limitation without saying what covers it instead. Both turn the docstring test red. --- qt/hand_anchor_manifest.py | 39 +++++++++++-- tests/test_frozen_input_verification.py | 75 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/qt/hand_anchor_manifest.py b/qt/hand_anchor_manifest.py index 8a2d3aa..92b55d2 100644 --- a/qt/hand_anchor_manifest.py +++ b/qt/hand_anchor_manifest.py @@ -26,6 +26,30 @@ "it is pinned" for "it is complete" is not, so the shortfall is a field in the manifest and an assertion here rather than a sentence someone might not read. +RANGE OF THE INVENTORY CHECK -- read this before relying on it +-------------------------------------------------------------- +Measured from the CURRENT pinned record (70 / 20 / 0 / False), not reasoned: + +* a plain ``qt.hand_anchor_rows`` rerun changes NO inventory field. Same seed, + same stratified selection, so the row counts and the key set come out + identical while the values inside move. Such a rerun is REFUSED while the sha + is stale and ACCEPTED the moment the sha is honestly refreshed -- so on that + path the inventory contributes nothing, and **the protection is the human + reading the git diff, not this check**; +* it does still bite where counts or keys move: a record that GAINS + ``daily_engine_compared`` rows, or grows an ``all_ok_daily`` key, is refused + even with a matching sha. + +The direction that motivated the check -- ``daily_engine_compared`` silently +going to zero, the 2026-07-25 accident -- is no longer reachable FROM HERE: that +loss already happened, and what is pinned is the state after it. The field is +already on the floor. The check would catch that shape on a record that still +had those rows; this record does not. + +Both facts are stated because either alone misleads. "The inventory is checked" +invites more confidence than it earns on the rerun path; "the inventory is +useless" is false in the other direction. + A stronger form exists and is deliberately NOT built yet: have ``hand_anchor_rows`` record the ``panels_d2`` canonical hashes it computed against, so the record is tied to the panels it describes rather than merely @@ -90,10 +114,17 @@ def verify_anchor_record( ) -> AnchorRecordCheck: """Verify the record's bytes AND its inventory against the git-tracked manifest. - Raises :class:`AnchorRecordMismatch` on any disagreement. Both halves matter: - the sha catches an edit, and the inventory catches a manifest that was - updated to match a new file without anyone noticing what changed inside it - (in particular, a ``daily_engine_compared`` that silently went to zero). + Raises :class:`AnchorRecordMismatch` on any disagreement. The two halves catch + different things and neither subsumes the other: + + * the sha catches ANY byte change, including the one the inventory cannot see + -- a rerun that keeps every count identical while the values move; + * the inventory catches a manifest refreshed to match a record whose SHAPE + changed: rows gained or lost, a key appearing or vanishing. + + See the module docstring's RANGE section for what this does NOT cover from + the currently pinned record. Short version: on the rerun-plus-refreshed-sha + path the inventory adds nothing, and the reviewed git diff is the guard. """ record_path = Path(record_path) manifest_path = Path(manifest_path) diff --git a/tests/test_frozen_input_verification.py b/tests/test_frozen_input_verification.py index 65adc83..28c6508 100644 --- a/tests/test_frozen_input_verification.py +++ b/tests/test_frozen_input_verification.py @@ -446,3 +446,78 @@ def test_a_new_key_cannot_appear_without_the_manifest_noticing(tmp_path: Path): with pytest.raises(AnchorRecordMismatch, match="top_level_keys"): verify_anchor_record(record, manifest) + + +def test_the_inventory_adds_nothing_on_the_rerun_path(tmp_path: Path): + """The documented RANGE, measured rather than asserted. + + A plain `hand_anchor_rows` rerun keeps every count and the key set identical + (same seed, same stratified selection) while the values inside move. It is + refused with a stale sha and ACCEPTED once the sha is honestly refreshed -- + so on that path the guard is the reviewed git diff, not this check. + + This test therefore asserts a LIMITATION. If someone later strengthens the + check so this case is refused, this test fails -- and that is the intended + signal to update the module's RANGE section in the same commit, so the + docstring cannot drift wider than what the code does. + """ + import hashlib + + record, manifest = _record(tmp_path) + assert verify_anchor_record(record, manifest) # green control + + payload = json.loads(record.read_text()) + before = record_inventory(payload) + payload["frozen14"][0]["engine"] = 999.0 # a value moves... + payload["frozen14"][0]["ok"] = False + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + assert record_inventory(payload) == before, "premise broken: a count changed" + + with pytest.raises(AnchorRecordMismatch, match="sha256"): + verify_anchor_record(record, manifest) # ...caught only by the sha + + refreshed = json.loads(manifest.read_text()) + refreshed["sha256"] = hashlib.sha256(record.read_bytes()).hexdigest() + manifest.write_text(json.dumps(refreshed), encoding="utf-8") + verify_anchor_record(record, manifest) # ACCEPTED — the documented limit + + +def test_the_inventory_still_bites_where_the_shape_moves(tmp_path: Path): + """The other half of the range: it is limited, not useless. + + Stating only the limitation would be its own overclaim in the opposite + direction. + """ + import hashlib + + record, manifest = _record(tmp_path) + payload = json.loads(record.read_text()) + payload["daily_engine_compared"] = [{"ok": True}, {"ok": True}] # rows gained + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + refreshed = json.loads(manifest.read_text()) + refreshed["sha256"] = hashlib.sha256(record.read_bytes()).hexdigest() + manifest.write_text(json.dumps(refreshed), encoding="utf-8") + + with pytest.raises(AnchorRecordMismatch, match="daily_engine_compared_rows"): + verify_anchor_record(record, manifest) + + +def test_the_range_section_states_where_the_protection_actually_is(): + """The claim and the code move together: the docstring must name the rerun + path as uncovered, and must not still promise the unreachable case.""" + import re + + import qt.hand_anchor_manifest as module + + # Whitespace-normalised: these sentences WRAP in the source, so a raw + # substring match tests line breaks rather than the claim. (Same trap as the + # earlier wrapped-string scan in this suite.) + def flat(text: str) -> str: + return re.sub(r"\s+", " ", text or "") + + doc = flat(module.__doc__) + assert "RANGE OF THE INVENTORY CHECK" in doc + assert "the protection is the human reading the git diff" in doc + assert "no longer reachable FROM HERE" in doc + # the old overclaim must not survive in the function docstring either + assert "silently went to zero" not in flat(module.verify_anchor_record.__doc__) From 5803d70a33cc5d68a0e1a0dcc66ecf7608708662 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 10:39:19 -0700 Subject: [PATCH 6/6] docs(factors): the range statement was too NARROW as well as once too wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 0d91164, prompted by the standing rule that a claim wider OR narrower than the behaviour both count as unfixed. Narrower is a real defect, not pedantry: it invites someone to add a redundant guard for something already covered. The first version said the inventory "bites where counts or keys move". Measured, that understates it: ``all_ok_frozen14`` is a bool verdict flag — neither a row count nor a key — and it IS compared. counts identical, key set identical, all_ok_frozen14 False -> True, sha honestly refreshed -> REFUSED So the range now names all three things the shape comprises: the three counts, the key set, and the verdict flag. And the exemption is restated as what it actually is — an UNCHANGED SHAPE, not the act of rerunning. A rerun today lands in the exemption because same seed gives the same counts and keys and the same inputs give the same flag; a rerun whose flag flipped would be caught. Tests now measure all three directions rather than two: the limitation (unchanged shape plus refreshed sha is accepted), rows gained, and the flag flip — the last with explicit premise assertions that no count and no key moved, so it cannot pass for the wrong reason. The docstring test pins the widened wording, including that it must not read "counts or keys". Mutations 31/31; the new one narrows the claim back and turns the docstring test red. --- qt/hand_anchor_manifest.py | 21 ++++++++------- tests/test_frozen_input_verification.py | 36 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/qt/hand_anchor_manifest.py b/qt/hand_anchor_manifest.py index 92b55d2..50486f6 100644 --- a/qt/hand_anchor_manifest.py +++ b/qt/hand_anchor_manifest.py @@ -30,15 +30,18 @@ -------------------------------------------------------------- Measured from the CURRENT pinned record (70 / 20 / 0 / False), not reasoned: -* a plain ``qt.hand_anchor_rows`` rerun changes NO inventory field. Same seed, - same stratified selection, so the row counts and the key set come out - identical while the values inside move. Such a rerun is REFUSED while the sha - is stale and ACCEPTED the moment the sha is honestly refreshed -- so on that - path the inventory contributes nothing, and **the protection is the human - reading the git diff, not this check**; -* it does still bite where counts or keys move: a record that GAINS - ``daily_engine_compared`` rows, or grows an ``all_ok_daily`` key, is refused - even with a matching sha. +* it bites wherever the recorded SHAPE moves, which is more than row counts: + the three counts, the KEY SET, and the ``all_ok_frozen14`` verdict flag are + all compared. A record that gains ``daily_engine_compared`` rows, grows an + ``all_ok_daily`` key, or flips that flag is refused even with a matching sha; +* it contributes NOTHING when all five stay identical -- which is what a plain + ``qt.hand_anchor_rows`` rerun produces today. Same seed and same stratified + selection give the same counts and key set; the same inputs give the same + flag; only the values inside move. Such a rerun is REFUSED while the sha is + stale and ACCEPTED the moment the sha is honestly refreshed, so on that path + **the protection is the human reading the git diff, not this check**. + (A rerun whose verdict flag DID flip would be caught -- the exemption is the + unchanged shape, not the act of rerunning.) The direction that motivated the check -- ``daily_engine_compared`` silently going to zero, the 2026-07-25 accident -- is no longer reachable FROM HERE: that diff --git a/tests/test_frozen_input_verification.py b/tests/test_frozen_input_verification.py index 28c6508..996a6e2 100644 --- a/tests/test_frozen_input_verification.py +++ b/tests/test_frozen_input_verification.py @@ -519,5 +519,41 @@ def flat(text: str) -> str: assert "RANGE OF THE INVENTORY CHECK" in doc assert "the protection is the human reading the git diff" in doc assert "no longer reachable FROM HERE" in doc + # NOT NARROWER than reality either: the flag is neither a count nor a key, + # and it IS compared. Saying "counts or keys" would understate the guard and + # invite someone to add a redundant one. + assert "the ``all_ok_frozen14`` verdict flag" in doc + assert "more than row counts" in doc # the old overclaim must not survive in the function docstring either assert "silently went to zero" not in flat(module.verify_anchor_record.__doc__) + + +def test_the_inventory_also_bites_when_the_verdict_flag_flips(tmp_path: Path): + """The direction that showed the first RANGE wording was too NARROW. + + ``all_ok_frozen14`` is a bool: neither a row count nor a key. Describing the + guard as catching "counts or keys" understated it — and an understated range + is a defect in its own right, because it invites someone to add a redundant + guard for something already covered. + """ + import hashlib + + record, manifest = _record(tmp_path) + payload = json.loads(record.read_text()) + before = record_inventory(payload) + payload["all_ok_frozen14"] = not payload["all_ok_frozen14"] + record.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + after = record_inventory(payload) + assert after["top_level_keys"] == before["top_level_keys"], "premise: no key moved" + assert all( + after[k] == before[k] + for k in ("frozen14_rows", "daily_pending_engine_rows", "daily_engine_compared_rows") + ), "premise: no count moved" + + refreshed = json.loads(manifest.read_text()) + refreshed["sha256"] = hashlib.sha256(record.read_bytes()).hexdigest() + manifest.write_text(json.dumps(refreshed), encoding="utf-8") + + with pytest.raises(AnchorRecordMismatch, match="all_ok_frozen14"): + verify_anchor_record(record, manifest)