From cc1c987ba3cf1dbca97ea13877fc3a89f9f3094b Mon Sep 17 00:00:00 2001 From: Newman Gao Date: Fri, 31 Jul 2026 16:04:07 +0800 Subject: [PATCH 1/6] Add walk-forward split utilities --- src/quant_toolkit/splits.py | 66 +++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/quant_toolkit/splits.py diff --git a/src/quant_toolkit/splits.py b/src/quant_toolkit/splits.py new file mode 100644 index 0000000..2d97ff6 --- /dev/null +++ b/src/quant_toolkit/splits.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +import pandas as pd + + +@dataclass(frozen=True) +class WalkForwardSplit: + """Index-window split with strict train-before-test ordering.""" + + train_start: pd.Timestamp + train_end: pd.Timestamp + test_start: pd.Timestamp + test_end: pd.Timestamp + + def __post_init__(self) -> None: + if not self.train_start <= self.train_end < self.test_start <= self.test_end: + raise ValueError("split dates must satisfy train_start <= train_end < test_start <= test_end") + + def as_dict(self) -> dict[str, str]: + return { + "train_start": self.train_start.date().isoformat(), + "train_end": self.train_end.date().isoformat(), + "test_start": self.test_start.date().isoformat(), + "test_end": self.test_end.date().isoformat(), + } + + +def build_walk_forward_splits( + dates: Iterable[object], + train_window: int, + test_window: int, + step: int | None = None, +) -> list[WalkForwardSplit]: + if train_window <= 0 or test_window <= 0: + raise ValueError("train_window and test_window must be positive") + step = test_window if step is None else step + if step <= 0: + raise ValueError("step must be positive") + + unique_dates = pd.Index(pd.to_datetime(list(dates))).drop_duplicates().sort_values() + splits: list[WalkForwardSplit] = [] + start = 0 + while start + train_window + test_window <= len(unique_dates): + train_start = unique_dates[start] + train_end = unique_dates[start + train_window - 1] + test_start = unique_dates[start + train_window] + test_end = unique_dates[start + train_window + test_window - 1] + splits.append(WalkForwardSplit(train_start, train_end, test_start, test_end)) + start += step + return splits + + +def apply_split( + panel: pd.DataFrame, + split: WalkForwardSplit, + date_col: str = "date", +) -> tuple[pd.DataFrame, pd.DataFrame]: + if date_col not in panel.columns: + raise ValueError(f"missing date column: {date_col}") + dates = pd.to_datetime(panel[date_col]) + train_mask = (dates >= split.train_start) & (dates <= split.train_end) + test_mask = (dates >= split.test_start) & (dates <= split.test_end) + return panel.loc[train_mask].copy(), panel.loc[test_mask].copy() From e553e08a670dce55364d1c8b207e992f5c7b0bb1 Mon Sep 17 00:00:00 2001 From: Newman Gao Date: Fri, 31 Jul 2026 16:04:32 +0800 Subject: [PATCH 2/6] Add data manifest helper --- src/quant_toolkit/manifest.py | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/quant_toolkit/manifest.py diff --git a/src/quant_toolkit/manifest.py b/src/quant_toolkit/manifest.py new file mode 100644 index 0000000..2471806 --- /dev/null +++ b/src/quant_toolkit/manifest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +import pandas as pd + + +@dataclass(frozen=True) +class DataManifest: + source: str + created_at: str + rows: int + columns: tuple[str, ...] + date_col: str + asset_col: str + known_limits: tuple[str, ...] = () + + @classmethod + def from_panel( + cls, + panel: pd.DataFrame, + source: str, + date_col: str = "date", + asset_col: str = "asset", + known_limits: Iterable[str] = (), + ) -> "DataManifest": + missing = [col for col in (date_col, asset_col) if col not in panel.columns] + if missing: + raise ValueError(f"missing manifest identity columns: {missing}") + return cls( + source=source, + created_at=pd.Timestamp.utcnow().isoformat(), + rows=int(len(panel)), + columns=tuple(str(col) for col in panel.columns), + date_col=date_col, + asset_col=asset_col, + known_limits=tuple(known_limits), + ) + + def to_dict(self) -> dict[str, object]: + return { + "source": self.source, + "created_at": self.created_at, + "rows": self.rows, + "columns": list(self.columns), + "date_col": self.date_col, + "asset_col": self.asset_col, + "known_limits": list(self.known_limits), + } From fdbb9c59c675446b002b201097f0e285c339bf0d Mon Sep 17 00:00:00 2001 From: Newman Gao Date: Fri, 31 Jul 2026 16:04:57 +0800 Subject: [PATCH 3/6] Test walk-forward split utilities --- tests/test_splits.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_splits.py diff --git a/tests/test_splits.py b/tests/test_splits.py new file mode 100644 index 0000000..c8dd46e --- /dev/null +++ b/tests/test_splits.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import unittest + +import pandas as pd + +from quant_toolkit.demo import make_synthetic_panel +from quant_toolkit.splits import apply_split, build_walk_forward_splits + + +class WalkForwardSplitTest(unittest.TestCase): + def test_builds_ordered_non_overlapping_test_windows(self) -> None: + panel = make_synthetic_panel(days=18, assets=3) + splits = build_walk_forward_splits(panel["date"], train_window=8, test_window=3) + + self.assertEqual(len(splits), 3) + self.assertLess(splits[0].train_end, splits[0].test_start) + self.assertLess(splits[0].test_end, splits[1].test_start) + + def test_apply_split_returns_train_and_test_panels(self) -> None: + panel = make_synthetic_panel(days=12, assets=2) + split = build_walk_forward_splits(panel["date"], train_window=6, test_window=2)[0] + + train, test = apply_split(panel, split) + + self.assertEqual(train["date"].nunique(), 6) + self.assertEqual(test["date"].nunique(), 2) + self.assertLess(pd.to_datetime(train["date"]).max(), pd.to_datetime(test["date"]).min()) + + def test_rejects_bad_windows(self) -> None: + with self.assertRaises(ValueError): + build_walk_forward_splits(["2026-01-01"], train_window=0, test_window=1) + + +if __name__ == "__main__": + unittest.main() From 1a5f2db370e23652a68b2db0b6da64c19bb72185 Mon Sep 17 00:00:00 2001 From: Newman Gao Date: Fri, 31 Jul 2026 16:05:14 +0800 Subject: [PATCH 4/6] Test data manifest helper --- tests/test_manifest.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_manifest.py diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..cf41697 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import unittest + +from quant_toolkit.demo import make_synthetic_panel +from quant_toolkit.manifest import DataManifest + + +class DataManifestTest(unittest.TestCase): + def test_manifest_records_panel_shape_and_limits(self) -> None: + panel = make_synthetic_panel(days=3, assets=2) + manifest = DataManifest.from_panel(panel, source="synthetic-public-demo", known_limits=["not production data"]) + + payload = manifest.to_dict() + + self.assertEqual(payload["rows"], 6) + self.assertIn("momentum_5d", payload["columns"]) + self.assertEqual(payload["known_limits"], ["not production data"]) + + def test_requires_identity_columns(self) -> None: + panel = make_synthetic_panel(days=2, assets=2).drop(columns=["asset"]) + + with self.assertRaises(ValueError): + DataManifest.from_panel(panel, source="broken") + + +if __name__ == "__main__": + unittest.main() From 988837e7dcb013fa640be32b9a4de6891b8c6f38 Mon Sep 17 00:00:00 2001 From: Newman Gao Date: Fri, 31 Jul 2026 16:05:36 +0800 Subject: [PATCH 5/6] Export walk-forward and manifest helpers --- src/quant_toolkit/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/quant_toolkit/__init__.py b/src/quant_toolkit/__init__.py index 9ab3b73..37b5606 100644 --- a/src/quant_toolkit/__init__.py +++ b/src/quant_toolkit/__init__.py @@ -1,13 +1,19 @@ """Public clean-room quant research utilities.""" from .contracts import MarketPanelContract +from .manifest import DataManifest from .metrics import FactorDiagnostics, evaluate_factor from .registry import FactorRegistry, FactorSpec +from .splits import WalkForwardSplit, apply_split, build_walk_forward_splits __all__ = [ + "DataManifest", "FactorDiagnostics", "FactorRegistry", "FactorSpec", "MarketPanelContract", + "WalkForwardSplit", + "apply_split", + "build_walk_forward_splits", "evaluate_factor", ] From 80298e12b20c23e77f0a27af0daa16aa369c679b Mon Sep 17 00:00:00 2001 From: Newman Gao Date: Fri, 31 Jul 2026 16:05:53 +0800 Subject: [PATCH 6/6] Document walk-forward diagnostics capability --- reports/example-diagnostics.md | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/reports/example-diagnostics.md b/reports/example-diagnostics.md index 3cc0b14..742ab4a 100644 --- a/reports/example-diagnostics.md +++ b/reports/example-diagnostics.md @@ -1,24 +1,15 @@ -# Example Diagnostics Report +# Example Diagnostics -This report is generated from the deterministic synthetic demo. It is a smoke-test artifact, not a backtest. +This public demo is intentionally synthetic. It shows the research workflow shape without exposing employer data, private alpha formulas, or tradable production signals. -| Metric | Value | Read | -|---|---:|---| -| observations | 1200 | full synthetic panel used | -| dates | 40 | enough for a small pipeline smoke | -| coverage | 1.000000 | no missing factor/label rows in the demo | -| mean Rank IC | 0.062314 | weak positive rank relationship in synthetic data | -| Rank IC IR | 0.382932 | not enough to call robust | -| positive Rank IC rate | 0.625000 | more positive than negative days | -| mean turnover | 0.686991 | too high for a casual daily signal | -| top-quantile gross return | 0.000743 | positive before costs | -| top-quantile net return, 30 bps | -0.001318 | rejected after turnover costs | -| verdict | `reject_cost_adjusted_return` | correct conservative behavior | +## Current Capability -## What this shows +- `MarketPanelContract` validates a point-in-time market panel and rejects obvious leakage fields. +- `FactorRegistry` records factor lineage, input fields, and point-in-time rules. +- `evaluate_factor` reports rank IC, turnover, gross return, net return, and a rejection verdict when costs overwhelm the demo signal. +- `DataManifest` records dataset shape, identity columns, and known limitations for reproducible handoff. +- `build_walk_forward_splits` and `apply_split` create rolling train/test windows with strict train-before-test ordering. -The package does not stop at an IC number. It carries the result through coverage, turnover, transaction costs, and a simple verdict. The useful feature is not that the toy factor works; it is that the toolkit refuses to dress up a cost-eroded signal. +## Why It Matters -## Next useful artifact - -The next version should add a public point-in-time sample dataset and a walk-forward split. That would move the repo from toolkit foundation to a small reproducible research example. +The useful signal for recruiters is not that the synthetic factor is profitable. The useful signal is that the project treats alpha work as a research system: data contract first, leakage checks first, walk-forward evaluation first, and rejection reasons preserved instead of hidden.