From 5c6064cdb0d94423fdf5886cea4143ea33f483ee Mon Sep 17 00:00:00 2001 From: Yimin Du <5088553+initial-d@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:41:34 +0800 Subject: [PATCH 1/4] fix: separate forward label validity from factor availability --- README.md | 11 +++--- docs/mask_contract.md | 59 ++++++++++++++++++++++++++++++ src/mlquant/cli/main.py | 3 +- src/mlquant/data/panel.py | 9 ++--- src/mlquant/features/bias.py | 52 ++++++++++----------------- src/mlquant/features/target.py | 8 ++--- src/mlquant/training/dataset.py | 32 +++++++++++++---- tests/test_dataset_masks.py | 63 +++++++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 54 deletions(-) create mode 100644 docs/mask_contract.md create mode 100644 tests/test_dataset_masks.py diff --git a/README.md b/README.md index f5cee1d..b535d14 100644 --- a/README.md +++ b/README.md @@ -100,16 +100,17 @@ concrete way to inspect and extend a full factor-to-backtest workflow: |---|---| | **213 factor dimensions** | Mask-aware PyTorch factors with documented families and tensor primitives | | **One end-to-end path** | Data, masks, factors, models, portfolio construction, cost-aware backtest, and reports | -| **Mask-first factor protocol** | Non-tradable limit-move observations are masked before rolling-window factors, not only filtered later | +| **Mask-first factor protocol** | A documented limit-move exclusion policy is applied before rolling factors; observation, factor and label validity remain distinct | | **Public and synthetic data paths** | Start without private data, then test AkShare, Baostock, or yfinance | | **Evidence, including failures** | Costs, turnover, baselines, caveats, and negative or blocked runs stay visible | | **A contribution path** | CI, tests, report templates, Colab, and newcomer-sized research tasks | | **Typed portfolio-decision adapter** | Pipeline state can also be exposed as bounded portfolio intent, without turning the project into execution infrastructure | -If you are citing the paper, the most specific contribution is the mask-first -factor-computation protocol: non-tradable limit-move prices should be masked -before rolling-window factors so they do not contaminate downstream signals. See -the [citation conversion guide](docs/citation_conversion.md). +The mask-first protocol propagates a chosen exclusion policy through rolling +factor windows. An observed price at a limit can still be valid for a return +label or held-position P&L; see the [mask contract](docs/mask_contract.md). +Use the [citation conversion guide](docs/citation_conversion.md) with that +implementation boundary in mind. Try the live Hugging Face artifacts: the [100,000-row synthetic dataset](https://huggingface.co/datasets/dddyym/ml-quant-trading-synthetic) diff --git a/docs/mask_contract.md b/docs/mask_contract.md new file mode 100644 index 0000000..64ffb7d --- /dev/null +++ b/docs/mask_contract.md @@ -0,0 +1,59 @@ +# Observation, factor, label and execution masks + +These masks answer different questions. A price-limit close can be an observed +price even when a research policy excludes it from factor windows. It can also +be a valid endpoint of a historical return label. Neither fact establishes that +a particular buy or sell order would fill. + +| Mask | Question | Current implementation | +| --- | --- | --- | +| Observation | Does the loader accept this stock-day? | Original `Panel.mask`; loader-specific, sometimes halt-aware | +| Factor | Does the selected research policy accept the inputs and computed features at signal date t? | Mask-first panel, then factor-engine mask | +| Forward label | Is the return label aligned to t usable? | `target_01(original_panel)` returns values and an endpoint mask | +| Execution | Would a specified order fill at the chosen time/price? | Not provided by the masks above; requires side, timing, liquidity and queue assumptions | + +`limit_move_mask` is a conservative symmetric exclusion policy. Its return proxy +is a heuristic; explicit bands, board/date thresholds and price tolerance must +be reported. Masking before rolling factors implements that policy throughout +the window. This is not proof that observed limit prices are invalid or that a +different exclusion policy contains lookahead bias. + +## Training contract + +```python +from mlquant.features import compute_legacy_set +from mlquant.features.bias import apply_limit_mask +from mlquant.features.target import target_01 +from mlquant.training import FactorDataset + +# Keep the original observations for historical labels and held-position P&L. +factors, factor_mask, names = compute_legacy_set(apply_limit_mask(panel)) +labels, label_mask = target_01(panel) +dataset = FactorDataset(factors, factor_mask, labels, label_mask=label_mask) +``` + +`labels[t]` and `label_mask[t]` already refer to the return from t to t+1. +The dataset does not shift them again. A sample requires usable factors at t, +a valid forward label at t, and finite feature/label values. The final date is +excluded for this next-day dataset. + +For example, if tomorrow closes at a price limit, tomorrow's factor mask can +be false while today's forward label remains valid. The explicit label mask +retains that sample. Omitting `label_mask` keeps the old intersection of factor +masks at t and t+1 for API compatibility. The CLI now supplies the label mask. +This changes the training sample set, so prior demo model metrics can change. + +Future label availability is an offline training/evaluation filter. It must not +select today's predictions or portfolio universe. The existing public-validation +protocol separately documents [signal-date availability](public_data_validation.md#signal-date-availability). + +## Deterministic regression checks + +```bash +python -m pytest tests/test_dataset_masks.py tests/test_bias.py tests/test_trainer_split.py +``` + +The cases cover an observed limit close, an invalid zero-filled label, alignment, +nonfinite values, shape validation, and chronological samples. All inputs are +synthetic engineering controls, with no market-data fetch or profitability claim. +See the [recorded run](audits/training-masks-20261023/README.md). diff --git a/src/mlquant/cli/main.py b/src/mlquant/cli/main.py index 4a8a54e..0212316 100644 --- a/src/mlquant/cli/main.py +++ b/src/mlquant/cli/main.py @@ -258,7 +258,7 @@ def cmd_train(config_path: str) -> None: art = _artifacts_dir(cfg) panel = _load_panel(art) feat_blob = torch.load(art / "features.pt", weights_only=False) - fwd_ret, _ = target_01(panel) + fwd_ret, label_mask = target_01(panel) # Train only on dates before the out-of-sample window. Slicing to # ``oos + 1`` keeps the label of the last training day (close at ``oos``), @@ -269,6 +269,7 @@ def cmd_train(config_path: str) -> None: factors=feat_blob["factors"][: oos + 1], mask=feat_blob["mask"][: oos + 1], forward_returns=fwd_ret[: oos + 1], + label_mask=label_mask[: oos + 1], ) n_features = ds.features.shape[1] model = MLPRegressor(in_dim=n_features, hidden=cfg.get("hidden", 128)) diff --git a/src/mlquant/data/panel.py b/src/mlquant/data/panel.py index 743b590..bbdee8a 100644 --- a/src/mlquant/data/panel.py +++ b/src/mlquant/data/panel.py @@ -4,9 +4,10 @@ ------------ * Every numeric field has shape ``[T, N]`` (date × stock) and dtype ``float32``. -* ``mask[t, i] == True`` iff stock *i* was tradable on date *t*. Limit-up, - limit-down, halted, and pre-IPO cells are masked out — this is the - "bias correction" plumbing the paper relies on. +* ``mask[t, i]`` records the loader's usable-observation policy. Some loaders + also exclude halts. Limit-move exclusions are applied separately by the + research pipeline; a close at a price limit can still be an observed price. + This mask does not establish whether a buy or sell order would fill. * Panels are *immutable*: factor pipelines return new tensors rather than mutating in place. This keeps reasoning straightforward and is a hard requirement for autograd-backed factors. @@ -37,7 +38,7 @@ @dataclass(frozen=True) class Panel: - """OCHLV panel + tradability mask, all on the same device.""" + """OCHLV panel + usable-observation mask, all on the same device.""" dates: np.ndarray # shape [T], dtype object/datetime64 stocks: np.ndarray # shape [N], dtype object (ticker str) diff --git a/src/mlquant/features/bias.py b/src/mlquant/features/bias.py index b705b53..7657c9e 100644 --- a/src/mlquant/features/bias.py +++ b/src/mlquant/features/bias.py @@ -1,36 +1,19 @@ -"""Bias-correction utilities for A-share microstructure quirks. - -The two most common look-ahead biases in A-share alpha research are: - -1. **Limit-up days** — when a stock closes at the +10 % cap, the close - you see *did not actually fill any unsatisfied buy orders*. Treating - such a day as tradable inflates returns. -2. **Limit-down days** — symmetric problem on the downside. - -Both must be excluded from training labels and from the tradable -universe at portfolio construction time. - -Two regimes ------------ -* **Real limits available** (``Panel.limit_up`` / ``Panel.limit_down`` set): - use the exchange-published prices — this is the regime real Wind - data lands in. A cell is masked if ``close >= limit_up - eps`` or - ``close <= limit_down + eps``. -* **Proxy regime** (synthetic data, or any feed without explicit limits): - fall back to the ``|return| > limit_pct`` heuristic, which is the - unambiguous signature of a limit move on most A-shares. - -A single scalar ``limit_pct`` only fits Main Board names. ChiNext and STAR -Market names move up to ±20 % and Beijing Stock Exchange names up to ±30 %, -so a 9.8 % proxy wrongly masks ordinary moves on those boards while never -catching their real limit days. :func:`board_limit_pct` builds a per-cell -threshold from ticker codes and dates; pass it as ``limit_pct``. It cannot -see ST status (±5 %) or the no-limit first days after an IPO — feeds that -care about those should supply real ``limit_up`` / ``limit_down`` prices. - -Both code paths are exercised by the test suite via the synthetic -generator (which fills both real and derived fields) so this module -behaves identically on synthetic and real feeds. +"""Conservative price-limit exclusion policy for factor research. + +A close at a price limit is an observed price, and a position already held +still earns its return. This module excludes such observations symmetrically +from factor windows as a research policy. It does not establish lookahead +bias, invalidate the observed price, or simulate directional order fills. +Buy/sell feasibility depends on order timing, side, liquidity and queue state. + +With explicit ``limit_up`` / ``limit_down`` prices, cells near either band +edge are excluded. Missing bands fall back to ``|return| <= limit_pct``; +that proxy is a heuristic, not an unambiguous identification of limit days. +``eps`` is an absolute price tolerance and must suit the feed's units. + +A single scalar threshold only fits some boards. :func:`board_limit_pct` +handles board/date rules, but cannot identify ST status or no-limit IPO days. +Prefer reliable explicit bands and document the exclusion policy in reports. """ from __future__ import annotations @@ -173,7 +156,8 @@ def apply_limit_mask( computing factors on the returned panel keeps limit-move prices out of every rolling window; intersecting :func:`limit_move_mask` with the factor mask afterwards only drops the limit day itself and still lets - its price leak into the following ``window`` days. + that excluded observation enter later rolling windows. This difference + demonstrates exclusion-policy propagation, not invalid market prices. Keep using the original panel for P&L: a position held into a limit move still earns that return. diff --git a/src/mlquant/features/target.py b/src/mlquant/features/target.py index 6ec0c9f..74425d1 100644 --- a/src/mlquant/features/target.py +++ b/src/mlquant/features/target.py @@ -6,9 +6,9 @@ target_01[t, i] = close[t+1, i] / close[t, i] - 1 masked by ``mask[t, i] & mask[t+1, i]``. Concretely, this is the -realised one-day forward simple return, computed only on stock-days -where the stock was tradable both at signal time *t* and at execution -time *t+1*. The last row is always masked because there is no ``t+1``. +realised one-day forward simple return, computed only where the panel's +observation mask accepts both endpoints. This is label availability, not +proof of order execution. The last row is masked because there is no ``t+1``. We expose two helpers: @@ -30,7 +30,7 @@ def forward_return(panel: Panel, horizon: int = 1) -> Tuple[Tensor, Tensor]: - """``close[t+horizon] / close[t] - 1`` masked by tradability at both ends. + """``close[t+horizon] / close[t] - 1`` masked by observation availability. Parameters ---------- diff --git a/src/mlquant/training/dataset.py b/src/mlquant/training/dataset.py index 3fb350e..805eb68 100644 --- a/src/mlquant/training/dataset.py +++ b/src/mlquant/training/dataset.py @@ -3,9 +3,9 @@ Each sample is *one stock-day* — features at date ``t``, label is the next-day return at ``t+1``. The dataset takes care of: -* aligning the factor tensor with the bias-corrected mask, -* shifting the label by one day, -* dropping cells where either side of the prediction is non-tradable, +* selecting features with the signal-time factor mask, +* consuming labels already aligned to signal date ``t`` (no second shift), +* selecting labels with a separate forward-label mask when supplied, * exposing batches as plain ``(features, labels)`` pairs. """ from __future__ import annotations @@ -17,24 +17,44 @@ class FactorDataset(Dataset): - """A flat dataset of (factor vector, next-day return) pairs.""" + """A flat dataset of (factor vector, next-day return) pairs. + + ``label_mask[t, n]`` describes the validity of the already-aligned label + at ``t``. It need not require usable factors at ``t+1``. Omitting it keeps + the legacy rule ``mask[t, n] & mask[t+1, n]``. These offline sample filters + are not order-fill rules and must not select live portfolio holdings. + """ def __init__( self, factors: torch.Tensor, # [T, N, F] mask: torch.Tensor, # [T, N] - forward_returns: torch.Tensor, # [T, N] already bias-corrected + forward_returns: torch.Tensor, # [T, N] label for signal date t *, + label_mask: Optional[torch.Tensor] = None, # [T, N], already aligned to t date_index: Optional[torch.Tensor] = None, stock_index: Optional[torch.Tensor] = None, ) -> None: if factors.dim() != 3: raise ValueError("factors must be [T, N, F]") T, N, _ = factors.shape + if mask.shape != (T, N) or mask.dtype != torch.bool: + raise ValueError("mask must be bool [T, N]") + if forward_returns.shape != (T, N): + raise ValueError("forward_returns must be [T, N]") + if label_mask is not None and ( + label_mask.shape != (T, N) or label_mask.dtype != torch.bool + ): + raise ValueError("label_mask must be bool [T, N]") # Drop the last day (no forward return available) and any masked # cells. - valid = mask[:-1] & mask[1:] & ~torch.isnan(forward_returns[:-1]) + label_valid = mask[1:] if label_mask is None else label_mask[:-1] + valid = ( + mask[:-1] & label_valid + & torch.isfinite(forward_returns[:-1]) + & torch.isfinite(factors[:-1]).all(dim=-1) + ) flat_idx = valid.nonzero(as_tuple=False) # [K, 2] (t, n) self.features = factors[:-1][flat_idx[:, 0], flat_idx[:, 1]] diff --git a/tests/test_dataset_masks.py b/tests/test_dataset_masks.py new file mode 100644 index 0000000..6f16357 --- /dev/null +++ b/tests/test_dataset_masks.py @@ -0,0 +1,63 @@ +"""Offline label validity is independent of next-day factor availability.""" +from dataclasses import replace + +import pytest +import torch + +from mlquant.data.synthetic import SyntheticConfig, make_synthetic_panel +from mlquant.features.bias import limit_move_mask +from mlquant.features.target import target_01 +from mlquant.training.dataset import FactorDataset + + +def test_observed_limit_close_remains_a_valid_label(): + p = make_synthetic_panel(SyntheticConfig(n_dates=6, n_stocks=2, seed=4)) + close = torch.tensor([[10., 10.], [10., 10.], [11., 10.], + [11., 10.], [11., 10.], [11., 10.]]) + p = replace(p, close=close, mask=torch.ones_like(close, dtype=torch.bool), + last_close=None, limit_up=None, limit_down=None) + factors = close.unsqueeze(-1) + factor_mask = limit_move_mask(p) + labels, label_mask = target_01(p) + legacy = FactorDataset(factors, factor_mask, labels) + explicit = FactorDataset(factors, factor_mask, labels, label_mask=label_mask) + assert p.mask[2, 0] and not factor_mask[2, 0] + assert not ((legacy.dates == 1) & (legacy.stocks == 0)).any() + sample = (explicit.dates == 1) & (explicit.stocks == 0) + assert sample.sum() == 1 + assert torch.allclose(explicit.labels[sample], torch.tensor([0.1])) + # The limit-day feature itself remains excluded by the research policy. + assert not ((explicit.dates == 2) & (explicit.stocks == 0)).any() + + +def test_label_mask_is_aligned_and_rejects_zero_filled_missing_labels(): + factors = torch.arange(8.).reshape(4, 2, 1) + mask = torch.ones(4, 2, dtype=torch.bool) + labels = torch.zeros(4, 2) + label_mask = mask.clone() + label_mask[1, 0] = False + ds = FactorDataset(factors, mask, labels, label_mask=label_mask) + assert len(ds) == 5 + assert not ((ds.dates == 1) & (ds.stocks == 0)).any() + assert ((ds.dates == 0) & (ds.stocks == 0)).any() + assert (ds.dates < 3).all() + + +def test_nonfinite_features_and_labels_never_reach_training(): + factors = torch.ones(4, 2, 2) + labels = torch.zeros(4, 2) + factors[0, 0, 1] = float("nan") + factors[1, 0, 0] = float("inf") + labels[2, 0] = float("-inf") + ds = FactorDataset(factors, torch.ones(4, 2, dtype=torch.bool), labels) + assert ds.stocks.tolist() == [1, 1, 1] + assert torch.isfinite(ds.features).all() and torch.isfinite(ds.labels).all() + + +@pytest.mark.parametrize("field", ["mask", "forward_returns", "label_mask"]) +def test_mismatched_shapes_are_rejected(field): + args = dict(factors=torch.ones(4, 2, 1), mask=torch.ones(4, 2, dtype=torch.bool), + forward_returns=torch.zeros(4, 2), label_mask=torch.ones(4, 2, dtype=torch.bool)) + args[field] = args[field][:, :1] + with pytest.raises(ValueError, match=field): + FactorDataset(**args) From a08314f529dffb9b35773597a934fa87da61487e Mon Sep 17 00:00:00 2001 From: Yimin Du <5088553+initial-d@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:42:08 +0800 Subject: [PATCH 2/4] docs: preserve training mask regression and environment evidence --- docs/audits/training-masks-20261023/README.md | 26 ++ .../training-masks-20261023/environment.json | 17 + .../initial-environment-failures.txt | 302 ++++++++++++++++++ .../audits/training-masks-20261023/pytest.txt | 4 + 4 files changed, 349 insertions(+) create mode 100644 docs/audits/training-masks-20261023/README.md create mode 100644 docs/audits/training-masks-20261023/environment.json create mode 100644 docs/audits/training-masks-20261023/initial-environment-failures.txt create mode 100644 docs/audits/training-masks-20261023/pytest.txt diff --git a/docs/audits/training-masks-20261023/README.md b/docs/audits/training-masks-20261023/README.md new file mode 100644 index 0000000..123544e --- /dev/null +++ b/docs/audits/training-masks-20261023/README.md @@ -0,0 +1,26 @@ +# Training-mask regression — 2026-10-23 + +Code commit: `5c6064cdb0d94423fdf5886cea4143ea33f483ee`. [Mask contract](../../mask_contract.md). +Raw checks: [pytest](pytest.txt), [environment](environment.json). + +```bash +python -m pytest +python -m ruff check src tests +``` + +The clean Python 3.12.14 / PyTorch 2.14.1 environment on macOS +26.6.2, Apple M5 CPU passed 152 tests; Ruff passed. No GPU or external market data +was used. These are synthetic/unit-test engineering checks, with no trading +performance estimate and no applicable cost assumption. + +The explicit forward-label mask preserves a valid observed return endpoint even +when next-day factor eligibility is false. Nonfinite training inputs are excluded. +The CLI uses this contract; callers omitting the new mask retain the old mask +intersection. Historical demo model metrics can therefore change. No archived +public/proprietary performance result is revalidated here. + +The first run in an older Python 3.9.6 / PyTorch 2.8.0 environment failed three +existing tests: missing AkShare and SciPy numerical warnings promoted to errors. +[Full failure details](initial-environment-failures.txt) are preserved. A clean +`python -m pip install -e '.[dev]'` installation resolved those environment +failures without changing unrelated tests. diff --git a/docs/audits/training-masks-20261023/environment.json b/docs/audits/training-masks-20261023/environment.json new file mode 100644 index 0000000..81b78d0 --- /dev/null +++ b/docs/audits/training-masks-20261023/environment.json @@ -0,0 +1,17 @@ +{ + "commit": "5c6064cdb0d94423fdf5886cea4143ea33f483ee", + "python": "3.12.14 (main, Aug 25 2026, 13:50:33) [Clang 22.1.3 ]", + "pytorch": "2.14.1", + "os": "macOS-26.6.2-arm64-arm-64bit", + "cpu": "Apple M5", + "device": "CPU", + "gpu_used": false, + "cuda_available": false, + "data_source": "deterministic synthetic/unit-test inputs only", + "cost_assumptions": "not applicable: sample-selection regression, no performance benchmark", + "commands": [ + "python -m pytest", + "python -m ruff check src tests" + ], + "python_executable": "/tmp/projects-push-20261023-venv/bin/python" +} diff --git a/docs/audits/training-masks-20261023/initial-environment-failures.txt b/docs/audits/training-masks-20261023/initial-environment-failures.txt new file mode 100644 index 0000000..bf3da86 --- /dev/null +++ b/docs/audits/training-masks-20261023/initial-environment-failures.txt @@ -0,0 +1,302 @@ +Error processing line 1 of /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/distutils-precedence.pth: + + Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/site.py", line 169, in addpackage + exec(line) + File "", line 1, in + ModuleNotFoundError: No module named '_distutils_hack' + +Remainder of file ignored +Error processing line 1 of /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/distutils-precedence.pth: + + Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/site.py", line 169, in addpackage + exec(line) + File "", line 1, in + ModuleNotFoundError: No module named '_distutils_hack' + +Remainder of file ignored +.FF....................................................................F [ 47%] +........................................................................ [ 94%] +........ [100%] +=================================== FAILURES =================================== +_________________________ test_load_akshare_panel_mock _________________________ + + def test_load_akshare_panel_mock(): + # 构造包含 akshare 真实中文列名的模拟数据 + def make_mock_df(*, symbol, period, start_date, end_date, adjust): + assert symbol in {"600519", "000001"} + assert period == "daily" + assert start_date == "20230101" + assert end_date == "20230104" + assert adjust == "qfq" + return pd.DataFrame( + { + "日期": ["2023-01-03", "2023-01-04"], + "开盘": [10.0, 10.5], + "收盘": [10.5, 11.5], + "最高": [11.0, 12.0], + "最低": [9.0, 10.0], + "成交量": [1000, 2000], + "成交额": [10000, 22000], + "振幅": [20.0, 19.0], + "涨跌幅": [5.0, 9.5], + "涨跌额": [0.5, 1.0], + "换手率": [1.0, 2.0], + } + ) + +> with mock.patch("akshare.stock_zh_a_hist") as mock_hist: + +tests/test_akshare.py:37: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/unittest/mock.py:1389: in __enter__ + self.target = self.getter() +/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/unittest/mock.py:1564: in + getter = lambda: _importer(target) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +target = 'akshare' + + def _importer(target): + components = target.split('.') + import_path = components.pop(0) +> thing = __import__(import_path) +E ModuleNotFoundError: No module named 'akshare' + +/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/unittest/mock.py:1236: ModuleNotFoundError +____________________ test_load_akshare_panel_empty_tickers _____________________ + +module = 'akshare', package = None + + def require(module: str, package: str | None = None) -> ModuleType: + """Import ``module`` or explain how to install the ``[data]`` extra.""" + try: +> return importlib.import_module(module) + +src/mlquant/data/_optional.py:11: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +name = 'akshare', package = None + + def import_module(name, package=None): + """Import a module. + + The 'package' argument is required when performing a relative import. It + specifies the package to use as the anchor point from which to resolve the + relative import to an absolute import. + + """ + level = 0 + if name.startswith('.'): + if not package: + msg = ("the 'package' argument is required to perform a relative " + "import for {!r}") + raise TypeError(msg.format(name)) + for character in name: + if character != '.': + break + level += 1 +> return _bootstrap._gcd_import(name[level:], package, level) +E ModuleNotFoundError: No module named 'akshare' + +/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py:127: ModuleNotFoundError + +The above exception was the direct cause of the following exception: + + def test_load_akshare_panel_empty_tickers(): + with pytest.raises(ValueError, match="Tickers list cannot be empty"): +> load_akshare_panel([], "2023-01-01", "2023-01-04") + +tests/test_akshare.py:66: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/mlquant/data/akshare_loader.py:45: in load_akshare_panel + akshare = require("akshare") +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +module = 'akshare', package = None + + def require(module: str, package: str | None = None) -> ModuleType: + """Import ``module`` or explain how to install the ``[data]`` extra.""" + try: + return importlib.import_module(module) + except ImportError as exc: +> raise ImportError( + f"{package or module} is required for this data loader. " + "Install the public-data extra with: python -m pip install 'mlquantx[data]'" + ) from exc +E ImportError: akshare is required for this data loader. Install the public-data extra with: python -m pip install 'mlquantx[data]' + +src/mlquant/data/_optional.py:13: ImportError +_______________ test_problem_is_dpp_and_cached_per_universe_size _______________ + + def test_problem_is_dpp_and_cached_per_universe_size(): + """The risk term must be DPP, or cvxpy re-canonicalises on every solve.""" + import warnings + + mu, history = _toy_problem(n=30, T=60) + opt = MarkowitzOptimizer(MarkowitzConfig(weight_cap=0.1)) + with warnings.catch_warnings(): + warnings.simplefilter("error") +> opt.solve(mu, history) + +tests/test_markowitz.py:49: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/mlquant/portfolio/markowitz.py:104: in solve + factor_param.value = self._factor(self._covariance(returns_history)) +src/mlquant/portfolio/markowitz.py:132: in _covariance + return LedoitWolf().fit(history).covariance_ +../bench-venv/lib/python3.9/site-packages/sklearn/base.py:1389: in wrapper + return fit_method(estimator, *args, **kwargs) +../bench-venv/lib/python3.9/site-packages/sklearn/covariance/_shrunk_covariance.py:610: in fit + self._set_covariance(covariance) +../bench-venv/lib/python3.9/site-packages/sklearn/covariance/_empirical_covariance.py:215: in _set_covariance + self.precision_ = linalg.pinvh(covariance, check_finite=False) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +a = array([[ 3.96465560e-04, 2.87114913e-06, -3.91175685e-06, + -8.46098715e-06, 2.41282946e-06, -5.44693402e-06, +... 4.54741682e-07, -5.99319668e-06, 6.98083596e-06, + 6.69104616e-06, 2.15540385e-06, 3.82956434e-04]]) +atol = 0.0, rtol = np.float64(6.661338147750939e-15), lower = True +return_rank = False, check_finite = False + + def pinvh(a, atol=None, rtol=None, lower=True, return_rank=False, + check_finite=True): + """ + Compute the (Moore-Penrose) pseudo-inverse of a Hermitian matrix. + + Calculate a generalized inverse of a complex Hermitian/real symmetric + matrix using its eigenvalue decomposition and including all eigenvalues + with 'large' absolute value. + + Parameters + ---------- + a : (N, N) array_like + Real symmetric or complex hermetian matrix to be pseudo-inverted + + atol : float, optional + Absolute threshold term, default value is 0. + + .. versionadded:: 1.7.0 + + rtol : float, optional + Relative threshold term, default value is ``N * eps`` where + ``eps`` is the machine precision value of the datatype of ``a``. + + .. versionadded:: 1.7.0 + + lower : bool, optional + Whether the pertinent array data is taken from the lower or upper + triangle of `a`. (Default: lower) + return_rank : bool, optional + If True, return the effective rank of the matrix. + check_finite : bool, optional + Whether to check that the input matrix contains only finite numbers. + Disabling may give a performance gain, but may result in problems + (crashes, non-termination) if the inputs do contain infinities or NaNs. + + Returns + ------- + B : (N, N) ndarray + The pseudo-inverse of matrix `a`. + rank : int + The effective rank of the matrix. Returned if `return_rank` is True. + + Raises + ------ + LinAlgError + If eigenvalue algorithm does not converge. + + See Also + -------- + pinv : Moore-Penrose pseudoinverse of a matrix. + + Examples + -------- + + For a more detailed example see `pinv`. + + >>> import numpy as np + >>> from scipy.linalg import pinvh + >>> rng = np.random.default_rng() + >>> a = rng.standard_normal((9, 6)) + >>> a = np.dot(a, a.T) + >>> B = pinvh(a) + >>> np.allclose(a, a @ B @ a) + True + >>> np.allclose(B, B @ a @ B) + True + + """ + a = _asarray_validated(a, check_finite=check_finite) + s, u = _decomp.eigh(a, lower=lower, check_finite=False) + t = u.dtype.char.lower() + maxS = np.max(np.abs(s)) + + atol = 0. if atol is None else atol + rtol = max(a.shape) * np.finfo(t).eps if (rtol is None) else rtol + + if (atol < 0.) or (rtol < 0.): + raise ValueError("atol and rtol values must be positive.") + + val = atol + maxS * rtol + above_cutoff = (abs(s) > val) + + psigma_diag = 1.0 / s[above_cutoff] + u = u[:, above_cutoff] + +> B = (u * psigma_diag) @ u.conj().T +E RuntimeWarning: divide by zero encountered in matmul + +../bench-venv/lib/python3.9/site-packages/scipy/linalg/_basic.py:1542: RuntimeWarning +=============================== warnings summary =============================== +tests/test_cli.py: 94 warnings +tests/test_markowitz.py: 8 warnings + /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/scipy/linalg/_basic.py:1542: RuntimeWarning: divide by zero encountered in matmul + B = (u * psigma_diag) @ u.conj().T + +tests/test_cli.py: 94 warnings +tests/test_markowitz.py: 8 warnings + /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/scipy/linalg/_basic.py:1542: RuntimeWarning: overflow encountered in matmul + B = (u * psigma_diag) @ u.conj().T + +tests/test_cli.py: 94 warnings +tests/test_markowitz.py: 8 warnings + /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/scipy/linalg/_basic.py:1542: RuntimeWarning: invalid value encountered in matmul + B = (u * psigma_diag) @ u.conj().T + +tests/test_markowitz.py::test_rank_deficient_history_solves + /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/cvxpy/reductions/solvers/solving_chain.py:251: UserWarning: Your problem has too many parameters for efficient DPP compilation. We suggest setting 'ignore_dpp = True'. + warnings.warn( + +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves + /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/cvxpy/atoms/affine/binary_operators.py:114: RuntimeWarning: divide by zero encountered in matmul + return values[0] @ values[1] + +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves + /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/cvxpy/atoms/affine/binary_operators.py:114: RuntimeWarning: overflow encountered in matmul + return values[0] @ values[1] + +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves +tests/test_markowitz.py::test_rank_deficient_history_solves + /Users/duyimin/Documents/Codex/2026-07-04/ban/work/bench-venv/lib/python3.9/site-packages/cvxpy/atoms/affine/binary_operators.py:114: RuntimeWarning: invalid value encountered in matmul + return values[0] @ values[1] + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +=========================== short test summary info ============================ +FAILED tests/test_akshare.py::test_load_akshare_panel_mock - ModuleNotFoundEr... +FAILED tests/test_akshare.py::test_load_akshare_panel_empty_tickers - ImportE... +FAILED tests/test_markowitz.py::test_problem_is_dpp_and_cached_per_universe_size +3 failed, 149 passed, 322 warnings in 2.88s diff --git a/docs/audits/training-masks-20261023/pytest.txt b/docs/audits/training-masks-20261023/pytest.txt new file mode 100644 index 0000000..2ff58e5 --- /dev/null +++ b/docs/audits/training-masks-20261023/pytest.txt @@ -0,0 +1,4 @@ +........................................................................ [ 47%] +........................................................................ [ 94%] +........ [100%] +152 passed in 22.00s From 13f81da5f8ecc5dc6270a09c50188b6c54938b7a Mon Sep 17 00:00:00 2001 From: Yimin Du <5088553+initial-d@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:55:50 +0800 Subject: [PATCH 3/4] fix: reject unusable forward return endpoints --- docs/mask_contract.md | 6 +++ src/mlquant/features/target.py | 34 ++++++++++++---- tests/test_forward_labels.py | 72 ++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 tests/test_forward_labels.py diff --git a/docs/mask_contract.md b/docs/mask_contract.md index 64ffb7d..d7b9939 100644 --- a/docs/mask_contract.md +++ b/docs/mask_contract.md @@ -37,6 +37,12 @@ The dataset does not shift them again. A sample requires usable factors at t, a valid forward label at t, and finite feature/label values. The final date is excluded for this next-day dataset. +Forward labels also require finite, positive close prices at both endpoints and +a finite computed return. Zero/negative prices are rejected rather than clamped +into extreme returns. Invalid values are zero-filled while their masks stay +false; a genuine zero return can have a true mask. Multi-row horizons check only +their endpoints, and horizons at or beyond the panel length have no valid labels. + For example, if tomorrow closes at a price limit, tomorrow's factor mask can be false while today's forward label remains valid. The explicit label mask retains that sample. Omitting `label_mask` keeps the old intersection of factor diff --git a/src/mlquant/features/target.py b/src/mlquant/features/target.py index 74425d1..b5fa501 100644 --- a/src/mlquant/features/target.py +++ b/src/mlquant/features/target.py @@ -19,6 +19,7 @@ """ from __future__ import annotations +from numbers import Integral from typing import Tuple import torch @@ -36,21 +37,40 @@ def forward_return(panel: Panel, horizon: int = 1) -> Tuple[Tensor, Tensor]: ---------- panel : Panel horizon : int - Number of periods to look ahead. Must be >= 1. + Positive integer number of panel rows to look ahead. A horizon at or + beyond the panel length returns all-zero values and an all-false mask. + + Both endpoints must be accepted by the observation mask and have finite, + positive closes. Nonfinite computed returns are invalid too. Invalid labels + are exactly zero, including when a masked source cell contains NaN/Inf. + For multi-row horizons only endpoints are checked; this is not a claim + about intermediate-session observations or execution feasibility. """ - if horizon < 1: - raise ValueError(f"horizon must be >= 1, got {horizon}") - T, N = panel.close.shape + if isinstance(horizon, bool) or not isinstance(horizon, Integral) or horizon < 1: + raise ValueError(f"horizon must be a positive integer, got {horizon}") + horizon = int(horizon) out = torch.zeros_like(panel.close) out_mask = torch.zeros_like(panel.mask) + if horizon >= panel.close.shape[0]: + return out, out_mask fut_close = panel.close[horizon:] - cur_close = panel.close[:-horizon].clamp_min(1e-9) + cur_close = panel.close[:-horizon] fut_mask = panel.mask[horizon:] cur_mask = panel.mask[:-horizon] - out[:-horizon] = (fut_close / cur_close - 1.0) * (fut_mask & cur_mask).float() - out_mask[:-horizon] = fut_mask & cur_mask + valid = ( + fut_mask & cur_mask & torch.isfinite(cur_close) & torch.isfinite(fut_close) + & (cur_close > 0) & (fut_close > 0) + ) + # Do not use NaN * 0 as masking, or clamp a zero/negative price into an + # apparently valid extreme return. Neutral operands keep invalid cells safe. + numerator = torch.where(valid, fut_close, torch.ones_like(fut_close)) + denominator = torch.where(valid, cur_close, torch.ones_like(cur_close)) + values = numerator / denominator - 1.0 + valid = valid & torch.isfinite(values) + out[:-horizon] = torch.where(valid, values, torch.zeros_like(values)) + out_mask[:-horizon] = valid return out, out_mask diff --git a/tests/test_forward_labels.py b/tests/test_forward_labels.py new file mode 100644 index 0000000..1ec3ab3 --- /dev/null +++ b/tests/test_forward_labels.py @@ -0,0 +1,72 @@ +"""Forward labels reject unusable prices without confusing zero with validity.""" +from dataclasses import replace + +import numpy as np +import pytest +import torch + +from mlquant.data.synthetic import SyntheticConfig, make_synthetic_panel +from mlquant.features.target import forward_return, target_01 +from mlquant.training.dataset import FactorDataset + + +def panel_with_close(close): + close = torch.tensor(close, dtype=torch.float32) + panel = make_synthetic_panel(SyntheticConfig(n_dates=6, n_stocks=2, seed=4)) + return replace(panel, close=close, mask=torch.ones_like(close, dtype=torch.bool)) + + +@pytest.mark.parametrize("bad", [0., -1., float("nan"), float("inf"), float("-inf")]) +@pytest.mark.parametrize("endpoint", [0, 1]) +def test_unusable_endpoint_is_not_a_valid_label(bad, endpoint): + p = panel_with_close([[10., 10.], [11., 10.], [12., 10.]]) + p.close[endpoint, 0] = bad + values, mask = target_01(p) + assert not mask[0, 0] + assert values[0, 0] == 0 + assert torch.isfinite(values).all() + # A genuine zero return in the control stock still has a valid mask. + assert values[0, 1] == 0 and mask[0, 1] + ds = FactorDataset(torch.ones(3, 2, 1), p.mask, values, label_mask=mask) + assert not ((ds.dates == 0) & (ds.stocks == 0)).any() + + +def test_masked_nan_does_not_escape_as_nan_times_zero(): + p = panel_with_close([[10., 10.], [float("nan"), 11.], [12., 12.]]) + p.mask[1, 0] = False + values, mask = target_01(p) + assert torch.equal(values[:, 0], torch.zeros(3)) + assert not mask[:, 0].any() + + +def test_positive_finite_prices_with_overflowing_return_are_invalid(): + p = panel_with_close([[torch.finfo(torch.float32).tiny, 10.], + [torch.finfo(torch.float32).max, 11.]]) + values, mask = target_01(p) + assert not mask[0, 0] and values[0, 0] == 0 + assert mask[0, 1] and torch.isfinite(values).all() + + +def test_multi_row_horizon_uses_endpoint_observations_and_alignment(): + p = panel_with_close([[10., 20.], [11., 22.], [12., 24.], [13., 26.]]) + p.mask[1, 0] = False + values, mask = forward_return(p, np.int64(2)) + assert mask[0, 0] # endpoints are valid despite an unavailable middle row + assert not mask[1, 0] + assert torch.allclose(values[0], torch.tensor([0.2, 0.2])) + assert not mask[-2:].any() and not values[-2:].any() + + +@pytest.mark.parametrize("horizon", [3, 4, 100]) +def test_horizon_without_future_rows_returns_empty_validity(horizon): + p = panel_with_close([[10., 10.], [11., 11.], [12., 12.]]) + values, mask = forward_return(p, horizon) + assert values.shape == p.close.shape and mask.shape == p.mask.shape + assert not values.any() and not mask.any() + + +@pytest.mark.parametrize("horizon", [0, -1, True, 1.5, float("nan")]) +def test_invalid_horizon_is_rejected(horizon): + p = panel_with_close([[10., 10.], [11., 11.]]) + with pytest.raises(ValueError, match="positive integer"): + forward_return(p, horizon) From 06fb38c518b275fabd243112cd0d1972257299bf Mon Sep 17 00:00:00 2001 From: Yimin Du <5088553+initial-d@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:55:50 +0800 Subject: [PATCH 4/4] docs: record forward label endpoint regression --- docs/audits/forward-labels-20261024/README.md | 23 +++++++++++++++++++ .../audits/forward-labels-20261024/pytest.txt | 4 ++++ 2 files changed, 27 insertions(+) create mode 100644 docs/audits/forward-labels-20261024/README.md create mode 100644 docs/audits/forward-labels-20261024/pytest.txt diff --git a/docs/audits/forward-labels-20261024/README.md b/docs/audits/forward-labels-20261024/README.md new file mode 100644 index 0000000..d184a1c --- /dev/null +++ b/docs/audits/forward-labels-20261024/README.md @@ -0,0 +1,23 @@ +# Forward-label endpoint regression — 2026-10-24 + +Code commit: `13f81da5f8ecc5dc6270a09c50188b6c54938b7a`. [Mask contract](../../mask_contract.md). + +```bash +python -m pytest +python -m ruff check src tests +``` + +Result: 173 tests passed; Ruff passed. [Raw test output](pytest.txt). +Python 3.12.14, PyTorch 2.14.1, macOS-26.6.2-arm64-arm-64bit, +Apple M5, CPU only; no GPU use. Input source: deterministic synthetic and +unit-test fixtures, including NaN, infinite, nonpositive prices and overflow +controls. No external market data. Cost assumptions are not applicable to +label-construction regression; this is engineering evidence, not an alpha or +performance estimate. + +Invalid endpoints no longer produce a true label mask or a clamped extreme +return. Masked NaN/Inf cells yield zero values with false masks. Genuine zero +returns remain valid. Multi-row horizons check endpoints only; they do not prove +intermediate-session coverage or order feasibility. Positive finite closes whose +computed float32 return overflows are also excluded. This can change training +sample counts on affected input data; no archived market result is revalidated. diff --git a/docs/audits/forward-labels-20261024/pytest.txt b/docs/audits/forward-labels-20261024/pytest.txt new file mode 100644 index 0000000..52a0e0e --- /dev/null +++ b/docs/audits/forward-labels-20261024/pytest.txt @@ -0,0 +1,4 @@ +........................................................................ [ 41%] +........................................................................ [ 83%] +............................. [100%] +173 passed in 2.80s