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 逐类验证归档页)。
This commit is contained in:
@@ -12,14 +12,14 @@ import pandas as pd
|
||||
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
|
||||
from app.quant.factors import FactorError, get_factor
|
||||
from app.quant.local_engine import TopKBacktestRunner, run_spec_factor_test
|
||||
from app.quant.selection import score_panel_for_factors
|
||||
from app.quant.selection import condition_needed_columns, score_panel_for_factors
|
||||
|
||||
# LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
|
||||
_CLOSE = {"close"}
|
||||
|
||||
|
||||
def factor_required_columns(spec: ResearchSpec) -> set[str]:
|
||||
"""spec 因子计算 + 回测撮合所需的行情数值列(含 close)。"""
|
||||
"""spec 因子计算 + 选股条件 + 回测撮合所需的行情数值列(含 close)。"""
|
||||
needed = set(_CLOSE)
|
||||
for fs in spec.factors:
|
||||
try:
|
||||
@@ -27,6 +27,10 @@ def factor_required_columns(spec: ResearchSpec) -> set[str]:
|
||||
except FactorError:
|
||||
continue # 未知因子由执行期统一报错
|
||||
needed.update(defn.requires)
|
||||
if spec.conditions:
|
||||
# 条件字段同样决定装配列(dv_ratio / volume / ma60 依赖列等);
|
||||
# 与选股路径共用 condition_needed_columns(v2 §25 一致性)
|
||||
needed.update(condition_needed_columns(spec))
|
||||
return needed
|
||||
|
||||
|
||||
@@ -42,7 +46,14 @@ class QuantEngine(Protocol):
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21
|
||||
) -> FactorTestReport: ...
|
||||
|
||||
def run_backtest(self, daily: pd.DataFrame, spec: ResearchSpec) -> BacktestResult: ...
|
||||
def run_backtest(
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
|
||||
) -> BacktestResult:
|
||||
"""eligibility_fn(as_of: date) -> set[symbol] | None:选股条件过滤(可选)。
|
||||
|
||||
None 表示不过滤;由业务层注入(复用 selection.eligible_symbols),
|
||||
保证「历史某日 Selection == 回测当日 Selection」(v3 §28)。
|
||||
"""
|
||||
|
||||
|
||||
class LocalEngine:
|
||||
@@ -59,8 +70,10 @@ class LocalEngine:
|
||||
report, _panels = run_spec_factor_test(daily, spec, horizon_days)
|
||||
return report
|
||||
|
||||
def run_backtest(self, daily: pd.DataFrame, spec: ResearchSpec) -> BacktestResult:
|
||||
def run_backtest(
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
|
||||
) -> BacktestResult:
|
||||
# 评分面板与选股共用同一构建(v2 §25:回测与当前选股同引擎)
|
||||
score = score_panel_for_factors(daily, spec.factors)
|
||||
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
|
||||
return TopKBacktestRunner(spec, score, close).run()
|
||||
return TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""因子引擎:因子注册表、元数据与计算(Phase 2,低频选股因子)。
|
||||
|
||||
数据形态:行情长表 DataFrame(列 symbol/trade_date/close/high/low/volume/amount),
|
||||
数据形态:行情长表 DataFrame(列 symbol/trade_date/close/high/low/volume/amount,
|
||||
以及经 ResearchService 并入的每日指标列如 dv_ratio/dv_ttm),
|
||||
因子计算返回 面板 DataFrame(index=trade_date,columns=symbol)。
|
||||
所有内置因子只用行情字段(无财务),天然规避未来函数;财务因子接入时必须以
|
||||
announce_date 控制可见性(见 domain.entities.market.FinancialIndicator)。
|
||||
行情内置因子只用行情字段(无财务),天然规避未来函数;每日指标(daily_basic)
|
||||
为逐日时点值,按 trade_date <= as_of 取值同样无未来函数;
|
||||
财务因子接入时必须以 announce_date 控制可见性(见 domain.entities.market.FinancialIndicator)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -205,3 +207,58 @@ def _ma_bias_20(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
)
|
||||
def _reversal_5(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return -1.0 * _rolling_return(fields["close"], 5)
|
||||
|
||||
|
||||
# ---------- 每日指标(daily_basic)因子 ----------
|
||||
# 数据来源:daily_basic 表(Tushare daily_basic 接口),由 ResearchService / SelectionService
|
||||
# 装配后并入 daily 长表(见 quant/service.load_basic_df)。requires 里的列名即
|
||||
# domain.entities.market.DAILY_BASIC_NUMERIC_FIELDS 中的列。
|
||||
|
||||
# 特别分红导致的股息率畸高阈值(%):dv_ratio 会因一次性特别分红冲到 30%+,
|
||||
# 直接用「最高股息率」排序会被这类非经常性事件占满头部(实测 600738 在 2020-01-02
|
||||
# 为 37.2%)。本因子不隐式截断(截断属选股条件,应由用户在 conditions 里显式配置),
|
||||
# 但把阈值作为常量暴露,供前端/条件模板引用。
|
||||
DIVIDEND_YIELD_SPECIAL_CAP_PCT = 30.0
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"dividend_yield",
|
||||
"股息率(近 12 个月现金分红 / 总市值 × 100,%)",
|
||||
"dv_ratio(Tushare daily_basic,逐日时点值)",
|
||||
brief=(
|
||||
"高股息:熊市/震荡市防御性较强,分红提供现金回报底;"
|
||||
"需警惕「高股息陷阱」——股息率高常因股价下跌或一次性特别分红,"
|
||||
"建议配合 dv_ratio 上限过滤与盈利质量条件使用。"
|
||||
),
|
||||
frequency="daily",
|
||||
lookback=0, # 时点截面值,无滚动窗口
|
||||
direction="higher_is_better",
|
||||
requires=("dv_ratio",),
|
||||
)
|
||||
)
|
||||
def _dividend_yield(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
"""股息率面板(index=trade_date, columns=symbol)。
|
||||
|
||||
直接取当日 dv_ratio 时点值:该值由数据源按「过去 12 个月现金分红 / 当日总市值」
|
||||
逐日重算,只含已发生事件,按 trade_date <= as_of 取值即无未来函数。
|
||||
缺失值保持 NaN(由复合分/排序统一 dropna 处理),不做 0 填充 —— 0 会被误读成
|
||||
「股息率为 0 的合格标的」,从而污染横截面排序。
|
||||
"""
|
||||
return fields["dv_ratio"]
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"dividend_yield_ttm",
|
||||
"股息率 TTM(近 12 个月滚动现金分红 / 总市值 × 100,%)",
|
||||
"dv_ttm(Tushare daily_basic,逐日时点值)",
|
||||
brief="同 dividend_yield,但口径为 TTM;与 dv_ratio 多数日期取值一致,可作交叉验证。",
|
||||
frequency="daily",
|
||||
lookback=0,
|
||||
direction="higher_is_better",
|
||||
requires=("dv_ttm",),
|
||||
)
|
||||
)
|
||||
def _dividend_yield_ttm(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return fields["dv_ttm"]
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""LocalEngine —— 默认研究引擎(纯 pandas,AGENT.md §40 简单可替换优先)。
|
||||
|
||||
无未来函数纪律:
|
||||
- 调仓日 t 的选股只使用 <=t 的因子值与收盘价
|
||||
- 成交发生在 t 收盘(价格 = close[t] ± 滑点);t 当日组合收益用 t-1 收盘持仓结算,
|
||||
- 择股日 s 的选股只使用 <= s 的因子值、条件字段与收盘价
|
||||
- 成交发生在调仓日 t 收盘(价格 = close[t] ± 滑点);t 当日组合收益用 t-1 收盘持仓结算,
|
||||
调仓在 t 收盘生效、自 t+1 起计收益 —— 不存在「当日买入当日计收益」的未来函数
|
||||
- 顺延买入(defer_buy)只在**之后的交易日**补成交,绝不回溯到择股日之前
|
||||
- 涨跌停 / 停牌约束按可达信息近似建模,未建模部分显式写入结果 unimplemented
|
||||
|
||||
周期模型(本次扩展,见 ResearchSpec):
|
||||
- 择股日集合 S:每 m 个月(selection_interval_months),锚定回测起始月
|
||||
- 调仓日集合 R:每 y 个月(rebalance_interval_months,缺省 = m)
|
||||
- m 未给 → S = R(每次调仓都重新择股,与历史行为一致)
|
||||
- 候选池 = S 日按因子分排序的 Top n(top_n);实际持仓 = 池内前 x(hold_top_x)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +32,7 @@ from app.domain.entities.research import (
|
||||
Position,
|
||||
RankedPick,
|
||||
ResearchSpec,
|
||||
SymbolCurve,
|
||||
Trade,
|
||||
YearlyReturn,
|
||||
)
|
||||
@@ -41,9 +49,41 @@ from app.quant.portfolio import (
|
||||
)
|
||||
|
||||
TRADING_DAYS = 252
|
||||
|
||||
# 个股收益曲线数量上限:
|
||||
# None(默认)= 不截断,期内持有的每只都输出(「完整存档」;体积由归档侧的
|
||||
# 字节预算兜底,见 app/application/services/experiment_archive.py);
|
||||
# 数字 = 按 |期末收益| 降序截断,且如实写入 unimplemented 说明。
|
||||
# 取值优先级:本模块变量(测试 monkeypatch 用)> config research.archive_curve_limit。
|
||||
_MAX_SYMBOL_CURVES: int | None = None
|
||||
|
||||
|
||||
def _resolved_curve_limit() -> int | None:
|
||||
"""当前生效的曲线数量上限(None = 完整输出)。"""
|
||||
if _MAX_SYMBOL_CURVES is not None:
|
||||
return _MAX_SYMBOL_CURVES
|
||||
from app.core.config import get_settings
|
||||
|
||||
return get_settings().research_archive_curve_limit
|
||||
|
||||
# 恒定的未建模说明(AGENT.md §24:未实现项必须在结果中显式标注)
|
||||
_DEFAULT_UNIMPLEMENTED = [
|
||||
"涨跌停按收盘价相对上一有效收盘近似判定(未建模开盘一字 / 集合竞价路径)",
|
||||
"成交假设发生在调仓日收盘(未建模盘中价格路径与流动性冲击)",
|
||||
(
|
||||
"调仓为「全部卖出 → 按目标等权重新买入」,未做权重漂移微调:"
|
||||
"保留在目标名单中的股票也会产生一次完整买卖往返,交易成本估计偏保守"
|
||||
),
|
||||
(
|
||||
"股票池来自本地行情表(已含退市股:stock.status='D' 且带 delist_date,"
|
||||
"退市日之后自动退出池子)。残余偏差:库里仅有 2019-12 之后退市的标的,"
|
||||
"更早退市者无行情数据"
|
||||
),
|
||||
(
|
||||
"exclude_st 的名称口径见 config_snapshot.price_basis.name_basis:时点口径依赖 "
|
||||
"stock_name_history(sync namechange),未同步时回退最新名称快照,"
|
||||
"会漏掉「曾是高股息、后来才变 ST」的股息陷阱样本"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -57,16 +97,92 @@ def _limit_up_ratio(symbol: str) -> float:
|
||||
return 1.099
|
||||
|
||||
|
||||
def rebalance_dates(index: pd.Index, rebalance: str, start: date) -> list[pd.Timestamp]:
|
||||
"""按频率取首个交易日(>= start)。"""
|
||||
periods = index.to_period("M" if rebalance == "monthly" else "W")
|
||||
def _month_firsts(index: pd.Index) -> list[pd.Timestamp]:
|
||||
"""每个自然月的首个交易日(按 index 顺序)。"""
|
||||
periods = index.to_period("M")
|
||||
seen: dict = {}
|
||||
order: list[pd.Timestamp] = []
|
||||
for ts, per in zip(index, periods, strict=True):
|
||||
if per not in seen:
|
||||
seen[per] = ts
|
||||
order.append(ts)
|
||||
return [ts for ts in order if ts.date() >= start]
|
||||
return order
|
||||
|
||||
|
||||
def _week_firsts(index: pd.Index) -> list[pd.Timestamp]:
|
||||
"""每个自然周的首个交易日。"""
|
||||
periods = index.to_period("W")
|
||||
seen: dict = {}
|
||||
order: list[pd.Timestamp] = []
|
||||
for ts, per in zip(index, periods, strict=True):
|
||||
if per not in seen:
|
||||
seen[per] = ts
|
||||
order.append(ts)
|
||||
return order
|
||||
|
||||
|
||||
def _month_seq(ts: pd.Timestamp) -> int:
|
||||
"""月序号(year*12+month),用于「每 m 个月」的锚定计算。"""
|
||||
return int(ts.year) * 12 + int(ts.month)
|
||||
|
||||
|
||||
def rebalance_dates(
|
||||
index: pd.Index,
|
||||
rebalance: str,
|
||||
start: date,
|
||||
end: date | None = None,
|
||||
every_months: int | None = None,
|
||||
) -> list[pd.Timestamp]:
|
||||
"""调仓/择股日集合(按频率取首个交易日,>= start)。
|
||||
|
||||
every_months=n(n>0):忽略 rebalance 频率,改用「每 n 个月」——
|
||||
锚定 **首个 >= start 的交易日所在月**(锚点月 t0),取月序号满足
|
||||
`(t - t0) % n == 0` 的月份的首个交易日。
|
||||
|
||||
这样 2020-01-01 起、n=6 → 2020-01、2020-07、2021-01 …;
|
||||
起始日改为 2020-03-15(该月首个交易日 03-02 早于 start)→ **2020-03-16**
|
||||
(03 月内首个 >= start 的交易日)、2020-09、2021-03 …。
|
||||
⚠️ 刻意**不丢弃锚点月**:若把锚点月整体过滤掉,m=y=6 且起始日非月初时
|
||||
会白等 6 个月才首次建仓(净值在前期恒等于初始资金,指标明显失真)。
|
||||
|
||||
every_months=None:沿用 weekly / monthly 频率(原行为,保持向后兼容)。
|
||||
"""
|
||||
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
|
||||
if every_months and every_months > 0:
|
||||
# 锚点 = start 所在月内首个 >= start 的交易日(可能不是该月首个交易日)
|
||||
days = pd.DatetimeIndex(index)
|
||||
after_start = days[days >= pd.Timestamp(start)]
|
||||
if len(after_start) == 0:
|
||||
return []
|
||||
anchor_ts = after_start[0]
|
||||
anchor = _month_seq(anchor_ts)
|
||||
# 后续月份:月序号与锚点月相差整数倍 m,取该月首个交易日
|
||||
out = [anchor_ts] + [
|
||||
ts
|
||||
for ts in firsts
|
||||
if _month_seq(ts) > anchor
|
||||
and (_month_seq(ts) - anchor) % every_months == 0
|
||||
and ts.date() >= start
|
||||
and ts != anchor_ts
|
||||
]
|
||||
else:
|
||||
out = [ts for ts in firsts if ts.date() >= start]
|
||||
if end is not None:
|
||||
out = [ts for ts in out if ts.date() <= end]
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingBuy:
|
||||
"""顺延买单:调仓日买不进(涨停/停牌)时挂起,之后逐日重试。
|
||||
|
||||
仅当 SelectionSpec.defer_buy=True 时产生;到下一次调仓日仍未成交则作废。
|
||||
`budget` 是调仓日按等权/上限为该标的预留的资金,成交时按 min(budget, 可用现金) 执行。
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
budget: float
|
||||
since: date
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,9 +194,15 @@ class EngineResult:
|
||||
|
||||
|
||||
class TopKBacktestRunner:
|
||||
"""TopK 等权、固定调仓频率的低频回测。"""
|
||||
"""TopK 等权、固定调仓频率的低频回测(支持择股/调仓双周期与顺延买入)。"""
|
||||
|
||||
def __init__(self, spec: ResearchSpec, score: pd.DataFrame, close: pd.DataFrame) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
spec: ResearchSpec,
|
||||
score: pd.DataFrame,
|
||||
close: pd.DataFrame,
|
||||
eligibility_fn=None,
|
||||
) -> None:
|
||||
self.spec = spec
|
||||
close = close.copy()
|
||||
close.index = pd.to_datetime(close.index)
|
||||
@@ -89,18 +211,49 @@ class TopKBacktestRunner:
|
||||
self.costs = spec.costs
|
||||
# 上一有效收盘(用于涨跌停与收益结算,处理停牌日)
|
||||
self.prev_close = self.close.ffill().shift(1)
|
||||
# 条件过滤(可选):(as_of: date) -> set[symbol] | None
|
||||
# 由 Service 注入(复用 selection.eligible_symbols),保证回测与选股同一套求值逻辑
|
||||
self.eligibility_fn = eligibility_fn
|
||||
# M9-2:调仓意图与信号/成交记录(v3 §20.3/§22.3)
|
||||
self.selection_history: list[RankedPick] = []
|
||||
self.signal_history: list[ActionRecord] = []
|
||||
# 当前候选池(择股日刷新):current_ranked 为全市场可评分排序,current_pool = 前 n
|
||||
self.current_ranked: list[str] = []
|
||||
self.current_pool: list[str] = []
|
||||
# 本次回测期内被持有过的股票(用于个股收益曲线)
|
||||
self.traded_symbols: list[str] = []
|
||||
self._traded: set[str] = set()
|
||||
# 无前收导致涨停无法判定、按可买处理并**实际成交**的标的集合(结果中如实标注)
|
||||
self._no_prev_close_symbols: set[str] = set()
|
||||
|
||||
# ---- 主流程 ----
|
||||
|
||||
def run(self) -> BacktestResult:
|
||||
end_date = self.spec.period[1]
|
||||
dates = [d for d in self.close.index if self.spec.period[0] <= d.date() <= end_date]
|
||||
rebal = {
|
||||
d
|
||||
for d in rebalance_dates(self.close.index, self.spec.rebalance, self.spec.period[0])
|
||||
if d.date() <= end_date
|
||||
}
|
||||
if not dates:
|
||||
raise ValueError(
|
||||
f"回测区间 {self.spec.period[0]}~{end_date} 内没有任何行情数据,无法回测"
|
||||
)
|
||||
|
||||
m = self.spec.effective_selection_months
|
||||
y = self.spec.effective_rebalance_months
|
||||
rebal = set(
|
||||
rebalance_dates(
|
||||
self.close.index, self.spec.rebalance, self.spec.period[0], end_date, every_months=y
|
||||
)
|
||||
)
|
||||
if m is None:
|
||||
# 未给 m:每次调仓都重新择股(与历史行为一致)
|
||||
select = set(rebal)
|
||||
else:
|
||||
select = set(
|
||||
rebalance_dates(
|
||||
self.close.index, self.spec.rebalance, self.spec.period[0], end_date,
|
||||
every_months=m,
|
||||
)
|
||||
)
|
||||
|
||||
cash = float(self.spec.initial_capital)
|
||||
shares: dict[str, float] = {}
|
||||
entry_date: dict[str, date] = {}
|
||||
@@ -109,6 +262,10 @@ class TopKBacktestRunner:
|
||||
trades: list[Trade] = []
|
||||
positions: list[Position] = []
|
||||
notional: list[float] = []
|
||||
pending: list[PendingBuy] = []
|
||||
# 个股收益曲线:cum = 该股「持仓期间」的累计净值(1.0 = 未涨未跌)
|
||||
cum: dict[str, float] = {}
|
||||
curve_rows: dict[str, list[CurvePoint]] = {}
|
||||
|
||||
def _value(d: pd.Timestamp) -> float:
|
||||
total = cash
|
||||
@@ -122,21 +279,56 @@ class TopKBacktestRunner:
|
||||
return total
|
||||
|
||||
for d in dates:
|
||||
# 1) 先用「上一交易日收盘持仓」结算当日个股收益(与组合净值同一时序口径:
|
||||
# 当日收益来自昨日持仓)→ 建仓当日不计收益、卖出当日仍有收益
|
||||
self._accrue_symbol_returns(d, shares, cum, curve_rows)
|
||||
# 2) 择股 / 调仓(成交发生在当日收盘)
|
||||
if d in select:
|
||||
self.current_ranked, self.current_pool = self._select(d)
|
||||
if d in rebal:
|
||||
# 上一次调仓挂起的顺延单作废(只在两次调仓之间有效)
|
||||
pending = []
|
||||
cash = self._rebalance(
|
||||
d, cash, shares, entry_date, entry_price, trades, positions, notional
|
||||
d, cash, shares, entry_date, entry_price, trades, positions, notional,
|
||||
pending,
|
||||
)
|
||||
elif pending:
|
||||
cash = self._fill_pending(d, cash, shares, entry_date, entry_price, pending, notional)
|
||||
equity_rows[d] = _value(d)
|
||||
# 3) 建仓当日补「基准点」:成交在当日收盘、收益自次日起计;该点使 BUY 标注
|
||||
# 能精确落在曲线上,也让多段持仓的分段起点可见(见 _mark_curve_dates)
|
||||
self._mark_curve_dates(d, shares, cum, curve_rows)
|
||||
|
||||
equity = pd.Series(equity_rows).sort_index()
|
||||
return self._to_result(equity, trades, positions, notional)
|
||||
return self._to_result(equity, trades, positions, notional, cum, curve_rows)
|
||||
|
||||
# ---- 择股(择股日 s:只用 <= s 的数据) ----
|
||||
|
||||
def _select(self, d: pd.Timestamp) -> tuple[list[str], list[str]]:
|
||||
"""返回 (全市场可评分排序, 候选池 top n),并记录 selection_history。"""
|
||||
score_d = self.score.loc[d].dropna()
|
||||
eligible = None
|
||||
if self.eligibility_fn is not None:
|
||||
eligible = self.eligibility_fn(d.date())
|
||||
if eligible is not None:
|
||||
score_d = score_d[score_d.index.isin(eligible)]
|
||||
ranked = score_d.sort_values(ascending=False).index.tolist()
|
||||
n = self.spec.selection.top_n
|
||||
pool = ranked[:n]
|
||||
day = d.date()
|
||||
for rank, sym in enumerate(pool, start=1):
|
||||
self.selection_history.append(
|
||||
RankedPick(date=day, symbol=sym, rank=rank, score=round(float(score_d[sym]), 6))
|
||||
)
|
||||
return ranked, pool
|
||||
|
||||
# ---- 调仓(t 收盘执行,自 t+1 生效) ----
|
||||
|
||||
def _rebalance(self, d, cash, shares, entry_date, entry_price, trades, positions, notional):
|
||||
def _rebalance(
|
||||
self, d, cash, shares, entry_date, entry_price, trades, positions, notional, pending
|
||||
):
|
||||
close_d = self.close.loc[d]
|
||||
prev_d = self.prev_close.loc[d]
|
||||
sold_notional = 0.0
|
||||
day = d.date()
|
||||
|
||||
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
|
||||
@@ -156,12 +348,11 @@ class TopKBacktestRunner:
|
||||
continue # 跌停无法卖出:保留到下一调仓
|
||||
qty = shares[s]
|
||||
proceeds = qty * float(c) * (1 - self.costs.slippage_rate)
|
||||
fee = proceeds * (self.costs.commission_rate + self.costs.stamp_tax_rate)
|
||||
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
|
||||
fee = commission + proceeds * self.costs.stamp_tax_rate
|
||||
cash += proceeds - fee
|
||||
sold_notional += proceeds
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=True,
|
||||
price=float(c))
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c))
|
||||
)
|
||||
trades.append(
|
||||
Trade(
|
||||
@@ -177,43 +368,57 @@ class TopKBacktestRunner:
|
||||
entry_date.pop(s, None)
|
||||
entry_price.pop(s, None)
|
||||
|
||||
# 2) 买入:先记录「选股意图」(= select(as_of) 前 top_n,v3 §22.3)
|
||||
score_d = self.score.loc[d].dropna()
|
||||
top = score_d.sort_values(ascending=False).index.tolist()
|
||||
top_n = self.spec.selection.top_n
|
||||
picks = top[:top_n]
|
||||
for rank, sym in enumerate(picks, start=1):
|
||||
self.selection_history.append(
|
||||
RankedPick(date=day, symbol=sym, rank=rank,
|
||||
score=round(float(score_d[sym]), 6))
|
||||
# 2) 买入意图:候选池(= selection_history 记录的那批)
|
||||
picks = list(self.current_pool)
|
||||
x = min(self.spec.selection.x, len(picks))
|
||||
sel = self.spec.selection
|
||||
if x < self.spec.selection.x:
|
||||
self.signal_history.append(
|
||||
ActionRecord(
|
||||
date=day,
|
||||
symbol="",
|
||||
signal="BUY",
|
||||
filled=False,
|
||||
reject_reason=(
|
||||
f"候选池仅 {len(picks)} 只(< 目标持仓 x={self.spec.selection.x}),"
|
||||
"按池内数量持仓"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# 执行:顺序寻找可买(涨停/无价剔除;替补仅在意图被拒时进入)
|
||||
def _buyable(sym) -> tuple[bool, str | None]:
|
||||
c, p = close_d[sym], prev_d[sym]
|
||||
if _nan(c) or _nan(p) or p <= 0:
|
||||
if _nan(c):
|
||||
return False, "无行情(停牌),无法买入"
|
||||
if _nan(p) or p <= 0:
|
||||
# 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。
|
||||
# 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的),
|
||||
# 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。
|
||||
return True, None
|
||||
if c / p >= _limit_up_ratio(sym):
|
||||
return False, "涨停,无法追买"
|
||||
return True, None
|
||||
|
||||
# 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找
|
||||
targets: list[str] = []
|
||||
for sym in top:
|
||||
if len(targets) >= top_n:
|
||||
break
|
||||
ok, _ = _buyable(sym)
|
||||
if ok:
|
||||
targets.append(sym)
|
||||
target_set = set(targets)
|
||||
if sel.allow_substitute:
|
||||
for sym in self.current_ranked:
|
||||
if len(targets) >= self.spec.selection.x:
|
||||
break
|
||||
ok, _ = _buyable(sym)
|
||||
if ok:
|
||||
targets.append(sym)
|
||||
else:
|
||||
targets = picks[:x]
|
||||
|
||||
# BUY 信号/成交记录:意图入选(filled)或意图被拒(原因);替补成交同样如实记录
|
||||
pending_specs: list[tuple[str, str | None]] = []
|
||||
spends: dict[str, float] = {}
|
||||
if targets:
|
||||
cap = self.spec.portfolio.max_position_pct
|
||||
if cap is None:
|
||||
# 默认等权(与原实现位级一致,保持回归数值不变)
|
||||
# 默认等权:按「目标持仓数」均分可用现金(顺延未成交的部分留作现金)
|
||||
budget = equal_weight_budget(cash, len(targets))
|
||||
spends = {s: budget for s in targets}
|
||||
total_spend = budget * len(targets)
|
||||
else:
|
||||
# Portfolio v1.1:按单股上限(相对当日组合市值)分配,超出部分留现金
|
||||
equity_now = cash + sum(
|
||||
@@ -222,31 +427,61 @@ class TopKBacktestRunner:
|
||||
if qty > 0 and not _nan(self.close.at[d, s])
|
||||
)
|
||||
spends = allocate_with_max_position(cash, targets, equity_now, cap)
|
||||
total_spend = sum(spends.values())
|
||||
|
||||
for s in targets:
|
||||
budget = spends[s]
|
||||
if budget <= 1e-9:
|
||||
# 分配额过小(可用现金≈0 或上限约束):不成交且无额度可顺延,如实留痕
|
||||
self.signal_history.append(
|
||||
ActionRecord(
|
||||
date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason="分配额不足(可用现金≈0),未成交",
|
||||
)
|
||||
)
|
||||
continue
|
||||
c = float(close_d[s])
|
||||
price_in = c * (1 + self.costs.slippage_rate)
|
||||
invest = budget * (1 - self.costs.commission_rate)
|
||||
shares[s] = invest / price_in
|
||||
entry_date[s] = day
|
||||
entry_price[s] = price_in
|
||||
notional.append(budget)
|
||||
ok, reason = _buyable(s)
|
||||
if not ok:
|
||||
if sel.defer_buy:
|
||||
# 顺延:挂单到之后首个可成交交易日(本次不成交,资金留现金)
|
||||
pending_specs.append((s, reason))
|
||||
self.signal_history.append(
|
||||
ActionRecord(
|
||||
date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason=f"{reason},顺延到之后首个可成交日买入",
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.signal_history.append(
|
||||
ActionRecord(
|
||||
date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason=reason or "不可买入",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not self._execute_buy(
|
||||
s, budget, d, close_d[s], shares, entry_date, entry_price, notional
|
||||
):
|
||||
self.signal_history.append(
|
||||
ActionRecord(
|
||||
date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason="预算不足以覆盖最低佣金,未成交",
|
||||
)
|
||||
)
|
||||
continue
|
||||
cash -= budget
|
||||
|
||||
# 替补模式下目标名单取自 n 名之外,池内被跳过的标的也要记录意图,
|
||||
# 否则「信号有了却没买」无法解释(v3 §20.3 Signal↔Fill 透明化)。
|
||||
# 非替补模式下 targets == picks[:x],池内标的都已在上面留痕,无需再遍历。
|
||||
if sel.allow_substitute:
|
||||
for sym in picks:
|
||||
if sym in set(targets):
|
||||
continue
|
||||
_ok, reason = _buyable(sym)
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
|
||||
price=round(price_in, 4))
|
||||
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False,
|
||||
reject_reason=reason or "资金不足(未成交)")
|
||||
)
|
||||
cash -= total_spend
|
||||
for sym in picks:
|
||||
if sym in target_set:
|
||||
continue
|
||||
_ok, reason = _buyable(sym)
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False,
|
||||
reject_reason=reason or "资金不足(未成交)")
|
||||
)
|
||||
|
||||
# 3) 记录调仓后仓位
|
||||
total = cash + sum(
|
||||
@@ -262,11 +497,120 @@ class TopKBacktestRunner:
|
||||
date=day, symbol=s, weight=float(qty * self.close.at[d, s] / total)
|
||||
)
|
||||
)
|
||||
# 顺延单登记:预留额度 = 调仓日的等权/上限分配额(不因后续价格变化而变)
|
||||
for sym, _reason in pending_specs:
|
||||
pending.append(PendingBuy(symbol=sym, budget=spends.get(sym, 0.0), since=day))
|
||||
return cash
|
||||
|
||||
def _execute_buy(
|
||||
self, s, budget, d, close_value, shares, entry_date, entry_price, notional
|
||||
) -> bool:
|
||||
"""按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。
|
||||
|
||||
现金支出恒为 budget:shares = (budget - 佣金) / (收盘价 × (1 + 滑点))。
|
||||
返回是否成交(预算不足以覆盖最低佣金时不成交,调用方不得扣减现金)。
|
||||
"""
|
||||
c = float(close_value)
|
||||
price_in = c * (1 + self.costs.slippage_rate)
|
||||
commission = max(budget * self.costs.commission_rate, self.costs.min_commission)
|
||||
invest = budget - commission
|
||||
if invest <= 0:
|
||||
return False
|
||||
# 累加而非覆盖:避免「跌停/停牌未卖出而保留的旧仓位」被静默清零
|
||||
shares[s] = shares.get(s, 0.0) + invest / price_in
|
||||
entry_date[s] = d.date()
|
||||
entry_price[s] = price_in
|
||||
prev = self.prev_close.at[d, s] if d in self.prev_close.index else float("nan")
|
||||
if _nan(prev) or prev <= 0:
|
||||
self._no_prev_close_symbols.add(s) # 无前收→涨停不可判定,如实记入标注
|
||||
notional.append(budget)
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=d.date(), symbol=s, signal="BUY", filled=True,
|
||||
price=round(price_in, 4))
|
||||
)
|
||||
if s not in self._traded:
|
||||
self._traded.add(s)
|
||||
self.traded_symbols.append(s)
|
||||
return True
|
||||
|
||||
# ---- 顺延买入(defer_buy):之后逐日重试 ----
|
||||
|
||||
def _fill_pending(self, d, cash, shares, entry_date, entry_price, pending, notional):
|
||||
close_d = self.close.loc[d]
|
||||
prev_d = self.prev_close.loc[d]
|
||||
remaining: list[PendingBuy] = []
|
||||
for order in pending:
|
||||
if order.symbol in shares and shares[order.symbol] > 0:
|
||||
continue # 期间已通过其他路径持有 → 撤销该顺延单
|
||||
c, p = close_d.get(order.symbol), prev_d.get(order.symbol)
|
||||
if _nan(c) or _nan(p) or p <= 0:
|
||||
remaining.append(order)
|
||||
continue
|
||||
if c / p >= _limit_up_ratio(order.symbol):
|
||||
remaining.append(order) # 仍涨停 → 继续顺延
|
||||
continue
|
||||
budget = min(order.budget, cash)
|
||||
if budget <= 1e-9:
|
||||
remaining.append(order) # 无可用现金(理论上不会发生)
|
||||
continue
|
||||
if not self._execute_buy(
|
||||
order.symbol, budget, d, c, shares, entry_date, entry_price, notional
|
||||
):
|
||||
remaining.append(order) # 预算不足:保留挂单(下日现金可能已变化)
|
||||
continue
|
||||
cash -= budget
|
||||
pending[:] = remaining
|
||||
return cash
|
||||
|
||||
# ---- 个股收益曲线 ----
|
||||
|
||||
def _accrue_symbol_returns(self, d, shares, cum, curve_rows) -> None:
|
||||
"""逐日累计各持仓股的「持仓期收益」(以建仓日收盘为 0% 基准)。
|
||||
|
||||
口径:cum 以 1.0 起算,仅在该股**持有期间**按日复利(close/prev_close)。
|
||||
本方法在当日调仓**之前**调用,因此:
|
||||
- 建仓当日不计收益(成交发生在当日收盘)→ 不存在当日买入当日计收益的未来函数
|
||||
- 卖出当日仍计收益(当日收益来自昨日持仓)
|
||||
未持有期间不产生数据点(曲线不落点),多段持仓则以 cum 连乘衔接;
|
||||
前端以买卖点标注区分各段持仓区间。
|
||||
"""
|
||||
prev_d = self.prev_close.loc[d]
|
||||
close_d = self.close.loc[d]
|
||||
for s, qty in shares.items():
|
||||
if qty <= 0:
|
||||
continue
|
||||
c, p = close_d.get(s), prev_d.get(s)
|
||||
if _nan(c) or _nan(p) or p <= 0:
|
||||
continue # 停牌/无前收:无有效收益
|
||||
cum[s] = cum.get(s, 1.0) * (float(c) / float(p))
|
||||
# 只为「当日持有」的股票落点(未持有期间不落点,显著压缩结果体积)
|
||||
for s, qty in shares.items():
|
||||
if qty <= 0:
|
||||
continue
|
||||
curve_rows.setdefault(s, []).append(
|
||||
CurvePoint(date=d.date(), value=round((cum.get(s, 1.0) - 1.0) * 100, 4))
|
||||
)
|
||||
|
||||
def _mark_curve_dates(self, d, shares, cum, curve_rows) -> None:
|
||||
"""为当日持有但尚未落点的股票补一个基准点(建仓当日 / 顺延成交当日)。
|
||||
|
||||
值为该股当前的 `cum`(新标的为 1.0 → 0%,复买标的延续上一段的累计值),
|
||||
因此曲线总能在买卖点当日取到数值,前端标注不会因缺数据点而被丢弃。
|
||||
"""
|
||||
day = d.date()
|
||||
for s, qty in shares.items():
|
||||
if qty <= 0:
|
||||
continue
|
||||
points = curve_rows.setdefault(s, [])
|
||||
if points and points[-1].date == day:
|
||||
continue
|
||||
points.append(
|
||||
CurvePoint(date=day, value=round((cum.get(s, 1.0) - 1.0) * 100, 4))
|
||||
)
|
||||
|
||||
# ---- 指标 ----
|
||||
|
||||
def _to_result(self, equity, trades, positions, notional) -> BacktestResult:
|
||||
def _to_result(self, equity, trades, positions, notional, cum, curve_rows) -> BacktestResult:
|
||||
start, end = equity.index[0].date(), equity.index[-1].date()
|
||||
init = float(self.spec.initial_capital)
|
||||
final = float(equity.iloc[-1])
|
||||
@@ -322,6 +666,7 @@ class TopKBacktestRunner:
|
||||
total_trades=len(trades),
|
||||
avg_turnover_pct=round(avg_turn, 2),
|
||||
)
|
||||
curves, curve_note = self._symbol_curves(curve_rows, cum)
|
||||
return BacktestResult(
|
||||
summary=summary,
|
||||
equity_curve=eq_pts,
|
||||
@@ -333,11 +678,78 @@ class TopKBacktestRunner:
|
||||
selection_history=self.selection_history,
|
||||
signal_history=self.signal_history,
|
||||
fills=[a for a in self.signal_history if a.filled],
|
||||
symbol_curves=curves,
|
||||
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
|
||||
unimplemented=list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio),
|
||||
unimplemented=self._unimplemented(curve_note),
|
||||
config_snapshot=self.spec.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
def _symbol_curves(self, curve_rows, cum) -> tuple[list[SymbolCurve], str | None]:
|
||||
"""按「期末收益绝对值」降序输出个股曲线(前端默认展示前若干只)。
|
||||
|
||||
返回 (曲线列表, 截断说明)。默认**不截断**(`config research.archive_curve_limit`
|
||||
为 null):期内持有的每只都输出,保证归档完整;体积由归档侧的字节预算兜底
|
||||
(见 experiment_archive)。仅当配置了数字上限时才截断,并如实标注哪一部分
|
||||
被丢弃、为什么(AGENT §24:不静默降级,绝不假装完整)。
|
||||
"""
|
||||
marks: dict[str, list[ActionRecord]] = {}
|
||||
for a in self.signal_history:
|
||||
if a.filled and a.symbol:
|
||||
marks.setdefault(a.symbol, []).append(a)
|
||||
out: list[SymbolCurve] = []
|
||||
for s, points in curve_rows.items():
|
||||
if not points:
|
||||
continue
|
||||
out.append(
|
||||
SymbolCurve(
|
||||
symbol=s,
|
||||
points=points,
|
||||
marks=marks.get(s, []),
|
||||
final_return_pct=round((cum.get(s, 1.0) - 1.0) * 100, 4),
|
||||
)
|
||||
)
|
||||
out.sort(key=lambda c: abs(c.final_return_pct), reverse=True)
|
||||
note = None
|
||||
limit = _resolved_curve_limit()
|
||||
if limit is not None and len(out) > limit:
|
||||
note = (
|
||||
f"个股收益曲线仅输出收益绝对值最大的 {limit} 只"
|
||||
f"(期内共持有 {len(out)} 只):完整明细见 trades / signal_history"
|
||||
)
|
||||
out = out[:limit]
|
||||
return out, note
|
||||
|
||||
def _unimplemented(self, curve_note: str | None = None) -> list[str]:
|
||||
notes = list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio)
|
||||
if self._no_prev_close_symbols:
|
||||
notes.append(
|
||||
f"有 {len(self._no_prev_close_symbols)} 只标的成交时缺少上一有效收盘价,"
|
||||
"无法判定涨停(数据窗口起点或长期停牌后复牌),按可买处理"
|
||||
)
|
||||
if curve_note:
|
||||
notes.append(curve_note)
|
||||
sel = self.spec.selection
|
||||
m = self.spec.effective_selection_months
|
||||
y = self.spec.effective_rebalance_months
|
||||
if m is not None and y is not None and y < m:
|
||||
notes.append(
|
||||
f"调仓间隔 y={y} 个月 < 择股间隔 m={m} 个月:两次择股之间会复用同一候选池"
|
||||
"(池子陈旧),并非每次调仓都重新择股"
|
||||
)
|
||||
if sel.defer_buy:
|
||||
notes.append(
|
||||
"顺延买入:调仓日涨停/停牌无法买入的标的挂单至之后首个可成交交易日,"
|
||||
"按该日收盘价成交;到下一次调仓仍未成交则作废并留作现金"
|
||||
)
|
||||
if self.spec.price_adjustment == "none":
|
||||
notes.append(
|
||||
"行情口径为不复权:现金分红未计入收益,除权日的价格下移会被计为亏损。"
|
||||
"股息类策略建议使用 price_adjustment=hfq(后复权)"
|
||||
)
|
||||
if not self.spec.conditions:
|
||||
notes.append("未配置选股过滤条件(conditions),候选池仅由 universe + 因子排序决定")
|
||||
return notes
|
||||
|
||||
|
||||
def run_spec_factor_test(
|
||||
daily: pd.DataFrame,
|
||||
@@ -357,7 +769,13 @@ def run_spec_factor_test(
|
||||
|
||||
|
||||
def _nan(v) -> bool:
|
||||
"""缺失判定:None / NaN / 不可转 float 一律视为「无有效值」。
|
||||
|
||||
注意 Series.get(key) 对不存在的键返回 None(而非 NaN),故必须把 None 判为缺失。
|
||||
"""
|
||||
if v is None:
|
||||
return True
|
||||
try:
|
||||
return bool(math.isnan(float(v)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
@@ -61,7 +61,14 @@ class QlibEngine(QuantEngine):
|
||||
report, _panels = run_spec_factor_test(daily, spec, horizon_days)
|
||||
return report
|
||||
|
||||
def run_backtest(self, daily: pd.DataFrame, spec: ResearchSpec) -> BacktestResult:
|
||||
def run_backtest(
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
|
||||
) -> BacktestResult:
|
||||
"""回测(与 LocalEngine 共用 TopKBacktestRunner → 口径一致)。
|
||||
|
||||
`eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与
|
||||
`exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。
|
||||
"""
|
||||
panels = build_factor_panels(daily, spec.factors)
|
||||
score = composite_score(panels)
|
||||
|
||||
@@ -78,7 +85,7 @@ class QlibEngine(QuantEngine):
|
||||
)
|
||||
close = close.sort_index()
|
||||
|
||||
result = TopKBacktestRunner(spec, score, close).run()
|
||||
result = TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
|
||||
result.config_snapshot = spec.model_dump(mode="json")
|
||||
note = _ENGINE_NOTE
|
||||
result.unimplemented = [note, *result.unimplemented]
|
||||
|
||||
+114
-51
@@ -14,7 +14,7 @@ from datetime import date
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.market import FinancialIndicator
|
||||
from app.domain.entities.market import DAILY_BASIC_NUMERIC_FIELDS, FinancialIndicator
|
||||
from app.domain.entities.selection import (
|
||||
SelectionCandidate,
|
||||
SelectionQuery,
|
||||
@@ -182,8 +182,12 @@ _STATIC_PREFIX = "static."
|
||||
_FUNDAMENTAL_PREFIX = "fundamental."
|
||||
|
||||
|
||||
def condition_needed_columns(query: SelectionQuery) -> set[str]:
|
||||
"""条件引用的行情列(fundamental/static 走元数据与财务表,不需要行情列)。"""
|
||||
def condition_needed_columns(query) -> set[str]:
|
||||
"""条件引用的行情/指标列(fundamental/static 走元数据与财务表,不需要面板列)。
|
||||
|
||||
query 可以是 SelectionQuery,也可以是任何带 `conditions` 的对象
|
||||
(ResearchSpec 亦然)—— 回测与选股共用本函数,保证列裁剪一致。
|
||||
"""
|
||||
needed = {"close"}
|
||||
names = [c.field for c in query.conditions] + [
|
||||
c.ref for c in query.conditions if c.ref and not c.ref.startswith(_FUNDAMENTAL_PREFIX)
|
||||
@@ -195,16 +199,110 @@ def condition_needed_columns(query: SelectionQuery) -> set[str]:
|
||||
if f not in _TECH_DERIVED:
|
||||
needed.add(f)
|
||||
continue
|
||||
if f in DAILY_BASIC_NUMERIC_FIELDS:
|
||||
needed.add(f) # 每日指标列(dv_ratio / pe / pb / total_mv …)
|
||||
continue
|
||||
try: # 其余按已注册因子处理
|
||||
defn, _fn = get_factor(f)
|
||||
except FactorError:
|
||||
raise ValueError(
|
||||
f"条件字段未知:{f}(可用: 行情列/ma20/ma60/已注册因子/static.*/fundamental.*)"
|
||||
f"条件字段未知:{f}(可用: 行情列/ma20/ma60/已注册因子/"
|
||||
"每日指标列(dv_ratio 等)/static.*/fundamental.*)"
|
||||
) from None
|
||||
needed.update(defn.requires)
|
||||
return needed
|
||||
|
||||
|
||||
def build_condition_fields(
|
||||
daily: pd.DataFrame,
|
||||
conditions,
|
||||
obs: pd.Timestamp,
|
||||
) -> dict[str, pd.Series]:
|
||||
"""条件各字段在 obs(<= as_of 的最近交易日)的截面值。
|
||||
|
||||
返回 {字段名: Series(index=symbol)},覆盖:
|
||||
- 行情原列:close / open / high / low / volume / amount
|
||||
- 技术派生:ma20 / ma60
|
||||
- 每日指标列:dv_ratio / dv_ttm / pe / pb / total_mv …(由 Service 并入 daily)
|
||||
- 已注册因子:momentum_60 等(在 <=obs 的截断数据上计算,无未来函数)
|
||||
|
||||
回测与选股共用本函数(v2 §25 一致性)。
|
||||
"""
|
||||
view = daily[pd.to_datetime(daily["trade_date"]) <= obs]
|
||||
if view.empty:
|
||||
return {}
|
||||
close = view.pivot(index="trade_date", columns="symbol", values="close").sort_index()
|
||||
close.index = pd.to_datetime(close.index)
|
||||
|
||||
fields: dict[str, pd.Series] = {}
|
||||
for col in ("open", "high", "low", "volume", "amount"):
|
||||
if col in view.columns:
|
||||
panel = view.pivot(index="trade_date", columns="symbol", values=col).sort_index()
|
||||
panel.index = pd.to_datetime(panel.index)
|
||||
if obs in panel.index:
|
||||
fields[col] = panel.loc[obs]
|
||||
if obs in close.index:
|
||||
fields["close"] = close.loc[obs]
|
||||
ma20 = close.rolling(20).mean()
|
||||
ma60 = close.rolling(60).mean()
|
||||
if obs in ma20.index:
|
||||
fields["ma20"] = ma20.loc[obs]
|
||||
if obs in ma60.index:
|
||||
fields["ma60"] = ma60.loc[obs]
|
||||
|
||||
wanted: set[str] = set()
|
||||
for cond in conditions:
|
||||
for f in (cond.field, cond.ref):
|
||||
if not f or f.startswith((_STATIC_PREFIX, _FUNDAMENTAL_PREFIX)):
|
||||
continue
|
||||
if f in _TECH_DERIVED or f in fields:
|
||||
continue
|
||||
wanted.add(f)
|
||||
for f in sorted(wanted):
|
||||
if f in DAILY_BASIC_NUMERIC_FIELDS:
|
||||
if f in view.columns:
|
||||
panel = view.pivot(index="trade_date", columns="symbol", values=f).sort_index()
|
||||
panel.index = pd.to_datetime(panel.index)
|
||||
if obs in panel.index:
|
||||
fields[f] = panel.loc[obs]
|
||||
continue
|
||||
try:
|
||||
_defn, panel = compute_factor(f, view)
|
||||
except FactorError:
|
||||
continue # 已在 condition_needed_columns 报错;此处防御
|
||||
if obs in panel.index:
|
||||
fields[f] = panel.loc[obs]
|
||||
return fields
|
||||
|
||||
|
||||
def eligible_symbols(
|
||||
candidates,
|
||||
conditions,
|
||||
statics: dict[str, dict],
|
||||
fields: dict[str, pd.Series],
|
||||
financial: dict[str, FinancialIndicator],
|
||||
) -> dict[str, list[str]]:
|
||||
"""逐股求值全部条件(AND),返回 {symbol: [各条件通过情况文案]}(仅通过者)。
|
||||
|
||||
`candidates` 限定参与求值的股票(通常 = universe 过滤后的 symbol 列表)。
|
||||
回测(ResearchSpec.conditions)与选股(SelectionQuery.conditions)共用,
|
||||
确保「历史某日 Selection == 回测当日 Selection」(v2 §25 / v3 §28)。
|
||||
"""
|
||||
passed: dict[str, list[str]] = {}
|
||||
for sym in candidates:
|
||||
statuses: list[str] = []
|
||||
all_ok = True
|
||||
for cond in conditions:
|
||||
ok = _eval_condition(cond, sym, statics, fields, financial)
|
||||
statuses.append(
|
||||
f"{cond.field} {cond.op} {cond.ref or cond.value}: {'通过' if ok else '未通过'}"
|
||||
)
|
||||
all_ok = all_ok and ok
|
||||
if all_ok:
|
||||
passed[sym] = statuses
|
||||
return passed
|
||||
|
||||
|
||||
def run_condition_selection(
|
||||
daily: pd.DataFrame,
|
||||
stocks: list,
|
||||
@@ -229,57 +327,22 @@ def run_condition_selection(
|
||||
config_snapshot=query.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
view = daily[pd.to_datetime(daily["trade_date"]) <= obs]
|
||||
close = view.pivot(index="trade_date", columns="symbol", values="close").sort_index()
|
||||
close.index = pd.to_datetime(close.index)
|
||||
|
||||
# 技术字段面板(obs 行)
|
||||
tech: dict[str, pd.Series] = {}
|
||||
for col in ("close", "open", "high", "low", "volume", "amount"):
|
||||
if col in view.columns and col != "close":
|
||||
panel = view.pivot(index="trade_date", columns="symbol", values=col).sort_index()
|
||||
panel.index = pd.to_datetime(panel.index)
|
||||
tech[col] = panel.loc[obs]
|
||||
tech["close"] = close.loc[obs]
|
||||
tech["ma20"] = close.rolling(20).mean().loc[obs]
|
||||
tech["ma60"] = close.rolling(60).mean().loc[obs]
|
||||
# 因子字段按需计算
|
||||
for cond in query.conditions:
|
||||
for f in (cond.field, cond.ref):
|
||||
if f is None or f.startswith((_STATIC_PREFIX, _FUNDAMENTAL_PREFIX)) or f in tech:
|
||||
continue
|
||||
if f in _TECH_DERIVED or f in ("close", "open", "high", "low", "volume", "amount"):
|
||||
continue
|
||||
try:
|
||||
_defn, panel = compute_factor(f, view)
|
||||
except FactorError:
|
||||
continue # 已在 condition_needed_columns 报错;此处防御
|
||||
if obs in panel.index:
|
||||
tech[f] = panel.loc[obs]
|
||||
# 共享字段面板 + 求值器(回测与选股同一实现,v2 §25 一致性)
|
||||
tech = build_condition_fields(daily, query.conditions, obs)
|
||||
|
||||
statics = {s.symbol: s.model_dump() for s in stocks}
|
||||
passed = eligible_symbols(sorted(statics), query.conditions, statics, tech, financial or {})
|
||||
candidates: list[SelectionCandidate] = []
|
||||
passed_symbols: list[str] = []
|
||||
for sym in sorted(statics):
|
||||
statuses: list[str] = []
|
||||
all_ok = True
|
||||
for cond in query.conditions:
|
||||
ok = _eval_condition(cond, sym, statics, tech, financial or {})
|
||||
statuses.append(f"{cond.field} {cond.op} {cond.ref or cond.value}: {'通过' if ok else '未通过'}")
|
||||
all_ok = all_ok and ok
|
||||
if all_ok:
|
||||
passed_symbols.append(sym)
|
||||
candidates.append(
|
||||
SelectionCandidate(
|
||||
symbol=sym,
|
||||
rank=0, # 占位,末尾统一编号
|
||||
score=1.0,
|
||||
filter_status=statuses,
|
||||
selection_reason=[f"通过全部 {len(query.conditions)} 条条件"],
|
||||
)
|
||||
for rank, sym in enumerate(sorted(passed), start=1):
|
||||
candidates.append(
|
||||
SelectionCandidate(
|
||||
symbol=sym,
|
||||
rank=rank,
|
||||
score=1.0,
|
||||
filter_status=passed[sym],
|
||||
selection_reason=[f"通过全部 {len(query.conditions)} 条条件"],
|
||||
)
|
||||
for rank, c in enumerate(candidates, start=1):
|
||||
c.rank = rank
|
||||
)
|
||||
|
||||
return SelectionResult(
|
||||
as_of_date=resolved,
|
||||
|
||||
@@ -12,9 +12,15 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.market import (
|
||||
DAILY_BAR_NUMERIC_FIELDS,
|
||||
DAILY_BASIC_NUMERIC_FIELDS,
|
||||
FinancialIndicator,
|
||||
)
|
||||
from app.domain.entities.research import (
|
||||
BacktestResult,
|
||||
FactorCorrelationReport,
|
||||
@@ -28,7 +34,12 @@ from app.domain.repositories.market import (
|
||||
from app.quant.composite import build_factor_panels
|
||||
from app.quant.engine import QuantEngine
|
||||
from app.quant.evaluation import factor_correlation_report
|
||||
from app.quant.universe import filter_stocks, resolve_members # noqa: F401 —— 范围过滤
|
||||
from app.quant.selection import build_condition_fields, eligible_symbols
|
||||
from app.quant.universe import ( # noqa: F401 —— 范围过滤
|
||||
filter_stocks,
|
||||
names_as_of,
|
||||
resolve_members,
|
||||
)
|
||||
|
||||
# 流式路径每攒多少行落一个 DataFrame 分片(控制 concat 峰值)
|
||||
_FRAME_CHUNK_ROWS = 50_000
|
||||
@@ -79,22 +90,44 @@ def load_daily_df(
|
||||
end: date,
|
||||
columns: list[str],
|
||||
adjust: str = "none",
|
||||
price_adjust: str = "none",
|
||||
) -> pd.DataFrame:
|
||||
"""从 Repository 装配行情长表(供研究/选股共用)。
|
||||
|
||||
两个 adjust 语义不同(v3 §20.5):
|
||||
- `adjust`:行集过滤(stock_daily.adjust 主口径 none / 新浪兜底 qfq 行)
|
||||
- `price_adjust`:复权折算(none/qfq/hfq,按 adjust_factor 在 SQL 侧折算价格列)
|
||||
|
||||
优先走流式列裁剪(stream_range_many_columns,SQL 侧转 REAL、分批),
|
||||
失败或实现缺失时回退 get_range_many / 逐只 get_range。
|
||||
失败或实现缺失时回退 get_range_many / 逐只 get_range(回退路径不支持 price_adjust,
|
||||
此时由调用方保证 price_adjust=none,避免静默混入未折算价格)。
|
||||
"""
|
||||
if not symbols:
|
||||
return pd.DataFrame()
|
||||
streamer = getattr(daily_repo, "stream_range_many_columns", None)
|
||||
if streamer is not None:
|
||||
try:
|
||||
return _frame_from_stream(
|
||||
streamer(
|
||||
symbols, start, end, sorted(columns),
|
||||
adjust=adjust, price_adjust=price_adjust,
|
||||
),
|
||||
sorted(columns),
|
||||
)
|
||||
except TypeError: # 实现未升级(无 price_adjust 形参)→ 回退
|
||||
if price_adjust != "none":
|
||||
raise
|
||||
return _frame_from_stream(
|
||||
streamer(symbols, start, end, sorted(columns), adjust=adjust), sorted(columns)
|
||||
)
|
||||
except Exception: # noqa: BLE001 —— 流式路径失败回退旧路径(兼容非 SQL 实现)
|
||||
if price_adjust != "none":
|
||||
raise # 复权路径失败必须显式报错,禁止静默退回不复权
|
||||
pass
|
||||
elif price_adjust != "none":
|
||||
raise ValueError(
|
||||
f"行情仓储不支持流式列裁剪,无法按 {price_adjust} 复权装配(拒绝静默退回不复权)"
|
||||
)
|
||||
get_many = getattr(daily_repo, "get_range_many", None)
|
||||
if get_many is not None:
|
||||
bars = list(get_many(symbols, start, end, adjust=adjust))
|
||||
@@ -105,6 +138,127 @@ def load_daily_df(
|
||||
return bars_to_daily_df(bars)
|
||||
|
||||
|
||||
def split_factor_columns(columns: Iterable[str]) -> tuple[set[str], set[str]]:
|
||||
"""把因子所需列拆成 (行情列, 每日指标列)。
|
||||
|
||||
行情列来自 stock_daily(含 close 等);每日指标列来自 daily_basic(dv_ratio 等)。
|
||||
未识别的列按行情列处理(由仓储的列白名单兜底报错,错误信息更贴近调用点)。
|
||||
"""
|
||||
bars: set[str] = set()
|
||||
basics: set[str] = set()
|
||||
for col in columns:
|
||||
if col in DAILY_BASIC_NUMERIC_FIELDS and col not in DAILY_BAR_NUMERIC_FIELDS:
|
||||
basics.add(col)
|
||||
else:
|
||||
bars.add(col)
|
||||
return bars, basics
|
||||
|
||||
|
||||
def load_basic_df(
|
||||
basic_repo,
|
||||
symbols: list[str],
|
||||
start: date,
|
||||
end: date,
|
||||
columns: list[str],
|
||||
) -> pd.DataFrame:
|
||||
"""装配每日指标长表(daily_basic)—— 仅取所需列。
|
||||
|
||||
与 load_daily_df 同构(流式优先、回退批量/逐只)。
|
||||
"""
|
||||
if not symbols or not columns:
|
||||
return pd.DataFrame()
|
||||
streamer = getattr(basic_repo, "stream_range_many_columns", None)
|
||||
if streamer is not None:
|
||||
try:
|
||||
return _frame_from_stream(
|
||||
streamer(symbols, start, end, sorted(columns)), sorted(columns)
|
||||
)
|
||||
except Exception: # noqa: BLE001 —— 回退旧路径
|
||||
pass
|
||||
get_many = getattr(basic_repo, "get_range_many", None)
|
||||
if get_many is not None:
|
||||
rows = [
|
||||
{"symbol": r.symbol, "trade_date": r.trade_date, **{c: getattr(r, c) for c in columns}}
|
||||
for r in get_many(symbols, start, end)
|
||||
]
|
||||
else: # 兜底:逐只查询
|
||||
rows = []
|
||||
for sym in symbols:
|
||||
for r in basic_repo.get_range(sym, start, end):
|
||||
rows.append(
|
||||
{
|
||||
"symbol": r.symbol,
|
||||
"trade_date": r.trade_date,
|
||||
**{c: getattr(r, c) for c in columns},
|
||||
}
|
||||
)
|
||||
df = pd.DataFrame(rows)
|
||||
if df.empty:
|
||||
return df
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
for col in columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
return df
|
||||
|
||||
|
||||
def merge_basic_into_daily(daily: pd.DataFrame, basic: pd.DataFrame) -> pd.DataFrame:
|
||||
"""把每日指标列并入行情长表(按 symbol + trade_date 左连接)。
|
||||
|
||||
命名冲突保护:daily_basic 也有 close 列,若与行情 close 冲突则**丢弃指标侧**列
|
||||
(成交/估值口径以 stock_daily 不复权 close 为准,避免静默改口径,AGENT.md §8)。
|
||||
"""
|
||||
if basic is None or basic.empty:
|
||||
return daily
|
||||
if daily is None or daily.empty:
|
||||
return daily
|
||||
add_cols = [
|
||||
c for c in basic.columns if c not in ("symbol", "trade_date") and c not in daily.columns
|
||||
]
|
||||
if not add_cols:
|
||||
return daily
|
||||
left = daily.copy()
|
||||
left["trade_date"] = pd.to_datetime(left["trade_date"])
|
||||
right = basic[["symbol", "trade_date", *add_cols]].copy()
|
||||
right["trade_date"] = pd.to_datetime(right["trade_date"])
|
||||
merged = left.merge(right, on=["symbol", "trade_date"], how="left")
|
||||
return merged
|
||||
|
||||
|
||||
def _fill_names(result: BacktestResult, stocks) -> BacktestResult:
|
||||
"""把股票池的 `symbol → name` 回填进回测结果的各展示结构(就地修改并返回)。
|
||||
|
||||
为什么放在服务层而不是引擎里:名称只是展示增强,两个引擎(LocalEngine / QlibEngine)
|
||||
都经本方法返回,改一处即同时覆盖;引擎保持「只认 symbol」的纯计算职责,不引入
|
||||
名称查询/IO,也就不必在引擎内部为零散字段各写一次映射。
|
||||
|
||||
`stocks` 为空(universe 过滤后无标的、或调用方未装配)时**静默跳过**,不抛错:
|
||||
名称缺失只影响展示,不应让一个已经算完的回测失败(AGENT.md §24 的诚实标注
|
||||
由引擎的 unimplemented 承担,不由名称缺失承担)。
|
||||
"""
|
||||
if 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
|
||||
|
||||
def _fill(rows) -> None:
|
||||
for row in rows:
|
||||
if row.name is None:
|
||||
row.name = name_map.get(row.symbol)
|
||||
|
||||
_fill(result.symbol_curves)
|
||||
# marks 与 signal_history 在 LocalEngine 中共享同一批 ActionRecord 对象,
|
||||
# 这里仍显式回填一次:对独立实现(如 Qlib 适配器)也成立,幂等无副作用。
|
||||
for curve in result.symbol_curves:
|
||||
_fill(curve.marks)
|
||||
_fill(result.positions)
|
||||
_fill(result.trades)
|
||||
_fill(result.signal_history)
|
||||
_fill(result.fills)
|
||||
_fill(result.selection_history)
|
||||
return result
|
||||
|
||||
|
||||
class ResearchService:
|
||||
"""研究用例入口(因子测试 / 回测)。依赖注入 Repository 与引擎。"""
|
||||
|
||||
@@ -114,11 +268,31 @@ class ResearchService:
|
||||
daily_repo: DailyBarRepository,
|
||||
engine: QuantEngine,
|
||||
index_repo=None,
|
||||
basic_repo=None,
|
||||
financial_repo=None,
|
||||
name_repo=None,
|
||||
) -> None:
|
||||
self._stock_repo = stock_repo
|
||||
self._daily_repo = daily_repo
|
||||
self._engine = engine
|
||||
self._index_repo = index_repo
|
||||
# 名称变更历史仓储:universe.exclude_st 的**时点**口径依据。
|
||||
# 未注入 → 回退 stock.name 最新快照(旧行为,结果页会如实标注残余偏差)。
|
||||
self._name_repo = name_repo
|
||||
# 每日指标仓储(daily_basic):仅当 spec 因子引用 dv_ratio 等列时使用;
|
||||
# 未注入而在 spec 中引用 → 明确报错,不做静默降级(否则因子会全是 NaN)。
|
||||
self._basic_repo = basic_repo
|
||||
# 注:复权(qfq/hfq)折算在行情仓储 `stream_range_many_columns` 的 SQL 内完成
|
||||
# (与成交价同源,v3 §20.5),业务层不需要 adjust_factor 仓储;缺口统计走
|
||||
# `self._daily_repo.count_price_adjust_gaps`。
|
||||
# 财务仓储:spec.conditions 引用 fundamental.* 时使用
|
||||
self._financial_repo = financial_repo
|
||||
# 最近一次装配的复权因子缺口报告(未复权时为 None),用于结果如实标注
|
||||
self.last_adjust_gaps: dict | None = None
|
||||
# 最近一次装配的 exclude_st 名称口径(None = 未启用 ST 过滤)
|
||||
self.last_name_basis: dict | None = None
|
||||
# 最近一次装配的 universe 股票列表(条件求值需要 static.* 元数据)
|
||||
self._last_stocks: list = []
|
||||
|
||||
def run_factor_test(
|
||||
self, spec: ResearchSpec, horizon_days: int = 21, on_stage=None
|
||||
@@ -140,9 +314,12 @@ class ResearchService:
|
||||
_stage(on_stage, "data_loading")
|
||||
daily = self._load_daily(spec)
|
||||
_stage(on_stage, "backtesting")
|
||||
result = self._engine.run_backtest(daily, spec)
|
||||
eligibility = self._build_eligibility(spec, daily)
|
||||
result = self._engine.run_backtest(daily, spec, eligibility_fn=eligibility)
|
||||
_stage(on_stage, "analysis")
|
||||
return result
|
||||
self._annotate_price_basis(result, spec)
|
||||
# 两个引擎都在此汇合:名称回填只做一次(`_last_stocks` 在 _load_daily 中装配)
|
||||
return _fill_names(result, self._last_stocks)
|
||||
|
||||
def run_factor_correlation(self, spec: ResearchSpec) -> FactorCorrelationReport:
|
||||
"""多因子两两相关(v3 §12):同 universe/period 装配 → 横截面相关矩阵。"""
|
||||
@@ -156,20 +333,230 @@ class ResearchService:
|
||||
start, end = spec.period
|
||||
# 回测前预留因子 warmup(lookback≤120 交易日,取 300 自然日余量)
|
||||
data_start = start - timedelta(days=300)
|
||||
all_stocks = self._stock_repo.list()
|
||||
name_at, applied = names_as_of(all_stocks, start, self._name_repo)
|
||||
stocks = filter_stocks(
|
||||
self._stock_repo.list(), spec.universe, as_of=start,
|
||||
all_stocks, spec.universe, as_of=start,
|
||||
members=resolve_members(self._index_repo, spec.universe, start),
|
||||
name_at=name_at,
|
||||
)
|
||||
# 供结果如实标注:exclude_st 是否用了时点名称、覆盖了多少只
|
||||
self.last_name_basis = (
|
||||
{"point_in_time": applied[0], "covered": applied[1], "total": len(all_stocks)}
|
||||
if spec.universe.exclude_st
|
||||
else None
|
||||
)
|
||||
self._last_stocks = stocks
|
||||
# 引擎所需列裁剪(LocalEngine 只取 close + 因子字段;Qlib 回测取全 OHLCV)
|
||||
required = self._engine.required_columns(spec)
|
||||
return load_daily_df(
|
||||
bar_cols, basic_cols = split_factor_columns(required)
|
||||
symbols = [s.symbol for s in stocks]
|
||||
# 行集口径恒为 none(主口径);复权折算由 price_adjust 承担(v3 §20.5 严格分离)
|
||||
daily = load_daily_df(
|
||||
self._daily_repo,
|
||||
[s.symbol for s in stocks],
|
||||
symbols,
|
||||
data_start,
|
||||
end,
|
||||
sorted(required),
|
||||
adjust=spec.price_adjustment,
|
||||
sorted(bar_cols),
|
||||
adjust="none",
|
||||
price_adjust=spec.price_adjustment,
|
||||
)
|
||||
if spec.price_adjustment != "none":
|
||||
self.last_adjust_gaps = self._adjust_gap_report(symbols, data_start, end)
|
||||
if basic_cols:
|
||||
daily = self._attach_basic(daily, symbols, data_start, end, sorted(basic_cols))
|
||||
return daily
|
||||
|
||||
def _adjust_gap_report(self, symbols: list[str], start: date, end: date) -> dict:
|
||||
"""复权因子缺口统计(如实上报「按 1.0 兜底未折算」的行占比)。"""
|
||||
counter = getattr(self._daily_repo, "count_price_adjust_gaps", None)
|
||||
if counter is None:
|
||||
return {"checked": False, "reason": "仓储未实现 count_price_adjust_gaps"}
|
||||
total, missing = counter(symbols, start, end)
|
||||
return {
|
||||
"checked": True,
|
||||
"rows": total,
|
||||
"rows_without_factor": missing,
|
||||
"missing_pct": round(missing / total * 100, 4) if total else 0.0,
|
||||
}
|
||||
|
||||
def _build_eligibility(self, spec: ResearchSpec, daily: pd.DataFrame):
|
||||
"""按 spec.conditions 构造「择股日 → 合格股票集合」的求值闭包(可选)。
|
||||
|
||||
与选股路径共用 `build_condition_fields` / `eligible_symbols`,因此
|
||||
「历史某日 /api/selections 的候选」与「回测在该日的候选池」由同一实现产出
|
||||
(v2 §25 / v3 §28 一致性要求)。逐择股日结果缓存,避免重复计算。
|
||||
|
||||
未来函数红线:条件字段只取 <= as_of 的截面;fundamental.* 只取
|
||||
announce_date <= as_of 的已公告值(由仓储 list_announced_many 保证)。
|
||||
"""
|
||||
if not self._last_stocks:
|
||||
if not spec.conditions:
|
||||
return None
|
||||
raise ValueError("universe 过滤结果为空,无法构造选股条件求值器")
|
||||
statics = {s.symbol: s.model_dump() for s in self._last_stocks}
|
||||
candidates = sorted(statics)
|
||||
# 时点 ST 过滤:exclude_st 必须在**每个择股日**按当时名称重判,否则
|
||||
# 「2020 年入池、2022 年才变 ST」的标的会一直被当作合格候选(与
|
||||
# /api/selections 的单时点语义不一致,v2 §25)。名称历史缺失时降级为
|
||||
# 池子基准日口径(旧行为),由结果 name_basis 如实标注。
|
||||
st_fn = self._build_st_filter(spec, candidates)
|
||||
if not spec.conditions:
|
||||
if st_fn is None:
|
||||
return None
|
||||
# 只有 ST 约束时,eligible 必须是「候选 − 该日 ST」,不能直接返回 st_fn
|
||||
# (st_fn 返回的是**不合格**集合,直接返回会把语义取反)
|
||||
allowed_cache: dict[date, set[str]] = {}
|
||||
|
||||
def _st_only(as_of: date) -> set[str]:
|
||||
if as_of not in allowed_cache:
|
||||
allowed_cache[as_of] = set(candidates) - st_fn(as_of)
|
||||
return allowed_cache[as_of]
|
||||
|
||||
return _st_only
|
||||
uses_fundamental = any(
|
||||
f.startswith("fundamental.")
|
||||
for c in spec.conditions
|
||||
for f in (c.field, c.ref or "")
|
||||
)
|
||||
cache: dict[date, set[str]] = {}
|
||||
|
||||
def _fn(as_of: date) -> set[str]:
|
||||
if as_of in cache:
|
||||
return cache[as_of]
|
||||
financial = (
|
||||
self._load_financial(candidates, as_of) if uses_fundamental else {}
|
||||
)
|
||||
fields = build_condition_fields(daily, spec.conditions, pd.Timestamp(as_of))
|
||||
if not fields:
|
||||
cache[as_of] = set()
|
||||
return cache[as_of]
|
||||
passed = set(
|
||||
eligible_symbols(candidates, spec.conditions, statics, fields, financial)
|
||||
)
|
||||
if st_fn is not None:
|
||||
passed -= st_fn(as_of) # 该日名称含 ST → 不合格
|
||||
cache[as_of] = passed
|
||||
return cache[as_of]
|
||||
|
||||
return _fn
|
||||
|
||||
def _build_st_filter(self, spec: ResearchSpec, candidates: list[str]):
|
||||
"""「择股日 → 该日名称含 ST 的股票集合」;不可用(未启用/未注入)时返回 None。
|
||||
|
||||
与 `filter_stocks` 共用 `names_as_of`,因此回测在任一择股日的
|
||||
`exclude_st` 结果与该日 `/api/selections` 的候选池同口径(v2 §25)。
|
||||
逐日缓存;每次查询只取该日生效的名称区间。
|
||||
"""
|
||||
if not spec.universe.exclude_st or self._name_repo is None:
|
||||
return None
|
||||
cache: dict[date, set[str]] = {}
|
||||
|
||||
def _fn(as_of: date) -> set[str]:
|
||||
if as_of not in cache:
|
||||
name_at, applied = names_as_of(self._last_stocks, as_of, self._name_repo)
|
||||
if not applied[0]:
|
||||
# 无时点名称可用(表为空/查询失败):整段时间都退回池子基准日口径,
|
||||
# 不逐日过滤 —— 与 filter_stocks 的回退一致,且 name_basis 会标注 false
|
||||
cache[as_of] = set()
|
||||
else:
|
||||
# 逐股优先时点名称、缺失回退最新名称快照 —— 必须与
|
||||
# filter_stocks 的 `(name_at or {}).get(sym) or s.name` 完全同口径,
|
||||
# 否则同一择股日「回测入选」与「/api/selections 排除」会打架(v2 §25)
|
||||
st_syms: set[str] = set()
|
||||
for st in self._last_stocks:
|
||||
nm = (name_at or {}).get(st.symbol) or st.name
|
||||
if nm and "ST" in nm.upper():
|
||||
st_syms.add(st.symbol)
|
||||
cache[as_of] = st_syms
|
||||
return cache[as_of]
|
||||
|
||||
return _fn
|
||||
|
||||
def _load_financial(self, symbols: list[str], as_of: date) -> dict[str, Any]:
|
||||
"""按 announce_date <= as_of 批量取财务,每 symbol 保留最新一版。"""
|
||||
if self._financial_repo is None:
|
||||
raise ValueError(
|
||||
"回测条件引用了 fundamental.* 字段,但未注入 FinancialRepository。"
|
||||
"请检查 API/CLI 的依赖装配。"
|
||||
)
|
||||
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:
|
||||
latest = self._financial_repo.latest_announced(sym, as_of)
|
||||
if latest is not None:
|
||||
rows.append(latest)
|
||||
out: dict[str, FinancialIndicator] = {}
|
||||
for r in rows: # 约定升序返回 → 后者覆盖前者即「最新一版」
|
||||
out[r.symbol] = r
|
||||
return out
|
||||
|
||||
def _annotate_price_basis(self, result, spec: ResearchSpec) -> None:
|
||||
"""把价格口径写入结果(v3 §20.5:adjust_mode / price_basis / execution_price_basis)。
|
||||
|
||||
同时如实标注复权因子缺口(AGENT.md §24:未覆盖部分必须显式说明,禁止假装支持)。
|
||||
"""
|
||||
mode = spec.price_adjustment
|
||||
result.config_snapshot["price_basis"] = {
|
||||
"adjust_mode": mode,
|
||||
"price_basis": "adjust_factor" if mode != "none" else "raw_close",
|
||||
"execution_price_basis": "close_adj" if mode != "none" else "close_raw",
|
||||
"adjust_gaps": self.last_adjust_gaps,
|
||||
# exclude_st 的名称口径:时点(stock_name_history)还是最新快照
|
||||
"name_basis": self.last_name_basis,
|
||||
}
|
||||
nb = self.last_name_basis
|
||||
if nb is not None:
|
||||
if nb["point_in_time"]:
|
||||
result.unimplemented.append(
|
||||
f"exclude_st 采用**时点名称**(stock_name_history,覆盖 {nb['covered']} 只生效名称):"
|
||||
"「曾为高股息、后变 ST」的标的在其非 ST 期间会被正常纳入(股息陷阱可见)"
|
||||
)
|
||||
else:
|
||||
result.unimplemented.append(
|
||||
"exclude_st 回退**最新名称快照**(名称变更历史表未同步/未注入):"
|
||||
"「曾为高股息、后变 ST/退市」的标的会被整段排除,收益可能被高估;"
|
||||
"修复:python -m app.cli.sync namechange"
|
||||
)
|
||||
if mode == "none":
|
||||
return
|
||||
label = "后复权(hfq)" if mode == "hfq" else "前复权(qfq)"
|
||||
result.unimplemented.append(
|
||||
f"价格口径为{label}:价格列 = 原始价 × adjust_factor"
|
||||
+ (";现金分红按复权因子隐含再投资处理" if mode == "hfq" else "(以前复权归一)")
|
||||
+ ";volume/amount 不做复权折算"
|
||||
)
|
||||
gaps = self.last_adjust_gaps or {}
|
||||
if gaps.get("checked") and gaps.get("rows_without_factor", 0) > 0:
|
||||
result.unimplemented.append(
|
||||
f"复权因子覆盖不全:{gaps['rows_without_factor']} / {gaps['rows']} 行"
|
||||
f"({gaps['missing_pct']}%)无对应 adjust_factor,已按系数 1.0 兜底(未折算)"
|
||||
)
|
||||
|
||||
def _attach_basic(
|
||||
self,
|
||||
daily: pd.DataFrame,
|
||||
symbols: list[str],
|
||||
start: date,
|
||||
end: date,
|
||||
columns: list[str],
|
||||
) -> pd.DataFrame:
|
||||
"""把 daily_basic 列并入行情长表(dv_ratio 等因子依赖)。"""
|
||||
if self._basic_repo is None:
|
||||
raise ValueError(
|
||||
f"因子需要每日指标列 {columns}(daily_basic),但未注入 DailyBasicRepository。"
|
||||
"请检查 API/CLI 的依赖装配。"
|
||||
)
|
||||
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 _stage(cb, name: str) -> None:
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
"""策略说明书生成器:ResearchSpec / StrategyDefinition → 一句话说明 + 计算公式 + 步骤 + 注意事项。
|
||||
|
||||
纯函数模块:无 IO、无 DB、不调用引擎,因而可被 API 复用(含**未保存**的策略即时预览)
|
||||
并被单测直接覆盖。
|
||||
|
||||
为什么要有它(需求背景):
|
||||
- 策略必须「能被解释」:前端回测页要在一句话里说清「用什么因子/条件、取多少只、
|
||||
多久择股一次、多久调仓一次、什么口径」(AGENT.md §26 不暴露引擎内部配置);
|
||||
- 保存策略时说明不得为空:说明由 spec **真实推导**,而非让调用方手写或让 Agent 编造
|
||||
(AGENT.md §24 禁止假装支持、§28 Agent 只走 Tool)。
|
||||
|
||||
两条纪律:
|
||||
1. 所有文本只能来自 spec 与因子注册表元数据(`FactorDef.description/formula/brief/direction`,
|
||||
AGENT.md §22 因子元数据要求);元数据缺失、字段未知、约束未建模一律进 `warnings`,
|
||||
**绝不臆造公式**,也绝不把未建模的东西写成已支持。
|
||||
2. 条件渲染必须与 `app.quant.selection` 的真实求值语义一致(`_eval_condition` 的字段域与
|
||||
运算符分支),步骤必须与 `app.quant.local_engine` 的真实行为一致。两处都以代码为准。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.domain.entities.market import (
|
||||
DAILY_BAR_NUMERIC_FIELDS,
|
||||
DAILY_BASIC_NUMERIC_FIELDS,
|
||||
)
|
||||
from app.domain.entities.research import ResearchSpec
|
||||
from app.domain.entities.strategy import StrategyDefinition
|
||||
from app.quant.factors import FactorDef, FactorError, get_factor
|
||||
|
||||
# StrategyDefinition 没有回测区间字段(区间在回测时补全),但 to_research_spec 的
|
||||
# period 是必填的 —— 用一个不可能被误读为真实区间的占位区间满足校验,
|
||||
# 真正展示时以 `period_known=False` 走占位文案(不抛错、也不假装知道区间)。
|
||||
_PLACEHOLDER_PERIOD: tuple[date, date] = (date(1900, 1, 1), date(1900, 1, 2))
|
||||
_PERIOD_UNKNOWN_TEXT = "(回测区间未指定:策略定义本身不含 period,展开回测后才确定)"
|
||||
|
||||
# 运算符的展示文本(键集合由 ConditionSpec 的 pattern 保证)
|
||||
_OP_TEXT: dict[str, str] = {
|
||||
"gt": ">",
|
||||
"gte": ">=",
|
||||
"lt": "<",
|
||||
"lte": "<=",
|
||||
"eq": "==",
|
||||
"ne": "!=",
|
||||
"in": "∈",
|
||||
"not_in": "∉",
|
||||
}
|
||||
|
||||
# 与 selection.build_condition_fields 一致的技术派生字段(非行情原列,需滚动窗口)
|
||||
_TECH_DERIVED: tuple[str, ...] = ("ma20", "ma60")
|
||||
_STATIC_PREFIX = "static."
|
||||
_FUNDAMENTAL_PREFIX = "fundamental."
|
||||
|
||||
_ADJUST_TEXT: dict[str, str] = {
|
||||
"none": "不复权(原始收盘价;现金分红未计入收益,除权日价格下移会被计为亏损)",
|
||||
"qfq": "前复权(价格 = 原始价 × adjust_factor,以前复权归一)",
|
||||
"hfq": "后复权(价格 = 原始价 × adjust_factor,现金分红按因子隐含再投资)",
|
||||
}
|
||||
|
||||
|
||||
class StrategyDoc(BaseModel):
|
||||
"""策略说明书(前端 `<pre>` 直接展示 formula;summary 兼作策略默认 description)。"""
|
||||
|
||||
summary: str = Field(description="一句话功能说明(由 spec 真实推导)")
|
||||
formula: str = Field(description="计算公式(多行文本:打分/过滤/截断/成本与成交口径)")
|
||||
steps: list[str] = Field(default_factory=list, description="执行步骤(按回测引擎真实行为)")
|
||||
warnings: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="需要注意的点(未建模约束 / 未知因子 / 口径近似,诚实标注)",
|
||||
)
|
||||
|
||||
|
||||
def describe_strategy(
|
||||
spec: ResearchSpec | StrategyDefinition,
|
||||
*,
|
||||
factor_meta: Mapping[str, FactorDef] | None = None,
|
||||
) -> StrategyDoc:
|
||||
"""生成策略说明书。
|
||||
|
||||
`spec`:ResearchSpec(回测页参数,含 period)或 StrategyDefinition(策略库资产,
|
||||
无 period)。后者经 `to_research_spec(period=占位)` 展开 —— 区间缺失只影响展示文案
|
||||
(走 `_PERIOD_UNKNOWN_TEXT`),不影响其余推导,更不抛错。
|
||||
|
||||
`factor_meta`:可选的因子元数据覆盖/补充(如内置注册表之外的实验因子)。
|
||||
查找顺序为 `factor_meta` → `app.quant.factors.get_factor`;两处都没有则记入
|
||||
warnings(说明该因子的含义/公式/方向未知,执行期会直接报错)。
|
||||
"""
|
||||
rspec, period_text, period_known = _coerce_spec(spec)
|
||||
warnings: list[str] = []
|
||||
factors = _describe_factors(rspec, factor_meta, warnings)
|
||||
conditions = _describe_conditions(rspec, warnings)
|
||||
|
||||
summary = _build_summary(rspec, factors)
|
||||
formula = _build_formula(rspec, factors, conditions, period_text)
|
||||
steps = _build_steps(rspec, period_text)
|
||||
warnings.extend(_collect_warnings(rspec, period_known))
|
||||
return StrategyDoc(summary=summary, formula=formula, steps=steps, warnings=warnings)
|
||||
|
||||
|
||||
# ---------- 入参归一化 ----------
|
||||
|
||||
|
||||
def _coerce_spec(spec) -> tuple[ResearchSpec, str, bool]:
|
||||
"""归一化为 (ResearchSpec, 区间展示文本, 区间是否已知)。
|
||||
|
||||
StrategyDefinition 无 period:按需求用占位区间展开并如实标记「区间未知」,
|
||||
而不是抛错(策略库列表/详情页也要能看说明)。
|
||||
"""
|
||||
if isinstance(spec, StrategyDefinition):
|
||||
return spec.to_research_spec(period=_PLACEHOLDER_PERIOD), _PERIOD_UNKNOWN_TEXT, False
|
||||
if isinstance(spec, ResearchSpec):
|
||||
start, end = spec.period
|
||||
return spec, f"{start.isoformat()} ~ {end.isoformat()}", True
|
||||
raise TypeError(
|
||||
"describe_strategy 只接受 ResearchSpec 或 StrategyDefinition,"
|
||||
f"收到 {type(spec).__name__}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- 因子元数据 ----------
|
||||
|
||||
|
||||
def _lookup_factor(
|
||||
name: str, factor_meta: Mapping[str, FactorDef] | None
|
||||
) -> FactorDef | None:
|
||||
"""按 name 取因子元数据:先查传入覆盖,再查内置注册表;都取不到返回 None。"""
|
||||
if factor_meta is not None and name in factor_meta:
|
||||
return factor_meta[name]
|
||||
try:
|
||||
defn, _fn = get_factor(name)
|
||||
except FactorError:
|
||||
return None
|
||||
return defn
|
||||
|
||||
|
||||
def _describe_factors(
|
||||
spec: ResearchSpec,
|
||||
factor_meta: Mapping[str, FactorDef] | None,
|
||||
warnings: list[str],
|
||||
) -> list[tuple[str, float, FactorDef | None]]:
|
||||
"""返回 [(因子名, 权重, 元数据|None)];元数据缺失的因子记入 warnings。"""
|
||||
out: list[tuple[str, float, FactorDef | None]] = []
|
||||
for fs in spec.factors:
|
||||
defn = _lookup_factor(fs.name, factor_meta)
|
||||
if defn is None:
|
||||
warnings.append(
|
||||
f"未知因子 {fs.name}(权重 {fs.weight:g}):未在因子注册表中登记,"
|
||||
"无法给出含义/公式/方向;回测执行时会直接报错(不会静默忽略该因子)"
|
||||
)
|
||||
out.append((fs.name, float(fs.weight), defn))
|
||||
return out
|
||||
|
||||
|
||||
def _direction_text(defn: FactorDef | None) -> str:
|
||||
if defn is None:
|
||||
return "方向未知"
|
||||
return "越高越好" if defn.direction == "higher_is_better" else "越低越好"
|
||||
|
||||
|
||||
# ---------- summary ----------
|
||||
|
||||
|
||||
def _build_summary(
|
||||
spec: ResearchSpec, factors: list[tuple[str, float, FactorDef | None]]
|
||||
) -> str:
|
||||
"""一句话说明:股票池 + 排序依据 + 两级截断 + 择股/调仓周期 + 口径与成本。"""
|
||||
sel = spec.selection
|
||||
x = sel.x
|
||||
pool = _universe_text(spec.universe)
|
||||
cond_text = f",并先通过 {len(spec.conditions)} 条过滤条件" if spec.conditions else ""
|
||||
rank_text = _rank_text(factors)
|
||||
cycle_select, cycle_rebal = _cycle_text(spec)
|
||||
hold_text = (
|
||||
f"先筛出前 {sel.top_n} 只候选池、再持有其中前 {x} 只等权"
|
||||
if x != sel.top_n
|
||||
else f"按复合分取出前 {sel.top_n} 只等权持有"
|
||||
)
|
||||
# 买不进的处置直接决定「实际持有什么」,必须进一句话说明(不能只写在公式里)
|
||||
hold_text += f"({_buy_mode_short(sel)})"
|
||||
adjust = spec.price_adjustment
|
||||
adjust_text = {"none": "不复权", "qfq": "前复权", "hfq": "后复权"}[adjust]
|
||||
type_prefix = "" if spec.type == "backtest" else "单因子测试(非组合回测):"
|
||||
return (
|
||||
f"{type_prefix}{pool}{cond_text},{rank_text},{hold_text},"
|
||||
f"{cycle_select}、{cycle_rebal},{adjust_text}口径、按调仓日收盘价成交"
|
||||
f"(含佣金 {_pct(spec.costs.commission_rate)}/印花税 {_pct(spec.costs.stamp_tax_rate)}"
|
||||
f"/滑点 {_pct(spec.costs.slippage_rate)})。"
|
||||
)
|
||||
|
||||
|
||||
def _universe_text(universe) -> str:
|
||||
"""股票池口径(只写**实际生效**的过滤;未建模项只进 warnings,不在这里冒充生效)。"""
|
||||
if universe.index_code:
|
||||
base = f"{universe.index_code} 指数成分(按择股日历史成分)"
|
||||
else:
|
||||
base = "全市场"
|
||||
filters: list[str] = []
|
||||
if universe.symbols:
|
||||
filters.append(f"限定白名单 {len(universe.symbols)} 只")
|
||||
if universe.exclude_st:
|
||||
filters.append("剔除名称含 ST 的股票")
|
||||
if universe.min_listing_days:
|
||||
filters.append(f"上市满 {universe.min_listing_days} 个自然日")
|
||||
# 括号内只放结束句号外的短过滤项,避免出现「(…(…)…)」的嵌套括号
|
||||
return f"{base}({','.join(filters)})" if filters else base
|
||||
|
||||
|
||||
def _rank_text(factors: list[tuple[str, float, FactorDef | None]]) -> str:
|
||||
"""排序依据文案:单因子按该因子方向,多因子按复合分(截面 z-score 加权和)。"""
|
||||
names = "、".join(name for name, _w, _defn in factors)
|
||||
if len(factors) == 1:
|
||||
name, _w, defn = factors[0]
|
||||
label = defn.description if defn is not None else name
|
||||
# 方向未知时不猜方向:按「复合分从高到低」描述(复合分本身就是降序取头部)
|
||||
lower = defn is not None and defn.direction == "lower_is_better"
|
||||
direction = "从低到高" if lower else "从高到低"
|
||||
return f"按 {label}{direction}排序(因子 {name})"
|
||||
return f"按多因子复合分(截面 z-score 加权和)从高到低排序(因子 {names})"
|
||||
|
||||
|
||||
def _cycle_text(spec: ResearchSpec) -> tuple[str, str]:
|
||||
"""(择股周期文案, 调仓周期文案),与 ResearchSpec.effective_* / local_engine 一致。"""
|
||||
m = spec.effective_selection_months
|
||||
y = spec.effective_rebalance_months
|
||||
select_text = f"每 {m} 个月重新择股" if m is not None else "每次调仓都重新择股"
|
||||
if y is not None:
|
||||
rebal_text = f"每 {y} 个月调仓"
|
||||
else:
|
||||
rebal_text = "按月度调仓" if spec.rebalance == "monthly" else "按周度调仓"
|
||||
return select_text, rebal_text
|
||||
|
||||
|
||||
def _months_text(months: int | None, fallback: str) -> str:
|
||||
"""「每 n 个月」/ 回退文案(月数为 None 时用 fallback),避免散落的 % 格式化。"""
|
||||
return f"每 {months} 个月" if months is not None else fallback
|
||||
|
||||
|
||||
# ---------- formula ----------
|
||||
|
||||
|
||||
def _build_formula(
|
||||
spec: ResearchSpec,
|
||||
factors: list[tuple[str, float, FactorDef | None]],
|
||||
conditions: list[str],
|
||||
period_text: str,
|
||||
) -> str:
|
||||
lines: list[str] = [f"# 策略计算公式(由 spec 真实推导;回测区间:{period_text})", ""]
|
||||
lines += _formula_scoring(spec, factors)
|
||||
lines += _formula_conditions(conditions)
|
||||
lines += _formula_truncation(spec)
|
||||
lines += _formula_costs(spec)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _formula_scoring(
|
||||
spec: ResearchSpec, factors: list[tuple[str, float, FactorDef | None]]
|
||||
) -> list[str]:
|
||||
lines = [
|
||||
"【1】候选池打分(每个择股日在股票池内做横截面标准化,加权求和)",
|
||||
" z_f,i = (x_f,i - mean_i(x_f)) / std_i(x_f) # 因子 f 在当日截面上的 z-score",
|
||||
" d_f = -1(方向 lower_is_better)/ +1(higher_is_better)",
|
||||
" score_i = Σ_f w_f × d_f × z_f,i # 按 score 降序取候选池",
|
||||
" 权重与因子明细:",
|
||||
]
|
||||
for name, weight, defn in factors:
|
||||
if defn is None:
|
||||
lines.append(f" - {name} w = {weight:g} 方向:未知(元数据缺失,见 warnings)")
|
||||
continue
|
||||
lines += [
|
||||
f" - {name} w = {weight:g} 方向:{_direction_text(defn)}"
|
||||
f"({defn.direction}) 频率:{defn.frequency} 回看:{defn.lookback} 个交易日"
|
||||
f" 输入列:{', '.join(defn.requires)}",
|
||||
f" 含义:{defn.description}",
|
||||
f" 公式:{defn.formula}",
|
||||
]
|
||||
if defn.brief:
|
||||
lines.append(f" 用法:{defn.brief}")
|
||||
lines += [
|
||||
" 说明:因子值缺失(NaN)的标的当日不可评分,不进入候选池;",
|
||||
" 单只股票池(截面不足 2 只)时 z-score 退化为 0,仍保留为可候选值。",
|
||||
"",
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
def _formula_conditions(conditions: list[str]) -> list[str]:
|
||||
lines = ["【2】过滤条件(全部 AND;universe 之后、因子排序之前逐股求值)"]
|
||||
if not conditions:
|
||||
lines += [" (无)候选池仅由 universe + 因子排序决定。"]
|
||||
else:
|
||||
lines += [f" {text}" for text in conditions]
|
||||
lines += [
|
||||
" 字段域(与选股 /api/selections 使用同一求值器,逐字段语义如下):",
|
||||
" · static.<字段>:股票基础信息(industry / market / area / exchange / status …)",
|
||||
" · fundamental.<字段>:announce_date <= 择股日 的最新**已公告**值(防未来函数)",
|
||||
" · 其它:当日行情列(close/open/high/low/volume/amount)、ma20 / ma60、",
|
||||
" 每日指标列(dv_ratio / pe / pb / total_mv …)或已注册因子",
|
||||
" 缺失值语义:字段取不到(None/NaN)时,除 != 外一律判为「未通过」;",
|
||||
" != 在缺失时按「不等于字面量」通过(真实求值器行为)。",
|
||||
"",
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
def _formula_truncation(spec: ResearchSpec) -> list[str]:
|
||||
sel = spec.selection
|
||||
x = sel.x
|
||||
m = spec.effective_selection_months
|
||||
y = spec.effective_rebalance_months
|
||||
lines = [
|
||||
"【3】两级截断与择股 / 调仓周期",
|
||||
f" 候选池:n = {sel.top_n}(按 score 降序取前 n;池内可评分股票不足 n 时以实际数量为准)",
|
||||
f" 实际持仓:x = {x}"
|
||||
+ ("(未显式给出 hold_top_x,取 x = n)" if sel.hold_top_x is None else ""),
|
||||
f" 择股日:{_months_text(m, '每次调仓日')}(月序号锚定回测起始月)",
|
||||
f" 调仓日:{_months_text(y, '每月' if spec.rebalance == 'monthly' else '每周')}的首个交易日",
|
||||
" 资金分配:" + (
|
||||
f"等权(可用现金 / 目标持仓数);设置了单股上限 {spec.portfolio.max_position_pct:.0%},"
|
||||
"超出部分按上限分配并留作现金"
|
||||
if spec.portfolio.max_position_pct is not None
|
||||
else "等权(可用现金 / 目标持仓数)"
|
||||
),
|
||||
" 买不进的处理:" + _buy_fallback_text(sel),
|
||||
"",
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
def _buy_fallback_text(sel) -> str:
|
||||
"""与 local_engine._rebalance/_fill_pending 的真实分支一一对应。"""
|
||||
if sel.defer_buy:
|
||||
return (
|
||||
"不替补(顺延买入):涨停/停牌买不进的标的把买单顺延到之后首个可成交交易日、"
|
||||
"按当日收盘价买入;到下一次调仓仍未成交则作废,资金留作现金"
|
||||
)
|
||||
if sel.allow_substitute:
|
||||
return (
|
||||
"替补买入:从候选池之外继续按复合分往下找可买标的,补足 x 只"
|
||||
"(池内被跳过的标的也会在 signal_history 中留下未成交原因)"
|
||||
)
|
||||
return "不替补也不顺延:买不进则放弃该标的,资金留作现金"
|
||||
|
||||
|
||||
def _buy_mode_short(sel) -> str:
|
||||
"""一句话说明里的买不进处置短语(与 _buy_fallback_text 同源、同分支)。"""
|
||||
if sel.defer_buy:
|
||||
return "买不进则顺延买入"
|
||||
if sel.allow_substitute:
|
||||
return "买不进时从候选池之外替补"
|
||||
return "买不进则放弃"
|
||||
|
||||
|
||||
def _target_text(sel) -> str:
|
||||
"""调仓日「目标名单」如何产生 —— 与 local_engine._rebalance 的分支一一对应。"""
|
||||
if sel.allow_substitute:
|
||||
# 引擎从 current_ranked(全市场排序)往下扫描,而不是 picks[:x]
|
||||
return (
|
||||
f"替补买入:按复合分排序从头扫描(含候选池之外),"
|
||||
f"买得进的依次入选直到凑满 {sel.x} 只"
|
||||
)
|
||||
return f"取候选池前 {sel.x} 只为目标名单({_buy_mode_short(sel)})"
|
||||
|
||||
|
||||
def _universe_step(spec: ResearchSpec) -> str:
|
||||
"""股票池装配步骤(与 service._load_daily / universe.filter_stocks 的真实口径一致)。
|
||||
|
||||
时点必须写准:universe 过滤只在**回测起始日**执行一次(filter_stocks(as_of=start)),
|
||||
之后每个择股日仅按**当日名称**重判 exclude_st;不能写成「每日对全池重新过滤」。
|
||||
"""
|
||||
u = spec.universe
|
||||
filters = ["已退市标的排除"]
|
||||
if u.exclude_st:
|
||||
filters.append("名称含 ST 的标的排除")
|
||||
if u.min_listing_days:
|
||||
filters.append(f"上市不足 {u.min_listing_days} 个自然日的排除")
|
||||
if u.symbols:
|
||||
filters.append(f"限定为白名单 {len(u.symbols)} 只")
|
||||
if u.index_code:
|
||||
filters.append(f"限定为 {u.index_code} 的当日历史成分")
|
||||
text = "股票池:在回测起始日按 universe 过滤一次(" + ",".join(filters) + ")。"
|
||||
if u.exclude_st:
|
||||
text += (
|
||||
"此后每个择股日再按**当日名称**判定 exclude_st"
|
||||
"(名称变更历史未同步时回退最新名称快照)。"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def _formula_costs(spec: ResearchSpec) -> list[str]:
|
||||
c = spec.costs
|
||||
min_comm = (
|
||||
f"最低佣金 {c.min_commission:g} 元/笔"
|
||||
if c.min_commission > 0
|
||||
else "最低佣金 0(未启用)"
|
||||
)
|
||||
return [
|
||||
"【4】成本与成交价口径",
|
||||
f" 行情口径:{_ADJUST_TEXT[spec.price_adjustment]}",
|
||||
" 成交时点:调仓日收盘(调仓在当日收盘生效,自次日起计收益)",
|
||||
f" 买入:成交价 = 收盘价 × (1 + 滑点 {_pct(c.slippage_rate)});"
|
||||
f"佣金 = max(投入资金 × {_pct(c.commission_rate)}, {min_comm}),从投入资金中扣除",
|
||||
f" 卖出:成交金额 = 数量 × 收盘价 × (1 - 滑点 {_pct(c.slippage_rate)});"
|
||||
f"费用 = max(成交金额 × {_pct(c.commission_rate)}, {min_comm})"
|
||||
f" + 成交金额 × 印花税 {_pct(c.stamp_tax_rate)}(仅卖出)",
|
||||
f" 初始资金:{spec.initial_capital:,.0f} 元;对照基准:{c.benchmark}(仅展示,不参与交易)",
|
||||
]
|
||||
|
||||
|
||||
# ---------- steps ----------
|
||||
|
||||
|
||||
def _build_steps(spec: ResearchSpec, period_text: str) -> list[str]:
|
||||
"""按 local_engine.TopKBacktestRunner.run 的真实流程写(顺序、时序口径都以代码为准)。"""
|
||||
sel = spec.selection
|
||||
m = spec.effective_selection_months
|
||||
y = spec.effective_rebalance_months
|
||||
warmup = (
|
||||
"数据装配:自回测起始日往前 300 个自然日起装配行情/指标(覆盖因子预热窗口);"
|
||||
f"回测区间 {period_text}。所有截面只使用 <= 当日的数据(无未来函数)。"
|
||||
)
|
||||
cond_step = (
|
||||
f"先跑 {len(spec.conditions)} 条过滤条件(AND)"
|
||||
if spec.conditions
|
||||
else "无过滤条件,直接按复合分排序"
|
||||
)
|
||||
steps = [
|
||||
warmup,
|
||||
_universe_step(spec),
|
||||
(
|
||||
f"择股日(每 {m} 个月的首个交易日,锚定起始月)" if m is not None
|
||||
else "择股日(未配置 m:每次调仓日都重新择股)"
|
||||
)
|
||||
+ f":{cond_step},再按复合分降序取前 "
|
||||
+ f"{sel.top_n} 只作为候选池,逐条写入 selection_history(含 rank 与 score)。",
|
||||
(
|
||||
f"调仓日(每 {y} 个月的首个交易日)" if y is not None
|
||||
else ("调仓日(每月首个交易日)" if spec.rebalance == "monthly" else "调仓日(每周首个交易日)")
|
||||
)
|
||||
+ ":在**当日收盘**执行调仓。",
|
||||
"调仓第一步:卖出全部当前持仓(跌停、无行情/停牌时不成交并保留该持仓到下一次调仓),"
|
||||
"逐笔记入 signal_history。",
|
||||
f"调仓第二步:{_target_text(sel)};"
|
||||
"把可用现金等权分配到目标名单,按收盘价 + 滑点买入,佣金(含最低佣金)从投入资金中扣除。",
|
||||
"买入约束:涨停(收盘价/上一有效收盘 >= 板块涨停系数)或当日无行情(停牌)时不可买入。",
|
||||
"收益结算:每日先用**上一交易日收盘持仓**结算个股收益"
|
||||
"(建仓当日不计收益、卖出当日仍有收益),因此调仓日收盘生效、次日起计收益。",
|
||||
"期末:不强制平仓 —— 最后一次调仓之后的持仓一直保留,按最后一个交易日收盘估值;"
|
||||
"Trade 只记录「已卖出」的完整买卖往返。",
|
||||
]
|
||||
if spec.type != "backtest":
|
||||
steps.insert(0, "本 spec 的 type 为 factor_test:只计算因子 IC/分层收益,"
|
||||
"不执行选股、调仓与成本(下列步骤仅在转为 backtest 后生效)。")
|
||||
return steps
|
||||
|
||||
|
||||
# ---------- conditions ----------
|
||||
|
||||
|
||||
def _describe_conditions(spec: ResearchSpec, warnings: list[str]) -> list[str]:
|
||||
"""渲染过滤条件为可读表达式,并对未识别字段给出 warnings(不静默)。"""
|
||||
out: list[str] = []
|
||||
for cond in spec.conditions:
|
||||
out.append(_render_condition(cond))
|
||||
for field in (cond.field, cond.ref):
|
||||
if field and not _is_known_field(field):
|
||||
warnings.append(
|
||||
f"条件字段 {field} 未识别(不在行情列 / ma20 / ma60 / 每日指标列 / "
|
||||
"已注册因子 / static.* / fundamental.* 之内):回测装配列时会直接报错,"
|
||||
"该条件不会生效"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _render_condition(cond) -> str:
|
||||
"""单条条件 → 可读表达式(语义与 selection._eval_condition 一一对应)。
|
||||
|
||||
`ref` 非空时右操作数是**另一个字段**(同一股票同一日的取值),否则是字面量;
|
||||
in/not_in 的 value 是列表。
|
||||
"""
|
||||
op = _OP_TEXT.get(cond.op, cond.op)
|
||||
if cond.ref is not None:
|
||||
right = cond.ref
|
||||
elif cond.op in ("in", "not_in"):
|
||||
items = cond.value if isinstance(cond.value, Sequence) else [cond.value]
|
||||
right = "[" + ", ".join(_fmt_value(v) for v in items) + "]"
|
||||
else:
|
||||
right = _fmt_value(cond.value)
|
||||
return f"{cond.field} {op} {right}"
|
||||
|
||||
|
||||
def _is_known_field(field: str) -> bool:
|
||||
"""字段是否属于已知域(与 selection.condition_needed_columns 的识别范围一致)。"""
|
||||
if field.startswith((_STATIC_PREFIX, _FUNDAMENTAL_PREFIX)):
|
||||
return True
|
||||
if field in DAILY_BAR_NUMERIC_FIELDS or field in DAILY_BASIC_NUMERIC_FIELDS:
|
||||
return True
|
||||
if field in _TECH_DERIVED:
|
||||
return True
|
||||
try:
|
||||
get_factor(field)
|
||||
except FactorError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _fmt_value(value) -> str:
|
||||
if isinstance(value, bool): # bool 是 int 子类,先判
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:g}"
|
||||
if isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
return str(value)
|
||||
|
||||
|
||||
def _pct(rate: float) -> str:
|
||||
return f"{rate * 100:g}%"
|
||||
|
||||
|
||||
# ---------- warnings ----------
|
||||
|
||||
|
||||
def _collect_warnings(spec: ResearchSpec, period_known: bool) -> list[str]:
|
||||
"""诚实标注「未建模 / 近似 / 口径依赖」项(AGENT.md §24:禁止假装支持)。"""
|
||||
warnings: list[str] = []
|
||||
if not period_known:
|
||||
warnings.append(
|
||||
"入参为策略定义(无回测区间):说明中的区间为占位文本,"
|
||||
"实际回测区间在展开为 ResearchSpec 时确定"
|
||||
)
|
||||
if spec.type != "backtest":
|
||||
warnings.append(
|
||||
f"spec.type={spec.type}:这是因子测试而非组合回测,"
|
||||
"择股/调仓/成本/成交价等说明不适用于本次执行"
|
||||
)
|
||||
if spec.universe.exclude_st:
|
||||
warnings.append(
|
||||
"exclude_st 依据股票名称判定:若未同步名称变更历史(stock_name_history,"
|
||||
"python -m app.cli.sync namechange),则回退**最新名称快照**,"
|
||||
"「曾为高股息、后来才变 ST」的标的会被整段排除(收益可能被高估);"
|
||||
"实际口径见回测结果 config_snapshot.price_basis.name_basis"
|
||||
)
|
||||
if spec.universe.exclude_suspended:
|
||||
warnings.append(
|
||||
"exclude_suspended=True 但**停牌数据未建模**:universe 过滤不会剔除停牌股"
|
||||
"(仅有「当日无行情时不成交」的撮合近似)"
|
||||
)
|
||||
if spec.universe.index_code:
|
||||
warnings.append(
|
||||
f"指数成分过滤({spec.universe.index_code})依赖本地指数成分表:"
|
||||
"该指数历史成分未同步时,候选池可能为空或偏小"
|
||||
)
|
||||
if spec.portfolio.max_industry_weight_pct is not None:
|
||||
warnings.append(
|
||||
f"最大行业权重 {spec.portfolio.max_industry_weight_pct:.0%} **未建模**"
|
||||
"(需行业元数据注入),结果中会列在 unimplemented"
|
||||
)
|
||||
if spec.price_adjustment == "none":
|
||||
warnings.append(
|
||||
"行情口径为不复权:现金分红未计入收益,除权日价格下移会被计为亏损;"
|
||||
"股息类策略建议 price_adjustment=hfq"
|
||||
)
|
||||
if not spec.conditions:
|
||||
warnings.append("未配置过滤条件(conditions):候选池仅由 universe + 因子排序决定")
|
||||
m = spec.effective_selection_months
|
||||
y = spec.effective_rebalance_months
|
||||
if m is not None and y is not None and y < m:
|
||||
warnings.append(
|
||||
f"调仓间隔 y={y} 个月 < 择股间隔 m={m} 个月:两次择股之间复用同一候选池"
|
||||
"(池子陈旧),并非每次调仓都重新择股"
|
||||
)
|
||||
warnings += [
|
||||
"涨跌停按「收盘价 / 上一有效收盘」近似判定,未建模开盘一字板、集合竞价与盘中价格路径",
|
||||
"成交假设发生在调仓日收盘,未建模流动性冲击与冲击成本",
|
||||
"调仓为「全部卖出 → 按目标等权重新买入」:保留在目标名单中的股票也会产生一次完整买卖往返,成本估计偏保守",
|
||||
"股票池来自本地行情表(含 2019-12 之后退市的标的):更早退市者无行情数据,存在残余幸存者偏差",
|
||||
"期末不强制平仓:最后持仓按最后一个交易日收盘估值,浮盈浮亏计入净值但不产生 Trade 记录",
|
||||
]
|
||||
return warnings
|
||||
@@ -2,20 +2,23 @@
|
||||
|
||||
把 ResearchService.filter_stocks 的语义规则化并集中于此:
|
||||
- 当前日与历史日(as_of)都必须正确:退市股(delist < as_of)、上市时间(list_date)
|
||||
- exclude_st 按**当前名称快照**含 ST 判定(历史可追溯数据;历史改名无法回溯,属近似,
|
||||
见结果 unimplemented 说明)
|
||||
- exclude_st 优先用**时点名称**(`name_at` 映射,来自 stock_name_history)判定;
|
||||
未提供或该股无记录时回退最新名称快照(历史改名无法回溯,属近似,见 unimplemented 说明)
|
||||
- exclude_suspended 依赖停牌数据表(尚未建模),此处不做剔除,由上层显式标注
|
||||
- symbols 白名单:非空时仅这些 symbol 参与(自选池 / 测试用)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import date
|
||||
|
||||
from app.domain.entities.market import Stock
|
||||
from app.domain.entities.research import UniverseSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_members(index_repo, universe: UniverseSpec, as_of: date) -> set[str] | None:
|
||||
"""若 universe 指定指数成分 → 取 as_of 当日历史成分;否则 None(不过滤)。"""
|
||||
@@ -29,10 +32,15 @@ def filter_stocks(
|
||||
universe: UniverseSpec,
|
||||
as_of: date,
|
||||
members: set[str] | None = None,
|
||||
name_at: Mapping[str, str] | None = None,
|
||||
) -> list[Stock]:
|
||||
"""按股票池口径过滤,返回 as_of 时点应纳入的股票列表。
|
||||
|
||||
members:指数历史成分集合(resolve_members 结果);提供时取交集。
|
||||
name_at:各股票在 as_of **时点生效的名称**(stock_name_history 查询结果)。
|
||||
提供时 `exclude_st` 用它判定,缺失的股票回退 `stock.name`(最新快照)。
|
||||
这是「股息陷阱」能否被正确纳入的关键:用最新名称会把「后来才 ST」的标的
|
||||
在整段历史上提前排除(实测影响约 3.70pp 收益,见 DEV_PLAN §10.5)。
|
||||
"""
|
||||
symbols = set(universe.symbols) if universe.symbols else None
|
||||
out: list[Stock] = []
|
||||
@@ -43,8 +51,10 @@ def filter_stocks(
|
||||
continue
|
||||
if s.delist_date is not None and s.delist_date < as_of:
|
||||
continue
|
||||
if universe.exclude_st and s.name and "ST" in s.name.upper():
|
||||
continue
|
||||
if universe.exclude_st:
|
||||
name = (name_at or {}).get(s.symbol) or s.name
|
||||
if name and "ST" in name.upper():
|
||||
continue
|
||||
if (
|
||||
universe.min_listing_days
|
||||
and s.list_date
|
||||
@@ -53,3 +63,34 @@ def filter_stocks(
|
||||
continue
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def names_as_of(
|
||||
stocks: Sequence[Stock],
|
||||
as_of: date,
|
||||
name_repo,
|
||||
) -> tuple[dict[str, str] | None, tuple[bool, int]]:
|
||||
"""装配 as_of 时点的名称映射,供 `filter_stocks(exclude_st=True)` 使用。
|
||||
|
||||
返回 `(name_at, (是否时点口径, 覆盖只数))`:
|
||||
- `name_repo` 为 None(未注入 / 表为空)→ `(None, (False, 0))`,
|
||||
调用方回退 `stock.name`(最新快照,旧行为),结果页据此标注残余偏差;
|
||||
- 否则返回时点名称映射,`filter_stocks` 逐股优先取时点名称、缺失回退最新名称。
|
||||
|
||||
时点语义(无未来函数):取 `start_date <= as_of <= end_date` 的生效名称区间。
|
||||
名称自 `start_date` 起即对市场可见,故不构成前视;`ann_date` 仅作留痕。
|
||||
"""
|
||||
if name_repo is None:
|
||||
return None, (False, 0)
|
||||
try:
|
||||
name_at = name_repo.names_as_of([s.symbol for s in stocks], as_of)
|
||||
except Exception: # noqa: BLE001 —— 名称历史缺失不应让选股/回测整体失败
|
||||
logger.warning("名称变更历史查询失败,回退最新名称快照判定 exclude_st", exc_info=True)
|
||||
return None, (False, 0)
|
||||
if not name_at:
|
||||
# 表存在但**无任何生效区间**(未执行 sync namechange / 表被清空):
|
||||
# `filter_stocks` 会逐股回退最新名称,因此口径仍是快照,**不得**声称时点,
|
||||
# 否则结果页会把「未被修正的股息陷阱偏差」当成已修正上报(AGENT.md §24)。
|
||||
logger.warning("名称变更历史为空(as_of=%s),exclude_st 回退最新名称快照", as_of)
|
||||
return None, (False, 0)
|
||||
return name_at, (True, len(name_at))
|
||||
|
||||
Reference in New Issue
Block a user