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:
@@ -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")},
|
||||
)
|
||||
Reference in New Issue
Block a user