Files
qlib/backend/tests/test_symbol_names.py
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

240 lines
9.9 KiB
Python

"""股票名称(`name`)填充与 `GET /api/stocks/names` 接口测试。
覆盖三件事(需求:前端任何出现代码的地方都要能显示名称):
1) 回测结果的展示结构(symbol_curves / positions / trades / fills / signal_history /
selection_history)由 `ResearchService.run_backtest` 统一回填名称;
股票池为空时**静默跳过**(名称只是展示增强,不得让已算完的回测失败);
2) 选股结果 `candidates[].name` 由 `SelectionService` 用已装配股票池回填;
3) `GET /api/stocks/names` 返回 `{symbol: name}` **dict**(前端契约),
且 `GET /api/stocks/{symbol}` 未被 `/names` 破坏(路由顺序)。
用内存 Repository + 合成行情(不连真库、不跑长区间),秒级完成。
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from app.api import deps
from app.application.services.selection_service import SelectionService
from app.domain.entities.market import Stock
from app.domain.entities.research import (
FactorSpec,
ResearchSpec,
SelectionSpec,
UniverseSpec,
)
from app.domain.entities.selection import SelectionQuery
from app.main import app
from app.quant.engine import LocalEngine
from app.quant.service import ResearchService, _fill_names
from fastapi.testclient import TestClient
from conftest_quant import bars_dataframe_to_daily_bars, synthetic_daily
_SYMS = ["600000.SH", "600001.SH", "600002.SH"]
_NAMES = {"600000.SH": "浦发银行", "600001.SH": "邯郸钢铁", "600002.SH": "齐鲁石化"}
def _stocks(symbols: list[str] | None = None) -> list[Stock]:
return [
Stock(symbol=s, name=_NAMES[s], list_date=date(1999, 11, 10))
for s in (symbols or _SYMS)
]
class _MemStockRepo:
def __init__(self, stocks: list[Stock]) -> None:
self._stocks = stocks
def list(self) -> list[Stock]:
return self._stocks
def get_by_symbol(self, symbol: str) -> Stock | None:
return next((s for s in self._stocks if s.symbol == symbol), None)
class _MemDailyRepo:
def __init__(self, df: pd.DataFrame) -> None:
self._bars = bars_dataframe_to_daily_bars(df)
def get_range(self, symbol, start, end):
return [b for b in self._bars if b.symbol == symbol and start <= b.trade_date <= end]
def get_range_many(self, symbols, start, end, adjust="none"):
syms = set(symbols)
return [b for b in self._bars if b.symbol in syms and start <= b.trade_date <= end]
def _daily_df() -> pd.DataFrame:
return synthetic_daily(
{"600000.SH": 0.004, "600001.SH": 0.001, "600002.SH": -0.003}, n=300
)
def _backtest_spec() -> ResearchSpec:
return ResearchSpec(
type="backtest",
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
factors=[FactorSpec(name="momentum_20", weight=1.0)],
selection=SelectionSpec(top_n=2),
rebalance="monthly",
# 半年区间(约 150 个交易日):够触发月度调仓与卖出,又足够快
period=(date(2024, 6, 3), date(2024, 12, 31)),
)
class TestBacktestNameFill:
def test_all_display_structures_carry_name(self) -> None:
service = ResearchService(
_MemStockRepo(_stocks()), _MemDailyRepo(_daily_df()), LocalEngine()
)
result = service.run_backtest(_backtest_spec())
assert result.symbol_curves, "应持有过股票并输出个股曲线"
assert result.positions, "应有调仓后仓位记录"
assert result.selection_history, "应有择股记录"
assert result.signal_history, "应有交易意图记录"
for curve in result.symbol_curves:
assert curve.name == _NAMES[curve.symbol]
for pos in result.positions:
assert pos.name == _NAMES[pos.symbol]
for pick in result.selection_history:
assert pick.name == _NAMES[pick.symbol]
# 成交/意图记录里存在 symbol="" 的池子不足提示(非股票),其 name 保持 None
for action in result.signal_history + result.fills:
if action.symbol:
assert action.name == _NAMES[action.symbol]
else:
assert action.name is None
def test_trades_and_marks_carry_name(self) -> None:
service = ResearchService(
_MemStockRepo(_stocks()), _MemDailyRepo(_daily_df()), LocalEngine()
)
result = service.run_backtest(_backtest_spec())
assert result.trades, "月度调仓 + 因子换手应产生已卖出往返"
for trade in result.trades:
assert trade.name == _NAMES[trade.symbol]
# marks 与 signal_history 同源,也应带上名称(前端个股曲线标注用)
for curve in result.symbol_curves:
for mark in curve.marks:
assert mark.name == _NAMES[mark.symbol]
def test_fill_names_skips_silently_when_stock_pool_empty(self) -> None:
"""空股票池 → 静默跳过(名称缺失只影响展示,不得抛错)。"""
service = ResearchService(
_MemStockRepo(_stocks()), _MemDailyRepo(_daily_df()), LocalEngine()
)
result = service.run_backtest(_backtest_spec())
before = result.symbol_curves[0].name
returned = _fill_names(result, [])
assert returned is result
assert result.symbol_curves[0].name == before # 未被清空、也未被改写
def test_fill_names_is_idempotent_and_keeps_name_without_stock_record(self) -> None:
"""名称回填幂等;股票池里没有该代码时保持 None(不伪造名称)。"""
service = ResearchService(
_MemStockRepo(_stocks()), _MemDailyRepo(_daily_df()), LocalEngine()
)
result = service.run_backtest(_backtest_spec())
snapshot = [(c.symbol, c.name) for c in result.symbol_curves]
_fill_names(result, _stocks())
assert [(c.symbol, c.name) for c in result.symbol_curves] == snapshot
only_other = [Stock(symbol="601398.SH", name="工商银行", list_date=date(2006, 1, 1))]
_fill_names(result, only_other)
assert all(c.name == _NAMES[c.symbol] for c in result.symbol_curves)
class TestSelectionCandidateName:
def _service(self) -> SelectionService:
return SelectionService(_MemStockRepo(_stocks()), _MemDailyRepo(_daily_df()))
def test_score_mode_candidates_carry_name(self) -> None:
result = self._service().select(
SelectionQuery(
method="score",
factors=[{"name": "momentum_20", "weight": 1.0}],
top_n=2,
as_of=date(2024, 12, 31),
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
)
)
assert result.candidates
for cand in result.candidates:
assert cand.name == _NAMES[cand.symbol]
def test_condition_mode_candidates_carry_name(self) -> None:
result = self._service().select(
SelectionQuery(
method="condition",
conditions=[{"field": "close", "op": "gte", "ref": "ma20"}],
as_of=date(2024, 12, 31),
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
)
)
assert result.candidates
for cand in result.candidates:
assert cand.name == _NAMES[cand.symbol]
class TestQlibEngineNameFill:
def test_qlib_engine_path_also_fills_names(self, tmp_path) -> None:
"""名称回填写在服务层,因此 LocalEngine / QlibEngine 两条路径都必须生效。
这条用例走真实的 Qlib 数据管线(落盘 bin → D.features 读回),
而不是只测本地引擎后「假设」另一条路径也覆盖到了。
"""
pytest.importorskip("qlib") # 未安装 qlib 的环境跳过,不假装覆盖
from app.quant.qlib_adapter.engine import QlibEngine
service = ResearchService(
_MemStockRepo(_stocks()), _MemDailyRepo(_daily_df()), QlibEngine(qlib_dir=tmp_path)
)
result = service.run_backtest(_backtest_spec())
assert result.symbol_curves, "qlib 路径也应产生个股曲线"
for curve in result.symbol_curves:
assert curve.name == _NAMES[curve.symbol]
for pos in result.positions:
assert pos.name == _NAMES[pos.symbol]
for pick in result.selection_history:
assert pick.name == _NAMES[pick.symbol]
@pytest.fixture()
def client() -> TestClient:
"""只覆盖 stock 仓储(这三个接口只用 StockRepoDep,不需要 DB session)。"""
stocks = [*_stocks(), Stock(symbol="600519.SH", name="贵州茅台", list_date=date(2001, 8, 27))]
app.dependency_overrides[deps._stock_repo_factory] = lambda: _MemStockRepo(stocks) # noqa: SLF001
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
class TestStockNamesApi:
def test_names_returns_symbol_to_name_dict(self, client: TestClient) -> None:
resp = client.get("/api/stocks/names")
assert resp.status_code == 200
body = resp.json()
# 契约:dict(不是数组),前端按 Record<string, string> 消费
assert isinstance(body, dict)
assert body["600519.SH"] == "贵州茅台"
assert body["600000.SH"] == _NAMES["600000.SH"]
assert set(body) == {"600000.SH", "600001.SH", "600002.SH", "600519.SH"}
def test_symbol_path_not_swallowed_by_names_route(self, client: TestClient) -> None:
"""路由顺序回归:/{symbol} 仍按代码查询(证明 /names 未被参数吞掉、也没吞掉它)。"""
one = client.get("/api/stocks/600519.SH")
assert one.status_code == 200
assert one.json()["symbol"] == "600519.SH"
assert one.json()["name"] == "贵州茅台"
assert client.get("/api/stocks/999999.SZ").status_code == 404
assert client.get("/api/stocks?q=茅台").json()[0]["symbol"] == "600519.SH"