`unimplemented` 与「买卖说明」里都没写清一件事:理由覆盖的是 **signal_history 里的 每个买卖点(含涨停/停牌/跌停/现金不足等未成交情形)**,而「当日排名在 TopN 之外、 策略本来就无意买入」的候选根本不算买卖点 —— 用户看不到某只票的买入理由时, 应该能立刻分清「是漏了记录」还是「策略本来就没打算买」。 - 组合引擎与单策略引擎的 `_unimplemented` 各加一条说明,指向 `selection_history` 可查完整候选与名次(AGENT.md §24:没实现/有边界的要显式写出)。 - 「买卖说明」卡片底部同步写出这条边界。
994 lines
46 KiB
Python
994 lines
46 KiB
Python
"""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,
|
||
TradeReason,
|
||
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.factors import FactorDef
|
||
from app.quant.portfolio import (
|
||
allocate_with_max_position,
|
||
equal_weight_budget,
|
||
unimplemented_notes,
|
||
)
|
||
from app.quant.trade_reasons import (
|
||
BUY_SKIP_HALTED,
|
||
BUY_SKIP_LIMIT_UP,
|
||
BUY_SKIP_MIN_COMMISSION,
|
||
BUY_SKIP_NO_CASH,
|
||
SELL_DEFER_HALTED,
|
||
SELL_DEFER_LIMIT_DOWN,
|
||
SELL_DROP_TOPN,
|
||
SELL_REBALANCE_FULL,
|
||
build_factor_curves,
|
||
buy_filled,
|
||
buy_skipped,
|
||
factor_values,
|
||
sell_deferred,
|
||
sell_filled,
|
||
)
|
||
|
||
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,
|
||
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = 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
|
||
# 策略因子的**原始**面板(由 LocalEngine 用 build_factor_panels_full 一次算完后注入):
|
||
# 买卖理由里的「各因子当时的值」与 factor_curves 都从这里取,
|
||
# 与复合分用的是同一份数据 —— 理由不会去重算一遍因子而得到另一个数
|
||
self.factor_panels = factor_panels or {}
|
||
# 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._ranked_by_day: dict[pd.Timestamp, pd.Series] = {}
|
||
self._elig_by_day: dict[pd.Timestamp, set[str] | None] = {}
|
||
# 每个交易日的持仓市值(因子曲线按此加权;空仓日空 dict → 不落点)
|
||
self._weights_by_day: dict[pd.Timestamp, dict[str, float]] = {}
|
||
# 交易日位置索引:持有交易日按「交易日」计(跨周末不会虚增天数)
|
||
self._tday_pos: dict[pd.Timestamp, int] = {}
|
||
# 本次回测期内被持有过的股票(用于个股收益曲线)
|
||
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] = {}
|
||
# 建仓理由存进持仓结构:持有期间没有别的机会带上它,卖出成交时原样写进
|
||
# Trade.entry_reason,成交明细里「为什么买、为什么卖」才都齐
|
||
entry_reason: dict[str, TradeReason] = {}
|
||
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]] = {}
|
||
# 持有交易日按交易日序号相减(自然日会跨周末失真)
|
||
self._tday_pos = {ts: i for i, ts in enumerate(self.close.index)}
|
||
|
||
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, entry_reason, trades, positions,
|
||
notional, pending,
|
||
)
|
||
elif pending:
|
||
cash = self._fill_pending(
|
||
d, cash, shares, entry_date, entry_price, entry_reason, pending, notional
|
||
)
|
||
equity_rows[d] = _value(d)
|
||
# 3) 建仓当日补「基准点」:成交在当日收盘、收益自次日起计;该点使 BUY 标注
|
||
# 能精确落在曲线上,也让多段持仓的分段起点可见(见 _mark_curve_dates)
|
||
self._mark_curve_dates(d, shares, cum, curve_rows)
|
||
# 4) 记录当日持仓市值(因子曲线按此加权;空仓日记录空 dict → 曲线不落点)
|
||
self._weights_by_day[d] = self._holding_weights(d, shares)
|
||
|
||
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)
|
||
# 完整排名留下来:卖出理由要说「第几名」;只有 TopN 说不出这个数
|
||
self._ranked_by_day[d] = ranked
|
||
self._elig_by_day[d] = set(eligible) if eligible is not None else None
|
||
order = ranked.index.tolist()
|
||
n = self.spec.selection.top_n
|
||
pool = order[: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 order, pool
|
||
|
||
# ---- 买卖理由的上下文(与组合引擎同口径) ----
|
||
|
||
def _rank_of(self, d: pd.Timestamp, symbol: str) -> dict:
|
||
"""该股在 `d` 日的排名上下文:rank / total / score / in_pool。
|
||
|
||
三者必须分开:**在池但排名靠后**、**已被股票池/条件过滤**(如转为 ST)、
|
||
**当日没有分数**(非择股日 / 数据缺失)—— 都写成「跌出 TopN」会掩盖真相。
|
||
非择股日没有当日排名,返回 None 而不是拿上一次择股的名次冒充。
|
||
"""
|
||
out: dict = {"rank": None, "total": None, "score": None, "in_pool": None}
|
||
ranked = self._ranked_by_day.get(d)
|
||
if ranked is None:
|
||
return out # 非择股日(如顺延成交发生在两次调仓之间):没有当日排名
|
||
elig = self._elig_by_day.get(d)
|
||
out["total"] = int(len(ranked))
|
||
out["in_pool"] = True if elig is None else (symbol in elig)
|
||
if symbol in ranked.index:
|
||
loc = ranked.index.get_loc(symbol)
|
||
if isinstance(loc, int):
|
||
out["rank"] = loc + 1
|
||
out["score"] = round(float(ranked.loc[symbol]), 6)
|
||
return out
|
||
|
||
def _reason_ctx(self, d: pd.Timestamp, symbol: str, *, top_n: int | None) -> dict:
|
||
"""理由构造器的公共参数:当日排名 + 各因子当时的**原始值**。
|
||
|
||
`factors` 取不到值就传 None(而不是空 dict):构造器据此不写这个字段,
|
||
空 dict 与「真的没有因子值」在 data 里应当可区分。
|
||
"""
|
||
ctx = self._rank_of(d, symbol)
|
||
return {
|
||
"rank": ctx["rank"],
|
||
"total": ctx["total"],
|
||
"top_n": top_n,
|
||
"score": ctx["score"],
|
||
"factors": factor_values(self.factor_panels, d, symbol) or None,
|
||
"not_in_pool": ctx["in_pool"] is False,
|
||
}
|
||
|
||
def _buy_skip_reason(
|
||
self, code: str, *, symbol: str, ctx: dict, close=None, prev_close=None, budget=None
|
||
) -> TradeReason:
|
||
"""买入未成交理由:只有涨停需要用「收盘 / 前收 vs 阈值」的真实比值解释。
|
||
|
||
文案与 data 一律由 trade_reasons 的构造器决定(两套引擎不许各写一份措辞)。
|
||
"""
|
||
if code == BUY_SKIP_LIMIT_UP:
|
||
return buy_skipped(
|
||
code, close=float(close), prev_close=float(prev_close),
|
||
limit_ratio=_limit_up_ratio(symbol), **ctx,
|
||
)
|
||
if code == BUY_SKIP_NO_CASH:
|
||
return buy_skipped(code, budget=budget, **ctx)
|
||
if code == BUY_SKIP_MIN_COMMISSION:
|
||
return buy_skipped(
|
||
code, budget=budget, min_commission=self.costs.min_commission, **ctx
|
||
)
|
||
return buy_skipped(code, **ctx)
|
||
|
||
def _holding_weights(self, d: pd.Timestamp, shares) -> dict[str, float]:
|
||
"""当日持仓市值(因子曲线加权用)。取不到价的持仓不参与,空仓日返回空 dict。"""
|
||
out: dict[str, float] = {}
|
||
for s, qty in shares.items():
|
||
if qty <= 0 or s not in self.close.columns:
|
||
continue
|
||
px = self.close.at[d, s] if d in self.close.index else None
|
||
if _nan(px) or px <= 0:
|
||
continue
|
||
out[s] = float(qty) * float(px)
|
||
return out
|
||
|
||
def _held_trading_days(self, entry_day: date, d: pd.Timestamp) -> int:
|
||
"""从入场到当前经过的**交易日**数(不含入场当日)。"""
|
||
e = self._tday_pos.get(pd.Timestamp(entry_day))
|
||
c = self._tday_pos.get(d)
|
||
if e is None or c is None:
|
||
return 0
|
||
return max(0, c - e)
|
||
|
||
# ---- 调仓(t 收盘执行,自 t+1 生效) ----
|
||
|
||
def _rebalance(
|
||
self, d, cash, shares, entry_date, entry_price, entry_reason, trades, positions,
|
||
notional, pending,
|
||
):
|
||
close_d = self.close.loc[d]
|
||
prev_d = self.prev_close.loc[d]
|
||
day = d.date()
|
||
n = self.spec.selection.top_n # 候选池大小:理由里的 TopN 口径
|
||
|
||
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
|
||
for s in [s for s in shares if shares[s] > 0]:
|
||
c, p = close_d[s], prev_d[s]
|
||
held = self._held_trading_days(entry_date[s], d)
|
||
ctx = self._reason_ctx(d, s, top_n=n)
|
||
if _nan(c):
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||
reject_reason="无行情(停牌),保留持仓",
|
||
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
|
||
hold_days=held, **ctx))
|
||
)
|
||
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="跌停无法卖出,保留到下一调仓",
|
||
reason=sell_deferred(
|
||
SELL_DEFER_LIMIT_DOWN, cause="limit_down", hold_days=held,
|
||
close=float(c), prev_close=float(p),
|
||
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0), **ctx))
|
||
)
|
||
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
|
||
# 本引擎的调仓是「全部卖出 → 按目标等权重新买入」(见 unimplemented):
|
||
# 若该股**当时仍排在 TopN 内**,卖它不是因为掉出榜单,而是策略本身的换仓方式,
|
||
# 用 SELL_REBALANCE_FULL 如实说明;只有确实不在池 / 名次掉出 / 当日无分数
|
||
# 才归 SELL_DROP_TOPN。数字照旧取当日真实值,code 只是把事实说准。
|
||
in_topn = (
|
||
ctx["rank"] is not None and not ctx["not_in_pool"] and ctx["rank"] <= n
|
||
)
|
||
sell_reason = sell_filled(
|
||
code=SELL_REBALANCE_FULL if in_topn else SELL_DROP_TOPN,
|
||
rank=ctx["rank"], total=ctx["total"], top_n=n,
|
||
score=ctx["score"], factors=ctx["factors"], hold_days=held, price=float(c),
|
||
return_pct=(float(c) / entry_price[s] - 1.0) * 100,
|
||
not_in_pool=ctx["not_in_pool"],
|
||
)
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c),
|
||
reason=sell_reason)
|
||
)
|
||
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,
|
||
# 买卖理由跟着成交走:明细里「为什么买、为什么卖」两端齐全
|
||
entry_reason=entry_reason.get(s),
|
||
exit_reason=sell_reason,
|
||
)
|
||
)
|
||
shares[s] = 0.0
|
||
entry_date.pop(s, None)
|
||
entry_price.pop(s, None)
|
||
entry_reason.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, str | None]:
|
||
"""(可否买入, 拒绝文案, 未成交原因 code)。
|
||
|
||
文案保持原样(既有结果里的 reject_reason 不许变),额外把原因 code 带出来,
|
||
让 ActionRecord.reason 用**词表里的 code** 表达同一件事,而不是去解析文案。
|
||
"""
|
||
c, p = close_d[sym], prev_d[sym]
|
||
if _nan(c):
|
||
return False, "无行情(停牌),无法买入", BUY_SKIP_HALTED
|
||
if _nan(p) or p <= 0:
|
||
# 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。
|
||
# 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的),
|
||
# 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。
|
||
return True, None, None
|
||
if c / p >= _limit_up_ratio(sym):
|
||
return False, "涨停,无法追买", BUY_SKIP_LIMIT_UP
|
||
return True, None, 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, _, _code = _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]
|
||
ctx = self._reason_ctx(d, s, top_n=n)
|
||
if budget <= 1e-9:
|
||
# 分配额过小(可用现金≈0 或上限约束):不成交且无额度可顺延,如实留痕
|
||
self.signal_history.append(
|
||
ActionRecord(
|
||
date=day, symbol=s, signal="BUY", filled=False,
|
||
reject_reason="分配额不足(可用现金≈0),未成交",
|
||
reason=self._buy_skip_reason(
|
||
BUY_SKIP_NO_CASH, symbol=s, ctx=ctx, budget=budget
|
||
),
|
||
)
|
||
)
|
||
continue
|
||
ok, reason, code = _buyable(s)
|
||
if not ok:
|
||
skip_reason = self._buy_skip_reason(
|
||
code, symbol=s, ctx=ctx, close=close_d[s], prev_close=prev_d[s]
|
||
)
|
||
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},顺延到之后首个可成交日买入",
|
||
reason=skip_reason,
|
||
)
|
||
)
|
||
else:
|
||
self.signal_history.append(
|
||
ActionRecord(
|
||
date=day, symbol=s, signal="BUY", filled=False,
|
||
reject_reason=reason or "不可买入",
|
||
reason=skip_reason,
|
||
)
|
||
)
|
||
continue
|
||
buy_reason = buy_filled(
|
||
rank=ctx["rank"], total=ctx["total"], top_n=n, score=ctx["score"],
|
||
factors=ctx["factors"], price=float(close_d[s]) * (1 + self.costs.slippage_rate),
|
||
budget=budget,
|
||
)
|
||
if not self._execute_buy(
|
||
s, budget, d, close_d[s], shares, entry_date, entry_price, entry_reason,
|
||
notional, reason=buy_reason,
|
||
):
|
||
self.signal_history.append(
|
||
ActionRecord(
|
||
date=day, symbol=s, signal="BUY", filled=False,
|
||
reject_reason="预算不足以覆盖最低佣金,未成交",
|
||
reason=self._buy_skip_reason(
|
||
BUY_SKIP_MIN_COMMISSION, symbol=s, ctx=ctx, budget=budget
|
||
),
|
||
)
|
||
)
|
||
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, code = _buyable(sym)
|
||
if code is None:
|
||
code = BUY_SKIP_NO_CASH # 可买却未入选目标:资金分配已给别人
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False,
|
||
reject_reason=reason or "资金不足(未成交)",
|
||
reason=self._buy_skip_reason(
|
||
code, symbol=sym, ctx=self._reason_ctx(d, sym, top_n=n),
|
||
close=close_d.get(sym), prev_close=prev_d.get(sym),
|
||
budget=cash,
|
||
))
|
||
)
|
||
|
||
# 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, entry_reason,
|
||
notional, reason=None,
|
||
) -> 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
|
||
# 建仓理由存进持仓结构:等真正卖出时写进 Trade.entry_reason(中间不会丢)
|
||
entry_reason[s] = reason
|
||
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), reason=reason)
|
||
)
|
||
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, entry_reason, 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
|
||
# 顺延成交发生在两次调仓之间的普通交易日,**当日没有择股排名**:
|
||
# rank/total/score 一律为 None(不拿上次择股的名次冒充当日名次);
|
||
# 因子原始值与成交价/预算取成交当日的真实值。挂单当日「为什么被选中」
|
||
# 已记在那条 filled=False 的 BUY 信号上(reason=buy_skipped(...))。
|
||
fill_reason = buy_filled(
|
||
rank=None, total=None, top_n=None, score=None,
|
||
factors=factor_values(self.factor_panels, d, order.symbol) or None,
|
||
price=float(c) * (1 + self.costs.slippage_rate), budget=budget, deferred=True,
|
||
)
|
||
if not self._execute_buy(
|
||
order.symbol, budget, d, c, shares, entry_date, entry_price, entry_reason,
|
||
notional, reason=fill_reason,
|
||
):
|
||
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,
|
||
# 因子曲线 = 当日持仓按市值加权的因子**原始值**(空仓日不落点,见 trade_reasons)
|
||
factor_curves=build_factor_curves(self.factor_panels, self._weights_by_day),
|
||
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)
|
||
# 如实说明买卖理由的覆盖边界:每个买卖点(含涨停/停牌/现金不足等未成交)都有理由,
|
||
# 但「排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点(看选股明细即可)。
|
||
notes.append(
|
||
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
|
||
"「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
|
||
"要看完整候选与名次请查选股明细(selection_history)"
|
||
)
|
||
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 |