"""名称变更历史(时点 ST)测试:Repository、Provider 归一、universe 时点口径。 背景(实测):`stock.name` 只是最新名称快照。用它做 `exclude_st` 会把 「曾是高股息、后来才变 ST/退市」的标的在**整段历史**里排除 —— 而那正是 「股息陷阱」样本。实测对照(同一 spec 仅改 exclude_st):+35.71% → +32.01%, 即约 3.70pp 收益被名称快照口径隐藏。本模块锁定修复后的时点语义。 """ from __future__ import annotations from datetime import date from decimal import Decimal import pandas as pd import pytest from app.domain.entities.market import Stock, StockNameHistory from app.infrastructure.persistence.sqlalchemy.base import Base from app.infrastructure.persistence.sqlalchemy.models.market import ( DailyBasicModel, StockDailyModel, StockModel, StockNameHistoryModel, ) from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import ( SqlAlchemyStockNameHistoryRepository, ) from sqlalchemy import create_engine from sqlalchemy.orm import Session @pytest.fixture def session(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'name.db'}", future=True) Base.metadata.create_all(engine) s = Session(engine) yield s s.close() def _seed_dima(session: Session) -> None: """600565.SH 迪马股份:2002-07-23 上市 → 2024-05-06 变 ST迪马(真实数据)。""" session.add_all( [ StockModel( symbol="600565.SH", name="ST迪马", list_date=date(2002, 7, 23), status="L" ), StockModel( symbol="600519.SH", name="贵州茅台", list_date=date(2001, 8, 27), status="L" ), StockNameHistoryModel( symbol="600565.SH", name="迪马股份", start_date=date(2002, 7, 23), end_date=date(2024, 5, 5), ann_date=date(2002, 7, 23), change_reason="其他", ), StockNameHistoryModel( symbol="600565.SH", name="ST迪马", start_date=date(2024, 5, 6), end_date=None, ann_date=date(2024, 4, 30), change_reason="ST", ), StockNameHistoryModel( symbol="600519.SH", name="贵州茅台", start_date=date(2001, 8, 27), end_date=None, ann_date=date(2001, 8, 27), change_reason="其他", ), ] ) session.commit() class TestRepository: def test_names_as_of_picks_effective_span(self, session) -> None: _seed_dima(session) repo = SqlAlchemyStockNameHistoryRepository(session) symbols = ["600565.SH", "600519.SH"] # 2020 年:迪马股份(不是 ST) assert repo.names_as_of(symbols, date(2020, 1, 2))["600565.SH"] == "迪马股份" # 区间边界:末日仍为旧名,次日起为新名 assert repo.names_as_of(["600565.SH"], date(2024, 5, 5))["600565.SH"] == "迪马股份" assert repo.names_as_of(["600565.SH"], date(2024, 5, 6))["600565.SH"] == "ST迪马" # end_date 为空的区间至今有效 assert repo.names_as_of(["600565.SH"], date(2026, 9, 4))["600565.SH"] == "ST迪马" def test_unknown_symbol_absent_from_map(self, session) -> None: """无记录 → 不返回该键,由调用方回退最新名称(不抛错)。""" _seed_dima(session) repo = SqlAlchemyStockNameHistoryRepository(session) assert repo.names_as_of(["000001.SZ"], date(2020, 1, 2)) == {} def test_upsert_is_idempotent(self, session) -> None: repo = SqlAlchemyStockNameHistoryRepository(session) row = StockNameHistory( symbol="600565.SH", name="迪马股份", start_date=date(2002, 7, 23), end_date=date(2024, 5, 5), change_reason="其他", ) assert repo.upsert_many([row]) == 1 assert repo.upsert_many([row]) == 1 # 重跑无副作用 assert repo.count_rows() == 1 assert repo.namechange_dates() == (date(2002, 7, 23), date(2002, 7, 23)) def test_name_spans_grouped_by_symbol(self, session) -> None: _seed_dima(session) repo = SqlAlchemyStockNameHistoryRepository(session) spans = repo.name_spans(["600565.SH"]) assert [n for _s, _e, n in spans["600565.SH"]] == ["迪马股份", "ST迪马"] class TestUniversePointInTime: """filter_stocks(name_at=...) 的时点语义(案例收益口径的关键)。""" def _stocks(self) -> list[Stock]: return [ Stock(symbol="600565.SH", name="ST迪马", list_date=date(2002, 7, 23)), Stock(symbol="600519.SH", name="贵州茅台", list_date=date(2001, 8, 27)), ] def test_name_at_overrides_snapshot(self) -> None: from app.domain.entities.research import UniverseSpec from app.quant.universe import filter_stocks u = UniverseSpec(exclude_st=True, min_listing_days=0) # 旧口径(无 name_at):最新名称含 ST → 2020 年就被排除(股息陷阱被隐藏) assert "600565.SH" not in {s.symbol for s in filter_stocks(self._stocks(), u, date(2020, 1, 2))} # 时点口径:2020 年它叫「迪马股份」→ 必须纳入 got = { s.symbol for s in filter_stocks( self._stocks(), u, date(2020, 1, 2), name_at={"600565.SH": "迪马股份"} ) } assert "600565.SH" in got # 2024-05-06 起为 ST迪马 → 排除 got2 = { s.symbol for s in filter_stocks( self._stocks(), u, date(2024, 6, 3), name_at={"600565.SH": "ST迪马"} ) } assert "600565.SH" not in got2 def test_name_at_missing_falls_back_to_snapshot(self) -> None: from app.domain.entities.research import UniverseSpec from app.quant.universe import filter_stocks u = UniverseSpec(exclude_st=True, min_listing_days=0) got = filter_stocks(self._stocks(), u, date(2020, 1, 2), name_at={}) assert "600565.SH" not in {s.symbol for s in got} # 回退快照,行为不变 def test_exclude_st_false_ignores_names(self) -> None: from app.domain.entities.research import UniverseSpec from app.quant.universe import filter_stocks u = UniverseSpec(exclude_st=False, min_listing_days=0) got = filter_stocks(self._stocks(), u, date(2020, 1, 2), name_at={"600565.SH": "ST迪马"}) assert "600565.SH" in {s.symbol for s in got} class TestNamesAsOfHelper: def test_none_repo_reports_snapshot_basis(self) -> None: from app.quant.universe import names_as_of name_at, applied = names_as_of([], date(2020, 1, 2), None) assert name_at is None assert applied == (False, 0) def test_repo_failure_degrades_without_raising(self) -> None: """名称历史查询异常不得让选股/回测整体失败(降级为快照口径)。""" from app.quant.universe import names_as_of class Boom: def names_as_of(self, symbols, as_of): raise RuntimeError("表不存在") name_at, applied = names_as_of([], date(2020, 1, 2), Boom()) assert name_at is None and applied == (False, 0) class TestProviderNormalize: def test_normalize_name_history(self) -> None: from app.infrastructure.data_sources.tushare import TushareProvider rows = TushareProvider.normalize_name_history( [ { "ts_code": "600565.SH", "name": "ST迪马", "start_date": "20240506", "end_date": None, "ann_date": "20240430", "change_reason": "ST", }, { "ts_code": "600565.SH", "name": "迪马股份", "start_date": "20020723", "end_date": "20240505", "ann_date": "20020723", "change_reason": "其他", }, ] ) assert rows[1].start_date == date(2002, 7, 23) assert rows[0].end_date is None assert rows[0].is_risk_warned is True assert rows[1].is_risk_warned is False def test_nan_end_date_and_bad_code_skipped(self) -> None: """实测:namechange 的 end_date 是 float NaN,曾让 32/37 个分片整体失败。""" from app.infrastructure.data_sources.tushare import TushareProvider rows = TushareProvider.normalize_name_history( [ { "ts_code": "000001.SZ", "name": "平安银行", "start_date": "19910403", "end_date": float("nan"), "ann_date": float("nan"), "change_reason": None, }, {"ts_code": "T600018.SH", "name": "上港集箱(退)", "start_date": "19960101"}, ] ) assert len(rows) == 1 assert rows[0].end_date is None and rows[0].ann_date is None def test_to_date_rejects_nan_variants(self) -> None: from app.infrastructure.data_sources.tushare import _to_date assert _to_date(float("nan")) is None assert _to_date("nan") is None assert _to_date("None") is None assert _to_date("") is None assert _to_date(None) is None assert _to_date("20240506") == date(2024, 5, 6) class TestPerDateStFilter: """回测的 exclude_st 必须**逐择股日**重判(与 /api/selections 单时点同口径)。 否则「入池时非 ST、之后才变 ST」的标的会在之后所有择股日继续被选中 —— 正是高股息策略最危险的股息陷阱路径。 """ def _build(self, tmp_path): """A 高股息但 2024-06-01 变 ST;B 低股息且始终非 ST。""" from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import ( SqlAlchemyDailyBarRepository, SqlAlchemyDailyBasicRepository, SqlAlchemyStockNameHistoryRepository, SqlAlchemyStockRepository, ) engine = create_engine(f"sqlite:///{tmp_path / 'pit.db'}", future=True) Base.metadata.create_all(engine) session = Session(engine) syms = ["600000.SH", "600001.SH"] session.add_all( [ StockModel(symbol=syms[0], name="ST阿甲", list_date=date(2000, 1, 1), status="L"), StockModel(symbol=syms[1], name="阿乙", list_date=date(2000, 1, 1), status="L"), # 名称历史:A 2024-06-01 起为 ST阿甲 StockNameHistoryModel( symbol=syms[0], name="阿甲", start_date=date(2000, 1, 1), end_date=date(2024, 5, 31), change_reason="其他", ), StockNameHistoryModel( symbol=syms[0], name="ST阿甲", start_date=date(2024, 6, 1), end_date=None, change_reason="ST", ), StockNameHistoryModel( symbol=syms[1], name="阿乙", start_date=date(2000, 1, 1), end_date=None, change_reason="其他", ), ] ) days = pd.bdate_range("2023-12-01", "2024-12-31") for sym, dv in ((syms[0], 12.0), (syms[1], 4.0)): session.add_all( [ StockDailyModel( symbol=sym, trade_date=d.date(), open=Decimal("10"), high=Decimal("10"), low=Decimal("10"), close=Decimal("10"), volume=Decimal("1000"), amount=Decimal("10000"), source="tushare", ) for d in days ] ) session.add_all( [ DailyBasicModel(symbol=sym, trade_date=d.date(), dv_ratio=Decimal(str(dv))) for d in days ] ) session.commit() from app.quant.engine import LocalEngine from app.quant.service import ResearchService svc = ResearchService( SqlAlchemyStockRepository(session), SqlAlchemyDailyBarRepository(session), LocalEngine(), basic_repo=SqlAlchemyDailyBasicRepository(session), name_repo=SqlAlchemyStockNameHistoryRepository(session), ) return svc, session, syms def _spec(self): from app.domain.entities.research import ( FactorSpec, ResearchSpec, SelectionSpec, UniverseSpec, ) return ResearchSpec( type="backtest", universe=UniverseSpec(exclude_st=True, min_listing_days=0), factors=[FactorSpec(name="dividend_yield", weight=1.0)], selection=SelectionSpec(top_n=2, hold_top_x=1, allow_substitute=True), period=("2024-01-01", "2024-12-31"), selection_interval_months=6, rebalance_interval_months=6, initial_capital=100000.0, ) def test_st_stock_dropped_from_later_selection(self, tmp_path) -> None: svc, session, syms = self._build(tmp_path) try: result = svc.run_backtest(self._spec()) finally: session.close() picks = {} for p in result.selection_history: picks.setdefault(p.date, []).append(p.symbol) dates = sorted(picks) assert len(dates) >= 2, picks first, second = dates[0], dates[-1] # 首个择股日:A 非 ST(高股息)→ 入选 assert syms[0] in picks[first] # 变 ST 之后的择股日:A 必须消失,B 顶上 assert syms[0] not in picks[second] assert syms[1] in picks[second] # 口径标注:时点名称(不是快照回退) assert result.config_snapshot["price_basis"]["name_basis"]["point_in_time"] is True def test_snapshot_fallback_without_repo(self, tmp_path) -> None: """未注入名称历史 → 回退最新名称(旧行为),并如实标注 point_in_time=False。""" svc, session, syms = self._build(tmp_path) try: svc._name_repo = None result = svc.run_backtest(self._spec()) finally: session.close() # 最新名称是 ST阿甲 → 首个择股日就被排除(股息陷阱被隐藏,已标注) first = min(p.date for p in result.selection_history) assert syms[0] not in [p.symbol for p in result.selection_history if p.date == first] assert result.config_snapshot["price_basis"]["name_basis"]["point_in_time"] is False class TestReviewRegressions: """代码审查发现的缺陷回归(P1/P2/P3):不得复活。""" def test_empty_table_does_not_claim_point_in_time(self) -> None: """[P1] 表存在但为空时必须降级为快照口径,不得声称 point_in_time=true。 否则结果页会把「未被修正的 10.85pp 股息陷阱偏差」当成已修正上报(AGENT.md §24)。 """ from app.quant.universe import names_as_of class EmptyRepo: def names_as_of(self, symbols, as_of): return {} stocks = [Stock(symbol="600565.SH", name="ST迪马", list_date=date(2000, 1, 1))] name_at, applied = names_as_of(stocks, date(2020, 1, 2), EmptyRepo()) assert name_at is None assert applied == (False, 0) def test_empty_table_end_to_end_reports_snapshot_basis(self, tmp_path) -> None: """[P1] 端到端:名称表为空 → name_basis.point_in_time 必须为 False。""" svc, session, _syms = TestPerDateStFilter()._build(tmp_path) try: session.query(StockNameHistoryModel).delete() session.commit() result = svc.run_backtest(TestPerDateStFilter()._spec()) finally: session.close() assert result.config_snapshot["price_basis"]["name_basis"]["point_in_time"] is False assert any("最新名称快照" in n for n in result.unimplemented) def test_missing_span_falls_back_to_snapshot_in_backtest(self, tmp_path) -> None: """[P2] 某股无生效区间时,逐择股日 ST 必须与 filter_stocks 同口径回退最新名称。 否则同一择股日会出现「回测入选、/api/selections 排除」的打架(v2 §25)。 """ svc, session, syms = TestPerDateStFilter()._build(tmp_path) try: # 删掉 A 的**全部**名称区间:只剩最新名称 ST阿甲 session.query(StockNameHistoryModel).filter( StockNameHistoryModel.symbol == syms[0] ).delete() session.commit() result = svc.run_backtest(TestPerDateStFilter()._spec()) dates = sorted({p.date for p in result.selection_history}) first = dates[0] picks_first = [p.symbol for p in result.selection_history if p.date == first] finally: session.close() # 快照口径:A 名称含 ST → 首个择股日即被排除(与 filter_stocks 一致) assert syms[0] not in picks_first assert syms[1] in picks_first def test_protocol_declares_name_changes(self) -> None: """[P2] MarketDataProvider 必须声明 get_name_changes(§6 业务层只依赖抽象)。""" from app.domain.providers import MarketDataProvider assert hasattr(MarketDataProvider, "get_name_changes") assert "get_name_changes" in dir(MarketDataProvider) def test_sina_declares_not_supported(self) -> None: """[P2] 备用源必须显式 NotSupported,不得静默返回空列表(否则时点 ST 静默降级)。""" from app.infrastructure.data_sources.errors import DataSourceNotSupported from app.infrastructure.data_sources.sina import SinaProvider provider = SinaProvider.__new__(SinaProvider) with pytest.raises(DataSourceNotSupported): provider.get_name_changes(date(2024, 1, 1), date(2024, 12, 31)) def test_qlib_engine_accepts_eligibility_fn(self) -> None: """[P2] 引擎协议一致性:所有引擎都必须接受 eligibility_fn。 否则注入 QlibEngine 后任何回测都会 TypeError(且条件/时点 ST 会被静默忽略)。 """ import inspect from app.quant.engine import QuantEngine from app.quant.qlib_adapter.engine import QlibEngine for cls in (QuantEngine, QlibEngine): params = inspect.signature(cls.run_backtest).parameters assert "eligibility_fn" in params, cls.__name__