From f035cee55a8464b15bef5cf830f995e1c6a1ca10 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 11:46:09 -0700 Subject: [PATCH 1/6] refactor(qt): source phase0/phase2 factor values from the factor service The daily runners got their factor values from `factor.compute(panel)` in the runner, which is the second factor-sourcing path the refactor exists to retire (design decision 3). phase0 and phase2_baseline now read them from `factors.service` -- the store read-through onto the one materializer engine. Close view x close-to-close basis (the other legal pairing), under which the materializer applies no availability lag: a change of PATH, not of VALUES. Measured on a real phase2 run captured before and after the switch: 16,388 cells, max|diff| 0.0, NaN mask identical, and every headline metric identical at full precision (ic_mean, ic_ir, annual_return, max_drawdown, volatility, sharpe, avg_turnover, cost_drag, per-factor coverage, combo IC). The phase0 anchor (0.9600 / 0.8408) is unchanged. A SYNTHETIC data source is refused a durable store, structurally. The store key is (factor_id, params, code, view) plus a fingerprint over the PIT/schema modules; nothing in it says WHICH DATA produced the value. For demo data that is false and demonstrably so: `demo_feed` builds each price path from `arange(n)` counted off `data.start`, so the same (date, symbol) changes value when the window start changes. A value whose identity the key cannot capture is not storable, so the answer is refusal rather than a wider key -- a synthetic source gets an ephemeral per-run store, and `factor_store_root` raises rather than hand one a persistent root. Cost: a demo run recomputes every time, which is exactly what it did before. Already live, not hypothetical: `config/example.yaml`'s demo universe is spelled with real A-share tickers over real 2024 dates, and `test_provisional_anchor_d2` runs it against `artifacts/`. The demo run and the real phase2 run derive the IDENTICAL key (`7816ca515a5b9a1a__ca4d351df0bf40f6__close.parquet`, measured); they miss each other today only because SSE50 is Shanghai-only while the demo tickers are Shenzhen -- CSI300, which D6b's runners use over 2024, has 000001.SZ. The persistent root is derived from `output.root_dir` rather than configured separately, so a run that redirects its outputs takes its factor store with it (the 16 test call sites of run_phase0 did not, before this). The service answers a (date, symbol) question and returns a row for every cell it was asked about, carrying NaN where the factor has no value (D4c's fill footprint). An index universe's symbol list is the union of historical constituents, so that grid is larger than the loaded panel (measured: +2.2% for CSI300, +4.0% for CSI500). Every extra row is all-NaN so no value changes, but `per_factor[...]["coverage"]` counts rows -- so the result is reindexed back to the panel's own grid and the number of rows that drops is logged (red line #8: a published diagnostic must not move because the values arrived by another route). The phase2 panel is exactly dense, so that class is empty on the real run; the sparse geometry is exercised by unit test instead. `_compute_factor_panel` keeps its signature and body for the runners D6a does not migrate (oos_stability, subset_validation); the migrated pair calls the new `_serve_factor_panel`. Two names rather than one function with an optional store, so "which runners are still on the old path" is a grep and the old path is never a silent default. An earlier draft of this change altered the shared signature under those two runners and the whole suite stayed green -- they need real tushare data, so nothing in the suite executes their call. A guard now parses their call sites and binds them against the live signature. `DailyEvalPanelProvider` moves into the new module and `qt.factor_eval_providers` re-exports it (identity pinned by test): the daily runners need it too, and that module imports `qt.pipeline`, so `qt.pipeline` cannot import back from it. --- qt/factor_eval_providers.py | 45 ++--- qt/factor_source.py | 296 +++++++++++++++++++++++++++++++ qt/phase2_baseline.py | 6 +- qt/pipeline.py | 83 ++++++++- tests/test_factor_source.py | 339 ++++++++++++++++++++++++++++++++++++ 5 files changed, 729 insertions(+), 40 deletions(-) create mode 100644 qt/factor_source.py create mode 100644 tests/test_factor_source.py diff --git a/qt/factor_eval_providers.py b/qt/factor_eval_providers.py index 19ef47b..7e054ff 100644 --- a/qt/factor_eval_providers.py +++ b/qt/factor_eval_providers.py @@ -11,8 +11,9 @@ ``qt.factor_hotpath_smoke`` (single source; the smoke and the probes import it from here now). Zero live calls: ``IntradayParquetStore.read_range`` has no fetch closure, so a missing month is an empty read, never a warm. -* :class:`DailyEvalPanelProvider` — serves the pipeline's loaded daily panel - through the ``DailyPanelProvider`` protocol. +* :class:`DailyEvalPanelProvider` — RE-EXPORTED from ``qt.factor_source`` + (D6a moved it there so the daily runners share the one definition; this + module imports ``qt.pipeline``, so ``qt.pipeline`` cannot import from it). * :func:`build_eval_service` / :class:`EvalServiceBundle` — the one wiring of cache -> universe -> panel -> enrichments -> store + sources, in the SAME call order the legacy eval runners used (``qt/eval_jump_amount_corr.py``). @@ -34,9 +35,9 @@ empty_intraday_bars, normalize_intraday_bars, ) -from data.clean.schema import DATE_LEVEL, SYMBOL_LEVEL from factors.materialize import MaterializeSources from factors.store import FactorValueStore +from qt.factor_source import DailyEvalPanelProvider as _DailyEvalPanelProvider from qt.pipeline import ( _build_cache, _build_universe, @@ -97,38 +98,12 @@ def minute_bars(self, symbols, start, end): return pd.concat(parts).sort_index(kind="mergesort") -class DailyEvalPanelProvider: - """``DailyPanelProvider`` over the pipeline's loaded evaluation panel. - - CLOSE-VIEW, NOT LAGGED — and that is exactly right: the materializer's own - daily path applies ``factors.view_lag.daily_decision_lag`` for the decision - view (the prev-day shift with the field-level ``open`` exception, R18), so - the provider must hand it values dated at their natural close date. A - pre-lagged panel would be shifted TWICE. ``qt.pipeline._load_panel``'s - product (raw bars enriched with tradability flags, front-adjusted in - memory) is precisely such an un-lagged close-view panel, so this provider - is a thin window/symbol slicer over it. - - WINDOW SEMANTICS: the panel covers the configured ``[data.start, data.end]`` - window (the same window the legacy runners loaded). A materializer load - request reaching before the panel's first date is served what exists — - the trailing-trading-day trim then treats the panel's left edge like the - data start (honest under-warm NaN), which reproduces the legacy runners' - warmup geometry rather than silently inventing deeper history. - """ - - def __init__(self, panel: pd.DataFrame) -> None: - self._panel = panel - - def daily_panel(self, symbols, start, end): - if self._panel.empty: - return self._panel - dates = self._panel.index.get_level_values(DATE_LEVEL) - mask = (dates >= pd.Timestamp(start)) & (dates <= pd.Timestamp(end)) - out = self._panel[mask] - keep = [str(s) for s in symbols] - syms = out.index.get_level_values(SYMBOL_LEVEL) - return out[syms.isin(keep)] +#: RE-EXPORT, not a copy (D6a): the daily close-view provider now lives in +#: ``qt.factor_source``, because the daily runners need it too and this module +#: imports ``qt.pipeline`` (so ``qt.pipeline`` cannot import back from here). +#: Its "close-view, NOT lagged" contract is load-bearing on both planes and is +#: stated once, there. ``tests/test_factor_source.py`` pins the identity. +DailyEvalPanelProvider = _DailyEvalPanelProvider @dataclass(frozen=True) diff --git a/qt/factor_source.py b/qt/factor_source.py new file mode 100644 index 0000000..6b8a8b4 --- /dev/null +++ b/qt/factor_source.py @@ -0,0 +1,296 @@ +"""The pipeline's factor-VALUE source: one wiring of the factor service (D6a). + +Every daily runner used to get its factor values from ``factor.compute(panel)`` +directly. That was the second factor-sourcing path the refactor exists to kill +(design decision 3), so the values now come from ``factors.service`` — the store +read-through onto the ONE materializer engine. This module is the qt side of +that wiring for the daily close-view plane: the store policy, the daily panel +provider, and the served-panel assembly. + +VIEW. The daily runners decide at the close of ``d`` and hold from ``d+1``, so +their pairing is ``close`` x ``close_to_close`` — the other of the two legal +pairings (``data.availability_policy.require_legal_pairing`` enforces it at the +service boundary). Under the close view the materializer applies NO availability +lag, which is why routing these runners through the service is a change of PATH +and not of VALUES. + +STORE POLICY (the reason it exists is the reason it is enforced structurally). +A stored factor value is addressed by ``(factor_id, params, code, view)`` plus a +data fingerprint that covers the PIT/schema modules — nothing in that identity +says WHICH DATA produced the value. For a real market source that is sound and +deliberate (design §3.4): the same symbol-date bar is the same fact whoever asks +for it. For a SYNTHETIC source it is false, and demonstrably so: +``data.feed.demo_feed`` builds each price path from ``arange(n)`` counted off +``data.start``, so the demo close for one (date, symbol) CHANGES when the +configured window start changes, while the store key cannot see ``data.start`` +at all. + + A value whose identity the key cannot capture is, by definition, not + storable. + +So the answer here is not a wider key, it is REFUSAL: a synthetic source gets an +ephemeral per-run store and :func:`factor_store_root` raises rather than hand one +a persistent root. This is not a convention the callers must remember — there is +no code path from a synthetic config to a durable artifact. + +What refusing costs: a demo run recomputes its factors every time. That is +exactly what it did before the service existed, so nothing regresses. + +What a provenance dimension on the key WOULD additionally buy — telling two REAL +vintages apart when a cache backfill changes the bytes while the ``data.clean`` +module hashes do not — is a pre-existing D3 property, not something this step +introduces, and is registered as a structural follow-up rather than done here. + +Layering: this module imports the factor layer and the config leaf only; it must +NOT import ``qt.pipeline`` (which imports this), and it carries no feed and no +token. +""" + +from __future__ import annotations + +import logging +import typing +from collections.abc import Iterable, Mapping, Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory + +import pandas as pd + +from data.availability_policy import ReturnBasis, View +from data.clean.schema import DATE_LEVEL, SYMBOL_LEVEL +from factors import service as factor_service +from factors.base import Factor +from factors.materialize import MaterializeSources +from factors.store import FactorValueStore + +#: Sources whose bars are FABRICATED by the process that reads them. Their +#: values are not a function of (symbol, date) alone, so they are not storable +#: under a key that names neither the source nor the window (see module docstring). +SYNTHETIC_SOURCES: frozenset[str] = frozenset({"demo"}) + +#: Sources whose bars are observed market facts: the same (symbol, date) bar is +#: the same fact for every caller, which is what makes a shared store sound. +PERSISTABLE_SOURCES: frozenset[str] = frozenset({"tushare"}) + +#: Directory name of the factor value store under a run's ``output.root_dir``. +FACTOR_STORE_DIRNAME = "factor_store" + + +def known_sources() -> frozenset[str]: + """Every source this module has classified (synthetic + persistable).""" + return SYNTHETIC_SOURCES | PERSISTABLE_SOURCES + + +def config_declared_sources() -> frozenset[str]: + """The sources ``qt.config`` actually accepts (the drift-guard reference). + + Read off the ``DataCfg.source`` Literal rather than restated, so the pair of + sets above can be checked against the config surface instead of trusted. + """ + from qt.config import DataCfg + + return frozenset(typing.get_args(DataCfg.model_fields["source"].annotation)) + + +def is_synthetic_source(source: str) -> bool: + """True iff ``source`` fabricates its bars; readable error if unclassified. + + An unknown source is refused rather than assumed real: guessing would mean + silently granting a durable store to data nobody has reasoned about, which + is precisely the failure this module exists to make impossible (red line #9). + """ + name = str(source) + if name in SYNTHETIC_SOURCES: + return True + if name in PERSISTABLE_SOURCES: + return False + raise ValueError( + f"data.source {name!r} has no factor-store storability classification. " + f"Add it to qt.factor_source.SYNTHETIC_SOURCES (fabricated bars -> " + f"ephemeral store) or PERSISTABLE_SOURCES (observed market facts -> " + f"shared store); known: {sorted(known_sources())}." + ) + + +def factor_store_root(cfg) -> str: + """The PERSISTENT factor-store root for ``cfg`` (raises for a synthetic source). + + Derived from ``output.root_dir`` rather than configured separately: a run + that redirects its outputs (every test does) must take its factor store with + it, and one fewer knob is one fewer thing to point at the wrong tree. + + Asking this of a synthetic-source config is a readable error, not a fallback + — see the module docstring for why such values are not storable at all. + """ + if is_synthetic_source(cfg.data.source): + raise ValueError( + f"data.source={cfg.data.source!r} fabricates its bars, so its factor " + f"values have no persistent store root: the demo close for a (date, " + f"symbol) is built from a counter that starts at data.start, so the " + f"same key would address different values for different windows. Use " + f"open_factor_value_store(cfg), which gives a synthetic run an " + f"ephemeral store." + ) + return str(Path(cfg.output.root_dir) / FACTOR_STORE_DIRNAME) + + +@contextmanager +def open_factor_value_store(cfg, logger: logging.Logger | None = None): + """Yield the factor value store for ``cfg``: shared for real, ephemeral for demo. + + The synthetic branch's store lives in a temporary directory that is removed + on exit, so a demo run leaves no artifact any later run could be served. The + real branch uses :func:`factor_store_root`. Either way the ONE materializer + engine behind the service is the same — what differs is only whether its + output outlives the run. + """ + synthetic = is_synthetic_source(cfg.data.source) + if synthetic: + with TemporaryDirectory(prefix="qt-factor-store-ephemeral-") as tmp: + if logger is not None: + logger.info( + "factor store: EPHEMERAL (data.source=%s fabricates its bars; " + "its values are not addressable by the store key, so they are " + "never persisted). Factors are recomputed every run.", + cfg.data.source, + ) + yield FactorValueStore(tmp) + return + root = factor_store_root(cfg) + if logger is not None: + logger.info("factor store: shared read-through at %s", root) + yield FactorValueStore(root) + + +class DailyEvalPanelProvider: + """``DailyPanelProvider`` over an already-loaded daily panel. + + CLOSE-VIEW, NOT LAGGED — and that is exactly right: the materializer's own + daily path applies ``factors.view_lag.daily_decision_lag`` for the decision + view (the prev-day shift with the field-level ``open`` exception, R18), so + the provider must hand it values dated at their natural close date. A + pre-lagged panel would be shifted TWICE. ``qt.pipeline._load_panel``'s + product (raw bars enriched with tradability flags, front-adjusted in + memory) is precisely such an un-lagged close-view panel, so this provider + is a thin window/symbol slicer over it. + + WINDOW SEMANTICS: the panel covers the configured ``[data.start, data.end]`` + window (the same window the legacy runners loaded). A materializer load + request reaching before the panel's first date is served what exists — + the trailing-trading-day trim then treats the panel's left edge like the + data start (honest under-warm NaN), which reproduces the legacy runners' + warmup geometry rather than silently inventing deeper history. + """ + + def __init__(self, panel: pd.DataFrame) -> None: + self._panel = panel + + def daily_panel(self, symbols, start, end): + if self._panel.empty: + return self._panel + dates = self._panel.index.get_level_values(DATE_LEVEL) + mask = (dates >= pd.Timestamp(start)) & (dates <= pd.Timestamp(end)) + out = self._panel[mask] + keep = [str(s) for s in symbols] + syms = out.index.get_level_values(SYMBOL_LEVEL) + return out[syms.isin(keep)] + + +def decision_points(panel: pd.DataFrame) -> list[factor_service.DecisionPoint]: + """One DecisionPoint per trading day present in ``panel``. + + The within-day cutoff is the ``DecisionPoint`` default; it selects minute + bars and is inert on this close-view daily plane, where no factor reads a + minute endpoint. (``qt.factor_eval_runner._decisions`` is the same reduction + on the evaluation plane; converging them means touching the eval path, which + this step deliberately leaves frozen — D6c.) + """ + dates = pd.DatetimeIndex( + sorted({pd.Timestamp(d).normalize() for d in panel.index.get_level_values(DATE_LEVEL)}) + ) + return [factor_service.DecisionPoint(d) for d in dates] + + +@dataclass(frozen=True) +class ServedFactorValues: + """One service read, reduced to the panel's own grid (+ what that dropped).""" + + frame: pd.DataFrame + served_rows: int + footprint_rows_dropped: int + + +def factor_values( + factors: Sequence[Factor], + panel: pd.DataFrame, + symbols: Iterable[str], + *, + store: FactorValueStore, + params_by_id: Mapping[str, Mapping[str, object]] | None = None, +) -> ServedFactorValues: + """Raw values for ``factors`` over ``panel``'s grid, from the factor service. + + Close view x close-to-close basis (the legal pairing for a runner that + decides at the close of ``d`` and holds from ``d+1``). + + WHY THE RESULT IS REINDEXED TO ``panel.index``. The service answers a + (date, symbol) question and therefore returns a row for every cell it was + ASKED about, carrying an explicit NaN where the factor has no value (D4c's + fill footprint, which is what lets a warm store tell computed-empty from + never-computed). An index universe's symbol list is the union of historical + constituents, so that grid is strictly larger than the loaded panel: measured + on the real panels, 2.2% larger for CSI300 and 4.0% for CSI500. Every one of + those extra rows is all-NaN, so no VALUE changes either way — but a consumer + that counts rows (``per_factor[...]["coverage"]`` is ``notna().mean()``) is + looking at a different denominator, and a published diagnostic must not move + because the values arrived by a different route (red line #8). The count of + rows this drops is returned so the reduction is disclosed rather than + invisible. + + A cell present in the panel but ABSENT from the service's answer is a loud + error: it would mean the request and the panel disagreed about the grid, and + a silently short cross-section is the sample-coverage bias the I5a red line + forbids. + """ + factor_ids = [f.name for f in factors] + served = factor_service.panel( + factor_ids, + symbols, + decision_points(panel), + store=store, + sources=MaterializeSources(daily=DailyEvalPanelProvider(panel)), + view=View.CLOSE, + basis=ReturnBasis.CLOSE_TO_CLOSE, + params_by_id=dict(params_by_id or {}), + ) + unserved = panel.index.difference(served.index) + if len(unserved): + raise ValueError( + f"the factor service returned no row for {len(unserved)} (date, symbol) " + f"cell(s) the market panel carries, e.g. {list(unserved[:3])}. The " + f"request grid and the panel must agree; serving the panel's factor " + f"values short would drop names from a cross-section silently." + ) + return ServedFactorValues( + frame=served.reindex(panel.index), + served_rows=int(len(served)), + footprint_rows_dropped=int(len(served) - len(panel.index)), + ) + + +__all__ = [ + "FACTOR_STORE_DIRNAME", + "PERSISTABLE_SOURCES", + "SYNTHETIC_SOURCES", + "DailyEvalPanelProvider", + "ServedFactorValues", + "config_declared_sources", + "decision_points", + "factor_store_root", + "factor_values", + "is_synthetic_source", + "known_sources", + "open_factor_value_store", +] diff --git a/qt/phase2_baseline.py b/qt/phase2_baseline.py index ca0f040..056dfa3 100644 --- a/qt/phase2_baseline.py +++ b/qt/phase2_baseline.py @@ -35,6 +35,7 @@ from factors.compute.financial import FinancialFactor from portfolio.construct import TopNEqualWeight from qt.config import RootConfig, load_config +from qt.factor_source import open_factor_value_store from qt.pipeline import ( _FrameScores, _alpha_disclosure, @@ -45,7 +46,7 @@ _build_scores, _build_universe, _collect_downgrades, - _compute_factor_panel, + _serve_factor_panel, _factor_analytics, _standard_analytics, _load_panel, @@ -372,7 +373,8 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: panel = _maybe_enrich_listing(cfg, panel, symbols, logger, cache) # P4-1/P4-2: one cache-stats line after every cached endpoint has run. _log_run_cache_stats(cache, logger) - factor_panel = _compute_factor_panel(cfg, panel, factors, logger) + with open_factor_value_store(cfg, logger) as store: + factor_panel = _serve_factor_panel(cfg, panel, factors, symbols, logger, store=store) processed = _process_factors(cfg, factor_panel, panel) alpha = _build_alpha(cfg) score_panel = _build_scores( diff --git a/qt/pipeline.py b/qt/pipeline.py index 98e1c53..9114233 100644 --- a/qt/pipeline.py +++ b/qt/pipeline.py @@ -60,6 +60,7 @@ from factors.process.orchestrate import covariates_from_panel, process_factor_panel from portfolio.construct import TopNEqualWeight from qt.config import RootConfig, load_config +from qt.factor_source import factor_values, open_factor_value_store from qt.reports import write_phase0_summary from runtime.backtest.driver import BacktestDriver from runtime.backtest.sim_execution import SimExecution @@ -440,7 +441,8 @@ def run_phase0(config_path: str) -> Phase0Result: # P4-1/P4-2: one concise cache-stats line after every cached endpoint has run # (market bars + universe/tradability). A warm historical rerun shows all 0s. _log_run_cache_stats(cache, logger) - factor_panel = _compute_factor_panel(cfg, panel, factors, logger) + with open_factor_value_store(cfg, logger) as store: + factor_panel = _serve_factor_panel(cfg, panel, factors, symbols, logger, store=store) processed = _process_factors(cfg, factor_panel, panel) alpha = _build_alpha(cfg) @@ -929,18 +931,93 @@ def _maybe_enrich_listing( return panel -def _compute_factor_panel( +def _factor_params_by_id(cfg: RootConfig, factors: list) -> dict[str, dict]: + """Config params keyed by the BUILT factor's id (what the store key hashes). + + Keyed by the instance name rather than the config name: the registry already + refuses a config name that disagrees with its params (D1's three-point + check), and keying by the object the service is actually asked for leaves no + second spelling to drift. ``strict`` zip guards against this list and + :func:`_build_factors`' enabled list falling out of step. + """ + enabled = [f for f in cfg.factors if f.enabled] + return { + factor.name: dict(spec.params) + for spec, factor in zip(enabled, factors, strict=True) + } + + +def _serve_factor_panel( cfg: RootConfig, panel: pd.DataFrame, factors: list, + symbols: list[str], logger: logging.Logger, + *, + store, ) -> pd.DataFrame: - """Compute every factor as its own column and persist the multi-column panel. + """Get every factor's values from the SERVICE and persist the panel (D6a). One column per enabled factor (P3-1); a single-factor config produces the same one-column frame as before. All columns share the panel's MultiIndex(date, symbol), so downstream processing stays per-date and per-column. + + D6a (factor-refactor): the values come from ``factors.service`` — the store + read-through onto the ONE materializer engine — instead of calling + ``factor.compute(panel)`` here, which was the second factor-sourcing path + the refactor exists to retire (design decision 3). The pairing is the close + view x close-to-close basis these runners already decide on, under which the + materializer applies no availability lag: a change of PATH, not of VALUES. + ``qt.factor_source.factor_values`` owns the wiring and the reduction back to + this panel's own grid; the rows that reduction drops are logged rather than + left invisible. + + This is a SECOND entry point next to :func:`_compute_factor_panel` on + purpose, and only until D6b: the migration moves the runners one group at a + time, so for the length of that migration some runners are on the service + and some are not. Two differently-named functions make "which runners are + still on the old path" a grep instead of a reading exercise, where one + function with an optional ``store`` would have made the old path a silent + default (red line #9). + """ + served = factor_values( + factors, + panel, + symbols, + store=store, + params_by_id=_factor_params_by_id(cfg, factors), + ) + factor_panel = served.frame + _write_factor_panel(cfg, factor_panel) + logger.info( + "factors: %s served by the factor service (%d rows x %d columns; the " + "service answered %d requested (date, symbol) cell(s), %d of them " + "all-NaN footprint row(s) outside the market panel and reduced away)", + [f.name for f in factors], len(factor_panel), factor_panel.shape[1], + served.served_rows, served.footprint_rows_dropped, + ) + return factor_panel + + +def _compute_factor_panel( + cfg: RootConfig, + panel: pd.DataFrame, + factors: list, + logger: logging.Logger, +) -> pd.DataFrame: + """PRE-D6a path: compute every factor here and persist the panel. + + Still the live path for the runners D6a does not migrate + (``qt.oos_stability``, ``qt.subset_validation``, and ``qt.robustness`` + through the former). It is the second factor-sourcing path the refactor is + retiring; D6b moves those runners onto :func:`_serve_factor_panel` and D6d + removes this function. Do NOT add a new caller. + + Kept behaviour-identical rather than adapted: routing those runners through + the service changes what their factor values come from, which needs its own + reconciliation on their universes and windows — it is D6b's work, not a side + effect of migrating phase0/phase2. """ columns = [factor.compute(panel).rename(factor.name) for factor in factors] factor_panel = pd.concat(columns, axis=1) diff --git a/tests/test_factor_source.py b/tests/test_factor_source.py new file mode 100644 index 0000000..54465b9 --- /dev/null +++ b/tests/test_factor_source.py @@ -0,0 +1,339 @@ +"""D6a: the factor-value source wiring (store policy + served-panel assembly). + +Two properties carry this step: + +* a SYNTHETIC data source can never reach a durable factor-store artifact — + structurally, not by convention (``qt/factor_source.py`` explains why such + values are not storable at all); and +* routing a daily runner through the service changes the PATH and not the + VALUES: what the service returns is reduced back to the panel's own grid, and + the rows that reduction drops are counted rather than hidden. +""" + +from __future__ import annotations + +from pathlib import Path + +import pandas as pd +import pytest +import yaml + +from factors.compute.momentum import MomentumFactor +from factors.store import FactorValueStore +from qt import factor_source +from qt.config import load_config +from qt.factor_source import ( + DailyEvalPanelProvider, + config_declared_sources, + factor_store_root, + factor_values, + is_synthetic_source, + known_sources, + open_factor_value_store, +) +from tests.fixtures.panel_factory import make_demo_panel + + +def _cfg(tmp_path: Path, example_config_path: str, *, source: str = "demo"): + """The example config with ``source`` and every output dir under tmp_path.""" + raw = yaml.safe_load(Path(example_config_path).read_text(encoding="utf-8")) + out = tmp_path / "artifacts" + raw["data"]["source"] = source + raw["output"] = { + "root_dir": str(out), + "data_dir": str(out / "data"), + "factor_dir": str(out / "factors"), + "report_dir": str(out / "reports"), + "log_dir": str(out / "logs"), + "overwrite": True, + } + path = tmp_path / f"cfg_{source}.yaml" + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + return load_config(str(path)) + + +# --------------------------------------------------------------------------- # +# storability classification +# --------------------------------------------------------------------------- # +def test_every_config_source_has_a_storability_classification(): + """A new ``data.source`` must declare whether its values are storable. + + The drift guard, not a restatement: the reference side is read off the + ``DataCfg.source`` Literal, so adding a source without classifying it fails + here instead of silently inheriting a durable store. + """ + assert known_sources() == config_declared_sources() + + +def test_the_two_classes_are_disjoint(): + assert not (factor_source.SYNTHETIC_SOURCES & factor_source.PERSISTABLE_SOURCES) + + +def test_demo_is_synthetic_and_tushare_is_not(): + assert is_synthetic_source("demo") is True + assert is_synthetic_source("tushare") is False + + +def test_an_unclassified_source_is_refused_not_assumed_real(): + with pytest.raises(ValueError, match="storability classification"): + is_synthetic_source("some_new_vendor") + + +# --------------------------------------------------------------------------- # +# store root +# --------------------------------------------------------------------------- # +def test_real_store_root_follows_the_run_output_root(tmp_path, example_config_path): + cfg = _cfg(tmp_path, example_config_path, source="tushare") + assert factor_store_root(cfg) == str(tmp_path / "artifacts" / "factor_store") + + +def test_a_synthetic_source_has_no_persistent_store_root(tmp_path, example_config_path): + """Not a fallback: asking is the error, and the message says why. + + The demo close for a (date, symbol) is built from a counter that starts at + ``data.start``, so two windows produce different values under one key. + """ + cfg = _cfg(tmp_path, example_config_path, source="demo") + with pytest.raises(ValueError, match="data.start"): + factor_store_root(cfg) + + +def test_a_synthetic_run_gets_an_ephemeral_store_that_leaves_nothing( + tmp_path, example_config_path +): + cfg = _cfg(tmp_path, example_config_path, source="demo") + with open_factor_value_store(cfg) as store: + seen = _store_root_of(store) + assert seen.exists() + # not under the run's own output tree, so no later run can be served it + assert tmp_path not in seen.parents + assert not seen.exists() + assert not (tmp_path / "artifacts" / "factor_store").exists() + + +def test_a_real_run_gets_the_shared_store_at_the_derived_root( + tmp_path, example_config_path +): + cfg = _cfg(tmp_path, example_config_path, source="tushare") + with open_factor_value_store(cfg) as store: + assert _store_root_of(store) == Path(factor_store_root(cfg)) + + +def _store_root_of(store: FactorValueStore) -> Path: + """The store's on-disk root, via its public ``values_root``.""" + return store.values_root.parent + + +# --------------------------------------------------------------------------- # +# served panel: reindexed to the panel grid, footprint rows counted +# --------------------------------------------------------------------------- # +def _served(panel, symbols, tmp_path): + return factor_values( + [MomentumFactor(window=5)], + panel, + symbols, + store=FactorValueStore(tmp_path / "store"), + params_by_id={"momentum_5": {"window": 5}}, + ) + + +def test_a_dense_panel_drops_no_footprint_rows(tmp_path): + panel = make_demo_panel() + symbols = sorted(set(panel.index.get_level_values("symbol"))) + out = _served(panel, symbols, tmp_path) + + assert out.footprint_rows_dropped == 0 + assert out.frame.index.equals(panel.index) + + +def test_a_sparse_panel_reindexes_back_and_counts_what_that_dropped(tmp_path): + """The index-universe geometry: the service answers over the grid it is asked + about, the panel is smaller, and the reduction is reported.""" + panel = make_demo_panel() + symbols = sorted(set(panel.index.get_level_values("symbol"))) + dates = pd.DatetimeIndex(sorted(set(panel.index.get_level_values("date")))) + late = symbols[-1] + d = panel.index.get_level_values("date") + s = panel.index.get_level_values("symbol") + sparse = panel[~((s == late) & (d < dates[10]))] + + out = _served(sparse, symbols, tmp_path) + + assert out.footprint_rows_dropped == 10 + assert out.served_rows == len(dates) * len(symbols) + assert out.frame.index.equals(sparse.index) + + +def test_the_reindex_preserves_every_value_the_service_served(tmp_path): + """Reindexing is a row selection, never a value change.""" + panel = make_demo_panel() + symbols = sorted(set(panel.index.get_level_values("symbol"))) + dates = pd.DatetimeIndex(sorted(set(panel.index.get_level_values("date")))) + d = panel.index.get_level_values("date") + s = panel.index.get_level_values("symbol") + sparse = panel[~((s == symbols[-1]) & (d < dates[10]))] + + out = _served(sparse, symbols, tmp_path) + full = factor_values( + [MomentumFactor(window=5)], + sparse, + symbols, + store=FactorValueStore(tmp_path / "store2"), + params_by_id={"momentum_5": {"window": 5}}, + ).frame + + common = out.frame.index + pd.testing.assert_frame_equal(out.frame, full.reindex(common)) + + +def test_the_service_values_equal_a_direct_compute_on_the_same_panel(tmp_path): + """The step's whole claim, on the close view: same values, different path.""" + panel = make_demo_panel() + symbols = sorted(set(panel.index.get_level_values("symbol"))) + factor = MomentumFactor(window=5) + + legacy = factor.compute(panel).rename(factor.name).to_frame() + served = _served(panel, symbols, tmp_path).frame + + pd.testing.assert_frame_equal(served, legacy, check_exact=True) + + +def test_a_panel_cell_the_service_cannot_serve_is_loud(tmp_path): + """A short cross-section is the sample bias the I5a red line forbids.""" + panel = make_demo_panel() + symbols = sorted(set(panel.index.get_level_values("symbol"))) + + with pytest.raises(ValueError, match="returned no row for"): + _served(panel, symbols[:-1], tmp_path) # one panel symbol not requested + + +# --------------------------------------------------------------------------- # +# end to end: a demo run persists no factor value, and says so +# --------------------------------------------------------------------------- # +def test_a_demo_run_leaves_no_factor_store_behind(tmp_path, example_config_path): + """The B1 guard at the runner level, not just at the helper. + + ``config/example.yaml``'s demo universe is spelled with REAL A-share tickers + over real 2024 dates, so a persisted demo value would be addressed by a key + a later real run over the same cells would hit. + """ + from qt.pipeline import run_phase0 + + cfg_path = _write_cfg_file(tmp_path, example_config_path, source="demo") + result = run_phase0(str(cfg_path)) + + assert not (tmp_path / "artifacts" / "factor_store").exists() + assert result.factor_path.exists() # the per-run panel artifact still lands + + +def test_a_demo_run_discloses_that_its_store_is_ephemeral(tmp_path, example_config_path): + from qt.pipeline import run_phase0 + + cfg_path = _write_cfg_file(tmp_path, example_config_path, source="demo") + result = run_phase0(str(cfg_path)) + + log = result.log_path.read_text(encoding="utf-8") + assert "factor store: EPHEMERAL" in log + + +def test_the_run_log_discloses_the_footprint_reduction(tmp_path, example_config_path): + """Restoring the panel's grid is allowed to be silent about nothing.""" + from qt.pipeline import run_phase0 + + cfg_path = _write_cfg_file(tmp_path, example_config_path, source="demo") + result = run_phase0(str(cfg_path)) + + log = result.log_path.read_text(encoding="utf-8") + assert "all-NaN footprint row(s) outside the market panel" in log + + +def test_the_persisted_factor_panel_keeps_the_market_panel_grid( + tmp_path, example_config_path +): + from qt.pipeline import run_phase0 + + cfg_path = _write_cfg_file(tmp_path, example_config_path, source="demo") + result = run_phase0(str(cfg_path)) + + stored = pd.read_parquet(result.factor_path) + market = pd.read_parquet(result.data_path) + assert len(stored) == len(market) + + +def _write_cfg_file(tmp_path: Path, example_config_path: str, *, source: str) -> Path: + raw = yaml.safe_load(Path(example_config_path).read_text(encoding="utf-8")) + out = tmp_path / "artifacts" + raw["data"]["source"] = source + raw["data"]["end"] = "2024-06-30" # keep the run small + raw["output"] = { + "root_dir": str(out), + "data_dir": str(out / "data"), + "factor_dir": str(out / "factors"), + "report_dir": str(out / "reports"), + "log_dir": str(out / "logs"), + "overwrite": True, + } + path = tmp_path / "run_cfg.yaml" + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- # +# mid-migration: the runners D6a does not move must still be able to call +# --------------------------------------------------------------------------- # +#: Runners still on the pre-D6a ``_compute_factor_panel`` path. D6b empties this +#: list; D6d deletes the function. It is spelled out here because those runners +#: need real tushare data, so NOTHING in the suite executes their call — the +#: first D6a draft changed the shared signature under them and every test stayed +#: green (the break was found by reading the call sites, not by a failure). +UNMIGRATED_RUNNERS = ("qt/oos_stability.py", "qt/subset_validation.py") + + +@pytest.mark.parametrize("module_path", UNMIGRATED_RUNNERS) +def test_an_unmigrated_runners_call_still_matches_the_legacy_signature(module_path): + """Their call is parsed from source and bound against the LIVE signature.""" + import ast + import inspect + + from qt import pipeline + + repo_root = Path(__file__).resolve().parents[1] + tree = ast.parse((repo_root / module_path).read_text(encoding="utf-8")) + calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_compute_factor_panel" + ] + assert calls, f"{module_path} no longer calls _compute_factor_panel" + + signature = inspect.signature(pipeline._compute_factor_panel) + for call in calls: + signature.bind( + *[""] * len(call.args), + **{kw.arg: "" for kw in call.keywords if kw.arg}, + ) + + +def test_the_legacy_entry_point_still_computes(tmp_path, example_config_path): + """Real coverage for the path those runners are still on.""" + from qt.pipeline import _compute_factor_panel + + cfg = _cfg(tmp_path, example_config_path, source="demo") + panel = make_demo_panel() + factor = MomentumFactor(window=5) + logger = __import__("logging").getLogger("test.legacy_entry") + + out = _compute_factor_panel(cfg, panel, [factor], logger) + + pd.testing.assert_series_equal(out[factor.name], factor.compute(panel).rename(factor.name)) + + +# --------------------------------------------------------------------------- # +# author-once: the eval wiring re-exports this provider, it does not copy it +# --------------------------------------------------------------------------- # +def test_the_eval_provider_is_this_provider(): + from qt import factor_eval_providers + + assert factor_eval_providers.DailyEvalPanelProvider is DailyEvalPanelProvider From c9d5daaf67e0fd2b3336ea3aa704d266f49d32d0 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 11:46:24 -0700 Subject: [PATCH 2/6] docs(factors): register what D6a deliberately deferred Five items D6a found and did not do, each with the reason and the step that owns it. A deferral nobody recorded is indistinguishable from a defect nobody noticed. Four are deferrals: the provenance dimension on the store key (a real-vintage question, pre-existing in D3, orthogonal to routing runners through the service); the `isinstance` enrichment dispatch that red line #5 forbids while the `requires` declaration it should read already exists and `registry.requirements()` has zero production callers; three runners that do not pass the shared cache to three enrichments, so `fina_indicator` / `daily_basic` / `index_member_all` are fetched live every run; and `phase2_real_baseline.yaml` having no cache block. Each is deferred for the same reason: D6a's value is that it is a path switch provable to be a no-op. Any of these would have changed something else at the same time, so a moved number would have had more than one candidate explanation. The fifth is an observation, not a deferral: the phase2 baseline's archived headline numbers no longer reproduce on `main`. Captured before touching anything -- annual_return -10.19% archived vs -9.41% measured, sharpe -0.5703 vs -0.5168, turnover 1.0818 vs 1.0727 -- while the factor IC is unchanged, which points at the neutralization covariates rather than the panel or the factor. The consequence for a reviewer is that the archived numbers are not a valid baseline for a change made today; compare a capture taken on the tree being changed, immediately before changing it. --- docs/factors/d6_deferred_register.md | 127 +++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/factors/d6_deferred_register.md diff --git a/docs/factors/d6_deferred_register.md b/docs/factors/d6_deferred_register.md new file mode 100644 index 0000000..7e1c384 --- /dev/null +++ b/docs/factors/d6_deferred_register.md @@ -0,0 +1,127 @@ +# D6 deferred register + +Things D6a **found and deliberately did not do**, with the reason and the step +that owns them. The point of writing them down is that a deferral nobody +recorded is indistinguishable from a defect nobody noticed. + +D6a's value is that it is a **path switch provable to be a no-op**: the factor +values stop coming from `factor.compute(panel)` in the runner and start coming +from `factors.service`, and the reconciliation shows the values did not move. +Every item below would have changed something else at the same time, which is +exactly what would have destroyed that property — if a number had moved there +would no longer be a single candidate explanation. + +--- + +## D6a-1 — provenance dimension on the store key + +**Status:** deferred, structural follow-up. **Owner:** not yet assigned. + +The store key is `(factor_id, params, code, view)` plus a data fingerprint over +the PIT/schema modules. Nothing in it says *which data* produced the value. + +D6a closed the half of this that is a **correctness** problem — a synthetic +source's values are not a function of `(symbol, date)` at all (see +`qt/factor_source.py`), so they are refused a durable store outright. + +What a provenance dimension would *additionally* buy is telling two **real** +vintages apart: a cache backfill or a tail refresh can change the bytes a +factor was computed from while the `data.clean` module hashes stay put, and the +stored value is then reused as-is. That is a **pre-existing D3 property** +(`factors/service.py::_record_fill_footprint` already records it in its closing +note), not something D6a introduced, and it applies to every consumer of the +store — the evaluation plane included. + +**Why not now:** it is a change to the D3 store's identity model, it invalidates +every stored artifact when it lands, and it is orthogonal to routing the daily +runners through the service. + +--- + +## D6a-2 — the enrichment dispatch still uses `isinstance` + +**Status:** deferred. **Owner:** D6d (or its own PR). + +`qt/pipeline.py::_maybe_enrich_financials` and `::_maybe_enrich_value` decide +which columns to fetch by testing `isinstance(f, FinancialFactor)` / +`isinstance(f, ValueFactor)`. That is the pattern red line #5 forbids +("依赖显式声明,编排通用消费"), and the declaration it should be reading +already exists and is already correct: + +* `FinancialFactor.spec.requires == (PanelField(field, source=FINA_INDICATOR),)` +* `ValueFactor.spec.requires == (PanelField(pe|pb, source=DAILY_BASIC),)` + +and `factors.registry.requirements(names, params)` — whose own docstring calls +itself "the D4 materializer's one-stop shopping list" — has **zero production +callers**. + +**Note this is a pre-existing violation, not one D6a introduced.** Converting the +dispatch needs a source-endpoint → enricher routing (the `requires` name the +*source* field `pe`, while the enricher writes the *derived* column `value_ep`), +so it is a real change with its own behaviour surface, not a rename. + +--- + +## D6a-3 — three runners do not pass the shared cache to three enrichments + +**Status:** deferred, behaviour-preserving-on-purpose. **Owner:** D6b. + +`qt/phase2_baseline.py`, `qt/oos_stability.py` and `qt/subset_validation.py` +call + +``` +_maybe_enrich_financials(cfg, panel, symbols, factors, logger) +_maybe_enrich_value(cfg, panel, symbols, factors, logger) +_maybe_enrich_covariates(cfg, panel, symbols, logger) +``` + +with five positional arguments, so the trailing `cache=None` default applies and +those three endpoints (`fina_indicator`, `daily_basic`, `index_member_all`) are +fetched **live on every run**, bypassing the P4 read-through cache. +`qt.pipeline.run_phase0` passes the cache to all four. + +**Why not now:** passing it *is* a behaviour change (gap-fetch counts and wall +time move, and a cached read can serve a different vintage than a live one), and +D6a's whole claim is that nothing but the factor-sourcing path changed. Fixing it +inside a path switch would mean a moved number with two candidate causes. + +D6b touches these three runners anyway. + +--- + +## D6a-4 — `config/phase2_real_baseline.yaml` has no cache block + +**Status:** noted. **Owner:** D6b or ops. + +The un-cached phase2 config cannot run cache-only; `config/phase2_real_baseline_cached.yaml` +is its cached twin (same universe, same window) and is what D6a's real-run +reconciliation used. + +--- + +## D6a-5 — the phase2 baseline's published headline numbers no longer reproduce + +**Status:** observed, **not** caused by the refactor. **Owner:** whoever next +cites those numbers. + +D6a captured a phase2 run on **unmodified `main`** before switching anything +(that capture is the reconciliation's reference). Its headline metrics do not +match the ones recorded in the progress archive for the same config: + +| metric | archived | measured on `main`, 2026-07-31 | +|---|---|---| +| `ic_mean` | 0.0083 | 0.008275 | +| `annual_return` | −10.19% | −9.41% | +| `max_drawdown` | −16.52% | −16.33% | +| `volatility` | 16.59% | 16.64% | +| `sharpe` | −0.5703 | −0.5168 | +| `avg_turnover` | 1.0818 | 1.0727 | + +The factor IC is unchanged, so the market panel and the factor are not what +moved; the selection is, which points at the neutralization covariates +(PIT SW industry via `index_member_all`, market cap via `daily_basic`) — both +fetched **live** on this path (see D6a-3) and both subject to upstream revision. + +**Consequence for anyone reading a reconciliation:** the archived numbers are not +a valid baseline for a code change made today. Compare a capture you took +yourself, on the tree you are changing, immediately before you change it. From 2f95bd98ecf88920019211a25ed88b68042f238d Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 12:21:12 -0700 Subject: [PATCH 3/6] test(factors): guard that every daily factor value is universe-independent Review LOW-2. The store key is (factor_id, params, code, view) and has no universe dimension -- deliberately, and it cannot have one: a PIT universe over a date range is a time-varying SET, not a value a key dimension could hold (design revision A2). That is sound only while a stored value is universe-independent. The minute plane learned this the hard way and it was measured: `intraday_amp_cut` ran its cross-sectional z-score during materialization, so its stored value WAS a function of the loaded universe while the key said otherwise -- fill with 12 names, read asking for 24, and 24 wrong cells were served with zero recompute and zero error. D4c fixed it by storing the per-symbol intermediate and combining at read-assembly. THE DAILY PLANE NEVER INHERITED THAT FIX, because until D6a no daily factor value was ever stored. D6a is the step that puts them in the shared store, so the property that keeps the daily plane sound becomes load-bearing here. It held before by accident of what happened to be written; from here it holds on purpose. Adding the guard in the same step that makes it load-bearing is new protection, not a behaviour change -- the path switch stays provably no-op. The census is BEHAVIOURAL: compute every registered daily factor over a 5-name universe and a 3-name subset, require the shared symbols bit-identical. A source scan for `groupby(level="date")` / `.rank(` would be both too strict (`groupby(level="symbol").rank()` is a per-symbol time-series rank, a false positive) and too loose. Mutation evidence for the second half: a cross-sectional demean written `x.unstack().sub(x.unstack().mean(axis=1), axis=0).stack()` turns the census RED while containing ZERO of those tokens (measured by the harness, which greps the mutated file and reports the count). The enumeration is DERIVED from the registry's dispatch tables, not a hand-kept list -- a newly registered daily factor lands in the census without anyone remembering to add it, which is the case the guard exists for. Two anti-vacuity tests back it: an enumeration returning nothing fails (mutation: red), and a fixture short enough to make every value NaN fails (mutation: red). A red here is STOP-AND-REPORT. The fix is D4c's, not an exemption list -- an exemption would rebuild exactly the silent-wrong-value failure D4c closed, and the assertion message says so. Recorded because it cost a wrong conclusion for a minute: the first fixture keyed each symbol's synthetic prices to its position in the PASSED list, so dropping two names shifted the survivors' own data and all ten daily factors "failed". The census caught it immediately -- which is the only reason we know it has power -- but a probe reporting that everything is broken is usually the broken thing. --- ...test_daily_factor_universe_independence.py | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/test_daily_factor_universe_independence.py diff --git a/tests/test_daily_factor_universe_independence.py b/tests/test_daily_factor_universe_independence.py new file mode 100644 index 0000000..45d2646 --- /dev/null +++ b/tests/test_daily_factor_universe_independence.py @@ -0,0 +1,178 @@ +"""Every DAILY factor's value must not depend on which universe was loaded. + +WHY THIS IS LOAD-BEARING, AND WHY IT BECAME SO IN D6a. The factor store key is +``(factor_id, params, code, view)``; it has NO universe dimension, deliberately +(design revision A2: a PIT universe over a date range is a time-varying SET, so +"the universe" is not a value a key dimension could hold). That is only sound +while the stored value is universe-INDEPENDENT. + +On the minute plane this was learned the hard way: ``intraday_amp_cut``'s +cross-sectional z-score ran during materialization, so its stored value WAS a +function of the loaded universe while the key said otherwise — measured, 12 names +filled then 24 read served 24 wrong cells with zero recompute and zero error. +D4c fixed it by storing the per-symbol intermediate and moving the combine to +read-assembly. + +THE DAILY PLANE NEVER INHERITED THAT FIX, because until D6a no daily factor value +was ever stored. D6a is the step that puts them in the shared store, so the +invariant that keeps the daily plane sound — every daily factor is per-symbol — +becomes load-bearing here. It held before by accident of what was written; from +here it has to hold on purpose. + +RED MEANS STOP AND REPORT. A daily factor whose value moves when the universe +changes cannot be stored under this key. The fix is D4c's (store the per-symbol +intermediate, combine at read-assembly) — NOT adding the factor to an exemption +list, which would restore exactly the silent-wrong-value failure D4c closed. + +WHY BEHAVIOURAL AND NOT A SOURCE SCAN. Scanning ``factors/compute`` for +``groupby(level="date")`` / ``.rank(`` is both too strict and too loose: +``groupby(level="symbol").rank()`` is a per-symbol time-series rank and would be +a false positive, while a cross-sectional standardization written as +``x.unstack().sub(x.unstack().mean(axis=1), axis=0).stack()`` contains neither +token and would sail through. Computing the factor over two universes and +comparing the shared symbols tests the property itself. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from data.clean.schema import CORE_COLUMNS, normalize_panel +from factors import registry as factor_registry +from factors.materialize import is_minute_factor + +_SYMBOLS = ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ", "000005.SZ"] +_SUBSET = ["000001.SZ", "000003.SZ", "000005.SZ"] +_N_DAYS = 60 + + +def _registered_factors() -> list: + """One instance per registry entry — DERIVED, never a hand-kept list. + + Reads the default registry's dispatch tables directly rather than through a + public accessor: adding one would be a production change, and this step is a + path switch that has to stay provably behaviour-neutral. The point is that a + newly registered factor lands in this census without anyone remembering to + add it — including the case this test exists for, a new DAILY factor. + + An entry's builder is called with the entry key: the exact-name builders + (financial fields, value ratios) use the name, and the prefix builders ignore + it and take their window from params defaults, so one call shape serves both. + """ + registry = factor_registry.DEFAULT_REGISTRY + entries = list(registry._exact.values()) + list(registry._prefixes) + return [entry.builder(entry.key, {}) for entry in entries] + + +def _daily_factors() -> list: + return [f for f in _registered_factors() if not is_minute_factor(f)] + + +def _panel(symbols: list[str], extra_fields: tuple[str, ...]) -> pd.DataFrame: + """A deterministic panel whose columns differ ACROSS symbols. + + Detection power depends on that: a cross-sectional operator only changes a + value when the peers it standardizes against change, so every symbol gets its + own level and slope. + + A symbol's data is keyed off its identity (its position in ``_SYMBOLS``), NOT + off its position in ``symbols``. The first draft used ``enumerate(symbols)`` + and every one of the ten daily factors "failed": dropping two names shifted + the survivors' own prices, so the two panels held different DATA and the + comparison was measuring the fixture. The census caught it instantly, which + is the one piece of evidence that it has power at all — but it is also the + reminder that a probe reporting "everything is broken" is usually broken + itself. + """ + dates = pd.bdate_range("2024-01-01", periods=_N_DAYS) + rows = [] + for symbol in symbols: + i = _SYMBOLS.index(symbol) + t = np.arange(_N_DAYS, dtype=float) + close = 50.0 + 10.0 * i + (1.0 + 0.5 * i) * t + np.sin(t / 3.0) * (1.0 + i) + frame = pd.DataFrame( + { + "date": dates, + "symbol": symbol, + "open": close * 0.99, + "high": close * 1.02, + "low": close * 0.97, + "close": close, + "volume": 1_000.0 + 100.0 * i + t, + "amount": (1_000.0 + 100.0 * i + t) * close, + "adj_factor": 1.0, + } + ) + for k, field in enumerate(extra_fields): + if field in frame.columns: + continue + frame[field] = 0.5 + 0.1 * i + 0.01 * k + t / 1000.0 + rows.append(frame) + return normalize_panel(pd.concat(rows, ignore_index=True)) + + +def _extra_fields(factor) -> tuple[str, ...]: + """Panel columns the factor reads beyond the core OHLCV set.""" + return tuple(f for f in (factor.spec.input_fields or ()) if f not in CORE_COLUMNS) + + +def test_the_census_is_not_empty_and_covers_the_known_daily_families(): + """A census that enumerates nothing passes vacuously; this refuses to. + + Not an exhaustive expected list (that would be the hand-kept register this + test exists to avoid) — just enough anchors that an enumeration returning + nothing, or losing whole families, cannot read as success. + """ + names = {f.name for f in _daily_factors()} + assert len(names) >= 8, f"suspiciously small daily-factor census: {sorted(names)}" + for anchor in ("momentum_20", "volatility_20", "value_ep", "roe"): + assert anchor in names, f"{anchor} missing from the census: {sorted(names)}" + + +def test_no_minute_factor_leaked_into_the_daily_census(): + """The split is what makes the daily claim meaningful; pin it.""" + assert not [f for f in _daily_factors() if f.spec.is_intraday] + + +@pytest.mark.parametrize("factor", _daily_factors(), ids=lambda f: f.name) +def test_a_daily_factor_value_does_not_depend_on_the_loaded_universe(factor): + """Drop 2 of 5 symbols; the surviving 3 must keep bit-identical values.""" + extra = _extra_fields(factor) + full = factor.compute(_panel(_SYMBOLS, extra)) + subset = factor.compute(_panel(_SUBSET, extra)) + + shared = subset.index + missing = shared.difference(full.index) + assert not len(missing), f"{factor.name}: subset produced rows the full run did not" + + a = full.reindex(shared) + b = subset + nan_mismatch = int((a.isna() != b.isna()).sum()) + both = a.notna() & b.notna() + worst = float((a[both] - b[both]).abs().max()) if both.any() else 0.0 + + assert nan_mismatch == 0 and worst == 0.0, ( + f"{factor.name} is CROSS-SECTIONAL on the daily plane: dropping two " + f"symbols moved {nan_mismatch} NaN cell(s) and up to {worst:.3e} in value " + f"for the symbols that stayed.\n" + f"STOP AND REPORT — do not add an exemption. Its value is a function of " + f"the loaded universe, but the factor store key " + f"(factor_id, params, code, view) does not name a universe, so a value " + f"filled under one universe would be served to another (the D5b defect, " + f"measured on the minute plane before D4c fixed it). The fix is D4c's: " + f"store the per-symbol intermediate and run the cross-sectional combine " + f"at read-assembly." + ) + + +def test_at_least_one_finite_value_is_actually_compared(request): + """Guards the parametrized test above from passing on all-NaN output.""" + for factor in _daily_factors(): + extra = _extra_fields(factor) + values = factor.compute(_panel(_SUBSET, extra)) + assert values.notna().any(), ( + f"{factor.name} produced no finite value on the census fixture, so the " + f"universe-independence comparison for it is vacuous." + ) From 2ab0e4498d28b6a9eb714e337469817dd1b93abe Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 12:21:12 -0700 Subject: [PATCH 4/6] docs(factors): state the reconciliation's premise and source the grid figures Review LOW-1 and NIT. LOW-1 -- phase2's neutralization covariates are fetched LIVE on this path (the runner does not pass the shared cache; D6a-3), so the before/after captures sit minutes apart against a revisable upstream. That puts a condition on the comparison which neither side had written down: the reconciliation is valid only if those covariates were not revised between the two captures. Nothing of the sort happened -- the factor panel matched cell-for-cell and every headline metric matched at full precision, which is itself strong evidence the covariates were stable across the window. But that is evidence collected after the fact, not a control designed in, and an upstream revision could equally have MASKED a real difference as invented one. Recorded in D6a-5 and in the reconciliation script's own docstring, with the instruction a future run needs: on any small movement, re-capture the legacy side and check legacy-vs-legacy before attributing anything to the code. NIT -- the "+2.2% CSI300 / +4.0% CSI500" grid-inflation figures in `factor_values`' docstring cannot be reproduced from anything this change emits. They are scouting-phase measurements off two local gitignored daily panels; the docstring now names the panels, their row/date/symbol counts, the arithmetic (`dates * symbols / rows - 1`), and says plainly that they size the effect rather than being evidence about this code. It also states that the phase2 panel actually reconciled on is exactly dense (68 x 241 = 16,388), so its footprint count is zero and the non-zero case is covered by unit test instead. Also registers D6a-0: the daily universe-independence invariant now guarded by tests/test_daily_factor_universe_independence.py, and why it is a different question from the provenance dimension in D6a-1 (whether a value depends on WHO ELSE was loaded, vs telling two real vintages apart). --- docs/factors/d6_deferred_register.md | 63 ++++++++++++++++++++++++++++ qt/factor_source.py | 17 ++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/factors/d6_deferred_register.md b/docs/factors/d6_deferred_register.md index 7e1c384..1d237b1 100644 --- a/docs/factors/d6_deferred_register.md +++ b/docs/factors/d6_deferred_register.md @@ -13,6 +13,47 @@ would no longer be a single candidate explanation. --- +## D6a-0 — INVARIANT (not a deferral): every daily factor is per-symbol + +**Status:** now guarded by +`tests/test_daily_factor_universe_independence.py`. **Owner:** anyone adding a +daily factor. + +The store key is `(factor_id, params, code, view)` and has **no universe +dimension** — deliberately, and it cannot have one: a PIT universe over a date +range is a time-varying *set*, not a value a key dimension could hold (design +revision A2). That is sound only while a stored value is universe-independent. + +On the minute plane this failed once already and was measured: `intraday_amp_cut` +ran its cross-sectional z-score during materialization, so its stored value *was* +a function of the loaded universe while the key said otherwise — fill a store +with 12 names, read it back asking for 24, and 24 wrong cells were served with +zero recompute, zero error, zero disclosure. **D4c** fixed it by storing the +per-symbol intermediate and moving the combine to read-assembly. + +**The daily plane never inherited that fix, because until D6a no daily factor +value was ever stored.** D6a is the step that puts them in the shared store, so +the property that keeps the daily plane sound becomes load-bearing here. It held +before by accident of what happened to be written; from here it holds on purpose. + +The guard is **behavioural**, not a source scan: it computes every registered +daily factor over a 5-name universe and a 3-name subset and requires the shared +symbols to be bit-identical. A scan for `groupby(level="date")` / `.rank(` is +both too strict (`groupby(level="symbol").rank()` is a per-symbol time-series +rank) and too loose — mutation evidence: a cross-sectional demean written as +`x.unstack().sub(x.unstack().mean(axis=1), axis=0).stack()` turns the census red +while containing **zero** of those tokens. + +**A red here is STOP-AND-REPORT.** The fix is D4c's — store the per-symbol +intermediate, combine at read-assembly — **not** an exemption list, which would +rebuild exactly the silent-wrong-value failure D4c closed. + +Note this is a different question from [D6a-1](#d6a-1--provenance-dimension-on-the-store-key) +below: that one is about telling two *real vintages* apart, this one is about +whether a value is a function of *who else was loaded*. + +--- + ## D6a-1 — provenance dimension on the store key **Status:** deferred, structural follow-up. **Owner:** not yet assigned. @@ -125,3 +166,25 @@ fetched **live** on this path (see D6a-3) and both subject to upstream revision. **Consequence for anyone reading a reconciliation:** the archived numbers are not a valid baseline for a code change made today. Compare a capture you took yourself, on the tree you are changing, immediately before you change it. + +### The premise D6a's own reconciliation rests on, stated + +The same live-covariate mechanism puts a condition on D6a's before/after +comparison, and it should be written down rather than left implicit: + +> **The reconciliation is valid only if the covariates were not revised upstream +> between the two captures.** + +They were taken 18 minutes apart (legacy 11:23, served 11:41), and because those +three endpoints are fetched live on this path (D6a-3), an upstream revision +landing in that window would have moved the served run for a reason that has +nothing to do with the change under test — in either direction, and equally able +to *mask* a real difference as to invent one. + +Nothing of the sort happened here: the factor panel matched cell-for-cell and +every headline metric matched at full precision, which is itself strong evidence +that the covariates were stable across the window (a revision would have had to +leave all of them exactly unchanged). But that is evidence collected *after* the +fact, not a control designed in. A future reconciliation on this path that shows +a small movement must rule this out **before** attributing it to the code — +re-capture the legacy side and check legacy-vs-legacy first. diff --git a/qt/factor_source.py b/qt/factor_source.py index 6b8a8b4..125ebe0 100644 --- a/qt/factor_source.py +++ b/qt/factor_source.py @@ -240,9 +240,20 @@ def factor_values( ASKED about, carrying an explicit NaN where the factor has no value (D4c's fill footprint, which is what lets a warm store tell computed-empty from never-computed). An index universe's symbol list is the union of historical - constituents, so that grid is strictly larger than the loaded panel: measured - on the real panels, 2.2% larger for CSI300 and 4.0% for CSI500. Every one of - those extra rows is all-NaN, so no VALUE changes either way — but a consumer + constituents, so that grid is strictly larger than the loaded panel. + + HOW MUCH larger, and where that number comes from: 2.2% for CSI300 and 4.0% + for CSI500. Those are SCOUTING-PHASE measurements, not products of this + change and not reproducible from anything it emits — they were read off two + local (gitignored) daily panels, ``artifacts/data/i5e_csi300_daily.parquet`` + (569,736 rows over 1,210 dates x 481 symbols) and + ``artifacts/data/d1_panel_freeze_daily.parquet`` (1,158,912 over 1,210 x + 996), as ``dates * symbols / rows - 1``. They size the effect; they are not + evidence about this code. The phase2 panel this step actually reconciles on + is exactly dense (68 x 241 = 16,388), so its footprint count is zero — the + non-zero case is covered by ``tests/test_factor_source.py`` instead. + + Every one of those extra rows is all-NaN, so no VALUE changes either way — but a consumer that counts rows (``per_factor[...]["coverage"]`` is ``notna().mean()``) is looking at a different denominator, and a published diagnostic must not move because the values arrived by a different route (red line #8). The count of From 3dcb035d752a2a1373bb6f27b5f8b0ca1cca8e99 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 12:28:57 -0700 Subject: [PATCH 5/6] test(factors): name the store-determinism test for what it measures, and pin the reduction it was mistaken for Review S1. `test_the_reindex_preserves_every_value_the_service_served` never tested that: both sides came from the same already-reindexed `factor_values` call, so for that property the comparison reduced to `frame == frame`. What it could actually catch was a difference between the two STORES, which is worth keeping -- a factor value must be a function of (factor, panel, universe) alone, and that is the assumption the read-through rests on. Renamed to `test_the_served_values_do_not_depend_on_which_store_they_were_filled_into`, tightened to `check_exact=True`. Mutation: making a served value depend on the store root turns it red. RENAMING ALONE WOULD HAVE LEFT A REAL GAP, so the assertion the old name promised is now added where it belongs -- inside the sparse-panel test, next to the reduction it constrains. Checking the file rather than trusting the earlier summary of it: * the sparse test asserted the INDEX only (`index.equals`); * the value-vs-direct-compute test runs on a DENSE panel, where the reduction is the identity. So nothing could see a reduction that lands the correct index carrying the wrong rows. Mutation, run against three tests to show the coverage is new and not a duplicate: replacing the label-based reindex with a positional one (`served.head(len(panel.index)).set_axis(panel.index)`) leaves the dense value test GREEN and the index+count assertions GREEN, and turns only the added assertion RED. Worth recording how the gap survived: the earlier self-review reported "the reindex path is covered" -- true of the path, not of value preservation -- and that report was then relied on when deciding this was a naming issue only. A test whose name overstates it does not just fail to cover; it actively suppresses the coverage someone would otherwise add, and the first person it misleads is whoever wrote it. The new name states the narrower thing it really pins, and its docstring points at where the wider claim is now enforced. --- tests/test_factor_source.py | 43 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/test_factor_source.py b/tests/test_factor_source.py index 54465b9..4aefbfb 100644 --- a/tests/test_factor_source.py +++ b/tests/test_factor_source.py @@ -158,14 +158,46 @@ def test_a_sparse_panel_reindexes_back_and_counts_what_that_dropped(tmp_path): sparse = panel[~((s == late) & (d < dates[10]))] out = _served(sparse, symbols, tmp_path) + factor = MomentumFactor(window=5) assert out.footprint_rows_dropped == 10 assert out.served_rows == len(dates) * len(symbols) assert out.frame.index.equals(sparse.index) + # ... and the SURVIVING VALUES are right, not just the surviving labels. + # Asserting only the index would accept a reduction that lands the correct + # index carrying the wrong rows -- and that defect is invisible on a dense + # panel, where the reduction is a no-op, so no other test in this file can + # see it (mutation: reducing positionally instead of by label keeps the + # index assertion and the dense value test green and turns this red). + pd.testing.assert_frame_equal( + out.frame, + factor.compute(sparse).rename(factor.name).to_frame(), + check_exact=True, + ) -def test_the_reindex_preserves_every_value_the_service_served(tmp_path): - """Reindexing is a row selection, never a value change.""" +def test_the_served_values_do_not_depend_on_which_store_they_were_filled_into( + tmp_path, +): + """Two cold stores, one request: the same values. + + NAMED FOR WHAT IT MEASURES. It was called + ``test_the_reindex_preserves_every_value_the_service_served``, which it never + tested: both sides came from the same already-reindexed ``factor_values`` + call, so the comparison reduced to ``frame == frame`` for that property and + could only ever have caught a difference between the two STORES. + + That misnaming did real damage before it was caught: it was read (by its + author, then by a reviewer taking the author's word) as evidence that + reindex value-preservation was pinned, and on that basis nobody added the + assertion that actually pins it. It is now in + ``test_a_sparse_panel_reindexes_back_and_counts_what_that_dropped`` -- which + is where it belongs, next to the reduction it constrains. + + What is left here is worth keeping on its own: a factor value must be a + function of (factor, panel, universe) alone, so filling store A and filling + store B must agree. That is the assumption the read-through rests on. + """ panel = make_demo_panel() symbols = sorted(set(panel.index.get_level_values("symbol"))) dates = pd.DatetimeIndex(sorted(set(panel.index.get_level_values("date")))) @@ -173,8 +205,8 @@ def test_the_reindex_preserves_every_value_the_service_served(tmp_path): s = panel.index.get_level_values("symbol") sparse = panel[~((s == symbols[-1]) & (d < dates[10]))] - out = _served(sparse, symbols, tmp_path) - full = factor_values( + into_a = _served(sparse, symbols, tmp_path).frame + into_b = factor_values( [MomentumFactor(window=5)], sparse, symbols, @@ -182,8 +214,7 @@ def test_the_reindex_preserves_every_value_the_service_served(tmp_path): params_by_id={"momentum_5": {"window": 5}}, ).frame - common = out.frame.index - pd.testing.assert_frame_equal(out.frame, full.reindex(common)) + pd.testing.assert_frame_equal(into_a, into_b, check_exact=True) def test_the_service_values_equal_a_direct_compute_on_the_same_panel(tmp_path): From a3c3bca879b03dde42ea9834b950c9bd34317ded Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 12:37:47 -0700 Subject: [PATCH 6/6] test(factors): derive the legacy-caller census from the repo, and assert the grid the end-to-end test was named for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review LOW-3 and NIT-2. LOW-3 -- the previous guard named its two files. The reviewer showed what that costs: add a third caller in a file the list does not name, change the shared signature, and dutifully update everything the list DOES name, and all 21 tests stayed green. Replaced by `tests/test_legacy_factor_panel_callers.py`, which derives the caller set from `git ls-files` UNION the untracked-but-not-ignored files (tracked-only would miss a caller added and not yet staged; a directory walk would drag in gitignored artifacts and, in a worktree, follow the `artifacts` symlink into another checkout). It resolves direct calls, attribute calls, and local import aliases, and REFUSES a `getattr`-by-name reference rather than skipping the form it cannot bind. What it still cannot see -- a name assembled at runtime -- is stated in its docstring rather than left implied. Mutations, all measured: a third caller in `qt/robustness.py` turns it red; the full MS4 scenario above turns it red; an aliased import with the wrong arity turns it red; a `getattr` reference turns it red; an empty file derivation turns the anti-vacuity test red. ON MS2, WHERE THE BRIEF AND THE EVIDENCE DISAGREE, AND THE EVIDENCE WINS. MS2 (rewriting a listed runner's call to attribute form) turned the OLD guard red, and the instruction was to keep that red. It should NOT stay red: the old guard only matched `ast.Name`, so attribute form made it find zero calls and fail its own `assert calls` -- red from a blind spot, not from anything being wrong. The derived guard handles attribute form, so that mutation is now correctly GREEN (measured), and the property MS2 was standing in for is pinned directly instead: attribute form PLUS a signature change turns the binding test red (measured). Preserving the old red would have been preserving an accident. The census deliberately includes `tests/test_factor_source.py`, which calls the legacy entry point as its only execution coverage -- derived means derived, not "derived except the inconvenient one". ⚠️ This module is SUPPOSED to go red in D6b and be deleted in D6d. D6b empties the expected set; the function itself then goes away. Written into the docstring so the next reader does not quietly "fix" it by emptying the set while the function still has callers. NIT-2 -- `test_the_persisted_factor_panel_keeps_the_market_panel_grid` asserted `len(stored) == len(market)`. The reviewer flagged it from reading and marked it UNVERIFIED. Verified before changing anything: under a reduction that keeps the length and shifts every date by one day, that test PASSES while three others go red. Confirmed, so it now compares the actual (date, symbol) index. The row count was the weakest observable the name could have meant. Also recorded: the mutation harness for this round backed up each file per EDIT rather than per FILE, so a two-edit mutation restored a half-mutated file. It was caught by the `git status` the harness prints after itself, not by any test -- the harness was fixed and every mutation above re-run from a clean tree. --- tests/test_factor_source.py | 50 ++--- tests/test_legacy_factor_panel_callers.py | 225 ++++++++++++++++++++++ 2 files changed, 241 insertions(+), 34 deletions(-) create mode 100644 tests/test_legacy_factor_panel_callers.py diff --git a/tests/test_factor_source.py b/tests/test_factor_source.py index 4aefbfb..ec78e64 100644 --- a/tests/test_factor_source.py +++ b/tests/test_factor_source.py @@ -288,7 +288,17 @@ def test_the_persisted_factor_panel_keeps_the_market_panel_grid( stored = pd.read_parquet(result.factor_path) market = pd.read_parquet(result.data_path) - assert len(stored) == len(market) + + # The GRID, not the row count. `len(stored) == len(market)` was what this + # asserted, and it passes under a reduction that keeps the length and moves + # every label -- measured: shifting each date by one day left it green while + # three other tests went red. The row count is the weakest observable that + # this test's name could possibly have meant. + grid = ["date", "symbol"] + pd.testing.assert_index_equal( + stored.set_index(grid).index.sort_values(), + market.set_index(grid).index.sort_values(), + ) def _write_cfg_file(tmp_path: Path, example_config_path: str, *, source: str) -> Path: @@ -312,39 +322,11 @@ def _write_cfg_file(tmp_path: Path, example_config_path: str, *, source: str) -> # --------------------------------------------------------------------------- # # mid-migration: the runners D6a does not move must still be able to call # --------------------------------------------------------------------------- # -#: Runners still on the pre-D6a ``_compute_factor_panel`` path. D6b empties this -#: list; D6d deletes the function. It is spelled out here because those runners -#: need real tushare data, so NOTHING in the suite executes their call — the -#: first D6a draft changed the shared signature under them and every test stayed -#: green (the break was found by reading the call sites, not by a failure). -UNMIGRATED_RUNNERS = ("qt/oos_stability.py", "qt/subset_validation.py") - - -@pytest.mark.parametrize("module_path", UNMIGRATED_RUNNERS) -def test_an_unmigrated_runners_call_still_matches_the_legacy_signature(module_path): - """Their call is parsed from source and bound against the LIVE signature.""" - import ast - import inspect - - from qt import pipeline - - repo_root = Path(__file__).resolve().parents[1] - tree = ast.parse((repo_root / module_path).read_text(encoding="utf-8")) - calls = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "_compute_factor_panel" - ] - assert calls, f"{module_path} no longer calls _compute_factor_panel" - - signature = inspect.signature(pipeline._compute_factor_panel) - for call in calls: - signature.bind( - *[""] * len(call.args), - **{kw.arg: "" for kw in call.keywords if kw.arg}, - ) +# WHICH files still call the pre-D6a entry point, and whether their calls still +# bind, is guarded by ``tests/test_legacy_factor_panel_callers.py`` -- a census +# DERIVED from the repo. It replaced a hand-kept pair of filenames here, which a +# third caller in an unnamed file walked straight past. What stays here is the +# only thing that actually EXERCISES the legacy path. def test_the_legacy_entry_point_still_computes(tmp_path, example_config_path): diff --git a/tests/test_legacy_factor_panel_callers.py b/tests/test_legacy_factor_panel_callers.py new file mode 100644 index 0000000..687f173 --- /dev/null +++ b/tests/test_legacy_factor_panel_callers.py @@ -0,0 +1,225 @@ +"""Who still calls the PRE-D6a factor path, derived from the repo — not listed. + +D6a moved phase0 and phase2_baseline onto ``_serve_factor_panel`` (the factor +service) and left ``qt.pipeline._compute_factor_panel`` in place, unchanged, for +the runners D6b migrates. Two things then need guarding, and NOTHING in the test +suite executes those runners: they need real tushare data, so a break in them is +invisible to every other test here. That is not hypothetical — the first draft of +D6a changed the shared signature under them and the whole suite stayed green. + + 1. every call to the legacy entry point still matches its live signature; and + 2. the set of files that call it is EXACTLY the set we think it is. + +(2) is the one that matters, and it is why this census is DERIVED rather than +listed. A hand-kept list of "runners still on the old path" only ever confirms +what its author already knew: add a third caller in a file the list does not +name, change the signature, and dutifully update everything the list DOES name, +and a listed guard passes in silence. Measured on the previous, hand-kept +version of this guard: exactly that scenario left all 21 tests green. + +SCOPE OF THE DERIVATION — stated because a guard that does not say what it +cannot see is worth less than no guard: + +* FILES: ``git ls-files`` UNION ``git ls-files --others --exclude-standard``, + i.e. tracked files plus untracked-but-not-ignored ones. Tracked-only would be + blind to a caller added but not yet staged; a bare directory walk would drag in + gitignored artifacts (and, in a worktree, follow the ``artifacts`` symlink into + another checkout entirely). +* CALL FORMS: a direct call (``_compute_factor_panel(...)``), an attribute call + on any object (``pipeline._compute_factor_panel(...)``), and a call through a + local import alias (``from qt.pipeline import _compute_factor_panel as X``; + ``X(...)``). ``getattr(obj, "_compute_factor_panel")`` cannot be bound, so it + is REFUSED by name rather than skipped. +* WHAT IT STILL CANNOT SEE: a call assembled from a dynamically built name + (``getattr(obj, "_compute_" + "factor_panel")``). No test here claims + otherwise. + +⚠️ THIS FILE IS SUPPOSED TO GO RED IN D6b, AND THEN BE DELETED. D6b moves +``oos_stability`` / ``subset_validation`` onto the service and D6d deletes +``_compute_factor_panel`` entirely; at that point ``EXPECTED_CALLERS`` no longer +matches and the symbol no longer exists. That is the guard reporting the +migration finished, not a stale test. Delete it with the function; do not "fix" +it by emptying the expected set while the function still has callers. +""" + +from __future__ import annotations + +import ast +import inspect +import subprocess +from pathlib import Path + +import pytest + +from qt import pipeline + +#: The legacy entry point this census tracks. +SYMBOL = "_compute_factor_panel" + +#: The module that DEFINES it — excluded from the caller census because a +#: definition is not a call. Derived below by finding the ``FunctionDef``, so a +#: move to another module does not silently drop the exclusion. +DEFINING_MODULE = Path(inspect.getsourcefile(pipeline)).name + +#: Every file that calls it today, repo-relative. +#: +#: ``qt/oos_stability.py`` and ``qt/subset_validation.py`` are the two runners +#: D6b migrates. ``tests/test_factor_source.py`` calls it too, deliberately: it +#: is the only execution coverage the legacy path has, since the two runners +#: cannot be run without real data. +EXPECTED_CALLERS: frozenset[str] = frozenset( + { + "qt/oos_stability.py", + "qt/subset_validation.py", + "tests/test_factor_source.py", + } +) + +#: A census that scans nothing passes vacuously. The repo has ~300 python files; +#: this floor only has to be high enough that an empty or broken file listing +#: cannot read as "no callers found". +_MIN_FILES_SCANNED = 100 + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _python_files() -> list[Path]: + root = _repo_root() + seen: dict[str, Path] = {} + for args in ( + ["git", "ls-files", "--", "*.py"], + ["git", "ls-files", "--others", "--exclude-standard", "--", "*.py"], + ): + out = subprocess.run( + args, cwd=root, capture_output=True, text=True, check=True + ).stdout + for line in out.splitlines(): + rel = line.strip() + if rel: + seen.setdefault(rel, root / rel) + return [path for _rel, path in sorted(seen.items())] + + +def _local_aliases(tree: ast.AST) -> set[str]: + """Local names bound to ``SYMBOL`` by an import in this module.""" + names = {SYMBOL} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name == SYMBOL and alias.asname: + names.add(alias.asname) + return names + + +def _calls_in(path: Path) -> tuple[list[ast.Call], list[str]]: + """(bindable call nodes, unbindable references) for one file.""" + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): # not our business to compile the repo + return [], [] + aliases = _local_aliases(tree) + calls: list[ast.Call] = [] + unbindable: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Name) and func.id in aliases: + calls.append(node) + elif isinstance(func, ast.Attribute) and func.attr == SYMBOL: + calls.append(node) + elif ( + isinstance(func, ast.Name) + and func.id == "getattr" + and any( + isinstance(a, ast.Constant) and a.value == SYMBOL for a in node.args + ) + ): + unbindable.append(f"{path.name}:{node.lineno} getattr(...)") + return calls, unbindable + + +def _census() -> tuple[dict[str, list[ast.Call]], list[str], int]: + root = _repo_root() + callers: dict[str, list[ast.Call]] = {} + unbindable: list[str] = [] + files = _python_files() + for path in files: + if path.name == DEFINING_MODULE: + continue + calls, bad = _calls_in(path) + unbindable.extend(bad) + if calls: + callers[path.relative_to(root).as_posix()] = calls + return callers, unbindable, len(files) + + +def test_the_file_census_actually_scanned_the_repo(): + """Distinguishes 'no callers' from 'the file listing came back empty'.""" + files = _python_files() + assert len(files) >= _MIN_FILES_SCANNED, ( + f"the python-file derivation returned only {len(files)} file(s); every " + f"census below would pass vacuously. Check the git listing, not the " + f"callers." + ) + + +def test_the_set_of_legacy_callers_is_exactly_what_we_think_it_is(): + """The load-bearing one: a NEW caller anywhere in the repo turns this red. + + Red here is STOP-AND-REPORT, and which report depends on which way it moved: + + * an ADDED file — someone put a new caller on the pre-D6a path. Route it + through ``_serve_factor_panel`` instead, or say why it must not be. + * a REMOVED file — expected exactly once, when D6b migrates the two runners. + Then this whole module goes away with ``_compute_factor_panel`` (D6d); do + not keep it alive against an empty set. + """ + callers, _unbindable, _n = _census() + found = frozenset(callers) + assert found, ( + f"no caller of {SYMBOL} found anywhere in the repo. Either the function " + f"is already dead (then delete it and this module together) or the " + f"derivation broke — check " + f"test_the_file_census_actually_scanned_the_repo first." + ) + assert found == EXPECTED_CALLERS, ( + f"the set of files calling {SYMBOL} changed.\n" + f" added: {sorted(found - EXPECTED_CALLERS)}\n" + f" removed: {sorted(EXPECTED_CALLERS - found)}\n" + f"STOP AND REPORT — see this test's docstring for which of the two " + f"situations you are in." + ) + + +def test_every_legacy_call_matches_the_live_signature(): + """Parsed from source, bound against the function object as it exists now.""" + callers, _unbindable, _n = _census() + signature = inspect.signature(getattr(pipeline, SYMBOL)) + for rel, calls in sorted(callers.items()): + for call in calls: + try: + signature.bind( + *[""] * len(call.args), + **{kw.arg: "" for kw in call.keywords if kw.arg}, + ) + except TypeError as exc: # noqa: PERF203 - one message per bad call site + pytest.fail( + f"{rel}:{call.lineno} calls {SYMBOL} with a shape its current " + f"signature {signature} does not accept ({exc}). Nothing in " + f"the suite executes that call, so this guard is the only " + f"thing between the change and a runtime TypeError." + ) + + +def test_no_caller_reaches_the_symbol_in_a_form_this_guard_cannot_bind(): + """``getattr`` by name is refused, not silently skipped.""" + _callers, unbindable, _n = _census() + assert not unbindable, ( + f"{SYMBOL} is referenced through getattr at {unbindable}, a form this " + f"census can find but cannot bind against the signature. Make it a " + f"direct call, or extend the guard deliberately — do not leave a " + f"reference it can only half-see." + )