按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 GlobalConfig(全局唯一):佣金/印花税/滑点/最低佣金/复权口径/基准
- 选股策略 SelectionStrategy(原 StrategyDefinition 改名):只剩股票池+因子+条件,
不再持有 selection/rebalance/costs/portfolio/区间/资金
- 回测组合 BacktestCombo:引用若干选股策略 + 回测时才定的参数
(起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、调仓时机 日/周/月、区间)
引擎(app/quant/combo_engine.py,新增):
- 多策略打分 = 并集 + Borda 秩和(各策略 1/名次 求和;不假设不同策略分值可比,
能容纳各策略股票池不同);抽出纯函数 borda_combine 便于单测
- 持仓天数区间 [Tmin,Tmax]:Tmax **每个交易日**强制了结(安全阀,月频下也不超期);
Tmin 仅在调仓日保护(掉出 TopN 但未满 Tmin 暂留,防频繁换手);调仓日为增量调仓
(只卖超期/掉队且满 Tmin 的,从 TopN 补买至 N 只,不主动减持以尊重 Tmin)
- 调仓时机 daily/weekly/monthly(local_engine.rebalance_dates 新增日频分支)
- 产出与旧 runner 同构的 BacktestResult,前端可视化无需改动;config_snapshot 固化
ComboRunSpec(组合+当时各策略定义+当时成本/复权)保证可复现
数据层:
- 新表 global_config(默认行:万三/hfq/最低佣金5元)、backtest_combo
- 迁移 b4c5d6e7f8a9:建两表 + 把存量 strategy.config_json 的回测参数键剥掉、
spec_type 收敛为 selection(已在真实 MariaDB 验证:STG-16BFBF08 清洗后只剩
universe/factors/conditions)
- 仓储 SqlAlchemyGlobalConfigRepository / SqlAlchemyComboRepository + Protocol
API:
- /api/config GET/PUT;/api/combos CRUD + /{id}/run + /run(kind=combo 异步 Job)
- job_executor 新增 combo 分支:取齐策略+读公共配置→ComboService.run,归档 kind
记 backtest(结果结构相同)
- /api/strategies 切到 SelectionStrategy,移除已废弃的 /{id}/expand
- strategy_doc.describe_strategy 支持 SelectionStrategy(只讲「怎么选」,如实声明
资金/持仓/调仓/成本/区间在回测组合里定)
旧的 ResearchSpec + /api/backtests 保留(因子测试与既有契约自检仍用),
作为底层 escape hatch;用户产品路径改为回测组合。
测试:新增 test_combo_engine(6)/test_combo_service(3)/test_combo_api(5),
改写 test_strategies/test_strategy_doc 适配新模型。全量 403 passed(原 388)。
683 lines
31 KiB
Python
683 lines
31 KiB
Python
"""策略说明书生成器:ResearchSpec(回测)/ SelectionStrategy(选股策略)→ 说明 + 公式 + 步骤 + 注意事项。
|
||
|
||
纯函数模块:无 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 SelectionStrategy, StrategyDefinition
|
||
from app.quant.factors import FactorDef, FactorError, get_factor
|
||
|
||
# 选股策略(SelectionStrategy)不含回测参数,走 _describe_selection_only 专用分支;
|
||
# 以下占位常量仅保留给历史 ResearchSpec 路径兼容(当前 describe_strategy 不再对
|
||
# SelectionStrategy 调用 to_research_spec)。
|
||
_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 | SelectionStrategy,
|
||
*,
|
||
factor_meta: Mapping[str, FactorDef] | None = None,
|
||
) -> StrategyDoc:
|
||
"""生成策略说明书。
|
||
|
||
`spec`:ResearchSpec(回测页参数,含 period/costs/selection)或 SelectionStrategy
|
||
(策略库资产,只有选股条件)。后者走 `_describe_selection_only`,只讲「怎么选」,
|
||
如实声明资金/持仓/调仓/成本/区间不在策略内(在回测组合里定)。
|
||
|
||
`factor_meta`:可选的因子元数据覆盖/补充(如内置注册表之外的实验因子)。
|
||
查找顺序为 `factor_meta` → `app.quant.factors.get_factor`;两处都没有则记入
|
||
warnings(说明该因子的含义/公式/方向未知,执行期会直接报错)。
|
||
"""
|
||
coerced = _coerce_spec(spec)
|
||
# 选股策略(无回测参数)走专用说明;回测 spec 走原有完整路径
|
||
if isinstance(coerced, (SelectionStrategy, StrategyDefinition)) and not isinstance(coerced, ResearchSpec):
|
||
return _describe_selection_only(coerced, factor_meta)
|
||
rspec, period_text, period_known = coerced
|
||
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 _describe_selection_only(st, factor_meta) -> StrategyDoc:
|
||
"""选股策略的说明:只讲「怎么选」(股票池 + 因子 + 条件),不涉及回测执行参数。
|
||
|
||
资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间都不属于选股策略,
|
||
在回测组合里才确定 —— 这里如实声明,避免读者以为策略自带这些口径。
|
||
"""
|
||
warnings: list[str] = []
|
||
factors = _describe_factors_from_specs(st.factors, factor_meta, warnings)
|
||
pool = _universe_text(st.universe)
|
||
cond_lines = _condition_lines(st.conditions, warnings) if st.conditions else []
|
||
|
||
factor_names = "、".join(f["name"] for f in factors) or "(无)"
|
||
pool_desc = _short_pool(st.universe)
|
||
summary = (
|
||
f"选股策略「{st.name}」:在{pool_desc}内"
|
||
+ (f"先通过 {len(st.conditions)} 条过滤条件,再" if st.conditions else "")
|
||
+ f"按因子({factor_names})打分排序,供回测组合取 TopN 持仓。"
|
||
)
|
||
formula_lines = [
|
||
"【选股口径】(仅定义「怎么选」,不含回测执行参数)",
|
||
f" 股票池:{pool}",
|
||
" 打分:score = Σ 权重 × 因子值(截面 z-score 标准化后加权,越大越优先)",
|
||
]
|
||
for f in factors:
|
||
formula_lines.append(f" · {f['line']}")
|
||
if cond_lines:
|
||
formula_lines.append(" 过滤条件(AND,先于打分执行):")
|
||
formula_lines.extend(f" · {ln}" for ln in cond_lines)
|
||
formula_lines.append(
|
||
" ⚠ 持仓数量 / 持仓天数区间 / 调仓时机 / 起始资金 / 费率 / 复权口径 / 回测区间"
|
||
"均不在本策略内 —— 它们在「回测组合」中指定,运行时与公共配置合并。"
|
||
)
|
||
steps = [
|
||
"1. 按股票池口径筛出候选 universe(市场 / 剔 ST / 上市天数 / 指数成分)。",
|
||
"2." + (" 逐条求值过滤条件(AND),剔除不满足者。" if st.conditions else " (未设过滤条件,候选 = universe。)"),
|
||
"3. 对剩余股票按上述因子打分并降序排列 → 得到候选排名(TopN 在回测组合里截取)。",
|
||
]
|
||
warnings.append(
|
||
"本说明只覆盖选股口径;回测的资金/持仓/调仓/成本/区间由「回测组合」+「公共配置」决定,"
|
||
"此处无法给出收益公式与成交口径。"
|
||
)
|
||
return StrategyDoc(summary=summary, formula="\n".join(formula_lines), steps=steps, warnings=warnings)
|
||
|
||
|
||
def _short_pool(universe) -> str:
|
||
parts = []
|
||
if universe.index_code:
|
||
parts.append(f"{universe.index_code} 成分股")
|
||
elif universe.symbols:
|
||
parts.append(f"指定的 {len(universe.symbols)} 只白名单股票")
|
||
else:
|
||
parts.append("全市场 A 股")
|
||
if universe.exclude_st:
|
||
parts.append("剔除 ST ")
|
||
return "、".join(parts)
|
||
|
||
|
||
def _describe_factors_from_specs(factor_specs, factor_meta, warnings) -> list[dict]:
|
||
"""从 FactorSpec 列表生成 [{label, line}](复用既有因子元数据查找逻辑)。"""
|
||
out: list[dict] = []
|
||
for fs in factor_specs:
|
||
meta = _lookup_factor(fs.name, factor_meta)
|
||
label = fs.name
|
||
direction = "越高越好"
|
||
formula_hint = ""
|
||
if meta is None:
|
||
warnings.append(
|
||
f"因子 {fs.name} 未在注册表中找到元数据:含义/公式/方向未知,"
|
||
"执行期会报错;说明里只能给出名字与权重。"
|
||
)
|
||
else:
|
||
label = meta.brief or meta.description or fs.name
|
||
direction = "越低越好" if meta.direction == "lower_is_better" else "越高越好"
|
||
formula_hint = f"({meta.formula})" if meta.formula else ""
|
||
out.append({
|
||
"name": fs.name,
|
||
"label": label if label != fs.name else fs.name,
|
||
"line": f"{fs.name}{formula_hint},权重 {fs.weight},{direction}",
|
||
})
|
||
return out
|
||
|
||
|
||
def _condition_lines(conditions, warnings) -> list[str]:
|
||
"""把 ConditionSpec 列表渲染成可读行(复用既有字段域校验逻辑)。"""
|
||
lines: list[str] = []
|
||
for c in conditions:
|
||
op = _OP_TEXT.get(c.op, c.op)
|
||
right = f"字段 {c.ref}" if c.ref else f"{c.value}"
|
||
lines.append(f"{c.field} {op} {right}")
|
||
return lines
|
||
|
||
|
||
# ---------- 入参归一化 ----------
|
||
|
||
|
||
def _coerce_spec(spec):
|
||
"""归一化输入。
|
||
|
||
- ResearchSpec(回测页参数,含 period/costs/selection)→ (ResearchSpec, 区间文本, True),
|
||
走完整回测说明书路径。
|
||
- SelectionStrategy(策略库资产,**只有选股条件**,无 period/costs/selection)→
|
||
直接返回该对象,由 `describe_selection_strategy` 生成「选股口径」说明。
|
||
重构后策略库不再持有回测执行参数,故不能也不应假装展开成 ResearchSpec。
|
||
"""
|
||
if isinstance(spec, ResearchSpec):
|
||
start, end = spec.period
|
||
return (spec, f"{start.isoformat()} ~ {end.isoformat()}", True)
|
||
if isinstance(spec, (SelectionStrategy, StrategyDefinition)):
|
||
return spec # 选股策略:单独处理
|
||
raise TypeError(
|
||
"describe_strategy 只接受 ResearchSpec 或 SelectionStrategy,"
|
||
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
|