Files
qlib/backend/app/quant/signal.py
T
Simon ba52edc2d6 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 通过
2026-09-09 00:35:37 +08:00

136 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")},
)