feat(monitor): add strategy signal event controls

This commit is contained in:
shy3130
2026-07-26 16:08:42 +08:00
parent 6f1c4032bb
commit b43b177899
14 changed files with 912 additions and 191 deletions
+1
View File
@@ -46,6 +46,7 @@ class RuleModel(BaseModel):
sector: str | None = None
strategy_id: str | None = None
direction: str = "entry" # entry | exit | both
notify_events: list[str] | None = None
conditions: list[ConditionModel] = []
logic: str = "and" # and | or
cooldown_seconds: int = 3600
+1 -1
View File
@@ -1059,7 +1059,7 @@ class QuoteService:
"source": ev["source"],
"type": ev["type"],
"rule_id": ev.get("rule_id"),
"strategy_id": ev.get("rule_id") if ev["source"] == "strategy" else None,
"strategy_id": ev.get("strategy_id") if ev["source"] == "strategy" else None,
"symbol": ev["symbol"],
"name": ev["name"],
"message": ev["message"],
+100 -6
View File
@@ -143,6 +143,8 @@ class StrategyResult:
total: int = 0
elapsed_ms: float = 0.0
scores: dict[str, float] = field(default_factory=dict)
entry_signal_hits: list[dict] = field(default_factory=list)
exit_signal_hits: list[dict] = field(default_factory=list)
@dataclass
@@ -629,6 +631,8 @@ class StrategyEngine:
as_of = context.as_of
overrides = overrides or {}
params = self.resolve_params(s, params, overrides)
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
if s.execution_backend == "matrix_native":
return self._run_matrix_strategy(
@@ -642,24 +646,40 @@ class StrategyEngine:
started_at=t0,
)
signal_df = context.current if context.current is not None else context.history
if signal_df is None:
signal_df = pl.DataFrame()
if not signal_df.is_empty() and "date" in signal_df.columns:
signal_df = signal_df.filter(pl.col("date") == as_of)
if pool and not signal_df.is_empty():
signal_df = signal_df.filter(pl.col("symbol").is_in(pool))
exit_signal_hits = self._collect_signal_hits(signal_df, exit_signals)
# 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。
if s.filter_history_fn:
if context.history is None:
raise ValueError(f"strategy {strategy_id} requires history data")
df = context.history
if df.is_empty():
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
return StrategyResult(
as_of=as_of,
strategy_id=strategy_id,
exit_signal_hits=exit_signal_hits,
)
df = s.filter_history_fn(df, params)
if df.is_empty():
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
if "date" in df.columns:
df = df.filter(pl.col("date") == as_of)
else:
if context.current is None:
raise ValueError(f"strategy {strategy_id} requires current data")
df = context.current
if df.is_empty():
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
if df.is_empty():
return StrategyResult(
as_of=as_of,
strategy_id=strategy_id,
exit_signal_hits=exit_signal_hits,
)
# 基础过滤: 策略默认 basic_filter 兜底, 用户 override 优先覆盖。
# 这样策略文件里写的 exclude_st/price_min 等默认值即使前端没保存也能生效。
@@ -686,6 +706,12 @@ class StrategyEngine:
if scoring_overrides:
scoring = {**scoring, **scoring_overrides}
df = self._apply_scoring(df, scoring)
entry_signal_hits = self._collect_signal_hits(df, entry_signals)
if not entry_signals and (s.filter_history_fn or s.filter_fn):
entry_signal_hits = [
{"symbol": str(symbol), "signals": []}
for symbol in df["symbol"].cast(pl.Utf8).unique().to_list()
]
# 排序 + 限制
limit = self._result_limit(s, overrides)
@@ -715,8 +741,41 @@ class StrategyEngine:
total=len(rows),
elapsed_ms=elapsed,
scores=scores,
entry_signal_hits=entry_signal_hits,
exit_signal_hits=exit_signal_hits,
)
@staticmethod
def _effective_signals(overrides: dict, key: str, default: list[str]) -> list[str]:
value = overrides.get(key)
if isinstance(value, list):
return [str(signal) for signal in value if signal]
return list(default or [])
@staticmethod
def _collect_signal_hits(df: pl.DataFrame, signals: list[str]) -> list[dict]:
if df.is_empty() or not signals or "symbol" not in df.columns:
return []
resolved = [
signal if signal.startswith(("signal_", "csg_")) else f"signal_{signal}"
for signal in signals
]
available = [
(signal, column)
for signal, column in zip(signals, resolved, strict=True)
if column in df.columns
]
if not available:
return []
hit_df = df.filter(pl.any_horizontal(pl.col(column).fill_null(False) for _, column in available))
return [
{
"symbol": str(row["symbol"]),
"signals": [signal for signal, column in available if row.get(column)],
}
for row in hit_df.iter_rows(named=True)
]
def run_all(
self,
context: StrategyDataContext,
@@ -880,12 +939,31 @@ class StrategyEngine:
if not target_ids:
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
target_time = target_ids[-1]
selected_assets = np.flatnonzero(signals.entry[target_time] != 0)
entry_active = signals.entry[target_time]
exit_active = signals.exit[target_time]
if asset_mask is not None:
entry_active = entry_active & asset_mask
exit_active = exit_active & asset_mask
entry_signal_hits = self._matrix_signal_hits(
entry_active,
signals.entry_signal_code[target_time],
signals.entry_signal_ids,
market.symbols,
)
exit_signal_hits = self._matrix_signal_hits(
exit_active,
signals.exit_signal_code[target_time],
signals.exit_signal_ids,
market.symbols,
)
selected_assets = np.flatnonzero(entry_active != 0)
if selected_assets.size == 0:
return StrategyResult(
as_of=as_of,
strategy_id=strategy_id,
elapsed_ms=(time.perf_counter() - started_at) * 1000,
entry_signal_hits=entry_signal_hits,
exit_signal_hits=exit_signal_hits,
)
target_frame = self._matrix_target_frame(source_panel, as_of)
@@ -916,8 +994,24 @@ class StrategyEngine:
total=len(rows),
elapsed_ms=(time.perf_counter() - started_at) * 1000,
scores=scores,
entry_signal_hits=entry_signal_hits,
exit_signal_hits=exit_signal_hits,
)
@staticmethod
def _matrix_signal_hits(
active: np.ndarray,
codes: np.ndarray,
signal_ids: tuple[str, ...],
symbols: tuple[str, ...],
) -> list[dict]:
hits = []
for asset_id in np.flatnonzero(active != 0):
code = int(codes[int(asset_id)])
signals = [signal_ids[code]] if 0 <= code < len(signal_ids) else []
hits.append({"symbol": symbols[int(asset_id)], "signals": signals})
return hits
@staticmethod
def _matrix_target_frame(panel: pl.DataFrame, as_of: date) -> pl.DataFrame:
if "datetime" in panel.columns:
+172 -85
View File
@@ -317,19 +317,23 @@ class MonitorRuleEngine:
- 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config
- 支持 scope (symbols/all/sector) 过滤作用域
- 支持 conditions + logic (AND/OR) 任意组合
- ★ cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发
- ★ cooldown 去重: 同一 (rule_id, symbol, event_type) 在冷却期内不重复触发
"""
def __init__(self, alert_handler: Callable[[dict], None] | None = None):
self._alert_handler = alert_handler
self._rules: dict[str, dict] = {} # rule_id → rule
# (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。
self._last_fire: dict[tuple[str, str], float] = {}
# (rule_id, symbol, event_type) → 上次触发时间戳(秒)。用于 cooldown 去重。
self._last_fire: dict[tuple[str, str, str], float] = {}
self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股
# symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填)
self._name_map: dict[str, str] = {}
# 策略选股池状态: strategy_id → 上期选股符号集合 (用于 diff 变更)
# 策略选股池状态: (rule_id, strategy_id, asset_type) → 上期选股符号集合
self._strategy_pools: dict[tuple[str, str, str], set[str]] = {}
# 策略信号状态: (rule_id, strategy_id, asset_type, event_type) → (K线日期, 命中集合)
self._strategy_signal_state: dict[tuple[str, str, str, str], tuple[str, set[str]]] = {}
# 同一根 K 线的信号即使盘中回落后再次命中也只通知一次。
self._strategy_signal_seen: dict[tuple[str, str, str, str, str], str] = {}
# 数据目录 (用于加载策略 overrides)
self._data_dir = None
# 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。
@@ -361,6 +365,8 @@ class MonitorRuleEngine:
def invalidate_strategy_state(self) -> None:
"""策略注册表变更后清除选股池、结果和矩阵快照。"""
self._strategy_pools.clear()
self._strategy_signal_state.clear()
self._strategy_signal_seen.clear()
self._latest_strategy_results = {}
self._building_strategy_results = {}
self._latest_strategy_result_ids.clear()
@@ -398,6 +404,17 @@ class MonitorRuleEngine:
self._name_map = name_map or {}
# ── 规则管理 ───────────────────────────────────────
@staticmethod
def _rule_state_signature(rule: dict) -> tuple[Any, ...]:
return (
rule.get("type"),
rule.get("strategy_id"),
rule.get("asset_type", "stock"),
rule.get("scope", "symbols"),
tuple(sorted(str(symbol) for symbol in rule.get("symbols", []))),
rule.get("sector"),
)
def set_rules(self, rules: list[dict]) -> None:
"""批量设置规则 (覆盖)。用于启动时 reload。
@@ -408,7 +425,31 @@ class MonitorRuleEngine:
for r in rules:
if r.get("enabled") is not False:
new_rules[r["id"]] = r
changed_ids = {
rule_id
for rule_id, rule in new_rules.items()
if rule_id in self._rules
and self._rule_state_signature(self._rules[rule_id])
!= self._rule_state_signature(rule)
}
self._rules = new_rules
active_ids = set(new_rules) - changed_ids
self._last_fire = {
key: value for key, value in list(self._last_fire.items()) if key[0] in active_ids
}
self._strategy_pools = {
key: value for key, value in list(self._strategy_pools.items()) if key[0] in active_ids
}
self._strategy_signal_state = {
key: value
for key, value in list(self._strategy_signal_state.items())
if key[0] in active_ids
}
self._strategy_signal_seen = {
key: value
for key, value in list(self._strategy_signal_seen.items())
if key[0] in active_ids
}
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
def add_rule(self, rule: dict) -> None:
@@ -419,12 +460,23 @@ class MonitorRuleEngine:
def remove_rule(self, rule_id: str) -> None:
self._rules.pop(rule_id, None)
# 清理对应的 cooldown 记录 (list 快照: 评估线程可能并发写 _last_fire)
self._last_fire = {k: v for k, v in list(self._last_fire.items()) if k[0] != rule_id}
self._strategy_pools = {
k: v for k, v in list(self._strategy_pools.items()) if k[0] != rule_id
}
self._strategy_signal_state = {
k: v for k, v in list(self._strategy_signal_state.items()) if k[0] != rule_id
}
self._strategy_signal_seen = {
k: v for k, v in list(self._strategy_signal_seen.items()) if k[0] != rule_id
}
def clear(self) -> None:
self._rules.clear()
self._last_fire.clear()
self._strategy_pools.clear()
self._strategy_signal_state.clear()
self._strategy_signal_seen.clear()
@property
def rules(self) -> dict[str, dict]:
@@ -613,7 +665,7 @@ class MonitorRuleEngine:
rtype = rule.get("type", "signal")
if rtype == "strategy":
# 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件
# 策略类型: 跑策略选股, 同时产出所选的信号和结果池变更事件
hit_rows = self._match_strategy(scoped, rule)
elif rtype == "ladder":
# 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message)
@@ -633,12 +685,10 @@ class MonitorRuleEngine:
events: list[dict] = []
for ev_type, sym, name, price, pct, hit_sigs in hit_rows:
# cooldown 键: 批量事件用特殊键, 单只事件用 (rule_id, symbol)
# cooldown 键包含事件类型, 同股不同策略事件互不压制。
is_batch = sym == "_batch"
if is_batch:
key = (rule["id"], f"_{ev_type}_batch")
else:
key = (rule["id"], sym)
key_symbol = f"_{ev_type}_batch" if is_batch else sym
key = (rule["id"], key_symbol, ev_type)
last = self._last_fire.get(key)
if last is not None and (now - last) < cooldown:
continue # 冷却期内, 跳过
@@ -660,6 +710,7 @@ class MonitorRuleEngine:
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"strategy_id": rule.get("strategy_id") if rtype == "strategy" else None,
"source": source,
"type": ev_type,
"symbol": "" if is_batch else sym,
@@ -707,11 +758,11 @@ class MonitorRuleEngine:
def _match_strategy(
self, df: pl.DataFrame, rule: dict,
) -> list[tuple[str, str, Any, Any, Any, list[str]]]:
"""策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。
"""策略类型评估: 一次执行同时产出交易信号和结果池变更事件。
返回 [(event_type, symbol, name, price, pct, signals)]
event_type: "new_entry" (新入选) | "dropped" (已移出)
单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch")
event_type: buy_signal | sell_signal | pool_entry | pool_exit
同类事件超过 5 只合并为一条批量事件 (symbol="_batch")
"""
if self._strategy_engine is None:
return []
@@ -804,7 +855,7 @@ class MonitorRuleEngine:
return []
# 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。
# 与下面的 diff 事件无关 无论是否产生 new_entry/dropped, 结果都该可用于回显。
# 与下面的事件无关, 无论是否产生通知结果都用于策略页实时回显。
# 策略结果缓存仅用于股票策略页 /cached 回显; ETF 策略页走实时单跑, 不写入。
# 写到 evaluate 提供的临时容器 (_building_strategy_results), 算完后整体替换,
# 避免并发读到半填充状态。
@@ -826,75 +877,109 @@ class MonitorRuleEngine:
current_pool: set[str] = {r["symbol"] for r in result.rows}
prev_pool = self._strategy_pools.get(pool_key)
# 首次运行: 仅记录当前选股池, 不产生事件
if prev_pool is None:
self._strategy_pools[pool_key] = current_pool
return []
new_entries = current_pool - prev_pool
dropped = prev_pool - current_pool
# 无变更
if not new_entries and not dropped:
return []
# 更新存储
self._strategy_pools[pool_key] = current_pool
notify_events = set(rule.get("notify_events") or ("pool_entry", "pool_exit"))
sname = s.meta.get("name", "") or s.meta.get("id", sid)
# 构建查找表 (新入选股票可在 result.rows 中找到; 移出股票需从 df 找)
row_map: dict[str, dict] = {r["symbol"]: r for r in result.rows}
dropped_map: dict[str, dict] = {}
if dropped:
try:
_dd = df.filter(pl.col("symbol").is_in(list(dropped)))
for row in _dd.iter_rows(named=True):
dropped_map[row["symbol"]] = row
except Exception:
pass
try:
for row in df.iter_rows(named=True):
row_map.setdefault(str(row.get("symbol", "")), row)
except Exception:
pass
changes: dict[str, set[str]] = {
"buy_signal": self._new_strategy_signals(
pool_key, "buy_signal", result.as_of, result.entry_signal_hits,
),
"sell_signal": self._new_strategy_signals(
pool_key, "sell_signal", result.as_of, result.exit_signal_hits,
),
"pool_entry": set() if prev_pool is None else current_pool - prev_pool,
"pool_exit": set() if prev_pool is None else prev_pool - current_pool,
}
results: list[tuple[str, str, Any, Any, Any, list[str]]] = []
# ── 新入选 ──
new_list = sorted(new_entries)
if len(new_list) > 5:
names: list[str] = []
for sym in new_list:
row = row_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
names.append(str(name))
message = f"策略「{sname}」进入 {len(new_entries)} 只:{''.join(names)}"
results.append(("new_entry", "_batch", message, None, None, []))
else:
for sym in new_list:
row = row_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
price = row.get("close")
pct = row.get("change_pct")
results.append(("new_entry", sym, name, price, pct, []))
# ── 已移出 ──
dropped_list = sorted(dropped)
if len(dropped_list) > 5:
names = []
for sym in dropped_list:
row = dropped_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
names.append(str(name))
message = f"策略「{sname}」移出 {len(dropped)} 只:{''.join(names)}"
results.append(("dropped", "_batch", message, None, None, []))
else:
for sym in dropped_list:
row = dropped_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
price = row.get("close")
pct = row.get("change_pct")
results.append(("dropped", sym, name, price, pct, []))
signal_map = {
"buy_signal": {
str(hit["symbol"]): list(hit.get("signals") or [])
for hit in result.entry_signal_hits
},
"sell_signal": {
str(hit["symbol"]): list(hit.get("signals") or [])
for hit in result.exit_signal_hits
},
}
action_labels = {
"buy_signal": "买入信号",
"sell_signal": "卖出信号",
"pool_entry": "进入选股结果",
"pool_exit": "移出选股结果",
}
for event_type, symbols in changes.items():
if event_type not in notify_events or not symbols:
continue
symbol_list = sorted(symbols)
if len(symbol_list) > 5:
names = [
str(row_map.get(symbol, {}).get("name") or self._name_map.get(symbol, symbol))
for symbol in symbol_list
]
message = (
f"策略「{sname}{action_labels[event_type]} {len(symbol_list)} 只: "
f"{''.join(names)}"
)
hit_signals = sorted({
signal
for symbol in symbol_list
for signal in signal_map.get(event_type, {}).get(symbol, [])
})
results.append((event_type, "_batch", message, None, None, hit_signals))
continue
for symbol in symbol_list:
row = row_map.get(symbol, {})
name = row.get("name") or self._name_map.get(symbol, symbol)
results.append((
event_type,
symbol,
name,
row.get("close"),
row.get("change_pct"),
signal_map.get(event_type, {}).get(symbol, []),
))
return results
def _new_strategy_signals(
self,
pool_key: tuple[str, str, str],
event_type: str,
as_of: Any,
hits: list[dict],
) -> set[str]:
rule_id, strategy_id, asset_type = pool_key
state_key = (rule_id, strategy_id, asset_type, event_type)
date_key = str(as_of)
current = {str(hit["symbol"]) for hit in hits}
previous = self._strategy_signal_state.get(state_key)
self._strategy_signal_state[state_key] = (date_key, current)
if previous is None:
for symbol in current:
self._strategy_signal_seen[(*state_key, symbol)] = date_key
return set()
previous_date, previous_symbols = previous
candidates = current if previous_date != date_key else current - previous_symbols
fresh = {
symbol
for symbol in candidates
if self._strategy_signal_seen.get((*state_key, symbol)) != date_key
}
for symbol in fresh:
self._strategy_signal_seen[(*state_key, symbol)] = date_key
return fresh
@staticmethod
def _match_conditions(
df: pl.DataFrame, rule: dict,
@@ -956,7 +1041,7 @@ class MonitorRuleEngine:
events: list[dict] = []
for row in hit.iter_rows(named=True):
sym = row.get("symbol", "")
key = (rule["id"], sym)
key = (rule["id"], sym, "ladder")
last = self._last_fire.get(key)
if last is not None and (now - last) < cooldown:
continue
@@ -1021,19 +1106,21 @@ class MonitorRuleEngine:
rn = rule.get("name", "")
sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略")
if ev_type == "new_entry":
action = {
"buy_signal": "买入信号",
"sell_signal": "卖出信号",
"pool_entry": "进入选股结果",
"pool_exit": "移出选股结果",
"new_entry": "进入选股结果",
"dropped": "移出选股结果",
}.get(ev_type)
if action:
pct_text = ""
if pct is not None:
sign = "+" if pct >= 0 else ""
pct_text = f" {sign}{pct * 100:.1f}%"
return f"策略「{sname}进入 {name}{pct_text}"
elif ev_type == "dropped":
pct_text = ""
if pct is not None:
sign = "+" if pct >= 0 else ""
pct_text = f" {sign}{pct * 100:.1f}%"
return f"策略「{sname}」移出 {name}{pct_text}"
return f"策略「{sname}」变更"
return f"策略「{sname}{action} {name}{pct_text}"
return f"策略「{sname}」事件"
# signal / price / market: 条件摘要 + 现价 + 涨跌幅
# 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2"
+18 -2
View File
@@ -31,6 +31,7 @@ RULE_TYPES = {"strategy", "signal", "price", "market", "ladder"}
SCOPES = {"symbols", "all", "sector"}
LOGICS = {"and", "or"}
DIRECTIONS = {"entry", "exit", "both"}
STRATEGY_NOTIFY_EVENTS = {"buy_signal", "sell_signal", "pool_entry", "pool_exit"}
SEVERITIES = {"info", "warn", "critical"}
OPS = {">", ">=", "<", "<=", "==", "!="}
# ladder 规则: 封单监控的指标 (量=手, 额=元)
@@ -59,7 +60,7 @@ def load_all(data_dir: Path) -> list[dict]:
out: list[dict] = []
for f in sorted(d.glob("*.json")):
try:
out.append(json.loads(f.read_text(encoding="utf-8")))
out.append(normalize(json.loads(f.read_text(encoding="utf-8"))))
except Exception as e:
logger.warning("monitor rule load failed %s: %s", f.name, e)
return out
@@ -70,7 +71,7 @@ def load_one(data_dir: Path, rule_id: str) -> dict | None:
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
return normalize(json.loads(p.read_text(encoding="utf-8")))
except Exception as e:
logger.warning("monitor rule load failed %s: %s", rule_id, e)
return None
@@ -112,6 +113,12 @@ def validate(rule: dict) -> None:
raise ValueError("策略类型规则必须指定 strategy_id")
if rule.get("direction", "entry") not in DIRECTIONS:
raise ValueError(f"direction 必须是 {DIRECTIONS} 之一")
notify_events = rule.get("notify_events")
if not isinstance(notify_events, list) or not notify_events:
raise ValueError("策略类型规则至少选择一个通知事件")
invalid_events = set(notify_events) - STRATEGY_NOTIFY_EVENTS
if invalid_events:
raise ValueError(f"notify_events 包含非法事件: {sorted(invalid_events)}")
elif rule.get("type") == "ladder":
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
@@ -182,6 +189,14 @@ def normalize(rule: dict) -> dict:
r.setdefault("strategy_id", None)
# direction 默认值: ladder 用 "up", 其余用 "entry"
r.setdefault("direction", "up" if r.get("type") == "ladder" else "entry")
if r.get("type") == "strategy":
if r.get("notify_events") is None:
# 兼容统一监控上线后的旧规则: 当时实际行为是同时通知进入和移出。
r["notify_events"] = ["pool_entry", "pool_exit"]
else:
r["notify_events"] = list(dict.fromkeys(r["notify_events"]))
else:
r.pop("notify_events", None)
r.setdefault("conditions", [])
# ladder 专属默认字段
r.setdefault("metric", "sealed_vol")
@@ -252,6 +267,7 @@ def migrate_strategy_monitors(data_dir: Path, strategy_ids: list[str], strategy_
"scope": "all",
"strategy_id": sid,
"direction": "entry",
"notify_events": ["pool_entry", "pool_exit"],
"conditions": [],
"cooldown_seconds": 3600,
"enabled": True,
@@ -0,0 +1,358 @@
from __future__ import annotations
from datetime import date
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import polars as pl
import pytest
from app.backtest.matrix import build_market_data_matrix, make_signal_matrix
from app.services import alert_store, preferences, quote_service
from app.services.quote_service import QuoteService
from app.strategy import monitor_rules
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine, StrategyResult
from app.strategy.monitor import MonitorRuleEngine
def _rule(*events: str, **overrides) -> dict:
return monitor_rules.normalize({
"id": "strategy_rule",
"name": "策略监控",
"type": "strategy",
"asset_type": "stock",
"scope": "all",
"symbols": [],
"strategy_id": "demo",
"notify_events": list(events),
"conditions": [],
"cooldown_seconds": 3600,
**overrides,
})
class _SequenceStrategyEngine:
def __init__(self, results: list[StrategyResult]):
self.results = list(results)
self.strategy = SimpleNamespace(
meta={"id": "demo", "name": "示例策略"},
execution_backend="polars_expr",
filter_history_fn=None,
)
def get(self, strategy_id: str):
assert strategy_id == "demo"
return self.strategy
def run(self, strategy_id: str, context, **kwargs):
assert strategy_id == "demo"
return self.results.pop(0)
def _result(
as_of: date,
pool: tuple[str, ...] = (),
buys: tuple[str, ...] = (),
sells: tuple[str, ...] = (),
) -> StrategyResult:
return StrategyResult(
as_of=as_of,
strategy_id="demo",
rows=[{"symbol": symbol, "close": 10.0, "change_pct": 0.01} for symbol in pool],
total=len(pool),
entry_signal_hits=[{"symbol": symbol, "signals": ["signal_buy"]} for symbol in buys],
exit_signal_hits=[{"symbol": symbol, "signals": ["signal_sell"]} for symbol in sells],
)
def _quotes() -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["A", "B"],
"close": [10.0, 20.0],
"change_pct": [0.01, -0.02],
})
def test_strategy_rule_compatibility_and_validation(tmp_path):
legacy = {
"id": "legacy", "name": "旧规则", "type": "strategy",
"scope": "all", "strategy_id": "demo",
}
monitor_rules.save_one(tmp_path, legacy)
loaded = monitor_rules.load_one(tmp_path, "legacy")
assert loaded is not None
assert loaded["notify_events"] == ["pool_entry", "pool_exit"]
assert monitor_rules.load_all(tmp_path)[0]["notify_events"] == ["pool_entry", "pool_exit"]
with pytest.raises(ValueError, match="至少选择一个通知事件"):
monitor_rules.validate(_rule())
with pytest.raises(ValueError, match="非法事件"):
monitor_rules.validate(_rule("unknown"))
monitor_rules.validate(_rule("buy_signal", "pool_exit"))
def test_strategy_events_baseline_dedupe_and_next_day_replay():
day1 = date(2026, 7, 24)
day2 = date(2026, 7, 25)
engine = MonitorRuleEngine()
engine.set_strategy_engine(_SequenceStrategyEngine([
_result(day1, pool=("A",), buys=("A",)),
_result(day1, pool=("A", "B"), buys=("A", "B")),
_result(day1, pool=("A", "B")),
_result(day1, pool=("A", "B"), buys=("B",)),
_result(day2, pool=("A", "B"), buys=("B",)),
]))
engine.set_rules([_rule("buy_signal", "pool_entry")])
with patch("app.strategy.monitor.time.time", side_effect=[100, 101, 102, 103, 4000]):
assert engine.evaluate(_quotes()) == []
events = engine.evaluate(_quotes())
assert {(event["type"], event["symbol"]) for event in events} == {
("buy_signal", "B"),
("pool_entry", "B"),
}
assert all(event["strategy_id"] == "demo" for event in events)
assert engine.evaluate(_quotes()) == []
assert engine.evaluate(_quotes()) == []
next_day = engine.evaluate(_quotes())
assert [(event["type"], event["symbol"]) for event in next_day] == [("buy_signal", "B")]
def test_strategy_sell_and_pool_exit_are_independent_events():
day = date(2026, 7, 24)
engine = MonitorRuleEngine()
engine.set_strategy_engine(_SequenceStrategyEngine([
_result(day, pool=("A", "B")),
_result(day, pool=("A",), sells=("B",)),
]))
engine.set_rules([_rule("sell_signal", "pool_exit")])
with patch("app.strategy.monitor.time.time", side_effect=[100, 101]):
assert engine.evaluate(_quotes()) == []
events = engine.evaluate(_quotes())
assert {(event["type"], event["symbol"]) for event in events} == {
("sell_signal", "B"),
("pool_exit", "B"),
}
def test_strategy_rule_reload_preserves_state_and_semantic_edit_resets_it():
day = date(2026, 7, 24)
engine = MonitorRuleEngine()
engine.set_strategy_engine(_SequenceStrategyEngine([
_result(day, buys=("A",)),
_result(day, buys=("A",)),
_result(day, buys=("A",)),
_result(day, buys=("A",)),
]))
rule = _rule("buy_signal")
engine.set_rules([rule])
assert engine.evaluate(_quotes()) == []
engine.set_rules([{**rule, "message": "新文案"}])
assert engine.evaluate(_quotes()) == []
engine.set_rules([{**rule, "scope": "symbols", "symbols": ["A"]}])
assert engine.evaluate(_quotes()) == []
engine.set_rules([])
assert not engine._strategy_signal_state
engine.set_rules([rule])
assert engine.evaluate(_quotes()) == []
def test_matrix_signal_hits_map_codes_and_keep_unlabelled_hits():
mapped = StrategyEngine._matrix_signal_hits(
np.array([1, 1, 0], dtype=np.uint8),
np.array([1, -1, -1], dtype=np.int16),
("signal_a", "signal_b"),
("A", "B", "C"),
)
assert mapped == [
{"symbol": "A", "signals": ["signal_b"]},
{"symbol": "B", "signals": []},
]
def test_matrix_strategy_pool_masks_rows_and_both_signal_directions():
day = date(2026, 7, 24)
panel = pl.DataFrame({
"symbol": ["A", "B"],
"date": [day, day],
"open": [10.0, 20.0],
"high": [10.0, 20.0],
"low": [10.0, 20.0],
"close": [10.0, 20.0],
"volume": [100.0, 100.0],
"amount": [1000.0, 2000.0],
})
calls = 0
class _AllSignals:
def required_fields(self):
return frozenset({"close"})
def required_warmup_bars(self, params):
return 1
def compute_signals(self, market, params):
nonlocal calls
calls += 1
active = np.ones(market.shape, dtype=np.uint8)
codes = np.zeros(market.shape, dtype=np.int16)
return make_signal_matrix(
market.shape,
entry=active,
exit=active,
entry_signal_code=codes,
exit_signal_code=codes,
entry_signal_ids=("signal_buy",),
exit_signal_ids=("signal_sell",),
)
strategy = StrategyDef(
meta={"id": "matrix", "scoring": {}, "limit": 100},
basic_filter={"enabled": False},
entry_signals=[],
exit_signals=[],
stop_loss=None,
trailing_stop=None,
trailing_take_profit_activate=None,
trailing_take_profit_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=None,
filter_history_fn=None,
lookback_days=1,
source="custom",
execution_backend="matrix_native",
matrix_strategy=_AllSignals(),
)
engine = StrategyEngine(strategy_dirs=[])
engine._strategies["matrix"] = strategy
result = engine.run(
"matrix",
StrategyDataContext(
"stock",
"1d",
day,
current=panel,
market=build_market_data_matrix(panel),
),
pool=["A"],
)
assert calls == 1
assert [row["symbol"] for row in result.rows] == ["A"]
assert result.entry_signal_hits == [{"symbol": "A", "signals": ["signal_buy"]}]
assert result.exit_signal_hits == [{"symbol": "A", "signals": ["signal_sell"]}]
def test_ordinary_strategy_uses_signal_overrides_and_ignores_malformed_values():
day = date(2026, 7, 24)
quotes = pl.DataFrame({
"symbol": ["A", "B"],
"signal_default_buy": [True, False],
"signal_override_buy": [False, True],
"signal_default_sell": [False, True],
"signal_override_sell": [True, False],
})
strategy = StrategyDef(
meta={"id": "ordinary", "scoring": {}, "limit": 100},
basic_filter={"enabled": False},
entry_signals=["signal_default_buy"],
exit_signals=["signal_default_sell"],
stop_loss=None,
trailing_stop=None,
trailing_take_profit_activate=None,
trailing_take_profit_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=None,
filter_history_fn=None,
lookback_days=1,
source="custom",
)
engine = StrategyEngine(strategy_dirs=[])
engine._strategies["ordinary"] = strategy
context = StrategyDataContext("stock", "1d", day, current=quotes)
result = engine.run(
"ordinary",
context,
overrides={
"entry_signals": ["signal_override_buy"],
"exit_signals": ["signal_override_sell"],
},
)
assert result.entry_signal_hits == [{"symbol": "B", "signals": ["signal_override_buy"]}]
assert result.exit_signal_hits == [{"symbol": "A", "signals": ["signal_override_sell"]}]
fallback = engine.run(
"ordinary",
context,
overrides={"entry_signals": None, "exit_signals": "signal_override_sell"},
)
assert fallback.entry_signal_hits == [{"symbol": "A", "signals": ["signal_default_buy"]}]
assert fallback.exit_signal_hits == [{"symbol": "B", "signals": ["signal_default_sell"]}]
def test_quote_service_forwards_real_strategy_id(monkeypatch, tmp_path):
event = {
"ts": 1,
"rule_id": "strategy_rule",
"strategy_id": "demo",
"source": "strategy",
"type": "buy_signal",
"symbol": "A",
"name": "测试股票",
"message": "策略买入信号",
"price": 10.0,
"change_pct": 0.01,
"signals": ["signal_buy"],
"severity": "info",
}
class _Engine:
rule_count = 1
def __init__(self):
self.rules = {"strategy_rule": {"webhook_channels": []}}
def set_name_map(self, name_map):
pass
def has_rule_type(self, rtype: str) -> bool:
return False
def has_asset_rules(self, asset_type: str) -> bool:
return False
def evaluate(self, df, asset_type: str):
return [event]
def consume_strategy_result_updates(self) -> bool:
return False
class _Repo:
store = SimpleNamespace(data_dir=tmp_path)
@staticmethod
def get_instruments():
return pl.DataFrame({"symbol": ["A"], "name": ["测试股票"]})
monkeypatch.setattr(alert_store, "append_many", lambda *args: None)
monkeypatch.setattr(preferences, "get_system_notify_enabled", lambda: False)
service = QuoteService()
subscriber = service.subscribe()
service.set_app_state(SimpleNamespace(monitor_engine=_Engine(), repo=_Repo()))
service._repo = _Repo()
service.get_enriched_today = lambda: (_quotes(), quote_service.cn_today())
with patch.object(QuoteService, "_is_continuous_trading", return_value=True):
service._evaluate_monitors(pl.DataFrame(), None)
assert subscriber.pop()["alerts"][0]["strategy_id"] == "demo"
+38 -18
View File
@@ -4,10 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion'
import { Bell, TrendingUp, TrendingDown, X } from 'lucide-react'
import type { AlertEvent } from '@/lib/api'
import { fmtPct, fmtPrice } from '@/lib/format'
import { cnSignal } from '@/lib/signals'
import { cn } from '@/lib/cn'
import { playNotificationSound } from '@/lib/notificationSound'
import { speakAlerts } from '@/lib/voiceBroadcast'
import { usePreferences } from '@/lib/useSharedQueries'
import { strategyEventMeta, strategyName } from '@/lib/strategyMonitorEvents'
/** 通知渠道分发 — 所有副作用渠道在此汇合, 新增渠道只改这里 */
function dispatchSideEffects(alerts: AlertEvent[]) {
@@ -86,8 +88,12 @@ const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
signal: { label: '信号', cls: 'bg-accent/15 text-accent' },
price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' },
market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' },
new_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' },
dropped: { label: '移出', cls: 'bg-danger/15 text-danger' },
pool_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' },
pool_exit: { label: '移出', cls: 'bg-warning/15 text-warning' },
buy_signal: { label: '买入', cls: 'bg-danger/15 text-danger' },
sell_signal: { label: '卖出', cls: 'bg-bear/15 text-bear' },
new_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' },
dropped: { label: '移出', cls: 'bg-warning/15 text-warning' },
}
// ===== 容器 — 挂在 Layout =====
@@ -122,18 +128,15 @@ export function AlertToastContainer() {
className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 w-[320px] pointer-events-none"
>
<AnimatePresence>
{items
.filter(item => !(item.alert.source === 'strategy' && !item.alert.symbol))
.map(item => {
{items.map(item => {
const ev = item.alert
const sev = SEVERITY_BAR[ev.severity ?? 'info'] ?? SEVERITY_BAR.info
const badgeKey = (ev.source === 'strategy' && ev.type) ? ev.type : ev.source
const badge = SOURCE_BADGE[badgeKey] ?? { label: badgeKey, cls: 'bg-elevated text-muted' }
const pct = ev.change_pct ?? 0
const isStrategy = ev.source === 'strategy'
const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
const sname = isStrategy ? strategyName(ev.message ?? '') : ''
const eventMeta = strategyEventMeta(ev.type)
return (
<motion.div
key={item.id}
@@ -177,16 +180,33 @@ export function AlertToastContainer() {
{/* 底行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
<div className="mt-1 flex items-center gap-1.5 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
<span className={cn('text-[11px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[11px] text-foreground/70"></span>
<span className="text-[11px] font-medium text-amber-400">{sname}</span>
<span className="flex-1" />
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
</div>
<>
{ev.symbol ? (
<div className="mt-1 flex min-w-0 items-center gap-1.5 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
<span className={cn('shrink-0 text-[11px] font-medium', eventMeta.className)}>
{eventMeta.action}
</span>
{sname
? <span className="truncate text-[11px] font-medium text-amber-400">{sname}</span>
: ev.message && <span className="truncate text-[10px] text-muted">{ev.message}</span>}
<span className="flex-1" />
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
</div>
) : (
<div className="mt-1 flex min-w-0 items-center gap-1.5 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
<span className="truncate text-[11px] text-foreground/70">{ev.message}</span>
</div>
)}
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1 pl-0.5">
{ev.signals.map(signal => (
<span key={signal} className="rounded bg-accent/8 px-1 py-px text-[9px] text-accent/80">{cnSignal(signal)}</span>
))}
</div>
)}
</>
) : (
<div className="mt-1 flex items-center gap-1.5 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
+73 -39
View File
@@ -2,7 +2,8 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity, Check, Plus, RadioTower, Save, Search, TrendingUp, Waypoints, X } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
import { api, genRuleId, type MonitorRule, type MonitorCondition, type StrategyNotifyEvent } from '@/lib/api'
import { DEFAULT_STRATEGY_NOTIFY_EVENTS, LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS } from '@/lib/strategyMonitorEvents'
import { QK } from '@/lib/queryKeys'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { MONITOR_INTRADAY_SIGNAL_OPTIONS, SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
@@ -64,14 +65,25 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(多选数组), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: {
...emptyRule(preset),
webhook_channels: preset?.webhook_channels ?? (prefs?.webhook_default_channels ?? []),
},
)
const [draft, setDraft] = useState<MonitorRule>(() => {
if (rule) {
return {
...rule,
notify_events: rule.type === 'strategy'
? [...(rule.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS)]
: undefined,
conditions: rule.conditions.map(c => ({ ...c })),
}
}
const initial = {
...emptyRule(preset),
webhook_channels: preset?.webhook_channels ?? (prefs?.webhook_default_channels ?? []),
}
if (initial.type === 'strategy' && !initial.notify_events) {
initial.notify_events = [...DEFAULT_STRATEGY_NOTIFY_EVENTS]
}
return initial
})
const assetType = draft.asset_type ?? 'stock'
// 策略列表跟随资产类型: ETF 只列技术类策略。
const strategies = useQuery({
@@ -103,7 +115,9 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
}
if (d.type === 'strategy') {
if (!d.strategy_id) throw new Error('策略监控必须选择一个策略')
if (!d.notify_events?.length) throw new Error('至少选择一个通知事件')
} else {
delete d.notify_events
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
for (const c of d.conditions) {
if (!c.field || !c.op) throw new Error('条件填写不完整')
@@ -149,6 +163,17 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
return { ...d, webhook_channels: cur.includes(ch) ? cur.filter(c => c !== ch) : [...cur, ch] }
})
const toggleStrategyEvent = (event: StrategyNotifyEvent) =>
setDraft(d => {
const current = d.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS
return {
...d,
notify_events: current.includes(event)
? current.filter(item => item !== event)
: [...current, event],
}
})
const thresholdFields = options.data?.threshold_fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const selectedSignals = draft.conditions.filter(c => c.op === 'truth').map(c => c.field)
@@ -160,7 +185,6 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
]
const thresholdConds = draft.conditions.filter(c => c.op !== 'truth')
const strategyPresets = strategies.data?.presets ?? []
const selectedStrategy = strategyPresets.find(strategy => strategy.id === draft.strategy_id)
const normalizedStrategyQuery = strategyQuery.trim().toLowerCase()
const visibleStrategies = strategyPresets.filter(strategy => {
if (strategyCategory !== 'all' && strategy.source !== strategyCategory) return false
@@ -325,11 +349,17 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
key={t.key}
type="button"
aria-pressed={active}
onClick={() => setDraft(d => ({
...d,
type: t.key as MonitorRule['type'],
scope: t.key === 'strategy' && d.scope === 'symbols' && d.symbols.length === 0 ? 'all' : d.scope,
}))}
onClick={() => setDraft(d => {
const type = t.key as MonitorRule['type']
return {
...d,
type,
notify_events: type === 'strategy'
? [...(d.notify_events ?? DEFAULT_STRATEGY_NOTIFY_EVENTS)]
: undefined,
scope: type === 'strategy' && d.scope === 'symbols' && d.symbols.length === 0 ? 'all' : d.scope,
}
})}
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-btn border px-2 text-xs font-medium transition-colors cursor-pointer ${
active
? 'border-accent/40 bg-accent/12 text-accent'
@@ -538,32 +568,36 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
})}
</div>
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-[11px] text-muted">
{selectedStrategy ? (
<> <span className="font-medium text-foreground">{selectedStrategy.name}</span></>
) : '尚未选择策略'}
<div className="border-t border-border/60 pt-3">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-[11px] text-muted"></span>
<span className="text-[9px] text-muted"></span>
</div>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[11px] text-muted"></span>
<div className="inline-flex rounded-btn border border-border bg-base p-0.5">
{(options.data?.directions ?? []).map(direction => (
<button
key={direction.key}
type="button"
aria-pressed={draft.direction === direction.key}
onClick={() => setDraft(d => ({ ...d, direction: direction.key as MonitorRule['direction'] }))}
className={`h-7 rounded px-2.5 text-[10px] font-medium transition-colors cursor-pointer ${
draft.direction === direction.key
? 'bg-accent/15 text-accent'
: 'text-muted hover:text-secondary'
}`}
>
{direction.label}
</button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
{(['signal', 'pool'] as const).map(group => (
<div key={group} className="rounded-btn border border-border bg-base p-2.5">
<div className="mb-2 text-[10px] font-medium text-secondary">
{group === 'signal' ? '交易信号' : '选股结果'}
</div>
<div className="space-y-2">
{STRATEGY_NOTIFY_EVENT_OPTIONS.filter(option => option.group === group).map(option => (
<label key={option.key} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={(draft.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).includes(option.key)}
onChange={() => toggleStrategyEvent(option.key)}
className="h-3.5 w-3.5 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground">{option.label}</span>
</label>
))}
</div>
</div>
))}
</div>
{(draft.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).length === 0 && (
<div className="mt-2 text-[10px] text-danger"></div>
)}
</div>
</div>
)}
+3
View File
@@ -509,6 +509,8 @@ export interface MonitorCondition {
value?: number | null // op 非 truth 时必填
}
export type StrategyNotifyEvent = 'buy_signal' | 'sell_signal' | 'pool_entry' | 'pool_exit'
export interface MonitorRule {
id: string
name: string
@@ -520,6 +522,7 @@ export interface MonitorRule {
sector?: string | null
strategy_id?: string | null
direction: 'entry' | 'exit' | 'both' | 'up' | 'down'
notify_events?: StrategyNotifyEvent[]
conditions: MonitorCondition[]
logic: 'and' | 'or'
cooldown_seconds: number
+73
View File
@@ -0,0 +1,73 @@
import type { StrategyNotifyEvent } from './api'
export interface StrategyEventMeta {
label: string
action: string
className: string
}
const META: Record<string, StrategyEventMeta> = {
buy_signal: {
label: '买入',
action: '买入信号',
className: 'text-danger',
},
sell_signal: {
label: '卖出',
action: '卖出信号',
className: 'text-bear',
},
pool_entry: {
label: '进入',
action: '进入选股结果',
className: 'text-emerald-400',
},
pool_exit: {
label: '移出',
action: '移出选股结果',
className: 'text-warning',
},
new_entry: {
label: '进入',
action: '进入选股结果',
className: 'text-emerald-400',
},
dropped: {
label: '移出',
action: '移出选股结果',
className: 'text-warning',
},
}
export const DEFAULT_STRATEGY_NOTIFY_EVENTS: StrategyNotifyEvent[] = [
'buy_signal',
'sell_signal',
]
export const LEGACY_STRATEGY_NOTIFY_EVENTS: StrategyNotifyEvent[] = [
'pool_entry',
'pool_exit',
]
export const STRATEGY_NOTIFY_EVENT_OPTIONS: {
key: StrategyNotifyEvent
label: string
group: 'signal' | 'pool'
}[] = [
{ key: 'buy_signal', label: '买入信号', group: 'signal' },
{ key: 'sell_signal', label: '卖出信号', group: 'signal' },
{ key: 'pool_entry', label: '进入选股结果', group: 'pool' },
{ key: 'pool_exit', label: '移出选股结果', group: 'pool' },
]
export function strategyEventMeta(type: string): StrategyEventMeta {
return META[type] ?? {
label: '事件',
action: '策略事件',
className: 'text-secondary',
}
}
export function strategyName(message: string): string {
return message.match(/策略「([^」]+)」/)?.[1] ?? ''
}
+5 -7
View File
@@ -12,6 +12,7 @@
*/
import type { AlertEvent } from './api'
import { strategyEventMeta, strategyName } from './strategyMonitorEvents'
const LS = {
enabled: 'voice_broadcast_enabled', // '1'/'0', 默认关
@@ -121,13 +122,10 @@ function buildSingleText(a: AlertEvent): string {
if (!a.symbol || a.symbol === '_batch') {
return a.message || name
}
// 单条事件: 从 message 提取策略名 (格式 "策略「X」" 或直接是策略名)
const sm = a.message?.match(/策略「([^」]+)」/)
const sname = sm ? sm[1] : a.message || ''
const action = a.type === 'new_entry' ? '进入' : a.type === 'dropped' ? '移出' : ''
const parts = [name]
if (action) parts.push(action)
if (sname) parts.push(`策略「${sname}`)
const sname = strategyName(a.message ?? '')
const parts = [name, strategyEventMeta(a.type).action]
const strategyText = sname ? `策略「${sname}` : (a.message || a.rule_name || '')
if (strategyText) parts.push(strategyText)
if (pctText) parts.push(pctText)
return parts.join(' ')
}
+35 -17
View File
@@ -14,6 +14,7 @@ import { SettingsModal } from '@/components/data/SettingsModal'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { strategyEventMeta, strategyName } from '@/lib/strategyMonitorEvents'
import { boardTag } from '@/components/stock-table/primitives'
function n(v: number | null | undefined) {
@@ -112,15 +113,12 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) =>
return (
<>
<div className="mt-1 space-y-1.5">
{events
.filter((ev: AlertEvent) => !(ev.source === 'strategy' && !ev.symbol))
.map((ev, i) => {
{events.map((ev, i) => {
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
const pct = ev.change_pct ?? 0
const isStrategy = ev.source === 'strategy'
const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
const sname = isStrategy ? strategyName(ev.message ?? '') : ''
const eventMeta = strategyEventMeta(ev.type)
return (
<motion.div
key={`${ev.ts}-${i}`}
@@ -160,17 +158,37 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) =>
</div>
{/* 第二行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('text-[9px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[9px] text-muted"></span>
<span className="text-[9px] font-medium text-amber-400">{sname}</span>
<span className="flex-1" />
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
<>
{ev.symbol ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
<span className={cn('shrink-0 text-[9px] font-medium', eventMeta.className)}>
{eventMeta.action}
</span>
{sname
? <span className="truncate text-[9px] font-medium text-amber-400">{sname}</span>
: ev.message && <span className="truncate text-[9px] text-muted">{ev.message}</span>}
<span className="flex-1" />
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
) : (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
<span className="truncate text-[9px] text-muted">{ev.message}</span>
<span className="flex-1" />
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
)}
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{ev.signals.map(signal => (
<span key={signal} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(signal)}</span>
))}
</div>
)}
</>
) : (
<>
<div className="mt-0.5 flex items-center gap-1.5">
+33 -16
View File
@@ -10,6 +10,7 @@ import { QK } from '@/lib/queryKeys'
import { fmtPrice, fmtPct } from '@/lib/format'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS, strategyEventMeta, strategyName } from '@/lib/strategyMonitorEvents'
import { boardTag } from '@/components/stock-table/primitives'
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
import { RuleEditor } from '@/components/monitor/RuleEditor'
@@ -340,9 +341,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
/>
) : (
<div className="space-y-2">
{events
.filter((ev: any) => !(ev.source === 'strategy' && !ev.symbol))
.map((ev: any, i: number) => {
{events.map((ev: any, i: number) => {
const sev = SEVERITY_CONFIG[ev.severity ?? 'info'] ?? SEVERITY_CONFIG.info
const SevIcon = sev.icon
const isNew = ev.ts > enterTs
@@ -367,9 +366,8 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
</div>
<div className="min-w-0 flex-1">
{ev.source === 'strategy' ? (() => {
const sm = ev.message?.match(/策略「([^」]+)」/)
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
const sname = strategyName(ev.message ?? '')
const eventMeta = strategyEventMeta(ev.type)
const _pct = ev.change_pct ?? 0
return (
<>
@@ -408,13 +406,25 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
{sname}
</span>
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className={cn('text-[11px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[11px] text-foreground/80"></span>
<span className="text-[11px] font-medium text-amber-400">{sname}</span>
</div>
{ev.symbol ? (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<span className={cn('shrink-0 text-[11px] font-medium', eventMeta.className)}>
{eventMeta.action}
</span>
{sname
? <span className="text-[11px] font-medium text-amber-400">{sname}</span>
: ev.message && <span className="truncate text-[10px] text-muted">{ev.message}</span>}
</div>
) : (
<div className="mt-1 truncate text-[11px] text-muted">{ev.message}</div>
)}
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{ev.signals.map((signal: string) => (
<span key={signal} className="rounded bg-accent/8 px-1.5 py-0.5 text-[9px] text-accent/70">{cnSignal(signal)}</span>
))}
</div>
)}
</>
)
})() : (
@@ -723,10 +733,17 @@ function RulesList({ rulesQuery, onEdit }: {
</div>
)}
{/* 第二行: 策略类型显示选股池变更监控 */}
{/* 第二行: 策略类型显示通知事件 */}
{r.type === 'strategy' && r.strategy_id ? (
<div className="mt-0.5 flex items-center gap-2 pl-0.5">
<span className="text-[9px] text-secondary"></span>
<div className="mt-1 flex flex-wrap items-center gap-1 pl-0.5">
{(r.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).map(event => {
const option = STRATEGY_NOTIFY_EVENT_OPTIONS.find(item => item.key === event)
return option ? (
<span key={event} className="rounded bg-elevated px-1.5 py-0.5 text-[9px] text-secondary">
{option.label}
</span>
) : null
})}
</div>
) : r.conditions.length > 0 && (
<div className="mt-0.5 flex items-center gap-1 pl-0.5">
+2
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store, RotateCcw, X } from 'lucide-react'
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
import { DEFAULT_STRATEGY_NOTIFY_EVENTS } from '@/lib/strategyMonitorEvents'
import { toast } from '@/components/Toast'
import { useDataStatus, usePreferences, useCapabilities, useQuoteStatus } from '@/lib/useSharedQueries'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
@@ -536,6 +537,7 @@ export function Screener() {
sector: null,
strategy_id: strategyId,
direction: 'entry',
notify_events: [...DEFAULT_STRATEGY_NOTIFY_EVENTS],
conditions: [],
logic: 'or',
cooldown_seconds: 3600,