* feat(bench): add horizon parameter for monthly-frequency IC revalidation
h-bar forward returns (close[t+h]/close[t]-1, no ffill — a NaN close never
bridges) sampled non-overlapping end-anchored, so the naive-iid t-stat and
the alive/dead thresholds stay honest without new statistics. Threaded
through run_bench / run_bench_strict / the alpha_bench tool + MCP wrapper /
CLI --horizon (argparse type + handler guard, exit 2 on bad input).
horizon=1 is byte-identical to the previous next-bar formula.
Follow-up #1 of issue #1789 (monthly-horizon revalidation of the 13
fundamental factors — the conventional reading for fundamental factors).
* fix(bench): exclude SEC-fundamental alphas off sp500 instead of failing the bench
--zoo fundamental --universe csi300 (the repro command printed in issue
#1789) hard-failed with 'Choose the S&P 500 universe' because the
validator rejects fund:* inputs on any non-sp500 universe, while the
mirror case (CN val:* alphas on sp500) skips cleanly per alpha. The
runners now split the selection up front, disclose the excluded ids in
the result envelope, and error only when nothing is left to bench; the
validator stays as the backstop for direct callers.
The CLI/tool JSON envelopes also state their own horizon,
forward_return_sampling and universe_excluded — a saved summary must
show the configuration its numbers came from.
* fix(bench): close review findings on horizon/exclusion disclosure
- multi-zoo CLI aggregation threads horizon / forward_return_sampling /
universe_excluded from the sub-runs into the aggregate envelope (a saved
all-zoos JSON now states its own horizon and reconciles its exclusions)
- alpha compare surfaces universe-excluded shortlist members as skipped
entries with the exclusion reason instead of silently dropping them
- horizon upper bound (1.._MAX_HORIZON) enforced consistently at the CLI
parser, the handler, and both runner entries BEFORE any panel load —
previously only the tool/MCP capped it and an unbounded CLI run fetched
the full panel before failing late
- non-int horizon can no longer TypeError in the entry schema construction
- categorise docstring notes the daily calibration of the |IC|>0.02 floor
at h>1; the sampling comment no longer overclaims (residual
autocorrelation from persistent factor exposures is not modeled)
- MCP wrapper uses _MAX_HORIZON instead of a hardcoded duplicate
Pinned by new tests: parser/handler/runner cap rejection (fetch not
called), multi-zoo disclosure aggregation, compare exclusion disclosure.
* feat(factors): industry-neutralized CN value alphas + PIT SW2021 sector tag
Three within-industry z-scored variants of the CN value factors
(fund_pb/fund_ps_ttm/fund_dividend_yield -> *_ind_neutral, zoo 475 -> 478):
raw A-share PB/PS/dy are heavily industry-confounded, so each name is
z-scored against its Shenwan L1 peers instead of the whole cross-section.
- industry_loader.py: tushare index_member_all (SW2021) membership with
in_date/out_date -> point-in-time labels per trading day; deterministic
overlap resolution; 2000-row truncation guard refetches per L1; degrades
to an unmapped frame on provider failure, never invents a label.
- csi300 bench attaches panel['sector'] outside the pickled price cache,
masked to the PIT membership cross-section, behind the same 0.9 coverage
gate the sp500 GICS tag uses; meta discloses sector_source/coverage.
- base.zscore_by_group: the z-scoring sibling of alpha101's _ind_neutralize
demean - no global fallback (an unlabelled cell stays NaN rather than
wearing an industry-neutral label it did not earn).
- README x7 + SKILL.md counts (incl. the stale 462 prose sites), i18n zh/en.
Follow-up #2 of issue #1789.
* fix(loaders): index_member_all takes no src param; fetch PIT history via is_new Y+N
Live-verified endpoint contract (pinned by test): index_member_all accepts
only the documented l1/l2/l3_code, ts_code and is_new filters — an
undocumented src param silently returns an empty frame (HTTP 200, 0 rows),
which degraded the first csi300 sector attach to coverage 0.0. src now goes
to index_classify (truncation fallback) only.
The server also defaults is_new to 'Y' (current members, no out_date rows),
so the historical half ('N', ~2000 rows with out_date back to the 1990s) is
fetched separately and concatenated — without it every label would be
today's classification and the loader's PIT claim false.
Live smoke: 350/350 csi300 PIT constituents covered (100%), 0.9 gate passes.
* docs(factors): surface the SW2021 taxonomy-retroactivity caveat; pin cache immutability
Review follow-ups: the loader docstring disclosed that SW2021
classifications are applied retroactively, but no user-facing surface
did — one sentence added to each ind-neutral alpha's notes, the i18n
en/zh entries and SKILL.md (membership is point-in-time; the taxonomy
version itself is not). The sector-attach test now pins that the
original pickled panel dict is never mutated by the attach, and the
sector_coverage meta semantics (labelled-somewhere fraction, not
per-date) are documented at the attach site.
* fix(factors): align ind-neutral PB/PS with the upstream nonpositive-multiple guard
The maintenance batch (d3c76819) added a guard to fund_pb/fund_ps_ttm:
nonpositive multiples are excluded rather than ranked as cheap. The
industry-neutral variants follow suit — the within-group z-score sees
the same values.where(values > 0) pre-filter, the guard sentence leads
the notes/i18n entries, and the upstream parametrized test is mirrored
for the neutral stems.
325 lines
10 KiB
Python
325 lines
10 KiB
Python
"""PIT contract for ``backtest.loaders.industry_loader``.
|
|
|
|
Every test injects a fake tushare API — no network. The behaviours pinned here
|
|
are the ones a silent bug would ruin: ``in_date`` inclusive / ``out_date``
|
|
exclusive membership boundaries, later-``in_date``-wins on overlapping rows,
|
|
the 2000-row truncation guard's per-L1 refetch, placeholder-token rejection,
|
|
graceful degradation on provider failure, and the never-invent-a-label rule
|
|
(unmapped symbols stay NaN).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from backtest.loaders.industry_loader import (
|
|
load_industry_frame,
|
|
load_industry_frame_with_coverage,
|
|
validate_industry_params,
|
|
)
|
|
|
|
_INDEX = pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04"])
|
|
_SYMBOLS = ["600000.SH", "000001.SZ"]
|
|
|
|
|
|
def _row(
|
|
ts_code: str,
|
|
l1_name: str,
|
|
in_date: str,
|
|
out_date: str | None = None,
|
|
*,
|
|
l2_name: str = "L2-default",
|
|
l3_name: str = "L3-default",
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"l1_code": f"800-{l1_name}",
|
|
"l1_name": l1_name,
|
|
"l2_code": f"801-{l2_name}",
|
|
"l2_name": l2_name,
|
|
"l3_code": f"802-{l3_name}",
|
|
"l3_name": l3_name,
|
|
"ts_code": ts_code,
|
|
"name": ts_code.split(".")[0],
|
|
"in_date": in_date,
|
|
"out_date": out_date,
|
|
"is_new": "Y" if out_date is None else "N",
|
|
}
|
|
|
|
|
|
def _frame(rows: list[dict[str, Any]]) -> pd.DataFrame:
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
class _FakeApi:
|
|
"""Serves a fixed membership snapshot; records every call.
|
|
|
|
Mirrors the real endpoint's ``is_new`` filtering (server default "Y"):
|
|
rows carry is_new = "Y" when out_date is null, "N" otherwise.
|
|
"""
|
|
|
|
def __init__(self, snapshot: pd.DataFrame | Exception) -> None:
|
|
self.snapshot = snapshot
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def index_member_all(self, **kwargs: Any) -> pd.DataFrame:
|
|
self.calls.append(kwargs)
|
|
if isinstance(self.snapshot, Exception):
|
|
raise self.snapshot
|
|
return _filter_is_new(self.snapshot, kwargs)
|
|
|
|
|
|
def _filter_is_new(frame: pd.DataFrame, kwargs: dict[str, Any]) -> pd.DataFrame:
|
|
is_new = kwargs.get("is_new")
|
|
if is_new is not None and "is_new" in getattr(frame, "columns", []):
|
|
return frame[frame["is_new"] == is_new]
|
|
return frame
|
|
|
|
|
|
def _load(api: Any, symbols: list[str] | None = None, **kwargs: Any) -> pd.DataFrame:
|
|
return load_industry_frame(
|
|
symbols=symbols or _SYMBOLS,
|
|
start="2024-01-01",
|
|
end="2024-01-04",
|
|
index=_INDEX,
|
|
api=api,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------- PIT boundaries
|
|
|
|
|
|
def test_in_date_is_inclusive() -> None:
|
|
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240102")]))
|
|
|
|
frame = _load(api)
|
|
|
|
assert pd.isna(frame.loc["2024-01-01", "600000.SH"])
|
|
assert frame.loc["2024-01-02", "600000.SH"] == "银行"
|
|
assert frame.loc["2024-01-04", "600000.SH"] == "银行" # null out_date = current
|
|
|
|
|
|
def test_out_date_is_exclusive() -> None:
|
|
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240101", "20240103")]))
|
|
|
|
frame = _load(api)
|
|
|
|
assert frame.loc["2024-01-01", "600000.SH"] == "银行"
|
|
assert frame.loc["2024-01-02", "600000.SH"] == "银行"
|
|
assert pd.isna(frame.loc["2024-01-03", "600000.SH"])
|
|
assert pd.isna(frame.loc["2024-01-04", "600000.SH"])
|
|
|
|
|
|
def test_historical_membership_applies_only_inside_its_interval() -> None:
|
|
"""A closed row labels the past; the open row labels the present."""
|
|
api = _FakeApi(
|
|
_frame(
|
|
[
|
|
_row("600000.SH", "旧行业", "20230101", "20240103"),
|
|
_row("600000.SH", "新行业", "20240103"),
|
|
]
|
|
)
|
|
)
|
|
|
|
frame = _load(api)
|
|
|
|
assert frame.loc["2024-01-01", "600000.SH"] == "旧行业"
|
|
assert frame.loc["2024-01-02", "600000.SH"] == "旧行业"
|
|
assert frame.loc["2024-01-03", "600000.SH"] == "新行业"
|
|
assert frame.loc["2024-01-04", "600000.SH"] == "新行业"
|
|
|
|
|
|
def test_overlapping_rows_resolve_to_the_later_in_date() -> None:
|
|
api = _FakeApi(
|
|
_frame(
|
|
[
|
|
_row("600000.SH", "A", "20240101"),
|
|
_row("600000.SH", "B", "20240103"),
|
|
]
|
|
)
|
|
)
|
|
|
|
frame = _load(api)
|
|
|
|
assert frame.loc["2024-01-02", "600000.SH"] == "A"
|
|
assert frame.loc["2024-01-03", "600000.SH"] == "B"
|
|
assert frame.loc["2024-01-04", "600000.SH"] == "B"
|
|
|
|
|
|
def test_row_order_does_not_change_the_winner() -> None:
|
|
rows = [_row("600000.SH", "A", "20240101"), _row("600000.SH", "B", "20240103")]
|
|
forward = _load(_FakeApi(_frame(rows)))
|
|
reversed_ = _load(_FakeApi(_frame(list(reversed(rows)))))
|
|
|
|
pd.testing.assert_frame_equal(forward, reversed_)
|
|
|
|
|
|
# ---------------------------------------------------------------- never invent
|
|
|
|
|
|
def test_unmapped_symbols_and_dates_stay_nan() -> None:
|
|
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240101")]))
|
|
|
|
frame, coverage = load_industry_frame_with_coverage(
|
|
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX, api=api
|
|
)
|
|
|
|
assert frame["000001.SZ"].isna().all()
|
|
assert list(frame.columns) == _SYMBOLS
|
|
assert list(frame.index) == list(_INDEX)
|
|
assert coverage == pytest.approx(0.5)
|
|
|
|
|
|
def test_rows_with_a_blank_label_are_dropped_not_defaulted() -> None:
|
|
api = _FakeApi(
|
|
_frame(
|
|
[
|
|
_row("600000.SH", None, "20240101"),
|
|
{"ts_code": "000001.SZ", "l1_name": "银行", "in_date": None, "out_date": None},
|
|
]
|
|
)
|
|
)
|
|
|
|
frame, coverage = load_industry_frame_with_coverage(
|
|
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX, api=api
|
|
)
|
|
|
|
assert frame.isna().all().all()
|
|
assert coverage == 0.0
|
|
|
|
|
|
def test_level_selects_the_name_column() -> None:
|
|
api = _FakeApi(
|
|
_frame([_row("600000.SH", "L1x", "20240101", l2_name="L2x", l3_name="L3x")])
|
|
)
|
|
|
|
assert _load(api, level="L1").loc["2024-01-01", "600000.SH"] == "L1x"
|
|
assert _load(api, level="L2").loc["2024-01-01", "600000.SH"] == "L2x"
|
|
assert _load(api, level="L3").loc["2024-01-01", "600000.SH"] == "L3x"
|
|
|
|
|
|
# ------------------------------------------------------------ validation gate
|
|
|
|
|
|
@pytest.mark.parametrize("level", ["L0", "l1", "L4", ""])
|
|
def test_invalid_level_raises(level: str) -> None:
|
|
with pytest.raises(ValueError, match="unsupported industry level"):
|
|
validate_industry_params(level, "SW2021")
|
|
|
|
|
|
@pytest.mark.parametrize("src", ["", " "])
|
|
def test_empty_src_raises(src: str) -> None:
|
|
with pytest.raises(ValueError, match="non-empty"):
|
|
validate_industry_params("L1", src)
|
|
|
|
|
|
def test_invalid_level_raises_through_the_loader() -> None:
|
|
with pytest.raises(ValueError, match="unsupported industry level"):
|
|
_load(_FakeApi(_frame([])), level="L9")
|
|
|
|
|
|
# ------------------------------------------------------------ truncation guard
|
|
|
|
|
|
def test_exactly_2000_rows_triggers_the_per_l1_refetch() -> None:
|
|
"""A snapshot at the documented cap is treated as truncated and refetched."""
|
|
truncated = _frame([_row(f"99999{i}.SZ", "填充", "20240101") for i in range(2000)])
|
|
real_a = _frame([_row("600000.SH", "银行", "20240101")])
|
|
real_b = _frame([_row("000001.SZ", "非银金融", "20240101")])
|
|
|
|
class _TruncatingApi:
|
|
def __init__(self) -> None:
|
|
self.member_calls: list[dict[str, Any]] = []
|
|
self.classify_calls: list[dict[str, Any]] = []
|
|
|
|
def index_member_all(self, **kwargs: Any) -> pd.DataFrame:
|
|
self.member_calls.append(kwargs)
|
|
if "l1_code" not in kwargs:
|
|
return _filter_is_new(truncated, kwargs)
|
|
return _filter_is_new(
|
|
{"801780.SI": real_a, "801790.SI": real_b}[kwargs["l1_code"]], kwargs
|
|
)
|
|
|
|
def index_classify(self, **kwargs: Any) -> pd.DataFrame:
|
|
self.classify_calls.append(kwargs)
|
|
return pd.DataFrame(
|
|
{"index_code": ["801780.SI", "801790.SI"], "industry_name": ["银行", "非银金融"]}
|
|
)
|
|
|
|
api = _TruncatingApi()
|
|
frame = _load(api)
|
|
|
|
assert api.classify_calls == [{"level": "L1", "src": "SW2021"}]
|
|
assert [call.get("l1_code") for call in api.member_calls] == [
|
|
None,
|
|
"801780.SI",
|
|
"801790.SI",
|
|
None,
|
|
]
|
|
assert frame.loc["2024-01-01", "600000.SH"] == "银行"
|
|
assert frame.loc["2024-01-01", "000001.SZ"] == "非银金融"
|
|
|
|
|
|
def test_below_the_cap_snapshot_is_used_as_is() -> None:
|
|
snapshot = _frame([_row("600000.SH", "银行", "20240101")])
|
|
api = _FakeApi(snapshot)
|
|
|
|
frame = _load(api)
|
|
|
|
assert api.calls == [{"is_new": "Y"}, {"is_new": "N"}]
|
|
assert frame.loc["2024-01-01", "600000.SH"] == "银行"
|
|
|
|
|
|
def test_src_is_never_passed_to_index_member_all() -> None:
|
|
"""Pins the live-verified endpoint quirk: an undocumented ``src`` param
|
|
silently empties the index_member_all response (0 rows, HTTP 200)."""
|
|
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240101")]))
|
|
|
|
_load(api, src="SW2021")
|
|
|
|
assert api.calls, "expected index_member_all to be called"
|
|
for call in api.calls:
|
|
assert "src" not in call
|
|
|
|
|
|
# ------------------------------------------------------------- failure paths
|
|
|
|
|
|
def test_provider_exception_degrades_to_an_all_none_frame() -> None:
|
|
api = _FakeApi(RuntimeError("provider down"))
|
|
|
|
frame, coverage = load_industry_frame_with_coverage(
|
|
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX, api=api
|
|
)
|
|
|
|
assert frame.shape == (len(_INDEX), len(_SYMBOLS))
|
|
assert frame.isna().all().all()
|
|
assert coverage == 0.0
|
|
|
|
|
|
def test_empty_snapshot_degrades_to_an_all_none_frame() -> None:
|
|
frame = _load(_FakeApi(_frame([])))
|
|
|
|
assert frame.isna().all().all()
|
|
|
|
|
|
def test_snapshot_missing_pit_columns_degrades_to_an_all_none_frame() -> None:
|
|
"""A payload without in_date/out_date cannot establish membership — degrade, never guess."""
|
|
frame = _load(_FakeApi(pd.DataFrame({"ts_code": ["600000.SH"], "l1_name": ["银行"]})))
|
|
|
|
assert frame.isna().all().all()
|
|
|
|
|
|
def test_placeholder_token_raises_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("TUSHARE_TOKEN", "")
|
|
monkeypatch.setitem(sys.modules, "tushare", SimpleNamespace(pro_api=lambda _token: None))
|
|
|
|
with pytest.raises(RuntimeError, match="TUSHARE_TOKEN not configured"):
|
|
load_industry_frame(
|
|
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX
|
|
)
|