From 596c93ce1c665876ca349b4dc46640c9fc1b4692 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 23 Jul 2026 13:52:48 -0700 Subject: [PATCH 1/3] feat(factors): add PanelField requires + FactorSpec contract v1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor-refactor D1, first slice (design v3.2 §3.1; D0 taxonomy in docs/factors/refactor_d0_contract.md). EXPLICITLY AUTHORIZED change to the previously frozen FactorSpec contract (stated outright per the #74 precedent): three declaration dimensions become MANDATORY on every spec — * requires: endpoint-level PanelField inputs (R25 endpoint-only; R8 endpoint closure + R7 market_daily field-level validation fire at declaration time inside PanelField); * adjustment / overnight_boundary: value domains are the data.availability_policy enums (single source, no second list). A missing declaration is a readable definition-/construction-time error, same as the pre-existing mandatory fields; the metaclass double-check and expected_ic_sign pre-registration are untouched. The D0 §1.1 static check (adjustment=none must not require a price-channel field) fires at construction; the converse is deliberately unchecked (VWAP-ratio factors carry price via sum(amount)/sum(volume) with no OHLC field). All 18 shipped Factor classes declare per the D0 pre-assignment table (evidence cited in each spec docstring): the finishing-line 14 land row-for-row, including minute_ideal_amplitude's CROSSED_DISCLOSED candidate whose missing PR-L-style deviation measurement is pinned in the docstring as a D2 obligation; valley_price_quantile additionally requires market_daily close (the T-1 rev20 leg reads the qfq daily panel — a real second endpoint beyond its minute fields). Factor math untouched: zero compute() changes. --- factors/compute/candidates.py | 73 +++++- factors/compute/financial.py | 12 +- factors/compute/intraday_derived.py | 157 ++++++++++++- factors/compute/momentum.py | 15 +- factors/requires.py | 67 ++++++ factors/spec.py | 158 ++++++++++++- tests/test_eval_figures.py | 6 +- tests/test_exec_basis_eval.py | 6 +- tests/test_exec_forward_returns.py | 5 +- tests/test_factor_eval_contract.py | 5 + tests/test_factor_eval_standard.py | 6 +- tests/test_factor_requires_spec_v1.py | 325 ++++++++++++++++++++++++++ 12 files changed, 825 insertions(+), 10 deletions(-) create mode 100644 factors/requires.py create mode 100644 tests/test_factor_requires_spec_v1.py diff --git a/factors/compute/candidates.py b/factors/compute/candidates.py index 8e6a41a..0c5244e 100644 --- a/factors/compute/candidates.py +++ b/factors/compute/candidates.py @@ -27,13 +27,19 @@ import numpy as np import pandas as pd +from data.availability_policy import DAILY_BASIC, MARKET_DAILY from factors.base import Factor from factors.compute.momentum import MomentumFactor -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField # daily_basic-derived value fields the pipeline can enrich + surface (P3-5). VALUE_FIELDS: tuple[str, ...] = ("value_ep", "value_bp") +# The daily_basic source field behind each surfaced value column (D1 requires +# declaration: the factor reads the enriched panel column, but the DATA it +# needs is the published ratio on this endpoint field). +_VALUE_SOURCE_FIELD: dict[str, str] = {"value_ep": "pe", "value_bp": "pb"} + # Per-field evaluation contract metadata for the value pack. expected_ic_sign=+1 # for both: the classic value prior (cheap earns more than expensive), and the # ONE hypothesis this project has confirmed on independent samples — P3-5 test @@ -68,6 +74,13 @@ def spec(self) -> FactorSpec: hypothesis is the exact negation of MomentumFactor's +1 prior (short- horizon losers bounce back). Project evidence: no signal — P3-5 found reversal_5/20 sign-flipping across cells. + + D1 declarations (derived): requires / adjustment / overnight_boundary + are INHERITED from the underlying momentum spec — negation changes the + sign of the value, not its inputs, its anchor dependence, or its + overnight-boundary behaviour (this class delegates ``compute`` to + :class:`MomentumFactor`, so the provenance is identical by + construction). """ base = self._momentum.spec return FactorSpec( @@ -79,6 +92,9 @@ def spec(self) -> FactorSpec: forward_return_horizon=base.forward_return_horizon, return_basis=base.return_basis, input_fields=base.input_fields, + requires=base.requires, + adjustment=base.adjustment, + overnight_boundary=base.overnight_boundary, family="reversal", min_history_bars=base.min_history_bars, ) @@ -115,6 +131,16 @@ def spec(self) -> FactorSpec: project's second independently-confirmed hypothesis: P3-5 test IC 3/3 negative (-0.044~-0.079), P3-7 SUPPORTED on 2/2 holdout cells, P3-8 GENERALIZES to CSI500 (test IC -0.0272). + + D1 declarations (D0 pre-assignment table row 14): adjustment= + returns_invariant — the input is per-symbol ``pct_change`` of the + pipeline's qfq close panel (``compute`` below), so the anchor cancels + in every return ratio (data-layer lock: + tests/test_adjust.py::test_front_adjust_returns_invariant_to_anchor). + overnight_boundary=none — all returns are ratios of SAME-basis qfq + closes; in the decision view every input is <= d-1 (the market_daily + close row of data/availability_policy.py), so no raw-price comparison + mixes day d's new basis with old-basis history. """ return FactorSpec( factor_id=self.name, @@ -128,6 +154,9 @@ def spec(self) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=(self._price_col,), + requires=(PanelField(self._price_col, source=MARKET_DAILY),), + adjustment="returns_invariant", + overnight_boundary="none", family="lowvol", # pct_change loses row 0 and the rolling std needs ``window`` returns # -> the leading ``window`` rows are NaN. @@ -180,6 +209,13 @@ def spec(self) -> FactorSpec: NEGATIVELY with forward returns. Project evidence: P3-5 test IC 3/3 negative but small in magnitude; P3-6 showed adding it to the value+lowvol subset is no free lunch (better on 1 cell, worse on 2). + + D1 declarations (derived, evidence): adjustment=none — the only input + is the turnover ``amount`` column (``compute`` below: rolling mean of + amount, then log); traded VALUE in RMB is not rescaled by any + split/dividend adjustment factor, so the price channel is never + touched. overnight_boundary=none — no price comparison exists at all, + let alone one crossing the overnight boundary. """ return FactorSpec( factor_id=self.name, @@ -193,6 +229,9 @@ def spec(self) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=(self._amount_col,), + requires=(PanelField(self._amount_col, source=MARKET_DAILY),), + adjustment="none", + overnight_boundary="none", family="liquidity", # rolling mean over ``window`` amounts -> first valid row is # ``window-1`` (no pct_change involved, unlike volatility). @@ -255,6 +294,18 @@ def spec(self) -> FactorSpec: NOT is_intraday: it is derived from DAILY open/close bars, so it carries no minute decision-cutoff / execution contract. + + D1 declarations (derived, evidence): adjustment=returns_invariant — + the value is a sum of log ratios ``log(open[t] / close[t-1])`` on the + FRONT-ADJUSTED panel; both legs carry the same qfq anchor, which + cancels in the ratio (class docstring: "Both prices are front-adjusted + by the same anchor, so the ratio is ex-dividend-safe"; data-layer lock + tests/test_adjust.py::test_front_adjust_returns_invariant_to_anchor). + overnight_boundary=none — the ratio does SPAN the overnight gap, but + both legs are qfq prices on one continuous basis, so no RAW-price + comparison crosses an ex-date basis BREAK (``front_adjust`` removed + it); the taxonomy's ``none`` is exactly "no raw-price comparison + crosses the boundary" (data/availability_policy.py). """ return FactorSpec( factor_id=self.name, @@ -268,6 +319,12 @@ def spec(self) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=(self._open_col, self._close_col), + requires=( + PanelField(self._open_col, source=MARKET_DAILY), + PanelField(self._close_col, source=MARKET_DAILY), + ), + adjustment="returns_invariant", + overnight_boundary="none", family="momentum", # row 0 has no prior close and the rolling sum needs ``window`` full # overnight returns -> the leading ``window`` rows are NaN. @@ -317,6 +374,15 @@ def spec(self) -> FactorSpec: expected_ic_sign=+1 for both fields — see ``_VALUE_META`` above for the prior and the project's independent confirmation (P3-5/P3-7/P3-8). ``min_history_bars=0``: the ratio is published same-day, no warm-up. + + D1 declarations (D0 pre-assignment table rows 12-13): adjustment=none + — the factor only surfaces the enriched 1/pe (resp. 1/pb) column: a + published same-day ratio with no price channel, no qfq and no + time-series logic of its own (``compute`` below is a bare column + select). overnight_boundary=none — no raw-price comparison exists. + ``requires`` names the daily_basic SOURCE field (pe / pb), not the + derived panel column: what the factor needs from the data layer is the + published ratio. """ return FactorSpec( factor_id=self.name, @@ -327,6 +393,11 @@ def spec(self) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=(self._field,), + requires=( + PanelField(_VALUE_SOURCE_FIELD[self._field], source=DAILY_BASIC), + ), + adjustment="none", + overnight_boundary="none", family="value", min_history_bars=0, ) diff --git a/factors/compute/financial.py b/factors/compute/financial.py index 35bf93b..97ef172 100644 --- a/factors/compute/financial.py +++ b/factors/compute/financial.py @@ -11,8 +11,9 @@ import pandas as pd +from data.availability_policy import FINA_INDICATOR from factors.base import Factor -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField # financial fields that may be requested as a factor (P1; grossprofit_margin # joined in P3-5 as the conservative quality candidate — same ann_date as-of @@ -65,6 +66,12 @@ def spec(self) -> FactorSpec: ``min_history_bars=0``: this factor does no temporal logic of its own — the value is already PIT-aligned upstream by ``ann_date`` as-of, so there is no warm-up window to exclude. + + D1 declarations (derived, evidence): adjustment=none — the factor only + surfaces an ann_date-aligned fina_indicator column (``compute`` below + is a bare column select); no price channel, no qfq, no time-series + logic. overnight_boundary=none — no raw-price comparison exists, so + nothing can cross the ex-date basis break. """ sign, family, description = _FIELD_META[self._field] return FactorSpec( @@ -76,6 +83,9 @@ def spec(self) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=(self._field,), + requires=(PanelField(self._field, source=FINA_INDICATOR),), + adjustment="none", + overnight_boundary="none", family=family, min_history_bars=0, ) diff --git a/factors/compute/intraday_derived.py b/factors/compute/intraday_derived.py index 26c8a76..052cbbe 100644 --- a/factors/compute/intraday_derived.py +++ b/factors/compute/intraday_derived.py @@ -31,6 +31,7 @@ import pandas as pd +from data.availability_policy import MARKET_DAILY, STK_MINS_1MIN from data.clean.intraday_aggregate import ( JUMP_LOOKBACK_DAYS, JUMP_MIN_PAIRS, @@ -91,7 +92,19 @@ VOLUME_PRV_SIGMA_K, ) from factors.base import Factor -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField + + +def _minute_requires(*fields: str) -> tuple[PanelField, ...]: + """The stk_mins_1min requires tuple of a minute-derived factor (D1). + + Truthful as-of-today provenance (design §9 D1 row: DECLARE, do not move + data flows — the materializer migration is D4): every factor in this + module is aggregated upstream from the cached 1min bars, so its endpoint + requirement is the 1min field set its aggregation reads. The daily panel + column each ``compute`` selects is DERIVED from exactly these fields. + """ + return tuple(PanelField(f, source=STK_MINS_1MIN) for f in fields) class JumpAmountCorrFactor(Factor): @@ -135,6 +148,15 @@ def spec(self) -> FactorSpec: >= ``JUMP_MIN_PAIRS`` jump-pairs accumulate in the trailing window), not a fixed leading count — the honest NaN rate is reported by data_coverage rather than hidden behind a fabricated warm-up window. + + D1 declarations (D0 pre-assignment table row 1): adjustment= + returns_invariant — the jump is a within-(symbol, day) amplitude + z-score of the SAME-DAY ratio (high-low)/open + (data/clean/intraday_aggregate.py:420) and the correlated quantity is + ``amount`` (anchor-free), so the adjustment anchor cancels. + overnight_boundary=none — jump/amount pairs are strictly same-session + adjacent minutes (intraday_aggregate.py:421-422); no raw-price + comparison crosses the overnight boundary. """ return FactorSpec( factor_id=self.name, @@ -155,6 +177,9 @@ def spec(self) -> FactorSpec: # are declared for honest provenance disclosure (data_coverage lists # them); the daily panel surfaces the pre-aggregated column itself. input_fields=("high", "low", "open", "amount"), + requires=_minute_requires("high", "low", "open", "amount"), + adjustment="returns_invariant", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -216,6 +241,22 @@ def spec(self) -> FactorSpec: DATA-dependent (a value appears once >= ``IDEAL_AMP_MIN_MINUTES`` valid pooled minutes accumulate in the trailing window), not a fixed leading count — the honest NaN rate is reported by data_coverage. + + D1 declarations (D0 pre-assignment table row 2 + note 1, the table's + flagged judgment call): adjustment=returns_invariant — the amplitude + itself is the same-day ratio high/low - 1 (anchor cancels; + data/clean/intraday_amplitude.py:24-27). overnight_boundary= + CROSSED_DISCLOSED (candidate) — the pooled ranking key is the RAW + minute close across the trailing multi-day window, so when the window + contains an ex-date the pooled ordering interleaves bars on two + different price bases; the definition is pinned to the report (Wind + ranks on the raw price) and deliberately kept. OPEN OBLIGATION, + tracked here per D0 note 1: of the three CROSSED_DISCLOSED + requirements (crossing is real / values kept by definition / deviation + MEASURED and disclosed) the third — a PR-L-style ex-date deviation + measurement (share of pooling windows containing a true ex-date + + realized rank-perturbation magnitude) — is still MISSING and is owed + in D2 alongside that stage's overnight-boundary property tests. """ return FactorSpec( factor_id=self.name, @@ -238,6 +279,9 @@ def spec(self) -> FactorSpec: # for honest provenance disclosure (data_coverage lists them); the daily # panel surfaces the pre-aggregated column itself. input_fields=("high", "low", "close"), + requires=_minute_requires("high", "low", "close"), + adjustment="returns_invariant", + overnight_boundary="crossed_disclosed", family="microstructure", min_history_bars=0, ) @@ -309,6 +353,13 @@ def spec(self) -> FactorSpec: The description spells out the FIVE pinned interpretations of the under-specified report (bar freq, lookback, threshold, weighted-vol operator, within-day lag) so a reader sees exactly what was assumed. + + D1 declarations (D0 pre-assignment table row 3): adjustment= + returns_invariant — amp is the same-day ratio high/low - 1 and the + selected-bar statistic is a std of minute RETURNS (ratios), so the + anchor cancels throughout. overnight_boundary=none — Δamp and the + bar-return are BOTH within-day lagged, "the overnight gap never + contaminates a pair" (data/clean/intraday_amp_anomaly.py:25-26). """ return FactorSpec( factor_id=self.name, @@ -335,6 +386,9 @@ def spec(self) -> FactorSpec: # honest provenance disclosure (data_coverage lists them); the daily panel # surfaces the pre-aggregated column itself. input_fields=("high", "low", "close"), + requires=_minute_requires("high", "low", "close"), + adjustment="returns_invariant", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -407,6 +461,14 @@ def spec(self) -> FactorSpec: report (PIT truncation, strictly-prior same-slot baseline, μ+σ eruptive threshold, mild-neighbour peak rule, valid-day gate) so a reader sees exactly what was assumed. + + D1 declarations (D0 pre-assignment table row 4 + note 3): adjustment= + none — the factor reads the pure volume channel, never a price + (data/clean/intraday_volume_prv.py:52-55). overnight_boundary=none — + no raw-price comparison exists. The KNOWN raw-volume magnitude jump + across split days is disclosed at the definition site and deliberately + NOT corrected (report alignment); per D0 note 3 it belongs to NEITHER + taxonomy axis — do not "fix" it via these declarations. """ return FactorSpec( factor_id=self.name, @@ -435,6 +497,9 @@ def spec(self) -> FactorSpec: # honest provenance disclosure (data_coverage lists it); the daily panel # surfaces the pre-aggregated column itself. input_fields=("volume",), + requires=_minute_requires("volume"), + adjustment="none", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -506,6 +571,13 @@ def spec(self) -> FactorSpec: cuts EACH DAY by the 1-MINUTE RETURN, then takes the trailing-10-valid-day mean / std of the daily cut and combines them cross-sectionally (the report finds the two only ~30% correlated). + + D1 declarations (D0 pre-assignment table row 5): adjustment= + returns_invariant — amp is the same-day ratio high/low - 1 and the + cut key r = close_t/close_{t-1} - 1 is a within-day ratio, so the + anchor cancels in both. overnight_boundary=none — r is WITHIN-DAY + lagged, "no overnight gap; PR-E precedent" + (data/clean/intraday_amp_cut.py:27-28). """ return FactorSpec( factor_id=self.name, @@ -534,6 +606,9 @@ def spec(self) -> FactorSpec: # honest provenance disclosure (data_coverage lists them); the daily panel # surfaces the pre-aggregated column itself. input_fields=("high", "low", "close"), + requires=_minute_requires("high", "low", "close"), + adjustment="returns_invariant", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -607,6 +682,14 @@ def spec(self) -> FactorSpec: statistic — the shape of the gap distribution rather than the peak count — plus the two interpretations the report leaves open (interval unit, kurtosis convention). + + D1 declarations (D0 pre-assignment table row 6): adjustment=none — the + peak identification is PR-F's pure volume machinery (reused, not + re-implemented; data/clean/intraday_peak_interval.py:8-9) and the + statistic is a kurtosis of trading-minute POSITION gaps (:20-21), so + no price is ever read. overnight_boundary=none — no raw-price + comparison exists. The split-day raw-volume σ pollution is disclosed + (same as PR-F, :51-53) and per D0 note 3 belongs to neither axis. """ return FactorSpec( factor_id=self.name, @@ -641,6 +724,9 @@ def spec(self) -> FactorSpec: # honest provenance disclosure (data_coverage lists it); the daily panel # surfaces the pre-aggregated column itself. input_fields=("volume",), + requires=_minute_requires("volume"), + adjustment="none", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -717,6 +803,15 @@ def spec(self) -> FactorSpec: classification, but a PRICE LEVEL at the VALLEYS rather than a count or timing of the PEAKS) plus the pinned choices, including the one place we KNOWINGLY DEVIATE from the report: our day VWAP covers the PIT-visible window only. + + D1 declarations (D0 pre-assignment table row 7): adjustment= + returns_invariant — both VWAP legs are sums over the SAME trading day + and "a split/dividend adjustment factor is constant within a day, so + it cancels exactly in the ratio" + (data/clean/intraday_valley_vwap.py:33-35). Price information arrives + via Σamount/Σvolume, which is why requires lists no OHLC field — the + anomaly is real and intended. overnight_boundary=none — both legs are + same-day; nothing crosses the overnight boundary. """ return FactorSpec( factor_id=self.name, @@ -755,6 +850,9 @@ def spec(self) -> FactorSpec: # honest provenance disclosure (data_coverage lists them); the daily panel # surfaces the pre-aggregated column itself. input_fields=("volume", "amount"), + requires=_minute_requires("volume", "amount"), + adjustment="returns_invariant", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -832,6 +930,13 @@ def spec(self) -> FactorSpec: VWAP identity, DENOMINATOR swapped from the whole visible day to the ridge bars) plus the pinned choices — above all the ASYMMETRIC bar floor, which exists because ridge bars are structurally far scarcer than valley bars. + + D1 declarations (D0 pre-assignment table row 8): adjustment= + returns_invariant — the same same-day two-leg cancellation argument as + PR-I, with the denominator swapped to the ridge VWAP + (data/clean/intraday_valley_ridge_vwap.py:42-45); price arrives via + Σamount/Σvolume, so requires lists no OHLC field. overnight_boundary= + none — both legs are same-day. """ return FactorSpec( factor_id=self.name, @@ -880,6 +985,9 @@ def spec(self) -> FactorSpec: # honest provenance disclosure (data_coverage lists them); the daily panel # surfaces the pre-aggregated column itself. input_fields=("volume", "amount"), + requires=_minute_requires("volume", "amount"), + adjustment="returns_invariant", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -959,6 +1067,14 @@ def spec(self) -> FactorSpec: a RETURN statistic instead of a count / timing moment / price level) plus the pinned choices — above all the SIMPLE-SUM convention, the within-day return lag and the raw closes, none of which the report specifies. + + D1 declarations (D0 pre-assignment table row 9): adjustment= + returns_invariant — the minute return close_t/close_(t-1) - 1 is a + within-day ratio, and "a split/dividend adjustment factor is constant + WITHIN a day, so it cancels exactly" + (data/clean/intraday_ridge_return.py:39-42). overnight_boundary=none — + the same docstring's own claim, "no return ever straddles an ex-date + boundary" (the within-day lag drops each day's first visible bar). """ return FactorSpec( factor_id=self.name, @@ -1014,6 +1130,9 @@ def spec(self) -> FactorSpec: # honest provenance disclosure (data_coverage lists them); the daily panel # surfaces the pre-aggregated column itself. input_fields=("volume", "close"), + requires=_minute_requires("volume", "close"), + adjustment="returns_invariant", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) @@ -1086,6 +1205,22 @@ def spec(self) -> FactorSpec: its five siblings: the PREV_CLOSE-extended range read at OUR 14:50 visibility rather than the report's true daily close, and the reversal neutralization taken at T-1 so day d's 15:00 close is never an input to day d's value. + + D1 declarations (D0 pre-assignment table row 10 + note 2 — the + two-axes-differ exemplar): adjustment=returns_invariant — the rev20 + leg is a ratio of FRONT-ADJUSTED daily closes (anchor cancels; + data/clean/intraday_valley_quantile.py:49-55, :403-404) and the minute + legs are raw but fully same-day, so an ANCHOR perturbation does not + move the value. overnight_boundary=CROSSED_DISCLOSED — prev_close is a + raw close on the PREVIOUS day's basis entering day d's range, the + pinned definition keeps the ex-date values, and the deviation is + MEASURED and disclosed (0.73% true ex-dates / ~0.21% ex-date AND + range-distorted; intraday_valley_quantile.py:56-72 and pinned in + tests/test_valley_price_quantile_factor.py). All three + CROSSED_DISCLOSED requirements are met — unlike PR-D, nothing is owed. + ``requires`` adds market_daily close beyond the minute fields: the T-1 + rev20 neutralization reads the qfq DAILY close panel (a real second + endpoint, declared truthfully rather than mirrored off input_fields). """ return FactorSpec( factor_id=self.name, @@ -1158,6 +1293,15 @@ def spec(self) -> FactorSpec: # provenance disclosure (data_coverage lists them); the daily panel surfaces # the pre-aggregated column itself. input_fields=("volume", "amount", "high", "low", "close"), + requires=( + *_minute_requires("volume", "amount", "high", "low", "close"), + # The T-1 rev20 neutralization reads the front-adjusted DAILY + # close panel — a genuine market_daily requirement on top of + # the minute fields (see the docstring's D1 paragraph). + PanelField("close", source=MARKET_DAILY), + ), + adjustment="returns_invariant", + overnight_boundary="crossed_disclosed", family="microstructure", min_history_bars=0, ) @@ -1239,6 +1383,14 @@ def spec(self) -> FactorSpec: The description spells out the AGGREGATION FORM, which is the one place this factor's definition departs from its siblings: the report specifies a RATIO OF 20-DAY SUMS, not the mean of daily ratios that §7.1 (PR-J) specifies. + + D1 declarations (D0 pre-assignment table row 11): adjustment=none — + "``amount`` is traded VALUE in RMB, which no split or dividend + adjustment factor rescales ... there is nothing to cancel" + (data/clean/intraday_amount_ratio.py:53-57); volume only drives the + reused classification, and no price is ever read (zero price content — + one disclaimer covers both axes). overnight_boundary=none — no + raw-price comparison exists. """ return FactorSpec( factor_id=self.name, @@ -1297,6 +1449,9 @@ def spec(self) -> FactorSpec: # for honest provenance disclosure (data_coverage lists them); the daily panel # surfaces the pre-aggregated column itself. input_fields=("volume", "amount"), + requires=_minute_requires("volume", "amount"), + adjustment="none", + overnight_boundary="none", family="microstructure", min_history_bars=0, ) diff --git a/factors/compute/momentum.py b/factors/compute/momentum.py index 7bb758f..00de5f6 100644 --- a/factors/compute/momentum.py +++ b/factors/compute/momentum.py @@ -21,8 +21,9 @@ import pandas as pd +from data.availability_policy import MARKET_DAILY from factors.base import Factor -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField class MomentumFactor(Factor): @@ -65,6 +66,15 @@ def spec(self) -> FactorSpec: P3-3/P3-4 found momentum_20 IC ~= 0 and sign-flipping across cells — but the prior stays the STATED hypothesis, which is the point: the sign is fixed before the run and the verdict then checks it factually. + + D1 declarations (derived, evidence): adjustment=returns_invariant — + ``compute`` is the ratio of two front-adjusted (qfq) closes + (``price / prev - 1``, this file), so the adjustment anchor cancels + exactly (data-layer lock: + tests/test_adjust.py::test_front_adjust_returns_invariant_to_anchor). + overnight_boundary=none — both legs sit on the SAME qfq basis, so no + RAW-price comparison ever crosses the ex-date basis break + (``front_adjust`` removed it upstream, data/clean/adjust.py). """ return FactorSpec( factor_id=self.name, @@ -78,6 +88,9 @@ def spec(self) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=(self._price_col,), + requires=(PanelField(self._price_col, source=MARKET_DAILY),), + adjustment="returns_invariant", + overnight_boundary="none", family="momentum", # value at t needs bars t-window..t -> the leading ``window`` rows # of every symbol are NaN by construction. diff --git a/factors/requires.py b/factors/requires.py new file mode 100644 index 0000000..f99d8f0 --- /dev/null +++ b/factors/requires.py @@ -0,0 +1,67 @@ +"""``PanelField``: one endpoint-level data requirement of a factor (D1, R25). + +A factor declares WHAT data it needs; the availability policy table +(``data/availability_policy.py``, the D0 single source of truth) declares WHEN +that data is visible per view. The two are deliberately kept apart (design +v3.2 §3.1): availability is a property of the ENDPOINT (field-level only for +``market_daily``, R7), never something a factor writes on itself. + +Endpoint-only by decision R25: ``source`` must name an endpoint of the +availability policy table. There are NO factor-id references and NO +topological sort here — the whole repo has zero factor-on-factor consumers, +so that machinery stays unbuilt until the first residual-momentum-style +factor is actually approved (design §11). + +Validation happens at CONSTRUCTION time (R8 endpoint closure): an undeclared +``source`` has no availability rule, and any fallback for it would be a +field-level lookahead entry point — so it is a readable error, never a +``dict.get`` default. For ``market_daily`` the FIELD is validated too (R7: +availability there is field-level; an unknown field has no rule). For every +other endpoint field-name validity stays the feed's business, exactly as +``data.availability_policy.rule_for`` documents. + +Leaf-module discipline: imports only the stdlib and the availability policy +(itself stdlib-only). Never import pandas, feeds, qt, or the registry — the +registry and the D4 materializer sit ABOVE this file. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from data.availability_policy import MARKET_DAILY, require_known_source, rule_for + + +@dataclass(frozen=True, slots=True) +class PanelField: + """One (field, source-endpoint) requirement, validated at construction. + + Attributes + ---------- + field : the column/field name the factor reads (e.g. ``"close"``, ``"pe"``). + source : the availability-policy endpoint publishing it (e.g. + ``"market_daily"``, ``"daily_basic"``, ``"stk_mins_1min"``). Must be a + declared endpoint of the policy table — R8 endpoint closure fires HERE, + at declaration time, not somewhere downstream. + """ + + field: str + source: str + + def __post_init__(self) -> None: + for attr in ("field", "source"): + value = getattr(self, attr) + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"PanelField.{attr} must be a non-empty string; got {value!r}." + ) + # R8 endpoint closure: unknown source -> readable error (single source + # of truth: data.availability_policy.require_known_source). + require_known_source(self.source) + # R7: market_daily availability is FIELD-level, so an unknown field has + # no availability rule -> the policy module's own resolver rejects it. + if self.source == MARKET_DAILY: + rule_for(self.source, self.field) + + +__all__ = ["PanelField"] diff --git a/factors/spec.py b/factors/spec.py index 4ac5ef4..9383270 100644 --- a/factors/spec.py +++ b/factors/spec.py @@ -1,5 +1,24 @@ """``FactorSpec``: the factor-INTRINSIC half of the factor-evaluation contract. +CONTRACT v1.0 (factor-refactor D1 — an EXPLICITLY AUTHORIZED change to the +previously frozen contract, following the #74 precedent of stating contract +version changes outright): three declaration dimensions become MANDATORY on +every spec — + + * ``requires`` — the endpoint-level :class:`~factors.requires.PanelField` + inputs (R25 endpoint-only; R8 endpoint closure fires inside PanelField); + * ``adjustment`` — how stored values relate to the price-adjustment anchor + (value domain: ``data.availability_policy.Adjustment``, the single source); + * ``overnight_boundary`` — how day-d values relate to the ex-date basis + break (value domain: ``data.availability_policy.OvernightBoundary``). + +A missing declaration is a readable construction-time error, exactly like the +pre-existing mandatory fields (for a class-attribute spec that means at class +DEFINITION time; for a property spec, at instantiation — the same two +enforcement points the metaclass already guards). The taxonomy semantics and +the per-factor pre-assignments live in ``docs/factors/refactor_d0_contract.md`` +(D0); this module never restates that table. + Two objects together form the provenance an evaluator requires (design doc ``tmp/design/factor_eval_contract_v0.1.md`` §1): @@ -13,8 +32,9 @@ enforces it at class-definition time), so if ``FactorSpec`` lived in ``analytics/`` then ``factors`` would import ``analytics`` — an UPWARD dependency. ``analytics`` importing ``factors.spec`` is downstream->upstream - and therefore fine. This module deliberately imports nothing from the - project (no pandas either): it is a pure declaration. + and therefore fine. This module imports ONLY other pure declaration leaves + (``data.availability_policy``, ``factors.requires`` — both stdlib-only, no + pandas): it stays a pure declaration. The construction-time validators below are enforcement layer #1 of three (design §7): declare the hypothesis and the PIT contract, or you cannot even build the @@ -27,6 +47,10 @@ from collections.abc import Sequence from dataclasses import dataclass +from enum import StrEnum + +from data.availability_policy import Adjustment, OvernightBoundary +from factors.requires import PanelField # The ONLY basis an intraday factor may declare: the minute tail model's holding # period runs execution-anchor to execution-anchor, exec(T) -> exec(T_next), and @@ -56,6 +80,34 @@ "execution_window", ) +# Price-channel field names for the D0 §1.1 static check: adjustment="none" +# declares the factor NEVER touches the price channel, so requiring any of +# these fields under that declaration is a contradiction caught at +# construction. Only this direction is checked — the converse (an adjustment +# that cancels the anchor without reading OHLC directly) is legitimate: a VWAP +# built as sum(amount)/sum(volume) is price information with no OHLC field. +_PRICE_CHANNEL_FIELDS: frozenset[str] = frozenset({"open", "high", "low", "close"}) + + +def _coerce_taxonomy( + enum_cls: type[StrEnum], value: object, field_name: str, factor_id: str +) -> StrEnum: + """Coerce a taxonomy declaration into its policy enum, readably. + + The value domain is the ``data.availability_policy`` enum itself (single + source, D0); string values are accepted for ergonomics and coerced, so no + second list of allowed values exists anywhere. + """ + try: + return enum_cls(value) # accepts the enum member or its string value + except ValueError: + allowed = ", ".join(repr(m.value) for m in enum_cls) + raise ValueError( + f"FactorSpec.{field_name} must be one of {allowed} (the " + f"data.availability_policy.{enum_cls.__name__} enum — the single " + f"value-domain source); got {value!r} for {factor_id!r}." + ) from None + @dataclass(frozen=True) class FactorSpec: @@ -75,6 +127,19 @@ class FactorSpec: return_basis : one of :data:`RETURN_BASES`. input_fields : the panel columns the factor actually reads, so an evaluator can check availability + coverage instead of guessing. + requires : MANDATORY (contract v1.0) — endpoint-level + :class:`~factors.requires.PanelField` inputs. ``input_fields`` names + panel COLUMNS for coverage checks; ``requires`` names the ENDPOINT each + input comes from, so the availability policy can resolve when it is + visible per view. Declared with a ``None`` default purely so the + keyword-style constructor stays orderable; ``None`` (i.e. not + declaring it) is a readable error. + adjustment : MANDATORY (contract v1.0) — ``data.availability_policy. + Adjustment`` member (or its string value, coerced). Drives the D3 + store-fingerprint derivation; see D0 §1.1 for semantics/testability. + overnight_boundary : MANDATORY (contract v1.0) — ``data.availability_ + policy.OvernightBoundary`` member (or its string value, coerced). See + D0 §1.2 for semantics/testability. price_adjust : one of :data:`PRICE_ADJUSTMENTS`. family : orthogonality grouping (momentum/value/lowvol/microstructure/...). min_history_bars : leading warm-up bars that are NaN by construction, so the @@ -91,6 +156,14 @@ class FactorSpec: forward_return_horizon: int return_basis: str input_fields: tuple[str, ...] + # Contract v1.0 mandatory declarations. The ``None`` defaults exist ONLY + # because dataclass field ordering forbids non-default fields after the + # defaulted tail below; ``_check_declarations`` rejects ``None`` readably, + # so omission still fails at definition/instantiation time like every + # other mandatory field. + requires: tuple[PanelField, ...] | None = None + adjustment: Adjustment | str | None = None + overnight_boundary: OvernightBoundary | str | None = None price_adjust: str = "qfq" family: str | None = None min_history_bars: int = 0 @@ -105,6 +178,7 @@ def __post_init__(self) -> None: self._check_hypothesis() self._check_measurement() self._check_inputs() + self._check_declarations() self._check_intraday_block() # -- validators (enforcement layer #1) -------------------------------- @@ -193,6 +267,85 @@ def _check_inputs(self) -> None: # immutable + hashable. object.__setattr__(self, "input_fields", normalized) + def _check_declarations(self) -> None: + """Contract v1.0: requires / adjustment / overnight_boundary are mandatory.""" + requires = self.requires + if requires is None: + raise ValueError( + f"FactorSpec({self.factor_id!r}) is missing the 'requires' " + f"declaration — contract v1.0 makes it MANDATORY. Declare the " + f"endpoint-level inputs as a tuple of PanelField(field, source) " + f"(factors/requires.py); sources must be endpoints of the " + f"availability policy table (data/availability_policy.py)." + ) + if isinstance(requires, (str, PanelField)) or not isinstance( + requires, Sequence + ): + raise ValueError( + f"FactorSpec.requires must be a sequence of PanelField entries " + f"(not a bare {type(requires).__name__}); got {requires!r} for " + f"{self.factor_id!r}." + ) + normalized = tuple(requires) + if not normalized: + raise ValueError( + f"FactorSpec.requires must be non-empty for {self.factor_id!r}: " + f"every factor reads SOME endpoint data, and an empty " + f"declaration would make its availability unresolvable." + ) + bad = [r for r in normalized if not isinstance(r, PanelField)] + if bad: + raise ValueError( + f"FactorSpec.requires entries must be PanelField instances; got " + f"{bad!r} for {self.factor_id!r}." + ) + dupes = sorted( + {(r.field, r.source) for r in normalized if normalized.count(r) > 1} + ) + if dupes: + raise ValueError( + f"FactorSpec.requires has duplicate (field, source) entries " + f"{dupes} for {self.factor_id!r}; declare each requirement once." + ) + object.__setattr__(self, "requires", normalized) + + for field_name, enum_cls in ( + ("adjustment", Adjustment), + ("overnight_boundary", OvernightBoundary), + ): + value = getattr(self, field_name) + if value is None: + raise ValueError( + f"FactorSpec({self.factor_id!r}) is missing the " + f"{field_name!r} declaration — contract v1.0 makes it " + f"MANDATORY. Declare a data.availability_policy." + f"{enum_cls.__name__} value (semantics + per-factor " + f"pre-assignments: docs/factors/refactor_d0_contract.md)." + ) + object.__setattr__( + self, + field_name, + _coerce_taxonomy(enum_cls, value, field_name, self.factor_id), + ) + + # D0 §1.1 static check (declaration-is-testable, the cheap half): + # adjustment="none" claims the factor never touches the price channel, + # so requiring a price-channel field under it is a contradiction. + if self.adjustment is Adjustment.NONE: + touched = sorted( + f"{r.source}.{r.field}" + for r in normalized + if r.field in _PRICE_CHANNEL_FIELDS + ) + if touched: + raise ValueError( + f"FactorSpec({self.factor_id!r}) declares adjustment='none' " + f"(never touches the price channel) but requires the " + f"price-channel field(s) {touched}. Declare " + f"'returns_invariant' or 'price_level' instead (D0 §1.1: " + f"the none/returns_invariant split IS the input list)." + ) + def _check_intraday_block(self) -> None: present = [f for f in INTRADAY_FIELDS if getattr(self, f) is not None] if self.is_intraday: @@ -242,6 +395,7 @@ def _check_intraday_block(self) -> None: __all__ = [ "FactorSpec", + "PanelField", # re-export: the requires entry type (single class, factors.requires) "RETURN_BASES", "PRICE_ADJUSTMENTS", "INTRADAY_FIELDS", diff --git a/tests/test_eval_figures.py b/tests/test_eval_figures.py index f4785ce..694e166 100644 --- a/tests/test_eval_figures.py +++ b/tests/test_eval_figures.py @@ -19,7 +19,7 @@ render_factor_dashboard, ) from data.clean.schema import DATE_LEVEL, SYMBOL_LEVEL -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField # --------------------------------------------------------------------------- # @@ -45,6 +45,10 @@ def _spec(**over): description="a synthetic factor computed as the 20d mean of X", expected_ic_sign=1, is_intraday=False, forward_return_horizon=1, return_basis="close_to_close", input_fields=("close", "amount"), + # Contract v1.0 mandatory declarations (D1). + requires=(PanelField("close", source="market_daily"), + PanelField("amount", source="market_daily")), + adjustment="returns_invariant", overnight_boundary="none", family="microstructure") kw.update(over) return FactorSpec(**kw) diff --git a/tests/test_exec_basis_eval.py b/tests/test_exec_basis_eval.py index b88d4e4..ec31def 100644 --- a/tests/test_exec_basis_eval.py +++ b/tests/test_exec_basis_eval.py @@ -24,7 +24,7 @@ from data.cache.intraday_cache import ENDPOINT as MINUTE_ENDPOINT from data.cache.intraday_parquet_store import KEY_COLS, IntradayParquetStore from data.clean.intraday_schema import RAW_INTRADAY_FREQ -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField from qt.config import ( AlphaCfg, BacktestCfg, @@ -126,6 +126,8 @@ def _fixture(tmp_path, n_days: int = 40): description="synthetic minute-derived factor", expected_ic_sign=1, is_intraday=False, forward_return_horizon=1, return_basis="close_to_close", input_fields=("close",), + requires=(PanelField("close", source="stk_mins_1min"),), + adjustment="returns_invariant", overnight_boundary="none", family="microstructure", ) eval_cfg = EvalConfig( @@ -227,6 +229,8 @@ def test_exec_basis_eval_shares_one_artifact_across_factors(tmp_path): description="a different synthetic minute factor", expected_ic_sign=-1, is_intraday=False, forward_return_horizon=1, return_basis="close_to_close", input_fields=("close",), + requires=(PanelField("close", source="stk_mins_1min"),), + adjustment="returns_invariant", overnight_boundary="none", ) other_factor = factor.rename("other_minute_factor") * -1.0 second = run_exec_basis_evaluation( diff --git a/tests/test_exec_forward_returns.py b/tests/test_exec_forward_returns.py index 12dd271..e33dbc6 100644 --- a/tests/test_exec_forward_returns.py +++ b/tests/test_exec_forward_returns.py @@ -22,7 +22,7 @@ from data.cache.intraday_cache import ENDPOINT as MINUTE_ENDPOINT from data.cache.intraday_parquet_store import KEY_COLS, IntradayParquetStore from data.clean.intraday_schema import RAW_INTRADAY_FREQ, normalize_intraday_bars -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField from qt.config import ( AlphaCfg, BacktestCfg, @@ -408,6 +408,9 @@ def _daily_spec() -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=("close",), + requires=(PanelField("close", source="stk_mins_1min"),), + adjustment="returns_invariant", + overnight_boundary="none", family="microstructure", ) diff --git a/tests/test_factor_eval_contract.py b/tests/test_factor_eval_contract.py index bbae9a8..d10a81c 100644 --- a/tests/test_factor_eval_contract.py +++ b/tests/test_factor_eval_contract.py @@ -68,6 +68,7 @@ INTRADAY_RETURN_BASIS, RETURN_BASES, FactorSpec, + PanelField, ) @@ -82,6 +83,10 @@ def _spec(**overrides) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=("close",), + # Contract v1.0 mandatory declarations (D1). + requires=(PanelField("close", source="market_daily"),), + adjustment="returns_invariant", + overnight_boundary="none", ) base.update(overrides) return FactorSpec(**base) diff --git a/tests/test_factor_eval_standard.py b/tests/test_factor_eval_standard.py index 6c9aab5..c26c1a4 100644 --- a/tests/test_factor_eval_standard.py +++ b/tests/test_factor_eval_standard.py @@ -58,7 +58,7 @@ spearman_series_by_date, ) from analytics.factor import compute_ic -from factors.spec import FactorSpec +from factors.spec import FactorSpec, PanelField from qt.intraday_groups import assign_quantile_buckets as canonical_buckets DATE = "date" @@ -156,6 +156,10 @@ def make_spec(**overrides) -> FactorSpec: forward_return_horizon=1, return_basis="close_to_close", input_fields=("close",), + # Contract v1.0 mandatory declarations (D1). + requires=(PanelField("close", source="market_daily"),), + adjustment="returns_invariant", + overnight_boundary="none", ) kwargs.update(overrides) return FactorSpec(**kwargs) diff --git a/tests/test_factor_requires_spec_v1.py b/tests/test_factor_requires_spec_v1.py new file mode 100644 index 0000000..64be00e --- /dev/null +++ b/tests/test_factor_requires_spec_v1.py @@ -0,0 +1,325 @@ +"""PanelField + FactorSpec contract v1.0 (factor-refactor D1). + +Locks the two new declaration layers: + + * :class:`factors.requires.PanelField` — endpoint-only requires entries with + R8 endpoint closure (unknown source -> readable error at DECLARATION time) + and R7 field-level validation for ``market_daily``. + * FactorSpec contract v1.0 — ``requires`` / ``adjustment`` / + ``overnight_boundary`` are MANDATORY, value domains are the + ``data.availability_policy`` enums (single source), and the D0 §1.1 + static check (adjustment='none' must not require a price-channel field) + fires at construction. + +Plus the D0 §2 pre-assignment table lock: every shipped factor's declarations +must match the reviewed paper contract row-for-row — a silent drift of any +declaration is exactly what these rows would catch. +""" + +from __future__ import annotations + +import pytest + +from data.availability_policy import ( + Adjustment, + OvernightBoundary, +) +from factors.compute.candidates import ( + LiquidityFactor, + OvernightMomentumFactor, + ReversalFactor, + ValueFactor, + VolatilityFactor, +) +from factors.compute.financial import FinancialFactor +from factors.compute.intraday_derived import ( + AmpMarginalAnomalyVolFactor, + IntradayAmpCutFactor, + JumpAmountCorrFactor, + MinuteIdealAmplitudeFactor, + PeakIntervalKurtosisFactor, + PeakRidgeAmountRatioFactor, + RidgeMinuteReturnFactor, + ValleyPriceQuantileFactor, + ValleyRelativeVwapFactor, + ValleyRidgeVwapRatioFactor, + VolumePeakCountFactor, +) +from factors.compute.momentum import MomentumFactor +from factors.spec import FactorSpec, PanelField + + +def _spec(**overrides) -> FactorSpec: + base = dict( + factor_id="unit_test_factor", + version="1.0", + description="A test factor.", + expected_ic_sign=+1, + is_intraday=False, + forward_return_horizon=1, + return_basis="close_to_close", + input_fields=("close",), + requires=(PanelField("close", source="market_daily"),), + adjustment="returns_invariant", + overnight_boundary="none", + ) + base.update(overrides) + return FactorSpec(**base) + + +# --------------------------------------------------------------------------- # +# PanelField +# --------------------------------------------------------------------------- # +def test_panel_field_is_frozen_and_hashable(): + pf = PanelField("close", source="market_daily") + assert (pf.field, pf.source) == ("close", "market_daily") + with pytest.raises(Exception): # frozen dataclass forbids mutation + pf.field = "open" + assert PanelField("close", source="market_daily") == pf + assert len({pf, PanelField("close", source="market_daily")}) == 1 + + +def test_panel_field_unknown_source_is_a_readable_error(): + # R8 endpoint closure at DECLARATION time: an undeclared endpoint must + # never be dict.get-defaulted into close-like availability downstream. + with pytest.raises(ValueError, match="unknown availability source"): + PanelField("net_mf_amount", source="moneyflow") + + +def test_panel_field_market_daily_field_level_validation(): + # R7: market_daily availability is field-level, so an unknown field has + # no availability rule and must be rejected at declaration time. + with pytest.raises(ValueError, match="unknown market_daily field"): + PanelField("vwap", source="market_daily") + # All six declared fields are constructible. + for field in ("open", "close", "high", "low", "volume", "amount"): + PanelField(field, source="market_daily") + + +def test_panel_field_non_market_endpoints_accept_feed_level_fields(): + # Field-name validity is the feed's business for endpoint-level sources + # (documented in data.availability_policy.rule_for). + PanelField("pe", source="daily_basic") + PanelField("roe", source="fina_indicator") + PanelField("volume", source="stk_mins_1min") + + +def test_panel_field_blank_parts_are_rejected(): + with pytest.raises(ValueError, match="field"): + PanelField("", source="market_daily") + with pytest.raises(ValueError, match="source"): + PanelField("close", source=" ") + + +# --------------------------------------------------------------------------- # +# FactorSpec contract v1.0: the three declarations are MANDATORY +# --------------------------------------------------------------------------- # +def test_missing_requires_is_a_readable_error(): + with pytest.raises(ValueError, match="missing the 'requires' declaration"): + _spec(requires=None) + + +def test_missing_adjustment_is_a_readable_error(): + with pytest.raises(ValueError, match="missing the 'adjustment' declaration"): + _spec(adjustment=None) + + +def test_missing_overnight_boundary_is_a_readable_error(): + with pytest.raises( + ValueError, match="missing the 'overnight_boundary' declaration" + ): + _spec(overnight_boundary=None) + + +def test_taxonomy_accepts_enum_members_and_strings_and_stores_enums(): + a = _spec(adjustment=Adjustment.RETURNS_INVARIANT, + overnight_boundary=OvernightBoundary.NONE) + b = _spec(adjustment="returns_invariant", overnight_boundary="none") + assert a.adjustment is Adjustment.RETURNS_INVARIANT + assert b.adjustment is Adjustment.RETURNS_INVARIANT + assert a.overnight_boundary is OvernightBoundary.NONE + assert b.overnight_boundary is OvernightBoundary.NONE + + +def test_unknown_taxonomy_values_are_readable_errors_listing_the_enum(): + # The allowed values in the message come from the policy enum itself — + # there is no second value list to drift. + with pytest.raises(ValueError, match="Adjustment"): + _spec(adjustment="qfq_level") + with pytest.raises(ValueError, match="OvernightBoundary"): + _spec(overnight_boundary="masked_disclosed") + with pytest.raises(ValueError, match="adjustment"): + _spec(adjustment=True) # bool is not a taxonomy value either + + +def test_requires_must_be_a_nonempty_panel_field_sequence(): + with pytest.raises(ValueError, match="sequence of PanelField"): + _spec(requires="close") # a bare string is the classic footgun + with pytest.raises(ValueError, match="sequence of PanelField"): + _spec(requires=PanelField("close", source="market_daily")) # bare entry + with pytest.raises(ValueError, match="non-empty"): + _spec(requires=()) + with pytest.raises(ValueError, match="PanelField instances"): + _spec(requires=("close",)) + + +def test_requires_list_is_normalized_to_tuple_and_duplicates_rejected(): + spec = _spec(requires=[PanelField("close", source="market_daily")]) + assert isinstance(spec.requires, tuple) + with pytest.raises(ValueError, match="duplicate"): + _spec( + requires=( + PanelField("close", source="market_daily"), + PanelField("close", source="market_daily"), + ) + ) + + +def test_same_field_from_two_sources_is_not_a_duplicate(): + # valley_price_quantile's real shape: minute close AND daily close. + spec = _spec( + requires=( + PanelField("close", source="stk_mins_1min"), + PanelField("close", source="market_daily"), + ) + ) + assert len(spec.requires) == 2 + + +def test_adjustment_none_with_a_price_channel_requirement_contradicts(): + # D0 §1.1 static check: 'none' IS the claim "never touches the price + # channel", so requiring OHLC under it must fail loudly. + with pytest.raises(ValueError, match="price-channel"): + _spec(adjustment="none") # base requires includes market_daily close + # ... while a volume/amount-only requires under 'none' is legitimate: + _spec( + adjustment="none", + input_fields=("amount",), + requires=(PanelField("amount", source="market_daily"),), + ) + # ... and returns_invariant WITHOUT an OHLC field is legitimate too (the + # VWAP-ratio factors: price arrives via sum(amount)/sum(volume)) — the + # converse direction is deliberately NOT checked. + _spec( + adjustment="returns_invariant", + input_fields=("volume", "amount"), + requires=( + PanelField("volume", source="stk_mins_1min"), + PanelField("amount", source="stk_mins_1min"), + ), + ) + + +# --------------------------------------------------------------------------- # +# D0 §2 pre-assignment table lock (all shipped factors, row for row) +# --------------------------------------------------------------------------- # +_MD = "market_daily" +_MIN = "stk_mins_1min" + +# (factory, adjustment, overnight_boundary, requires as (field, source) pairs) +_D0_TABLE = [ + # finishing-line 14 (docs/factors/refactor_d0_contract.md §2), rows 1-14 + (lambda: JumpAmountCorrFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, + (("high", _MIN), ("low", _MIN), ("open", _MIN), ("amount", _MIN))), + (lambda: MinuteIdealAmplitudeFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.CROSSED_DISCLOSED, + (("high", _MIN), ("low", _MIN), ("close", _MIN))), + (lambda: AmpMarginalAnomalyVolFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, + (("high", _MIN), ("low", _MIN), ("close", _MIN))), + (lambda: VolumePeakCountFactor(), Adjustment.NONE, OvernightBoundary.NONE, + (("volume", _MIN),)), + (lambda: IntradayAmpCutFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, + (("high", _MIN), ("low", _MIN), ("close", _MIN))), + (lambda: PeakIntervalKurtosisFactor(), Adjustment.NONE, + OvernightBoundary.NONE, (("volume", _MIN),)), + (lambda: ValleyRelativeVwapFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, (("volume", _MIN), ("amount", _MIN))), + (lambda: ValleyRidgeVwapRatioFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, (("volume", _MIN), ("amount", _MIN))), + (lambda: RidgeMinuteReturnFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, (("volume", _MIN), ("close", _MIN))), + (lambda: ValleyPriceQuantileFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.CROSSED_DISCLOSED, + (("volume", _MIN), ("amount", _MIN), ("high", _MIN), ("low", _MIN), + ("close", _MIN), ("close", _MD))), + (lambda: PeakRidgeAmountRatioFactor(), Adjustment.NONE, + OvernightBoundary.NONE, (("volume", _MIN), ("amount", _MIN))), + (lambda: ValueFactor("value_ep"), Adjustment.NONE, OvernightBoundary.NONE, + (("pe", "daily_basic"),)), + (lambda: ValueFactor("value_bp"), Adjustment.NONE, OvernightBoundary.NONE, + (("pb", "daily_basic"),)), + (lambda: VolatilityFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, (("close", _MD),)), + # remaining daily factors (declarations derived in D1, evidence in each + # spec docstring) + (lambda: MomentumFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, (("close", _MD),)), + (lambda: ReversalFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, (("close", _MD),)), + (lambda: LiquidityFactor(), Adjustment.NONE, OvernightBoundary.NONE, + (("amount", _MD),)), + (lambda: OvernightMomentumFactor(), Adjustment.RETURNS_INVARIANT, + OvernightBoundary.NONE, (("open", _MD), ("close", _MD))), + (lambda: FinancialFactor("roe"), Adjustment.NONE, OvernightBoundary.NONE, + (("roe", "fina_indicator"),)), + (lambda: FinancialFactor("netprofit_yoy"), Adjustment.NONE, + OvernightBoundary.NONE, (("netprofit_yoy", "fina_indicator"),)), + (lambda: FinancialFactor("grossprofit_margin"), Adjustment.NONE, + OvernightBoundary.NONE, (("grossprofit_margin", "fina_indicator"),)), +] + + +@pytest.mark.parametrize( + "factory, adjustment, overnight, requires", + _D0_TABLE, + ids=[f"{i:02d}" for i in range(len(_D0_TABLE))], +) +def test_shipped_factor_declarations_match_the_d0_table( + factory, adjustment, overnight, requires +): + spec = factory().spec + assert spec.adjustment is adjustment, spec.factor_id + assert spec.overnight_boundary is overnight, spec.factor_id + assert tuple((r.field, r.source) for r in spec.requires) == requires, ( + spec.factor_id + ) + + +def test_zero_price_level_members_today(): + # D0 statistics: PRICE_LEVEL has zero members in the shipped set. The + # fixture-tested store-fingerprint mechanism for it is a D3 obligation + # (kill archive A5-F02) — this row just keeps the census honest. + for factory, *_ in _D0_TABLE: + assert factory().spec.adjustment is not Adjustment.PRICE_LEVEL + + +def test_crossed_disclosed_members_are_exactly_the_two_declared(): + crossed = sorted( + factory().spec.factor_id + for factory, *_ in _D0_TABLE + if factory().spec.overnight_boundary is OvernightBoundary.CROSSED_DISCLOSED + ) + assert crossed == ["minute_ideal_amp_10", "valley_price_quantile_20"] + + +def test_minute_ideal_amplitude_docstring_tracks_the_d2_measurement_debt(): + # D0 §2 note 1: the CROSSED_DISCLOSED pre-assignment is a CANDIDATE whose + # third requirement (the measured deviation) is still missing; D1 pins + # the obligation in the spec docstring so it cannot silently evaporate. + doc = type(MinuteIdealAmplitudeFactor()).spec.fget.__doc__ + assert "MISSING" in doc and "D2" in doc + + +def test_spec_variant_replace_carries_the_new_declarations(): + # dataclasses.replace re-runs validation and must carry the v1.0 fields — + # the exec-basis spec variant path depends on this. + from dataclasses import replace + + spec = _spec() + twin = replace(spec, version="2.0") + assert twin.requires == spec.requires + assert twin.adjustment is spec.adjustment + assert twin.overnight_boundary is spec.overnight_boundary From 7b97f82b58c1003f4e13d19d48b8181127cd6d90 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 23 Jul 2026 13:53:03 -0700 Subject: [PATCH 2/3] feat(factors): add the factor registry with three-timepoint naming checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor-refactor D1, second slice. factors/registry is THE one name -> class mapping (red line #4): register() re-runs the metaclass mandatory- spec validation (red line #1 — reused, not reimplemented), demands a concrete Factor, checks a static class-attribute spec against the class name at definition time, and rejects duplicate keys and mutually- prefixing prefixes readably. build() reproduces the retired _build_factors params coercion exactly (same int()/str() calls, same defaults) and enforces config name == instance name == spec.factor_id — property specs are checked on the built INSTANCE, never forced back into class attributes (pit #2). requirements() aggregates deduplicated PanelField needs for the D4 materializer; require_legal_pairing() forwards the availability-policy view x basis legality check (deep wiring is D4 — D1 only guarantees it is callable from the factor layer). builtin.py is an explicit import + register list (pit #3: no pkgutil auto-discovery) covering the FULL 18-class surface in the retired chain's dispatch order, including the 11 minute-derived factors the chain never dispatched (eval runners construct them directly today). Collision tests run on fresh FactorRegistry instances so the default registry is never polluted. --- factors/registry/__init__.py | 32 ++++ factors/registry/builtin.py | 192 ++++++++++++++++++++ factors/registry/registry.py | 316 +++++++++++++++++++++++++++++++++ tests/test_factor_registry.py | 325 ++++++++++++++++++++++++++++++++++ 4 files changed, 865 insertions(+) create mode 100644 factors/registry/__init__.py create mode 100644 factors/registry/builtin.py create mode 100644 factors/registry/registry.py create mode 100644 tests/test_factor_registry.py diff --git a/factors/registry/__init__.py b/factors/registry/__init__.py new file mode 100644 index 0000000..63985da --- /dev/null +++ b/factors/registry/__init__.py @@ -0,0 +1,32 @@ +"""Factor registry package (D1): the single name -> class dispatch. + +Importing this package loads the explicit builtin registration list +(``factors.registry.builtin`` — design §6 pit #3: an explicit import list, +never pkgutil auto-discovery), so ``resolve`` / ``build`` are ready to use +from any access path. +""" + +from factors.registry.registry import ( + DEFAULT_REGISTRY, + Builder, + FactorRegistry, + RegistryEntry, + build, + register, + requirements, + require_legal_pairing, + resolve, +) +from factors.registry import builtin as _builtin # noqa: F401 (registers the builtins) + +__all__ = [ + "Builder", + "DEFAULT_REGISTRY", + "FactorRegistry", + "RegistryEntry", + "build", + "register", + "requirements", + "require_legal_pairing", + "resolve", +] diff --git a/factors/registry/builtin.py b/factors/registry/builtin.py new file mode 100644 index 0000000..593bc08 --- /dev/null +++ b/factors/registry/builtin.py @@ -0,0 +1,192 @@ +"""Explicit builtin registration list (design §6 pit #3: NO pkgutil discovery). + +Importing this module registers every shipped factor into the default +registry. The list is EXPLICIT on purpose: auto-discovery would turn an +import-order accident into a dispatch-table change, and the whole point of +red line #4 is that this file is the ONE place the name -> class mapping +lives. Add a factor = add its import and its ``register`` call here. + +Registration order is the retired ``_build_factors`` chain's order (financial +and value exact names first, then the name-family prefixes), preserving its +dispatch semantics verbatim: exact names always won over prefixes there +(membership tests preceded ``startswith``), and the family prefixes are +mutually non-overlapping (the registry enforces that at registration time). +Builders reproduce the chain's params coercion EXACTLY — same ``int(...)`` / +``str(...)`` calls, same defaults — so a config that built a factor before D1 +builds a byte-identical factor after it. + +The 11 minute-derived surface factors are registered too (their families were +never in the chain — eval runners construct them directly today), so the +registry covers the FULL factor surface the D4 service will serve; their +builders follow the same window-named convention with ``lookback_days``. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from data.clean.intraday_aggregate import JUMP_LOOKBACK_DAYS +from data.clean.intraday_amount_ratio import PEAK_RIDGE_LOOKBACK_DAYS +from data.clean.intraday_amp_anomaly import AMP_ANOMALY_LOOKBACK_DAYS +from data.clean.intraday_amp_cut import AMP_CUT_LOOKBACK_DAYS +from data.clean.intraday_amplitude import IDEAL_AMP_LOOKBACK_DAYS +from data.clean.intraday_peak_interval import PEAK_INTERVAL_LOOKBACK_DAYS +from data.clean.intraday_ridge_return import RIDGE_RETURN_LOOKBACK_DAYS +from data.clean.intraday_valley_quantile import VALLEY_QUANTILE_LOOKBACK_DAYS +from data.clean.intraday_valley_ridge_vwap import VALLEY_RIDGE_LOOKBACK_DAYS +from data.clean.intraday_valley_vwap import VALLEY_VWAP_LOOKBACK_DAYS +from data.clean.intraday_volume_prv import VOLUME_PRV_LOOKBACK_DAYS +from factors.compute.candidates import ( + VALUE_FIELDS, + LiquidityFactor, + OvernightMomentumFactor, + ReversalFactor, + ValueFactor, + VolatilityFactor, +) +from factors.compute.financial import SUPPORTED_FIELDS, FinancialFactor +from factors.compute.intraday_derived import ( + AmpMarginalAnomalyVolFactor, + IntradayAmpCutFactor, + JumpAmountCorrFactor, + MinuteIdealAmplitudeFactor, + PeakIntervalKurtosisFactor, + PeakRidgeAmountRatioFactor, + RidgeMinuteReturnFactor, + ValleyPriceQuantileFactor, + ValleyRelativeVwapFactor, + ValleyRidgeVwapRatioFactor, + VolumePeakCountFactor, +) +from factors.compute.momentum import MomentumFactor +from factors.registry.registry import register + +Params = Mapping[str, object] + + +def _window(params: Params) -> int: + """The chain's exact window coercion: ``int(params.get("window", 20))``.""" + return int(params.get("window", 20)) # type: ignore[arg-type] + + +# -- exact names (the chain's membership branches, in chain order) --------- + +register( + FinancialFactor, + exact=SUPPORTED_FIELDS, + builder=lambda name, params: FinancialFactor(field=name), +) +register( + ValueFactor, + exact=VALUE_FIELDS, + builder=lambda name, params: ValueFactor(name), +) + +# -- name-family prefixes (the chain's startswith branches, in chain order) - + +register( + OvernightMomentumFactor, + prefix="overnight_mom", + builder=lambda name, params: OvernightMomentumFactor( + window=_window(params), + open_col=str(params.get("open_col", "open")), + close_col=str(params.get("close_col", "close")), + ), +) +register( + MomentumFactor, + prefix="momentum", + builder=lambda name, params: MomentumFactor( + window=_window(params), price_col=str(params.get("price_col", "close")) + ), +) +register( + ReversalFactor, + prefix="reversal", + builder=lambda name, params: ReversalFactor( + window=_window(params), price_col=str(params.get("price_col", "close")) + ), +) +register( + VolatilityFactor, + prefix="volatility", + builder=lambda name, params: VolatilityFactor( + window=_window(params), price_col=str(params.get("price_col", "close")) + ), +) +register( + LiquidityFactor, + prefix="liquidity", + builder=lambda name, params: LiquidityFactor( + window=_window(params), amount_col=str(params.get("amount_col", "amount")) + ), +) + +# -- minute-derived surface factors (never in the chain; registered so the -- +# -- registry covers the full factor surface for D4) ------------------------ + + +def _lookback_builder(cls, default_days: int): + """Builder for the ``{family}_{lookback_days}`` minute-derived factors.""" + + def _build(name: str, params: Params): + return cls(lookback_days=int(params.get("lookback_days", default_days))) # type: ignore[arg-type] + + return _build + + +register( + JumpAmountCorrFactor, + prefix="jump_amount_corr", + builder=_lookback_builder(JumpAmountCorrFactor, JUMP_LOOKBACK_DAYS), +) +register( + MinuteIdealAmplitudeFactor, + prefix="minute_ideal_amp", + builder=_lookback_builder(MinuteIdealAmplitudeFactor, IDEAL_AMP_LOOKBACK_DAYS), +) +register( + AmpMarginalAnomalyVolFactor, + prefix="amp_marginal_anomaly_vol", + builder=_lookback_builder(AmpMarginalAnomalyVolFactor, AMP_ANOMALY_LOOKBACK_DAYS), +) +register( + VolumePeakCountFactor, + prefix="volume_peak_count", + builder=_lookback_builder(VolumePeakCountFactor, VOLUME_PRV_LOOKBACK_DAYS), +) +register( + IntradayAmpCutFactor, + prefix="intraday_amp_cut", + builder=_lookback_builder(IntradayAmpCutFactor, AMP_CUT_LOOKBACK_DAYS), +) +register( + PeakIntervalKurtosisFactor, + prefix="peak_interval_kurtosis", + builder=_lookback_builder(PeakIntervalKurtosisFactor, PEAK_INTERVAL_LOOKBACK_DAYS), +) +register( + ValleyRelativeVwapFactor, + prefix="valley_relative_vwap", + builder=_lookback_builder(ValleyRelativeVwapFactor, VALLEY_VWAP_LOOKBACK_DAYS), +) +register( + ValleyRidgeVwapRatioFactor, + prefix="valley_ridge_vwap_ratio", + builder=_lookback_builder(ValleyRidgeVwapRatioFactor, VALLEY_RIDGE_LOOKBACK_DAYS), +) +register( + RidgeMinuteReturnFactor, + prefix="ridge_minute_return", + builder=_lookback_builder(RidgeMinuteReturnFactor, RIDGE_RETURN_LOOKBACK_DAYS), +) +register( + ValleyPriceQuantileFactor, + prefix="valley_price_quantile", + builder=_lookback_builder(ValleyPriceQuantileFactor, VALLEY_QUANTILE_LOOKBACK_DAYS), +) +register( + PeakRidgeAmountRatioFactor, + prefix="peak_ridge_amount_ratio", + builder=_lookback_builder(PeakRidgeAmountRatioFactor, PEAK_RIDGE_LOOKBACK_DAYS), +) diff --git a/factors/registry/registry.py b/factors/registry/registry.py new file mode 100644 index 0000000..b482e0e --- /dev/null +++ b/factors/registry/registry.py @@ -0,0 +1,316 @@ +"""Factor registry: THE one name -> class dispatch (design v3.2 §3.1, D1). + +Red line #4: the mapping from a config factor name to a Factor class exists in +exactly ONE place — here. ``qt.pipeline._build_factors``'s if/elif chain is +retired; a second dispatch chain must never grow back. + +Five-name / three-timepoint consistency (design §6 pit #1 — the five names are +the class name, ``Factor.name``, ``spec.factor_id``, the config name, and the +factor-panel column, which IS ``factor.name`` by ``compute``'s rename): + + 1. REGISTRATION time (class-definition time for the explicit builtin list): + the class must be a concrete Factor whose declared spec passes the + metaclass validation (re-run here — red line #1: ``register`` reuses the + existing FactorSpec checks, it does not weaken or reimplement them), and + a STATIC class-attribute spec must agree with the class-level ``name``. + A property spec cannot be evaluated without an instance (pit #2 — window + parameterized ids), so its name check is DEFERRED to build time; do not + "fix" that by demanding class attributes. + 2. BUILD time: the config name must resolve in the registry; the built + instance's ``name`` must equal the config name (the pre-existing + name/params-mismatch semantics of ``_build_factors``, kept verbatim); + the instance spec's ``factor_id`` must equal ``factor.name``. + 3. CONFIG-PARSE time: ``qt.config`` calls :func:`resolve` on every enabled + factor entry, so an unknown name fails at ``validate-config`` instead of + minutes into a run. + +Layering (red line #10): this module imports ``factors.*`` and the pure +declaration leaf ``data.availability_policy`` only — never qt, never feeds, +never an orchestrator. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass + +from data import availability_policy as _policy +from factors.base import Factor, _validate_declared_spec +from factors.requires import PanelField +from factors.spec import FactorSpec + +# A builder maps (config_name, params) -> a Factor instance. It performs the +# SAME params coercion the retired if/elif chain performed for its family +# (e.g. ``int(params.get("window", 20))``) — behavior-preserving by design. +Builder = Callable[[str, Mapping[str, object]], Factor] + + +@dataclass(frozen=True) +class RegistryEntry: + """One dispatch rule: an exact config name or a name-family prefix.""" + + key: str + kind: str # "exact" | "prefix" + factor_cls: type[Factor] + builder: Builder + + +class FactorRegistry: + """Name -> class dispatch with the three-timepoint naming checks. + + A plain class (not module state) so tests can exercise collision / + validation behavior on a fresh instance without polluting the default + registry that ``factors.registry.builtin`` populates. + """ + + def __init__(self) -> None: + self._exact: dict[str, RegistryEntry] = {} + self._prefixes: list[RegistryEntry] = [] # kept in registration order + + # -- registration (timepoint 1) --------------------------------------- + + def register( + self, + cls: type[Factor], + *, + exact: Iterable[str] = (), + prefix: str | None = None, + builder: Builder, + ) -> type[Factor]: + """Register ``cls`` under exact name(s) OR a name-family prefix. + + Duplicate keys and ambiguous (mutually-prefixing) prefixes are + readable errors — a silent overwrite would be a second dispatch truth. + """ + exact_names = tuple(exact) + if bool(exact_names) == (prefix is not None): + raise ValueError( + f"register({cls!r}) needs EITHER exact names or a prefix " + f"(got exact={exact_names!r}, prefix={prefix!r})." + ) + self._validate_class(cls) + entry_names = exact_names if exact_names else (prefix,) + for name in entry_names: + if not isinstance(name, str) or not name.strip(): + raise ValueError( + f"register({cls.__name__}) got a blank/non-string key " + f"{name!r}; registry keys must be non-empty strings." + ) + if exact_names: + for name in exact_names: + if name in self._exact: + raise ValueError( + f"duplicate factor registration for exact name " + f"{name!r}: already registered to " + f"{self._exact[name].factor_cls.__name__}, refusing " + f"{cls.__name__}. The name -> class mapping must have " + f"exactly one truth (red line #4)." + ) + self._exact[name] = RegistryEntry(name, "exact", cls, builder) + return cls + if prefix is None: # unreachable (XOR-guarded above); no bare assert (-O safe) + raise ValueError("register() reached the prefix path without a prefix.") + for other in self._prefixes: + if other.key == prefix: + raise ValueError( + f"duplicate factor registration for prefix {prefix!r}: " + f"already registered to {other.factor_cls.__name__}, " + f"refusing {cls.__name__}." + ) + if other.key.startswith(prefix) or prefix.startswith(other.key): + raise ValueError( + f"ambiguous factor prefixes: {prefix!r} ({cls.__name__}) " + f"vs already-registered {other.key!r} " + f"({other.factor_cls.__name__}) — one is a prefix of the " + f"other, so dispatch would depend on registration order. " + f"Pick non-overlapping family prefixes." + ) + self._prefixes.append(RegistryEntry(prefix, "prefix", cls, builder)) + return cls + + @staticmethod + def _validate_class(cls: object) -> None: + """Timepoint-1 checks; first step REUSES the existing spec validation.""" + if not isinstance(cls, type) or not issubclass(cls, Factor): + raise TypeError( + f"register() needs a Factor subclass; got {cls!r}." + ) + if getattr(cls, "__abstractmethods__", frozenset()): + raise TypeError( + f"register({cls.__name__}) refused: the class is still " + f"abstract (a factor-family base, not a factor)." + ) + # Red line #1: reuse the mandatory-spec validation the metaclass runs + # at class definition (re-running is idempotent and keeps register() + # from ever accepting less than the base class demands). + _validate_declared_spec(cls) + # A CLASS-level name is optional: exact-name classes (Financial/Value) + # derive ``self.name`` purely from the constructor field, so their + # naming consistency is carried by the build-time checks. When a + # class-level default IS declared it must be sane, and a STATIC + # class-attribute spec must agree with it at definition time. + name = getattr(cls, "name", None) + if name is not None and (not isinstance(name, str) or not name.strip()): + raise TypeError( + f"register({cls.__name__}) refused: the ``name`` class " + f"attribute must be a non-empty string when declared (it is " + f"the factor-panel column default); got {name!r}." + ) + declared = cls.__dict__.get("spec") + if isinstance(declared, FactorSpec): + if name is None: + raise ValueError( + f"register({cls.__name__}) refused: a class-attribute " + f"spec (factor_id={declared.factor_id!r}) needs a class " + f"``name`` attribute to agree with — declare one." + ) + if declared.factor_id != name: + raise ValueError( + f"register({cls.__name__}) refused: class attribute spec " + f"declares factor_id={declared.factor_id!r} but the class " + f"name attribute is {name!r} — the five-name consistency " + f"starts at definition time. (Property specs are checked " + f"at build time instead, once constructor params exist.)" + ) + + # -- lookup / build (timepoints 2 and 3) ------------------------------ + + def resolve(self, name: str) -> RegistryEntry: + """Find the entry for a config factor name; unknown -> readable error. + + Exact names win over prefixes (same precedence the retired chain had: + membership tests came before ``startswith`` families); prefixes are + unambiguous by the registration-time mutual-prefix guard. + """ + if not isinstance(name, str) or not name.strip(): + raise ValueError( + f"factor name must be a non-empty string; got {name!r}." + ) + entry = self._exact.get(name) + if entry is not None: + return entry + for candidate in self._prefixes: + if name.startswith(candidate.key): + return candidate + exacts = sorted(self._exact) + prefixes = [f"{e.key}*" for e in self._prefixes] + raise ValueError( + f"Unknown factor {name!r}; the factor registry knows the name " + f"families {prefixes} and the exact names {exacts}. New factors " + f"register in factors/registry/builtin.py (the ONE name -> class " + f"mapping)." + ) + + def build(self, name: str, params: Mapping[str, object] | None = None) -> Factor: + """Instantiate the factor for a config (name, params) pair. + + Runs the timepoint-2 naming checks: config-name/instance-name equality + (the ``_build_factors`` name/params-mismatch semantics, kept verbatim) + and instance-name/spec-id equality (property specs get their deferred + check here — pit #2). + """ + entry = self.resolve(name) + factor = entry.builder(name, dict(params or {})) + if not isinstance(factor, entry.factor_cls): + raise TypeError( + f"registry builder for {entry.key!r} returned a " + f"{type(factor).__name__}, not the registered " + f"{entry.factor_cls.__name__} — the builder and the " + f"registration disagree about the class." + ) + # window-named factors derive their name from params: a spec named + # reversal_5 with params.window=10 would silently mislabel the column. + if factor.name != name: + raise ValueError( + f"Factor name/params mismatch: config names {name!r} but the " + f"params resolve to {factor.name!r} (window-named factors must " + "agree with params.window)." + ) + spec = factor.spec + if spec.factor_id != factor.name: + raise ValueError( + f"Factor name/spec mismatch for {type(factor).__name__}: " + f"instance name {factor.name!r} but spec.factor_id " + f"{spec.factor_id!r} — the panel column and the evaluation " + f"identity would disagree." + ) + return factor + + def requirements( + self, + names: Iterable[str], + params_by_name: Mapping[str, Mapping[str, object]] | None = None, + ) -> tuple[PanelField, ...]: + """Aggregate the deduplicated endpoint requirements of ``names``. + + Builds each factor (so every naming check applies — a window-named + factor with a non-default window needs its params here exactly like + in ``build``) and unions ``spec.requires`` preserving first-seen + order. This is the D4 materializer's one-stop shopping list; in D1 it + only needs to be callable and correct. + """ + params_by_name = params_by_name or {} + seen: dict[PanelField, None] = {} + for name in names: + factor = self.build(name, params_by_name.get(name)) + for requirement in factor.spec.requires or (): + seen.setdefault(requirement, None) + return tuple(seen) + + +#: The process-wide registry ``factors/registry/builtin.py`` populates. +DEFAULT_REGISTRY = FactorRegistry() + + +def register( + cls: type[Factor], + *, + exact: Iterable[str] = (), + prefix: str | None = None, + builder: Builder, +) -> type[Factor]: + """Register into the default registry (see :class:`FactorRegistry`).""" + return DEFAULT_REGISTRY.register(cls, exact=exact, prefix=prefix, builder=builder) + + +def resolve(name: str) -> RegistryEntry: + """Resolve a config factor name in the default registry.""" + return DEFAULT_REGISTRY.resolve(name) + + +def build(name: str, params: Mapping[str, object] | None = None) -> Factor: + """Build a factor from the default registry.""" + return DEFAULT_REGISTRY.build(name, params) + + +def requirements( + names: Iterable[str], + params_by_name: Mapping[str, Mapping[str, object]] | None = None, +) -> tuple[PanelField, ...]: + """Aggregate deduplicated requirements from the default registry.""" + return DEFAULT_REGISTRY.requirements(names, params_by_name) + + +def require_legal_pairing(view: object, basis: object): + """Forwarding call point for the view x return-basis legality check (D1). + + The single source stays ``data.availability_policy.require_legal_pairing`` + (design §1.4 mechanism 1); the registry only guarantees the check is + CALLABLE from the factor layer so the D4 materializer / store wiring has + its hook. Deep wiring (view in store keys, per-run enforcement) is D4 — + deliberately NOT done here. + """ + return _policy.require_legal_pairing(view, basis) + + +__all__ = [ + "Builder", + "DEFAULT_REGISTRY", + "FactorRegistry", + "RegistryEntry", + "build", + "register", + "requirements", + "require_legal_pairing", + "resolve", +] diff --git a/tests/test_factor_registry.py b/tests/test_factor_registry.py new file mode 100644 index 0000000..1eba709 --- /dev/null +++ b/tests/test_factor_registry.py @@ -0,0 +1,325 @@ +"""Factor registry (D1): the ONE name -> class dispatch, three naming timepoints. + +What is locked here: + + * timepoint 1 (registration / class definition): concrete-Factor-only, + mandatory-spec revalidation reuse, static-spec/class-name agreement, + duplicate keys and ambiguous prefixes as readable errors; + * timepoint 2 (build): unknown names, config-name/instance-name mismatch + (the retired ``_build_factors`` semantics, kept verbatim), instance-name/ + spec-id mismatch, property-spec (window-parameterized) compatibility — + the spec check runs on the INSTANCE, never forcing a class attribute; + * timepoint 3 (config parse): ``qt.config.FactorCfg`` resolves enabled + names in the registry, so ``validate-config`` fails before any run; + * dispatch parity with the retired chain (same class, same name, per + family) and ``requirements`` aggregation/dedup; + * the view x basis pairing forwarding call point (deep wiring is D4). + +Collision tests run on FRESH ``FactorRegistry`` instances so the default +registry ``factors.registry.builtin`` populated is never polluted. +""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from factors.base import Factor +from factors.compute.candidates import ( + LiquidityFactor, + OvernightMomentumFactor, + ReversalFactor, + ValueFactor, + VolatilityFactor, +) +from factors.compute.financial import FinancialFactor +from factors.compute.intraday_derived import JumpAmountCorrFactor +from factors.compute.momentum import MomentumFactor +from factors.registry import ( + DEFAULT_REGISTRY, + FactorRegistry, + build, + requirements, + require_legal_pairing, + resolve, +) +from factors.spec import FactorSpec, PanelField + + +def _spec_kwargs(factor_id: str) -> dict: + return dict( + factor_id=factor_id, + version="1.0", + description="A registry test factor.", + expected_ic_sign=+1, + is_intraday=False, + forward_return_horizon=1, + return_basis="close_to_close", + input_fields=("close",), + requires=(PanelField("close", source="market_daily"),), + adjustment="returns_invariant", + overnight_boundary="none", + ) + + +class _WindowFactor(Factor): + """Window-parameterized fixture: property spec (design §6 pit #2).""" + + name: str = "winfac_20" + + def __init__(self, window: int = 20) -> None: + self._window = window + self.name = f"winfac_{window}" + + @property + def spec(self) -> FactorSpec: + return FactorSpec(**_spec_kwargs(self.name)) + + def compute(self, panel: pd.DataFrame) -> pd.Series: + return panel["close"].rename(self.name) + + +def _window_builder(name, params): + return _WindowFactor(window=int(params.get("window", 20))) + + +# --------------------------------------------------------------------------- # +# timepoint 1: registration +# --------------------------------------------------------------------------- # +def test_register_rejects_a_non_factor_class(): + reg = FactorRegistry() + with pytest.raises(TypeError, match="Factor subclass"): + reg.register(object, prefix="obj", builder=lambda n, p: object()) + + +def test_register_rejects_an_abstract_family_base(): + class _FamilyBase(Factor): # no compute -> still abstract, not a factor + pass + + reg = FactorRegistry() + with pytest.raises(TypeError, match="abstract"): + reg.register(_FamilyBase, prefix="family", builder=lambda n, p: None) + + +def test_register_checks_static_spec_against_the_class_name(): + # timepoint 1 of the five-name consistency: a CLASS-ATTRIBUTE spec must + # agree with the class-level name at definition/registration time. + class _Lying(Factor): + name = "honest_name" + spec = FactorSpec(**_spec_kwargs("some_other_id")) + + def compute(self, panel): # pragma: no cover - never built + return panel["close"].rename(self.name) + + reg = FactorRegistry() + with pytest.raises(ValueError, match="factor_id"): + reg.register(_Lying, exact=("honest_name",), builder=lambda n, p: _Lying()) + + +def test_register_accepts_a_consistent_static_spec(): + class _Static(Factor): + name = "static_fac" + spec = FactorSpec(**_spec_kwargs("static_fac")) + + def compute(self, panel): + return panel["close"].rename(self.name) + + reg = FactorRegistry() + reg.register(_Static, exact=("static_fac",), builder=lambda n, p: _Static()) + assert isinstance(reg.build("static_fac", {}), _Static) + + +def test_register_needs_exactly_one_of_exact_or_prefix(): + reg = FactorRegistry() + with pytest.raises(ValueError, match="EITHER exact names or a prefix"): + reg.register(_WindowFactor, builder=_window_builder) + with pytest.raises(ValueError, match="EITHER exact names or a prefix"): + reg.register( + _WindowFactor, exact=("winfac_20",), prefix="winfac", + builder=_window_builder, + ) + + +def test_duplicate_exact_name_is_a_readable_error(): + reg = FactorRegistry() + reg.register(_WindowFactor, exact=("winfac_20",), builder=_window_builder) + with pytest.raises(ValueError, match="duplicate factor registration"): + reg.register(_WindowFactor, exact=("winfac_20",), builder=_window_builder) + + +def test_duplicate_prefix_is_a_readable_error(): + reg = FactorRegistry() + reg.register(_WindowFactor, prefix="winfac", builder=_window_builder) + with pytest.raises(ValueError, match="duplicate factor registration"): + reg.register(_WindowFactor, prefix="winfac", builder=_window_builder) + + +def test_mutually_prefixing_prefixes_are_rejected(): + # "win" vs "winfac": dispatch would depend on registration order — the + # exact ambiguity the guard exists for, in both directions. + reg = FactorRegistry() + reg.register(_WindowFactor, prefix="winfac", builder=_window_builder) + with pytest.raises(ValueError, match="ambiguous factor prefixes"): + reg.register(_WindowFactor, prefix="win", builder=_window_builder) + with pytest.raises(ValueError, match="ambiguous factor prefixes"): + reg.register(_WindowFactor, prefix="winfac_extra", builder=_window_builder) + + +def test_blank_registration_keys_are_rejected(): + reg = FactorRegistry() + with pytest.raises(ValueError, match="non-empty"): + reg.register(_WindowFactor, exact=("",), builder=_window_builder) + + +# --------------------------------------------------------------------------- # +# timepoint 2: build +# --------------------------------------------------------------------------- # +def test_unknown_factor_name_is_a_readable_error(): + with pytest.raises(ValueError, match="Unknown factor"): + build("totally_made_up", {}) + + +def test_name_params_mismatch_keeps_the_build_factors_semantics(): + # The retired chain's check, verbatim: config says reversal_5 but the + # params build reversal_10 -> a silent column mislabel, refused. + with pytest.raises(ValueError, match="mismatch|resolve"): + build("reversal_5", {"window": 10}) + + +def test_property_spec_window_factor_builds_and_checks_at_build_time(): + # Pit #2: the spec of a window-parameterized factor only exists on the + # INSTANCE. The registry must stay compatible — never demand a class + # attribute — and run the naming checks after construction. + reg = FactorRegistry() + reg.register(_WindowFactor, prefix="winfac", builder=_window_builder) + fac = reg.build("winfac_5", {"window": 5}) + assert isinstance(fac, _WindowFactor) and fac.name == "winfac_5" + with pytest.raises(ValueError, match="mismatch"): + reg.build("winfac_5", {}) # default window 20 -> winfac_20 != winfac_5 + + +def test_instance_spec_id_must_match_the_instance_name(): + class _DriftingSpec(Factor): + name = "drift_1" + + def __init__(self) -> None: + self.name = "drift_1" + + @property + def spec(self) -> FactorSpec: + return FactorSpec(**_spec_kwargs("drift_2")) # lies about the id + + def compute(self, panel): # pragma: no cover - never computed + return panel["close"].rename(self.name) + + reg = FactorRegistry() + reg.register(_DriftingSpec, exact=("drift_1",), builder=lambda n, p: _DriftingSpec()) + with pytest.raises(ValueError, match="name/spec mismatch"): + reg.build("drift_1", {}) + + +def test_builder_returning_the_wrong_class_is_refused(): + reg = FactorRegistry() + reg.register( + _WindowFactor, prefix="winfac", + builder=lambda n, p: MomentumFactor(window=20), + ) + with pytest.raises(TypeError, match="not the registered"): + reg.build("winfac_20", {}) + + +def test_exact_names_win_over_prefixes(): + # Same precedence as the retired chain (membership tests before + # startswith): an exact registration shadows a would-match prefix. + class _Exact(Factor): + name = "winfac_20" + + @property + def spec(self) -> FactorSpec: + return FactorSpec(**_spec_kwargs(self.name)) + + def compute(self, panel): + return panel["close"].rename(self.name) + + reg = FactorRegistry() + reg.register(_WindowFactor, prefix="winfac", builder=_window_builder) + reg.register(_Exact, exact=("winfac_20",), builder=lambda n, p: _Exact()) + assert isinstance(reg.build("winfac_20", {}), _Exact) + assert isinstance(reg.build("winfac_5", {"window": 5}), _WindowFactor) + + +# --------------------------------------------------------------------------- # +# dispatch parity with the retired if/elif chain (default registry) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "name, params, cls", + [ + ("momentum_20", {"window": 20, "price_col": "close"}, MomentumFactor), + ("reversal_5", {"window": 5}, ReversalFactor), + ("volatility_20", {"window": 20}, VolatilityFactor), + ("liquidity_20", {"window": 20}, LiquidityFactor), + ("overnight_mom_20", {"window": 20}, OvernightMomentumFactor), + ("value_ep", {}, ValueFactor), + ("value_bp", {}, ValueFactor), + ("roe", {}, FinancialFactor), + ("netprofit_yoy", {}, FinancialFactor), + ("grossprofit_margin", {}, FinancialFactor), + # never in the chain, but part of the registry's full surface: + ("jump_amount_corr_20", {}, JumpAmountCorrFactor), + ], +) +def test_default_registry_builds_every_family(name, params, cls): + factor = build(name, params) + assert isinstance(factor, cls) + assert factor.name == name + assert factor.spec.factor_id == name + + +def test_resolve_is_lookup_only_and_finds_every_family(): + for name in ("momentum_20", "reversal_5", "value_ep", "roe", + "valley_price_quantile_20"): + entry = resolve(name) + assert name.startswith(entry.key) + + +# --------------------------------------------------------------------------- # +# requirements aggregation +# --------------------------------------------------------------------------- # +def test_requirements_aggregates_and_deduplicates_in_first_seen_order(): + reqs = requirements(["momentum_20", "volatility_20", "value_ep"]) + # momentum and volatility both need market_daily close -> ONE entry. + assert [(r.field, r.source) for r in reqs] == [ + ("close", "market_daily"), + ("pe", "daily_basic"), + ] + + +def test_requirements_honours_per_name_params(): + reqs = requirements( + ["momentum_5"], params_by_name={"momentum_5": {"window": 5}} + ) + assert [(r.field, r.source) for r in reqs] == [("close", "market_daily")] + # ... and without the params the naming check fires exactly like build(): + with pytest.raises(ValueError, match="mismatch"): + requirements(["momentum_5"]) + + +# --------------------------------------------------------------------------- # +# view x basis pairing forwarding (deep wiring is D4; D1 = callable + tested) +# --------------------------------------------------------------------------- # +def test_pairing_forwarding_accepts_the_two_legal_pairs(): + view, basis = require_legal_pairing("decision", "exec_to_exec") + assert (view.value, basis.value) == ("decision", "exec_to_exec") + view, basis = require_legal_pairing("close", "close_to_close") + assert (view.value, basis.value) == ("close", "close_to_close") + + +def test_pairing_forwarding_rejects_the_cross_pairs_readably(): + with pytest.raises(ValueError, match="illegal view/basis pairing"): + require_legal_pairing("close", "exec_to_exec") + with pytest.raises(ValueError, match="illegal view/basis pairing"): + require_legal_pairing("decision", "close_to_close") + + +def test_default_registry_is_the_module_singleton_the_helpers_use(): + assert resolve("momentum_20") is DEFAULT_REGISTRY.resolve("momentum_20") From 6580dd633fa34349ce59fdccf067f407ec7c8268 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 23 Jul 2026 13:54:16 -0700 Subject: [PATCH 3/3] refactor(qt): dispatch _build_factors through the factor registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor-refactor D1, final slice — the dispatch replacement that IS D1's identity. _build_factors' if/elif chain (qt/pipeline.py:769) is retired for registry.build(): same classes, same params coercion, same config order; the no-enabled-factor and duplicate-name guards stay verbatim in _build_factors; unknown names and name/params mismatches keep readable errors (now single-sourced in the registry). The now-unused factor-class imports are dropped; FinancialFactor / ValueFactor / SUPPORTED_FIELDS stay — the _maybe_enrich_* isinstance dispatch is D4/D6 scope, untouched. qt.config.FactorCfg gains the third naming timepoint: every ENABLED factor entry resolves its name in the registry at parse time, so validate-config / load_config reject an unknown name before any run (pydantic ValidationError subclasses ValueError and carries the registry's message). Disabled entries stay unchecked, mirroring _build_factors. test_unknown_factor_name_raises moves to the parse-time boundary accordingly; the direct registry.build unknown-name path is locked in tests/test_factor_registry.py. Behavior anchors: run-phase0 demo unchanged (ic 0.9600 / annual 0.8408), all 31 configs validate, full suite green — factor math untouched by D1. --- qt/config.py | 21 ++++++++++ qt/pipeline.py | 68 ++++++-------------------------- tests/test_factor_registry.py | 22 +++++++++++ tests/test_financial_pipeline.py | 9 ++++- 4 files changed, 61 insertions(+), 59 deletions(-) diff --git a/qt/config.py b/qt/config.py index 93cac22..9048b12 100644 --- a/qt/config.py +++ b/qt/config.py @@ -176,6 +176,27 @@ class FactorCfg(_Strict): enabled: bool = True params: dict[str, Any] = Field(default_factory=dict) + @model_validator(mode="after") + def _name_hits_registry(self) -> "FactorCfg": + """Config-parse-time naming checkpoint (D1, design §3.1 / §6 pit #1). + + An ENABLED factor name must resolve in the factor registry, so an + unknown name fails at ``validate-config`` / ``load_config`` instead of + minutes into a run. Lookup only — params/window consistency needs the + built instance and stays a ``registry.build`` (run-time) check. + Disabled entries are skipped, mirroring ``_build_factors``, which + never dispatched them either. + """ + if self.enabled: + # Lazy import: keeps qt.config importable without loading the + # factor modules (pandas + data.clean) until a factor entry is + # actually validated; also keeps the layering one-directional + # (qt -> factors) at call time. + from factors.registry import resolve as _resolve_factor_name + + _resolve_factor_name(self.name) + return self + class StandardizeCfg(_Strict): enabled: bool = True diff --git a/qt/pipeline.py b/qt/pipeline.py index 27a8cc4..08dc369 100644 --- a/qt/pipeline.py +++ b/qt/pipeline.py @@ -53,17 +53,10 @@ from data.feed.tushare_fina import TushareFinancialFeed from data.feed.tushare_flags import TushareFlagsFeed from data.store.panel_store import PanelStore -from factors.compute.candidates import ( - VALUE_FIELDS, - LiquidityFactor, - OvernightMomentumFactor, - ReversalFactor, - ValueFactor, - VolatilityFactor, -) +from factors import registry as factor_registry +from factors.compute.candidates import ValueFactor from factors.compute.financial import SUPPORTED_FIELDS as SUPPORTED_FINANCIAL_FIELDS from factors.compute.financial import FinancialFactor -from factors.compute.momentum import MomentumFactor from factors.process.pipeline import ProcessingPipeline from portfolio.construct import TopNEqualWeight from qt.config import RootConfig, load_config @@ -769,9 +762,12 @@ def _enrich_tradability( def _build_factors(cfg: RootConfig) -> list: """Instantiate EVERY enabled factor from config, in config order (P3-1). - ``momentum*`` -> :class:`MomentumFactor` (price-based, P0). A financial field - name (e.g. ``roe``, ``netprofit_yoy``) -> :class:`FinancialFactor`, which needs - the tushare data path (ann_date alignment) — not available for demo data. + D1 (factor-refactor): dispatch goes through the factor REGISTRY — + :func:`factors.registry.build`, the ONE name -> class mapping (red line + #4); the old if/elif chain is retired. Behavior-preserving: same classes, + same params coercion, same config order; an unknown name and a + name/params mismatch still raise readable errors (now from the registry, + which also re-checks them at config-parse time via ``qt.config``). Multiple enabled factors each become their own factor-panel column; the combined score stays the alpha layer's equal-weight mean (no learned weights). @@ -783,51 +779,9 @@ def _build_factors(cfg: RootConfig) -> list: raise ValueError( "No enabled factor in config.factors; the pipeline needs at least one." ) - factors: list = [] - for spec in enabled: - params = dict(spec.params) - window = int(params.get("window", 20)) - if spec.name in SUPPORTED_FINANCIAL_FIELDS: - factor = FinancialFactor(field=spec.name) - elif spec.name in VALUE_FIELDS: - factor = ValueFactor(spec.name) - elif spec.name.startswith("overnight_mom"): - factor = OvernightMomentumFactor( - window=window, - open_col=str(params.get("open_col", "open")), - close_col=str(params.get("close_col", "close")), - ) - elif spec.name.startswith("momentum"): - factor = MomentumFactor( - window=window, price_col=str(params.get("price_col", "close")) - ) - elif spec.name.startswith("reversal"): - factor = ReversalFactor( - window=window, price_col=str(params.get("price_col", "close")) - ) - elif spec.name.startswith("volatility"): - factor = VolatilityFactor( - window=window, price_col=str(params.get("price_col", "close")) - ) - elif spec.name.startswith("liquidity"): - factor = LiquidityFactor( - window=window, amount_col=str(params.get("amount_col", "amount")) - ) - else: - raise ValueError( - f"Unknown factor {spec.name!r}; expected 'momentum*', 'reversal*', " - f"'volatility*', 'liquidity*', 'overnight_mom*', one of " - f"{VALUE_FIELDS} or one of {SUPPORTED_FINANCIAL_FIELDS}." - ) - # window-named factors derive their name from params: a spec named - # reversal_5 with params.window=10 would silently mislabel the column. - if factor.name != spec.name: - raise ValueError( - f"Factor name/params mismatch: config names {spec.name!r} but the " - f"params resolve to {factor.name!r} (window-named factors must " - "agree with params.window)." - ) - factors.append(factor) + factors: list = [ + factor_registry.build(spec.name, spec.params) for spec in enabled + ] names = [f.name for f in factors] dupes = sorted({n for n in names if names.count(n) > 1}) if dupes: diff --git a/tests/test_factor_registry.py b/tests/test_factor_registry.py index 1eba709..300af8e 100644 --- a/tests/test_factor_registry.py +++ b/tests/test_factor_registry.py @@ -321,5 +321,27 @@ def test_pairing_forwarding_rejects_the_cross_pairs_readably(): require_legal_pairing("decision", "close_to_close") +# --------------------------------------------------------------------------- # +# timepoint 3: config parse (qt side calls the registry) +# --------------------------------------------------------------------------- # +def test_factor_cfg_rejects_an_unknown_enabled_name_at_parse_time(): + from pydantic import ValidationError + + from qt.config import FactorCfg + + with pytest.raises(ValidationError, match="Unknown factor"): + FactorCfg(name="totally_made_up") + + +def test_factor_cfg_accepts_known_names_and_skips_disabled_entries(): + from qt.config import FactorCfg + + FactorCfg(name="momentum_20") + FactorCfg(name="value_ep", params={}) + # Disabled entries are not dispatched by _build_factors, so they are not + # name-checked either (mirrored behavior, locked here on purpose). + FactorCfg(name="totally_made_up", enabled=False) + + def test_default_registry_is_the_module_singleton_the_helpers_use(): assert resolve("momentum_20") is DEFAULT_REGISTRY.resolve("momentum_20") diff --git a/tests/test_financial_pipeline.py b/tests/test_financial_pipeline.py index d7f07ca..71ebd63 100644 --- a/tests/test_financial_pipeline.py +++ b/tests/test_financial_pipeline.py @@ -43,9 +43,14 @@ def test_financial_factor_on_demo_source_raises(tmp_path, example_config_path): def test_unknown_factor_name_raises(tmp_path, example_config_path): - cfg = _cfg(tmp_path, example_config_path, "totally_made_up", "demo") + # D1: the unknown-name check moved EARLIER — the registry lookup now also + # runs at config-parse time (the third naming checkpoint), so load_config + # itself rejects the name. pydantic's ValidationError subclasses + # ValueError and carries the registry's readable message. The same lookup + # still guards _build_factors / registry.build for non-config callers + # (locked separately in tests/test_factor_registry.py). with pytest.raises(ValueError, match="Unknown factor"): - _build_factors(cfg) + _cfg(tmp_path, example_config_path, "totally_made_up", "demo") def test_financial_fetch_uses_lookback_before_start(tmp_path, example_config_path, monkeypatch):