- 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 通过
136 lines
4.9 KiB
Python
136 lines
4.9 KiB
Python
"""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")},
|
||
)
|