Files
qlib/backend/app/application/services/selection_service.py
T
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

187 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""选股用例入口(ARCHITECTURE_v2 §14 Selection Engine · 业务层)。
- 输入:SelectionQuery(universe + method + factors/conditions + top_n/pct + as_of)
- 装配:股票池(universe 过滤)→ 行情长表(含预热窗口)→ Selection Engine
(method=score 因子评分 / method=condition 结构化条件)
- 输出:SelectionResult(可解释:factor_values / filter_status / selection_reason)
- 未来函数红线:行情只取 <= as_of;财务条件只取 announce_date <= as_of 的已公告值(v2 §9)
MVP 为同步执行(单日全市场因子/条件计算量轻);如需异步可复用 Job 链路。
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
from app.domain.entities.market import FinancialIndicator
from app.domain.entities.selection import SelectionQuery, SelectionResult
from app.domain.repositories.market import (
DailyBarRepository,
FinancialRepository,
StockRepository,
)
from app.quant.selection import (
condition_needed_columns,
factor_columns,
run_condition_selection,
run_score_selection,
)
from app.quant.service import (
load_basic_df,
load_daily_df,
merge_basic_into_daily,
split_factor_columns,
)
from app.quant.universe import filter_stocks, names_as_of, resolve_members
_FUNDAMENTAL_PREFIX = "fundamental."
def fill_candidate_names(result: SelectionResult, stocks: list) -> SelectionResult:
"""把股票池的 `symbol → name` 回填进候选股(展示增强,未命中保持 None)。
为什么在业务层做:名称是展示数据而非选股语义,引擎(quant/selection.py)
只做纯数值计算,不应感知名称;而本用例的 `stocks` 已是 universe 过滤后的
股票实体列表(天然带 name),在这里一次性建立映射即可,不必在各出口各自查库。
查不到名称的候选保持 None —— 前端按「名称未知」渲染,不伪造也不报错。
"""
if not result.candidates or not stocks:
return result
name_map = {s.symbol: s.name for s in stocks if getattr(s, "name", None)}
if not name_map:
return result
for cand in result.candidates:
if cand.name is None:
cand.name = name_map.get(cand.symbol)
return result
class SelectionService:
"""选股用例入口:select(query) → SelectionResult(当前或历史 as_of)。"""
def __init__(
self,
stock_repo: StockRepository,
daily_repo: DailyBarRepository,
financial_repo: FinancialRepository | None = None,
index_repo=None,
basic_repo=None,
name_repo=None,
) -> None:
self._stock_repo = stock_repo
self._daily_repo = daily_repo
self._financial_repo = financial_repo
self._index_repo = index_repo
# 每日指标仓储(daily_basic):score 因子/条件引用 dv_ratio 等列时使用
self._basic_repo = basic_repo
# 名称变更历史仓储:exclude_st 的时点口径(与回测口径一致,v2 §25)
self._name_repo = name_repo
def select(self, query: SelectionQuery) -> SelectionResult:
as_of = query.as_of or date.today()
all_stocks = self._stock_repo.list()
name_at, _applied = names_as_of(all_stocks, as_of, self._name_repo)
stocks = filter_stocks(
all_stocks, query.universe, as_of=as_of,
members=resolve_members(self._index_repo, query.universe, as_of),
name_at=name_at,
)
if not stocks:
return self._run(query, pd.DataFrame(), stocks, as_of, financial={})
symbols = [s.symbol for s in stocks]
if query.method == "score":
columns = sorted(factor_columns(query))
else:
columns = sorted(condition_needed_columns(query))
bar_cols, basic_cols = split_factor_columns(columns)
data_start = as_of - timedelta(days=query.warmup_days)
daily = load_daily_df(
self._daily_repo,
symbols,
data_start,
as_of,
sorted(bar_cols),
adjust="none",
price_adjust=query.price_adjustment,
)
if basic_cols:
daily = self._attach_basic(daily, symbols, data_start, as_of, sorted(basic_cols))
financial: dict[str, FinancialIndicator] = {}
if query.method == "condition" and self._uses_fundamental(query):
financial = self._load_financial(symbols, as_of)
return self._run(query, daily, stocks, as_of, financial)
# ---- 内部 ----
def _attach_basic(
self,
daily: pd.DataFrame,
symbols: list[str],
start: date,
end: date,
columns: list[str],
) -> pd.DataFrame:
"""并入 daily_basic 列(与 ResearchService 同一装配逻辑,保证 v2 §25 一致性)。"""
if self._basic_repo is None:
raise ValueError(
f"选股条件/因子需要每日指标列 {columns}(daily_basic),"
"但未注入 DailyBasicRepository。请检查 API 的依赖装配。"
)
basic = load_basic_df(self._basic_repo, symbols, start, end, columns)
if basic.empty:
raise ValueError(
f"daily_basic 表在 {start}~{end} 无数据,无法计算需要 {columns} 的因子/条件。"
"请先运行:python -m app.cli.sync daily_basic --start 20200101"
)
return merge_basic_into_daily(daily, basic)
def _run(
self,
query: SelectionQuery,
daily: pd.DataFrame,
stocks: list,
as_of: date,
financial: dict[str, FinancialIndicator],
) -> SelectionResult:
if query.method == "score":
result = run_score_selection(daily, query, as_of)
else:
result = run_condition_selection(daily, stocks, query, as_of, financial)
# 名称只在业务层回填:引擎(quant/selection.py)保持纯符号计算,
# 而 `stocks` 是本用例已经装配好的股票池,天然带 name,无需再查库/join。
return fill_candidate_names(result, stocks)
@staticmethod
def _uses_fundamental(query: SelectionQuery) -> bool:
for c in query.conditions:
if c.field.startswith(_FUNDAMENTAL_PREFIX) or (
c.ref is not None and c.ref.startswith(_FUNDAMENTAL_PREFIX)
):
return True
return False
def _load_financial(
self, symbols: list[str], as_of: date
) -> dict[str, FinancialIndicator]:
"""按 announce_date <= as_of 批量取财务,每 symbol 保留最新一版。"""
if self._financial_repo is None:
raise ValueError("condition 引用了 fundamental.* 字段,但未注入 FinancialRepository")
getter = getattr(self._financial_repo, "list_announced_many", None)
if getter is not None:
rows = list(getter(symbols, as_of))
else: # 回退逐只
rows = []
for sym in symbols:
rows.extend(self._financial_repo.list_announced(sym, as_of))
by_symbol: dict[str, FinancialIndicator] = {}
for row in rows:
cur = by_symbol.get(row.symbol)
if cur is None or (row.announce_date, row.report_date) > (
cur.announce_date,
cur.report_date,
):
by_symbol[row.symbol] = row
return by_symbol