mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
子策略当天只选出一只票时无法排名, 选股合并却按 max(count-1,1) 把它当成 "最优=1", 凭空抬高该票的融合分; 回测合并 (merge_signal_matrices 的 n <= 1 分支) 用的是中性分 0.5。同一天同一标的在选股页和回测里评分与排序 不一致 —— 正是本模块声明要防的口径分裂 (_NEUTRAL_NORM 注释也写明单候选 应取中性分)。
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""叠加策略合并器 — 选股与回测共用的纯函数。
|
|
|
|
为什么单独成模块: 选股(StrategyEngine._run_composite_strategy)和回测
|
|
(StrategyBacktestService)都要合并子策略结果, 必须共享同一套口径, 否则会出现
|
|
"选股与回测使用不同逻辑"的金融错误(CONTRIBUTING §5.1)。
|
|
|
|
两种合并入口:
|
|
- merge_results: 选股合并。输入各子 StrategyResult, 输出合并后的 StrategyResult。
|
|
- merge_signal_matrices: 回测合并。输入各子 SignalMatrix, 输出合并后的 SignalMatrix。
|
|
|
|
合并语义(首版):
|
|
- entry: union=OR(entries); intersect=Σ(entries) >= min_confirm
|
|
- score: 各子内部按 score 降序排名归一到 [0,1], 命中子策略间按权重加权。
|
|
排名是相对位置, 跨子策略天然可比, 不依赖各子 per-strategy 的 min-max 量纲。
|
|
- exit(回测): 来源投影。每个子策略 i 的 exit 仅在它自己 entry 后的持仓窗口内生效,
|
|
避免"B 的退出信号平掉 A 的仓位"。窗口由全局 max_hold 封顶。
|
|
|
|
退出投影的金融正确性: 现有撮合引擎(engine.py:993)读全局 matrix.exit, 不区分来源。
|
|
若直接 OR(exit) 会产生"幽灵平仓"(B 把 A 选的仓位卖掉)。来源投影在合并器(撮合前)
|
|
解决, 撮合层零改动。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import TYPE_CHECKING
|
|
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from app.strategy.engine import StrategyResult
|
|
|
|
# 中性分: 子策略无 score 或单候选(无法排名)时的占位, 不污染融合结果。
|
|
_NEUTRAL_NORM = 0.5
|
|
|
|
|
|
def _effective_weights(
|
|
children_weights: list[float],
|
|
hits_mask: list[bool],
|
|
) -> tuple[list[float], float]:
|
|
"""从权重列表中筛出命中子策略的权重, 返回(命中权重列表, 命中权重总和)。"""
|
|
effective = [w for w, hit in zip(children_weights, hits_mask, strict=True) if hit]
|
|
total = sum(effective)
|
|
return effective, total
|
|
|
|
|
|
def merge_results(
|
|
results: list[StrategyResult],
|
|
children_weights: list[float],
|
|
merge_mode: str,
|
|
min_confirm: int,
|
|
*,
|
|
as_of: date,
|
|
strategy_id: str,
|
|
elapsed_ms: float = 0.0,
|
|
) -> StrategyResult:
|
|
"""选股合并: 按 symbol 聚合各子结果, 标准化排名加权融合 score。
|
|
|
|
Args:
|
|
results: 各子策略的 StrategyResult(顺序与 children_weights 对齐)
|
|
children_weights: 各子权重(顺序对齐)
|
|
merge_mode: "union"(任一命中即入选) | "intersect"(至少 min_confirm 个命中)
|
|
min_confirm: intersect 模式下命中的最少子策略数; <=0 视为全部子策略
|
|
as_of / strategy_id: 合并结果归属(composite 自身)
|
|
"""
|
|
from app.strategy.engine import StrategyResult
|
|
|
|
n_children = len(results)
|
|
if n_children == 0:
|
|
return StrategyResult(as_of=as_of, strategy_id=strategy_id, elapsed_ms=elapsed_ms)
|
|
|
|
# 各子的 symbol → 排名归一 score。排名基于子策略内部的原始 score 降序。
|
|
# norm∈[0,1], 最优标的=1。单候选或无 score 时用中性分。
|
|
per_child_norm: list[dict[str, float]] = []
|
|
per_child_symbols: list[set[str]] = []
|
|
for res in results:
|
|
symbols = set(res.scores.keys())
|
|
per_child_symbols.append(symbols)
|
|
norm: dict[str, float] = {}
|
|
if symbols:
|
|
ordered = sorted(symbols, key=lambda s: res.scores[s], reverse=True)
|
|
count = len(ordered)
|
|
for rank, sym in enumerate(ordered, start=1):
|
|
# 单候选无法排名, 必须用中性分: 当成"最优=1"会凭空抬高融合分,
|
|
# 而回测合并 (merge_signal_matrices 的 n <= 1 分支) 用的是中性分,
|
|
# 两条路径同一天同一标的会给出不同评分与排序。
|
|
norm[sym] = (
|
|
_NEUTRAL_NORM if count <= 1 else 1 - (rank - 1) / (count - 1)
|
|
)
|
|
else:
|
|
# 子策略未产出 score: 命中即中性分, 不奖励也不惩罚。
|
|
for row in res.rows:
|
|
sym = str(row.get("symbol"))
|
|
if sym and sym not in norm:
|
|
norm[sym] = _NEUTRAL_NORM
|
|
per_child_norm.append(norm)
|
|
|
|
# 确定入围标的集合 + 各标的的命中子策略索引。
|
|
universe: set[str] = set()
|
|
for syms in per_child_symbols:
|
|
universe.update(syms)
|
|
# 也纳入 rows 里有但 scores 里没有的标的(子策略无 score 但产出行)。
|
|
for res in results:
|
|
for row in res.rows:
|
|
sym = str(row.get("symbol"))
|
|
if sym:
|
|
universe.add(sym)
|
|
|
|
effective_min = max(min_confirm, 1) if min_confirm and min_confirm > 0 else n_children
|
|
scores: dict[str, float] = {}
|
|
for sym in universe:
|
|
hits = [i for i in range(n_children) if sym in per_child_norm[i]]
|
|
if not hits:
|
|
continue
|
|
if merge_mode == "intersect" and len(hits) < effective_min:
|
|
continue
|
|
weights, total_w = _effective_weights(
|
|
children_weights, [i in hits for i in range(n_children)]
|
|
)
|
|
if total_w <= 0:
|
|
# 全部权重为 0: 退化为均等。
|
|
total_w = float(len(hits))
|
|
weights = [1.0] * len(hits)
|
|
blended = sum(w * per_child_norm[i][sym] for i, w in zip(hits, weights, strict=True))
|
|
scores[sym] = round(blended / total_w * 100, 4)
|
|
|
|
total = len(scores)
|
|
return StrategyResult(
|
|
as_of=as_of,
|
|
strategy_id=strategy_id,
|
|
rows=[],
|
|
total=total,
|
|
elapsed_ms=elapsed_ms,
|
|
scores=scores,
|
|
)
|
|
|
|
|
|
def _hold_masks_from_entries(entries: list[np.ndarray], max_hold: int) -> list[np.ndarray]:
|
|
"""计算每个子策略的持仓窗口掩码。
|
|
|
|
hold_mask_i[t, a] = True 当且仅当存在 t' <= t 使 entry_i[t', a] 触发,
|
|
且 t - t' < max_hold(即仍在最长持仓期内, 未被 max_hold 强制平仓)。
|
|
实现用前向填充: 从每个 entry 起向前扩展 max_hold-1 个 bar 为 True。
|
|
"""
|
|
if max_hold <= 0:
|
|
max_hold = 1
|
|
masks: list[np.ndarray] = []
|
|
for entry in entries:
|
|
raw = entry.astype(bool, copy=False)
|
|
# 对每个 asset 列, 把 True 向前传播 max_hold 个 bar。
|
|
# 用按行位移 OR 实现: mask[t] |= raw[t-k] for k in [0, max_hold-1]
|
|
mask = np.zeros_like(raw)
|
|
window = raw.copy()
|
|
mask |= window
|
|
for _k in range(1, max_hold):
|
|
window = np.roll(window, 1, axis=0)
|
|
window[0, :] = False # roll 会在顶部环绕, 置零防未来泄漏
|
|
mask |= window
|
|
masks.append(mask)
|
|
return masks
|
|
|
|
|
|
def merge_signal_matrices(
|
|
shape: tuple[int, int],
|
|
sigs: list,
|
|
children: list[tuple[str, float]],
|
|
merge_mode: str,
|
|
min_confirm: int,
|
|
max_hold: int,
|
|
):
|
|
"""回测合并: 产出合并 entry/exit/score/entry_signal_code 矩阵。
|
|
|
|
Args:
|
|
shape: (n_times, n_assets)
|
|
sigs: 各子策略 SignalMatrix(顺序与 children 对齐)
|
|
children: [(strategy_id, weight), ...]
|
|
merge_mode / min_confirm: 同 merge_results
|
|
max_hold: 全局最长持仓天数, 用于退出投影窗口封顶
|
|
|
|
返回合并后的 SignalMatrix。
|
|
"""
|
|
from app.backtest.matrix import make_signal_matrix
|
|
|
|
n_times, n_assets = shape
|
|
n_children = len(sigs)
|
|
if n_children == 0:
|
|
return make_signal_matrix(shape)
|
|
|
|
# ── entry ──
|
|
entries = [np.asarray(s.entry, dtype=bool) for s in sigs]
|
|
entry_stack = np.stack(entries, axis=0) # (n_children, n_times, n_assets)
|
|
if merge_mode == "intersect":
|
|
effective_min = max(min_confirm, 1) if min_confirm and min_confirm > 0 else n_children
|
|
confirm_count = entry_stack.sum(axis=0) # (n_times, n_assets)
|
|
merged_entry = confirm_count >= effective_min
|
|
else: # union
|
|
merged_entry = entry_stack.any(axis=0)
|
|
|
|
# ── exit 来源投影 ──
|
|
# 每个子策略的 exit 仅在自己持仓窗口内生效, 不串平其他子的仓位。
|
|
hold_masks = _hold_masks_from_entries(entries, max_hold)
|
|
merged_exit = np.zeros(shape, dtype=bool)
|
|
for i, s in enumerate(sigs):
|
|
child_exit = np.asarray(s.exit, dtype=bool)
|
|
merged_exit |= child_exit & hold_masks[i]
|
|
|
|
# ── score 标准化排名加权 ──
|
|
# 对每个 (time, asset), 在命中的子策略间按权重加权各自的内部排名归一值。
|
|
weights = np.array([w for _, w in children], dtype=np.float64)
|
|
# 预计算每个子策略、每个 time 上 asset 的排名归一。
|
|
norm_scores = np.zeros((n_children, n_times, n_assets), dtype=np.float64)
|
|
for i, s in enumerate(sigs):
|
|
raw = np.asarray(s.score, dtype=np.float64)
|
|
for t in range(n_times):
|
|
row = raw[t]
|
|
hit = entries[i][t]
|
|
if not hit.any():
|
|
continue
|
|
hit_idx = np.flatnonzero(hit)
|
|
hit_vals = row[hit_idx]
|
|
finite = np.isfinite(hit_vals)
|
|
if not finite.any():
|
|
norm_scores[i, t, hit_idx[finite]] = _NEUTRAL_NORM
|
|
continue
|
|
valid_idx = hit_idx[finite]
|
|
valid_vals = hit_vals[finite]
|
|
n = len(valid_idx)
|
|
if n <= 1:
|
|
norm_scores[i, t, valid_idx] = _NEUTRAL_NORM
|
|
continue
|
|
# 降序排名: 最优=1, 最差=0。
|
|
order = np.argsort(-valid_vals, kind="stable")
|
|
ranks = np.empty(n, dtype=np.float64)
|
|
ranks[order] = np.arange(1, n + 1, dtype=np.float64)
|
|
normalized = 1 - (ranks - 1) / (n - 1)
|
|
norm_scores[i, t, valid_idx] = normalized
|
|
|
|
hit_mask = entry_stack # (n_children, n_times, n_assets)
|
|
hit_weights = np.where(hit_mask, weights.reshape(-1, 1, 1), 0.0)
|
|
weight_sum = hit_weights.sum(axis=0) # (n_times, n_assets)
|
|
blended = (norm_scores * hit_weights).sum(axis=0)
|
|
safe_sum = np.where(weight_sum > 0, weight_sum, 1.0)
|
|
merged_score = np.where(merged_entry, blended / safe_sum, 0.0) * 100
|
|
merged_score = np.nan_to_num(merged_score, nan=0.0, posinf=0.0, neginf=0.0)
|
|
|
|
# ── entry_signal_code: 来源标记 ──
|
|
# code = 命中的第一个子策略索引; -1 表示无命中。归因用。
|
|
entry_codes = np.full(shape, -1, dtype=np.int16)
|
|
for i in range(n_children):
|
|
# 只给尚未标记的命中点打 code(第一个命中优先), 避免覆盖。
|
|
untagged = (entry_codes == -1) & entries[i]
|
|
entry_codes[untagged] = i
|
|
|
|
# exit_signal_code 沿用 -1(无独立信号来源); 回测按 "signal" reason 归因即可。
|
|
exit_codes = np.full(shape, -1, dtype=np.int16)
|
|
|
|
# entry_signal_ids 映射表: code i → "composite:child_id"
|
|
entry_signal_ids = tuple(f"composite:{cid}" for cid, _ in children)
|
|
|
|
return make_signal_matrix(
|
|
shape,
|
|
entry=merged_entry.astype(np.uint8),
|
|
exit=merged_exit.astype(np.uint8),
|
|
score=merged_score.astype(np.float32),
|
|
entry_signal_code=entry_codes,
|
|
exit_signal_code=exit_codes,
|
|
entry_signal_ids=entry_signal_ids,
|
|
)
|