"""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")}, )