mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(monitor): add strategy signal event controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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-'))} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] ?? ''
|
||||
}
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user