Files
qlib/backend/app/quant/local_engine.py
T
Simon 40bd603b44 feat(backend): 策略库重构为「选股策略 + 公共配置 + 回测组合」三件套
按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 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)。
2026-09-30 21:43:28 +08:00

788 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LocalEngine —— 默认研究引擎(纯 pandas,AGENT.md §40 简单可替换优先)。
无未来函数纪律:
- 择股日 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
import math
from dataclasses import dataclass
from datetime import date
import pandas as pd
from app.domain.entities.research import (
ActionRecord,
BacktestResult,
BacktestSummary,
CurvePoint,
FactorTestReport,
MonthlyReturn,
Position,
RankedPick,
ResearchSpec,
SymbolCurve,
Trade,
YearlyReturn,
)
from app.quant.composite import ( # noqa: F401 —— re-export(模块化后旧引用仍可用)
build_factor_panels,
composite_score,
cross_sectional_zscore,
)
from app.quant.evaluation import run_factor_test
from app.quant.portfolio import (
allocate_with_max_position,
equal_weight_budget,
unimplemented_notes,
)
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」的股息陷阱样本"
),
]
def _limit_up_ratio(symbol: str) -> float:
"""按板块近似涨跌停幅度。"""
code = symbol[:3]
if code in {"300", "301", "688"}:
return 1.199
if code.startswith(("8", "4", "92")):
return 1.299
return 1.099
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 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 / **daily** 频率。
- daily:区间内**每个交易日**都是调仓日(回测组合的「日频调仓」)。
- weekly / monthly:原行为,保持向后兼容。
"""
if every_months and every_months > 0:
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
# 锚点 = 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
]
elif rebalance == "daily":
# 日频:区间内每个交易日
days = pd.DatetimeIndex(index)
out = [ts for ts in days if ts.date() >= start]
else:
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
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
class EngineResult:
equity: pd.Series # index=date -> equity
trades: list[Trade]
positions: list[Position]
rebalance_notional: list[float]
class TopKBacktestRunner:
"""TopK 等权、固定调仓频率的低频回测(支持择股/调仓双周期与顺延买入)。"""
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)
self.close = close.sort_index()
self.score = score.reindex(self.close.index).sort_index()
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]
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] = {}
entry_price: dict[str, float] = {}
equity_rows: dict[pd.Timestamp, float] = {}
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
for s, qty in shares.items():
if qty <= 0:
continue
px = self.close.at[d, s] if d in self.close.index else None
if px is None or (isinstance(px, float) and math.isnan(px)):
continue # 无行情日不计该仓(停牌近似,见 unimplemented)
total += float(qty * px)
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,
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, 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, pending
):
close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d]
day = d.date()
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
for s in [s for s in shares if shares[s] > 0]:
c, p = close_d[s], prev_d[s]
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="无行情(停牌),保留持仓")
)
continue # 停牌无价:保留
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="跌停无法卖出,保留到下一调仓")
)
continue # 跌停无法卖出:保留到下一调仓
qty = shares[s]
proceeds = qty * float(c) * (1 - self.costs.slippage_rate)
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
fee = commission + proceeds * self.costs.stamp_tax_rate
cash += proceeds - fee
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c))
)
trades.append(
Trade(
entry_date=entry_date[s],
exit_date=day,
symbol=s,
entry_price=entry_price[s],
exit_price=float(c),
return_pct=(float(c) / entry_price[s] - 1.0) * 100,
)
)
shares[s] = 0.0
entry_date.pop(s, None)
entry_price.pop(s, None)
# 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):
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] = []
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]
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}
else:
# Portfolio v1.1:按单股上限(相对当日组合市值)分配,超出部分留现金
equity_now = cash + sum(
float(self.close.at[d, s] * qty)
for s, qty in shares.items()
if qty > 0 and not _nan(self.close.at[d, s])
)
spends = allocate_with_max_position(cash, targets, equity_now, cap)
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
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=sym, signal="BUY", filled=False,
reject_reason=reason or "资金不足(未成交)")
)
# 3) 记录调仓后仓位
total = cash + sum(
float(self.close.at[d, s] * qty)
for s, qty in shares.items()
if qty > 0 and not _nan(self.close.at[d, s])
)
if total > 0:
for s, qty in shares.items():
if qty > 0 and not _nan(self.close.at[d, s]):
positions.append(
Position(
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, 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])
rets = equity.pct_change().dropna()
n = len(rets)
total_ret = (final / init - 1.0) * 100 if init else 0.0
annual = (
((final / init) ** (TRADING_DAYS / max(n, 1)) - 1.0) * 100
if final > 0 and init > 0
else -100.0
)
mean_r, std_r = (float(rets.mean()), float(rets.std(ddof=1))) if n else (0.0, 0.0)
sharpe = mean_r / std_r * math.sqrt(TRADING_DAYS) if std_r and mean_r else 0.0
vol = std_r * math.sqrt(TRADING_DAYS) * 100
dd = (equity / equity.cummax() - 1.0).min() * 100
wins = [t for t in trades if t.return_pct > 0]
win_rate = len(wins) / len(trades) * 100 if trades else 0.0
avg_turn = (sum(notional) / len(notional) / ((init + final) / 2)) * 100 if notional else 0.0
eq_pts = [CurvePoint(date=d.date(), value=round(float(v), 2)) for d, v in equity.items()]
dd_series = (equity / equity.cummax() - 1.0) * 100
drawdown = [
CurvePoint(date=d.date(), value=round(float(v), 3)) for d, v in dd_series.items()
]
monthly: list[MonthlyReturn] = []
yearly: list[YearlyReturn] = []
if len(equity) > 1:
m = equity.resample("ME").last().pct_change().dropna()
monthly = [
MonthlyReturn(
year=int(d.year), month=int(d.month), return_pct=round(float(v) * 100, 3)
)
for d, v in m.items()
]
y = equity.resample("YE").last().pct_change().dropna()
yearly = [
YearlyReturn(year=int(d.year), return_pct=round(float(v) * 100, 3))
for d, v in y.items()
]
summary = BacktestSummary(
start=start,
end=end,
initial_capital=round(init, 2),
final_equity=round(final, 2),
total_return_pct=round(total_ret, 3),
annual_return_pct=round(annual, 3),
sharpe=round(sharpe, 3),
max_drawdown_pct=round(float(dd), 3),
volatility_pct=round(vol, 3),
win_rate_pct=round(win_rate, 2),
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,
drawdown=drawdown,
monthly_returns=monthly,
yearly_returns=yearly,
positions=positions,
trades=trades,
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=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,
spec: ResearchSpec,
horizon_days: int = 21,
) -> tuple[FactorTestReport, dict[str, pd.DataFrame]]:
"""单因子测试:因子面板 + 未来 horizon 收益 → FactorTestReport。"""
assert spec.type == "factor_test"
factor_name = spec.factors[0].name
panels = build_factor_panels(daily, spec.factors)
panel = panels[0][1]
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
close.index = pd.to_datetime(close.index)
forward = close.shift(-horizon_days) / close - 1.0
report = run_factor_test(panel, forward, factor_name=factor_name)
return report, {factor_name: panel}
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 True