feat(quant): 单策略引擎也给出买卖理由与因子曲线(口径与组合引擎一致)
「因子组合」/`POST /api/research/backtests` 走的是 `LocalEngine/TopKBacktestRunner`, 上一版只把理由接进了组合引擎,同一件事在两个引擎上就会有两种说法。这次补齐: - `engine.py` / `qlib_adapter/engine.py`:因子面板**只算一次** (`build_factor_panels_full`)→ 复合分与「理由里引用的因子原始值」同源同张面板; 复合分口径逐字未变(与 `selection.score_panel_for_factors` 相同)。 - `local_engine.py`:调仓日保留完整排名与合格集,各站点写入结构化理由 —— 买入(按名次建仓 / 顺延成交 / 涨停 / 停牌 / 现金不足 / 不足最低佣金)、 卖出(全量换仓 / 跌出 TopN / 不在候选池 / 停牌顺延 / 跌停顺延); `Trade.entry_reason/exit_reason` 两端齐全;每个交易日记录持仓市值, 结果填 `factor_curves`(持仓市值加权平均的因子原始值,空仓日不落点)。 - 新增 `SELL_REBALANCE_FULL`(「调仓换仓卖出」):单策略调仓是「先全清再建仓」, 被卖出的股票**可能仍排在 TopN 内**(如 rank=1),这时写「跌出 TopN」就是假解释; 按事实分 code(仍在 TopN 内 → 全量换仓;否则 → 跌出 TopN / 不在候选池)。 - 顺延成交不拿挂单日的旧名次冒充当日名次(rank/total/score=None,因子值/成交价/预算 取成交当日真实值);「候选池不足」的提示记录保持 reason=None(词表里没有对应语义, 硬套就是编理由)。 验证: - 新增 `tests/test_local_engine_reasons.py` 14 条:理由数字对回面板、涨停比值对回行情与 板块规则、停牌/跌停/现金不足/最低佣金、顺延成交、全量换仓 vs 不在池两个分支、 Trade 两端理由、因子曲线市值加权(手算加权值断言 + 等权平均对不上)、空仓不落点。 后端 524 条全过(510 + 14),ruff clean。 - 强回归:用改前引擎并排跑 9 个场景,`signal_history`(日期/方向/成交/原因文案/价格)、 `trades`、`positions`、`summary`、净值/回撤、`unimplemented` 逐条一致 —— 理由与曲线 是纯新增字段,成交行为零变化。
This commit is contained in:
@@ -10,9 +10,10 @@ from typing import Protocol
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
|
||||
from app.quant.composite import build_factor_panels_full, composite_score
|
||||
from app.quant.factors import FactorError, get_factor
|
||||
from app.quant.local_engine import TopKBacktestRunner, run_spec_factor_test
|
||||
from app.quant.selection import condition_needed_columns, score_panel_for_factors
|
||||
from app.quant.selection import condition_needed_columns
|
||||
|
||||
# LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
|
||||
_CLOSE = {"close"}
|
||||
@@ -73,7 +74,16 @@ class LocalEngine:
|
||||
def run_backtest(
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
|
||||
) -> BacktestResult:
|
||||
# 评分面板与选股共用同一构建(v2 §25:回测与当前选股同引擎)
|
||||
score = score_panel_for_factors(daily, spec.factors)
|
||||
# 因子面板**只算一次**:复合分(选股)与原始值(买卖理由 / 因子曲线)同源。
|
||||
# 若先 score_panel_for_factors 再单独算一遍原始面板,同一份行情会被算两遍,
|
||||
# 且两次结果理论上可能分叉 —— 打分用的面板与理由里引用的面板必须是同一张。
|
||||
# 复合分构建口径不变(与 selection.score_panel_for_factors 同为 z-score 加权和,
|
||||
# v2 §25:回测与当前选股同引擎)。
|
||||
panels = build_factor_panels_full(daily, spec.factors) # 未知因子在此抛 FactorError
|
||||
score = composite_score([(d.name, p, w, d.direction) for d, p, w in panels])
|
||||
# 同名因子只留一份(spec 已禁止重复因子名,这里再兜一层)
|
||||
factor_panels = {d.name: (d, p) for d, p, _w in panels}
|
||||
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
|
||||
return TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
|
||||
return TopKBacktestRunner(
|
||||
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
|
||||
).run()
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.domain.entities.research import (
|
||||
ResearchSpec,
|
||||
SymbolCurve,
|
||||
Trade,
|
||||
TradeReason,
|
||||
YearlyReturn,
|
||||
)
|
||||
from app.quant.composite import ( # noqa: F401 —— re-export(模块化后旧引用仍可用)
|
||||
@@ -42,11 +43,28 @@ from app.quant.composite import ( # noqa: F401 —— re-export(模块化后
|
||||
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
|
||||
|
||||
@@ -209,6 +227,7 @@ class TopKBacktestRunner:
|
||||
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()
|
||||
@@ -221,12 +240,24 @@ class TopKBacktestRunner:
|
||||
# 条件过滤(可选):(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()
|
||||
@@ -265,6 +296,9 @@ class TopKBacktestRunner:
|
||||
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] = []
|
||||
@@ -273,6 +307,8 @@ class TopKBacktestRunner:
|
||||
# 个股收益曲线: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
|
||||
@@ -296,15 +332,19 @@ class TopKBacktestRunner:
|
||||
# 上一次调仓挂起的顺延单作废(只在两次调仓之间有效)
|
||||
pending = []
|
||||
cash = self._rebalance(
|
||||
d, cash, shares, entry_date, entry_price, trades, positions, notional,
|
||||
pending,
|
||||
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, pending, notional)
|
||||
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)
|
||||
@@ -319,38 +359,131 @@ class TopKBacktestRunner:
|
||||
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()
|
||||
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 = ranked[: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 ranked, pool
|
||||
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, trades, positions, notional, pending
|
||||
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="无行情(停牌),保留持仓")
|
||||
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="跌停无法卖出,保留到下一调仓")
|
||||
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]
|
||||
@@ -358,8 +491,23 @@ class TopKBacktestRunner:
|
||||
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))
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c),
|
||||
reason=sell_reason)
|
||||
)
|
||||
trades.append(
|
||||
Trade(
|
||||
@@ -369,11 +517,15 @@ class TopKBacktestRunner:
|
||||
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)
|
||||
@@ -393,18 +545,23 @@ class TopKBacktestRunner:
|
||||
)
|
||||
)
|
||||
|
||||
def _buyable(sym) -> tuple[bool, str | None]:
|
||||
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, "无行情(停牌),无法买入"
|
||||
return False, "无行情(停牌),无法买入", BUY_SKIP_HALTED
|
||||
if _nan(p) or p <= 0:
|
||||
# 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。
|
||||
# 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的),
|
||||
# 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。
|
||||
return True, None
|
||||
return True, None, None
|
||||
if c / p >= _limit_up_ratio(sym):
|
||||
return False, "涨停,无法追买"
|
||||
return True, None
|
||||
return False, "涨停,无法追买", BUY_SKIP_LIMIT_UP
|
||||
return True, None, None
|
||||
|
||||
# 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找
|
||||
targets: list[str] = []
|
||||
@@ -412,7 +569,7 @@ class TopKBacktestRunner:
|
||||
for sym in self.current_ranked:
|
||||
if len(targets) >= self.spec.selection.x:
|
||||
break
|
||||
ok, _ = _buyable(sym)
|
||||
ok, _, _code = _buyable(sym)
|
||||
if ok:
|
||||
targets.append(sym)
|
||||
else:
|
||||
@@ -437,17 +594,24 @@ class TopKBacktestRunner:
|
||||
|
||||
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 = _buyable(s)
|
||||
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))
|
||||
@@ -455,6 +619,7 @@ class TopKBacktestRunner:
|
||||
ActionRecord(
|
||||
date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason=f"{reason},顺延到之后首个可成交日买入",
|
||||
reason=skip_reason,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -462,16 +627,26 @@ class TopKBacktestRunner:
|
||||
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, notional
|
||||
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
|
||||
@@ -484,10 +659,17 @@ class TopKBacktestRunner:
|
||||
for sym in picks:
|
||||
if sym in set(targets):
|
||||
continue
|
||||
_ok, reason = _buyable(sym)
|
||||
_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 "资金不足(未成交)")
|
||||
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) 记录调仓后仓位
|
||||
@@ -510,7 +692,8 @@ class TopKBacktestRunner:
|
||||
return cash
|
||||
|
||||
def _execute_buy(
|
||||
self, s, budget, d, close_value, shares, entry_date, entry_price, notional
|
||||
self, s, budget, d, close_value, shares, entry_date, entry_price, entry_reason,
|
||||
notional, reason=None,
|
||||
) -> bool:
|
||||
"""按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。
|
||||
|
||||
@@ -527,13 +710,15 @@ class TopKBacktestRunner:
|
||||
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))
|
||||
price=round(price_in, 4), reason=reason)
|
||||
)
|
||||
if s not in self._traded:
|
||||
self._traded.add(s)
|
||||
@@ -542,7 +727,9 @@ class TopKBacktestRunner:
|
||||
|
||||
# ---- 顺延买入(defer_buy):之后逐日重试 ----
|
||||
|
||||
def _fill_pending(self, d, cash, shares, entry_date, entry_price, pending, notional):
|
||||
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] = []
|
||||
@@ -560,8 +747,18 @@ class TopKBacktestRunner:
|
||||
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, notional
|
||||
order.symbol, budget, d, c, shares, entry_date, entry_price, entry_reason,
|
||||
notional, reason=fill_reason,
|
||||
):
|
||||
remaining.append(order) # 预算不足:保留挂单(下日现金可能已变化)
|
||||
continue
|
||||
@@ -686,6 +883,8 @@ class TopKBacktestRunner:
|
||||
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"),
|
||||
|
||||
@@ -21,10 +21,10 @@ from pathlib import Path
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
|
||||
from app.quant.composite import build_factor_panels_full
|
||||
from app.quant.engine import QuantEngine
|
||||
from app.quant.local_engine import (
|
||||
TopKBacktestRunner,
|
||||
build_factor_panels,
|
||||
composite_score,
|
||||
run_spec_factor_test,
|
||||
)
|
||||
@@ -69,8 +69,10 @@ class QlibEngine(QuantEngine):
|
||||
`eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与
|
||||
`exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。
|
||||
"""
|
||||
panels = build_factor_panels(daily, spec.factors)
|
||||
score = composite_score(panels)
|
||||
# 与 LocalEngine 同口径:复合分与买卖理由/因子曲线用**同一张**原始因子面板
|
||||
full = build_factor_panels_full(daily, spec.factors)
|
||||
score = composite_score([(d.name, p, w, d.direction) for d, p, w in full])
|
||||
factor_panels = {d.name: (d, p) for d, p, _w in full}
|
||||
|
||||
self.qlib_dir.mkdir(parents=True, exist_ok=True)
|
||||
uri = build_qlib_dataset(daily, self.qlib_dir)
|
||||
@@ -85,7 +87,9 @@ class QlibEngine(QuantEngine):
|
||||
)
|
||||
close = close.sort_index()
|
||||
|
||||
result = TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
|
||||
result = TopKBacktestRunner(
|
||||
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
|
||||
).run()
|
||||
result.config_snapshot = spec.model_dump(mode="json")
|
||||
note = _ENGINE_NOTE
|
||||
result.unimplemented = [note, *result.unimplemented]
|
||||
|
||||
@@ -37,6 +37,11 @@ BUY_SKIP_MIN_COMMISSION = "buy_skip_min_commission"
|
||||
# 卖出(成交)
|
||||
SELL_DROP_TOPN = "sell_drop_topn"
|
||||
SELL_FORCE_TMAX = "sell_force_tmax"
|
||||
# 卖出(成交):策略在调仓日**全量换仓**(先清仓再建仓),该股当时仍在 TopN 内。
|
||||
# 为什么单列一个 code:单策略回测(TopK runner)的调仓语义就是「全清再买」,
|
||||
# 被卖出的股票很可能仍然排在前列 —— 这时说「跌出 TopN」与 data 里的 rank=1 自相矛盾,
|
||||
# 等于给用户一个假的解释。分开写才是如实描述。
|
||||
SELL_REBALANCE_FULL = "sell_rebalance_full"
|
||||
# 卖出(顺延 / 未成交)
|
||||
SELL_DEFER_TMIN = "sell_defer_tmin"
|
||||
SELL_DEFER_HALTED = "sell_defer_halted"
|
||||
@@ -52,6 +57,7 @@ REASON_CODES = frozenset(
|
||||
BUY_SKIP_MIN_COMMISSION,
|
||||
SELL_DROP_TOPN,
|
||||
SELL_FORCE_TMAX,
|
||||
SELL_REBALANCE_FULL,
|
||||
SELL_DEFER_TMIN,
|
||||
SELL_DEFER_HALTED,
|
||||
SELL_DEFER_LIMIT_DOWN,
|
||||
@@ -68,6 +74,7 @@ REASON_LABELS: dict[str, str] = {
|
||||
BUY_SKIP_MIN_COMMISSION: "不足最低佣金",
|
||||
SELL_DROP_TOPN: "跌出 TopN",
|
||||
SELL_FORCE_TMAX: "持有超 Tmax",
|
||||
SELL_REBALANCE_FULL: "调仓换仓卖出",
|
||||
SELL_DEFER_TMIN: "Tmin 保护暂留",
|
||||
SELL_DEFER_HALTED: "停牌未卖",
|
||||
SELL_DEFER_LIMIT_DOWN: "跌停未卖",
|
||||
@@ -242,13 +249,24 @@ def sell_filled(
|
||||
return_pct: float | None = None,
|
||||
not_in_pool: bool = False,
|
||||
) -> TradeReason:
|
||||
"""卖出成交的理由(跌出 TopN / 持有超 Tmax),带持有交易日与当时名次。
|
||||
"""卖出成交的理由(跌出 TopN / 持有超 Tmax / 全量换仓),带持有交易日与当时名次。
|
||||
|
||||
`not_in_pool=True` 表示该股已**不在候选池**(被股票池/条件过滤,如转为 ST),
|
||||
与「在池内但排名掉出去」是两回事,文案与 data 都分开写。
|
||||
|
||||
`SELL_REBALANCE_FULL` 用于「策略每次调仓都先全清再建仓」的引擎:该股当时仍在前列,
|
||||
卖它不是因为掉出 TopN,而是策略本身的调仓方式 —— 不能套用跌出 TopN 的说法。
|
||||
"""
|
||||
if code == SELL_FORCE_TMAX:
|
||||
text = f"持有 {hold_days} 个交易日 > Tmax={tmax},强制了结(与排名无关)"
|
||||
elif code == SELL_REBALANCE_FULL:
|
||||
text = (
|
||||
f"调仓日全量换仓:该策略每次调仓先清仓再按新名单建仓"
|
||||
f"(该股当时仍在 TopN 内:{_rank_text(rank, total, top_n, score)});"
|
||||
f"持有 {hold_days} 个交易日"
|
||||
)
|
||||
if tmin is not None:
|
||||
text += f" ≥ Tmin={tmin}"
|
||||
else:
|
||||
code = SELL_DROP_TOPN
|
||||
if not_in_pool:
|
||||
|
||||
Reference in New Issue
Block a user