feat(signal): M8.1 交易信号引擎(规则 + signal_event 落库 + /api/signals)

- SignalRules(买入 rank 阈值/趋势 MA/动量 + 卖出区间/破位警示)+ SignalEvent
  (BUY/WATCH/SELL,score/price/trigger_reason 可解释)+ SignalResult/Meta
- quant/signal.generate_signals:与选股同一评分引擎取全市场 rank,按规则分类输出
- signal_snapshot/signal_event 表(migration d8e0b2f3c4d5,MySQL 已应用)+ Repo
- SignalService + POST /api/signals(同步+落库)、GET 详情/列表
- tests/test_signals.py(引擎分类/排序/破位不 BUY、service、API 提交读回);全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 00:35:37 +08:00
parent ef09d5b419
commit ba52edc2d6
12 changed files with 715 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
"""Signal Engine(v2 §15)—— 纯 pandas 执行。
输入:行情长表(<=as_of)+ SelectionQuery(评分因子)+ SignalRules。
流程:复合分 → 全市场 rank → 按规则判定 BUY / WATCH / SELL,
事件带 trigger_reason 与 score/price(可解释)。回测的买入逻辑(TopK+可买过滤)
与这里的 BUY 建议同源于同一评分引擎(v2 §25 一致)。
"""
from __future__ import annotations
from datetime import date
import pandas as pd
from app.domain.entities.selection import SelectionQuery
from app.domain.entities.signal import (
SignalEvent,
SignalResult,
SignalRules,
SignalStatistics,
)
from app.quant.composite import build_score_panel
from app.quant.selection import resolve_observation_date
def generate_signals(
daily: pd.DataFrame,
query: SelectionQuery,
rules: SignalRules,
as_of: date | None,
) -> SignalResult:
if not query.factors:
raise ValueError("signal 需要评分因子(SelectionQuery.factors)")
obs = resolve_observation_date(daily, as_of)
resolved = (obs.date() if obs is not None else as_of) or date.today()
if obs is None or daily.empty:
return _empty(query, rules, resolved)
view = daily[pd.to_datetime(daily["trade_date"]) <= obs]
score = build_score_panel(view, query.factors).loc[obs].dropna().sort_values(ascending=False)
close = view.pivot(index="trade_date", columns="symbol", values="close").sort_index()
close.index = pd.to_datetime(close.index)
c_d = close.loc[obs]
ma_d = close.rolling(rules.trend_ma).mean().loc[obs]
mom20_d = (close / close.shift(20) - 1.0).loc[obs]
events: list[SignalEvent] = []
stats = SignalStatistics(universe_size=int(len(score)))
for rank, (sym, sc) in enumerate(score.items(), start=1):
if rank > rules.max_output_rank:
break
c = _num(c_d.get(sym))
ma = _num(ma_d.get(sym))
mom = _num(mom20_d.get(sym))
price = float(c) if c is not None else None
reason: list[str] = []
event_type = "WATCH"
trend_ok = c is not None and ma is not None and c > ma
momentum_ok = mom is not None and mom > 0
if rank <= rules.buy_rank_threshold and (
not rules.buy_require_trend or trend_ok
) and (not rules.buy_require_momentum or momentum_ok):
event_type = "BUY"
reason = [f"综合分排名第 {rank}(≤买入阈值 {rules.buy_rank_threshold})"]
if rules.buy_require_trend:
reason.append(f"close > MA{rules.trend_ma}(趋势向上)")
if rules.buy_require_momentum:
reason.append("close > 20 日前收盘(动量为正)")
elif rank <= rules.buy_rank_threshold:
event_type = "WATCH"
reason = [f"综合分排名第 {rank}(买入区间)"]
if rules.buy_require_trend and not trend_ok:
reason.append(f"但 close < MA{rules.trend_ma}(趋势未确认)")
elif rank <= rules.sell_rank_threshold:
# 观望带
if rules.sell_on_trend_break and c is not None and ma is not None and c < ma:
event_type = "SELL"
reason = [f"跌破 MA{rules.trend_ma}(持仓者应卖出/减仓),rank={rank}"]
else:
event_type = "WATCH"
reason = [f"rank={rank}(买入区间外、卖出区间内:观望)"]
else:
event_type = "SELL"
reason = [f"综合分排名第 {rank}(>卖出阈值 {rules.sell_rank_threshold},持仓者应卖出)"]
if c is not None and c < 0:
continue # 防御负价
events.append(
SignalEvent(
symbol=sym,
signal_date=resolved,
signal_type=event_type,
score=round(float(sc), 6),
price=round(price, 4) if price is not None else None,
trigger_reason=reason,
)
)
if event_type == "BUY":
stats.buy += 1
elif event_type == "SELL":
stats.sell += 1
else:
stats.watch += 1
return SignalResult(
as_of_date=resolved,
rules=rules,
statistics=stats,
events=events,
config_snapshot={
"as_of": resolved.isoformat(),
"factors": [f.model_dump() for f in query.factors],
"rules": rules.model_dump(mode="json"),
},
)
def _num(v) -> float | None:
if v is None:
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
return None if f != f else f # NaN → None
def _empty(query: SelectionQuery, rules: SignalRules, resolved: date) -> SignalResult:
return SignalResult(
as_of_date=resolved,
rules=rules,
statistics=SignalStatistics(),
events=[],
config_snapshot={"as_of": resolved.isoformat(), "rules": rules.model_dump(mode="json")},
)