汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
240 lines
9.9 KiB
Python
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"
|