diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index ab27b10..fe0e4d4 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -67,9 +67,14 @@ _DEMO_TEMPLATES = [ ("market", "涨停封板", ["signal_limit_up"], "critical"), ("market", "连板异动", ["signal_limit_up"], "warn"), ("market", "炸板", ["signal_broken_limit_up"], "warn"), - ("strategy", "策略「趋势突破」买入信号", ["signal_n_day_high", "signal_volume_surge"], "info"), - ("strategy", "策略「趋势突破」卖出信号", ["signal_ma20_breakdown"], "info"), - ("strategy", "策略「新低反转」买入信号", ["signal_n_day_low"], "warn"), + # 新策略变更格式 + ("strategy", "策略「趋势突破」进入 贵州茅台 +2.3%", ["signal_n_day_high", "signal_volume_surge"], "info"), + ("strategy", "策略「趋势突破」移出 五粮液 -1.5%", ["signal_ma20_breakdown"], "info"), + ("strategy", "策略「新低反转」进入 平安银行 +1.1%", ["signal_n_day_low"], "warn"), + ("strategy", "策略「MACD金叉」移出 比亚迪 -0.8%", ["signal_macd_golden"], "info"), + # 批量变更 + ("strategy", "策略「趋势突破」进入 6 只:平安银行、宁德时代、比亚迪、东方财富、招商银行、立讯精密", [], "info"), + ("strategy", "策略「MACD金叉」移出 7 只:京东方A、平安银行、五粮液、立讯精密、招商银行、东方财富、比亚迪", [], "warn"), ] @@ -87,6 +92,16 @@ def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True): for i in range(count): source, message, signals, severity = _DEMO_TEMPLATES[i % len(_DEMO_TEMPLATES)] sym, name = _DEMO_STOCKS[i % len(_DEMO_STOCKS)] + # 策略类型按消息推导 type: new_entry / dropped, 否则沿用 source + if source == "strategy": + if "进入" in message: + ev_type = "new_entry" + elif "移出" in message: + ev_type = "dropped" + else: + ev_type = "strategy" + else: + ev_type = source # recent 模式: 时间戳从现在往前每条错开 30 秒 (最新在前) ts = now_ms - (i * 30000) if recent else now_ms - random.randint(60, 4320) * 60 * 1000 events.append({ @@ -94,12 +109,12 @@ def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True): "rule_id": f"demo_rule_{i}", "rule_name": message, "source": source, - "type": source, - "symbol": sym, + "type": ev_type, + "symbol": "" if source == "strategy" and ("只:" in message) else sym, "name": name, "message": message, - "price": round(random.uniform(8, 1800), 2), - "change_pct": round(random.uniform(-0.06, 0.098), 4), + "price": round(random.uniform(8, 1800), 2) if not (source == "strategy" and "只:" in message) else None, + "change_pct": round(random.uniform(-0.06, 0.098), 4) if not (source == "strategy" and "只:" in message) else None, "signals": signals, "severity": severity, }) diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 4902231..e0856af 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -161,8 +161,9 @@ from datetime import datetime, timezone def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str], conditions: list[dict], logic: str = "or", cooldown: int = 3600, - severity: str = "info", message: str = "") -> dict: - return monitor_rules.normalize({ + severity: str = "info", message: str = "", + strategy_id: str | None = None, direction: str = "entry") -> dict: + rule = monitor_rules.normalize({ "id": rule_id, "name": name, "type": rtype, @@ -175,6 +176,10 @@ def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[st "message": message, "enabled": True, }) + if rtype == "strategy": + rule["strategy_id"] = strategy_id + rule["direction"] = direction + return rule _DEMO_RULES_TEMPLATE = [ @@ -197,16 +202,34 @@ _DEMO_RULES_TEMPLATE = [ [{"field": "signal_ma20_breakdown", "op": "truth"}], "or", "info"), ] +# 策略类型单独声明 (格式不同: 含 strategy_id + direction) +_DEMO_STRATEGY_RULES: list[dict] = [ + {"name": "策略监控 · 趋势突破", "strategy_id": "trend_breakout", "direction": "entry"}, + {"name": "策略监控 · MACD金叉", "strategy_id": "macd_golden", "direction": "both"}, +] + @router.post("/seed") def seed_demo_rules(request: Request): - """生成演示监控规则 (Dev 页用)。覆盖 signal/price/market 三类。""" + """生成演示监控规则 (Dev 页用)。覆盖 signal/price/market/strategy 四类。""" ts = int(_time.time() * 1000) created = [] - for i, (name, rtype, scope, symbols, conditions, logic, severity, sev) in enumerate(_DEMO_RULES_TEMPLATE): + i = 0 + for (name, rtype, scope, symbols, conditions, logic, severity, sev) in _DEMO_RULES_TEMPLATE: rule_id = f"demo_{ts}_{i}" rule = _demo_rule(rule_id, name, rtype, scope, symbols, conditions, logic, 3600, sev) monitor_rules.save_one(_data_dir(request), rule) created.append(rule_id) + i += 1 + # 策略类型规则 + for sr in _DEMO_STRATEGY_RULES: + rule_id = f"demo_{ts}_{i}" + rule = _demo_rule( + rule_id, sr["name"], "strategy", "all", [], [], "and", 3600, "info", + strategy_id=sr["strategy_id"], direction=sr.get("direction", "entry"), + ) + monitor_rules.save_one(_data_dir(request), rule) + created.append(rule_id) + i += 1 _sync_engine(request) return {"ok": True, "generated": len(created), "ids": created} diff --git a/backend/app/main.py b/backend/app/main.py index 219c4f2..1894acc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -119,6 +119,7 @@ async def lifespan(app: FastAPI): from app.services import preferences monitor_engine = MonitorRuleEngine() monitor_engine.set_strategy_engine(strategy_engine) + monitor_engine.set_data_dir(store.data_dir) # 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页) try: diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py index 0346a9f..38fa054 100644 --- a/backend/app/strategy/ai_generator.py +++ b/backend/app/strategy/ai_generator.py @@ -18,12 +18,17 @@ GUIDE_PATH = Path(__file__).resolve().parent.parent.parent.parent / "docs" / "st _SYSTEM_PREFIX = """你是A股量化策略设计专家。根据用户描述的需求,参考下方的《策略开发指南》生成一个完整的策略Python文件。 +核心约束: +- 只创建这一个 .py 文件,不要修改任何现有文件,不要跨文件引用 +- 只 import polars as pl,不 import 其他模块 + 要求: 1. 用户可能调整的策略阈值通过 META["params"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化 -2. 遵循指南中的文件结构模板,但优先贴合用户规则,不要为了套模板歪曲策略含义 -3. 优先使用 Polars 表达式、窗口函数、聚合和 with_columns/filter 实现,避免逐行/逐股 Python 循环;只有表达式难以描述的复杂状态机才使用 partition_by/to_dicts -4. 只 import polars as pl,不 import 其他模块 -5. 直接输出Python代码,不要输出其他内容 +2. 遵循指南中的文件结构,但优先贴合用户规则,不要为了套模板歪曲策略含义 +3. ENTRY_SIGNALS/EXIT_SIGNALS 根据策略逻辑自行选择匹配的信号列,不要照搬示例 +4. scoring 权重根据策略核心逻辑定制,总和 = 1.0 +5. 优先使用 Polars 表达式、窗口函数、聚合和 with_columns/filter 实现,避免逐行/逐股 Python 循环;只有表达式难以描述的复杂状态机才使用 partition_by/to_dicts +6. 直接输出Python代码,不要输出其他内容 --- 策略开发指南 --- diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 03cdc5f..41cf219 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import datetime as _dt import logging import time from dataclasses import dataclass, field @@ -19,6 +20,7 @@ from typing import Any, Callable import polars as pl from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器 +from app.strategy import config as _strategy_config logger = logging.getLogger(__name__) @@ -267,14 +269,22 @@ class MonitorRuleEngine: self._rules: dict[str, dict] = {} # rule_id → rule # (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。 self._last_fire: dict[tuple[str, str], float] = {} - self._strategy_engine = None # 延迟注入, type=strategy 规则用它读策略信号 + self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股 # symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填) self._name_map: dict[str, str] = {} + # 策略选股池状态: strategy_id → 上期选股符号集合 (用于 diff 变更) + self._strategy_pools: dict[str, set[str]] = {} + # 数据目录 (用于加载策略 overrides) + self._data_dir = None def set_strategy_engine(self, engine) -> None: - """注入 StrategyEngine, type=strategy 规则据此读策略的 entry/exit_signals。""" + """注入 StrategyEngine, type=strategy 规则据此跑选股。""" self._strategy_engine = engine + def set_data_dir(self, data_dir) -> None: + """注入数据目录, 用于加载策略的用户覆盖配置。""" + self._data_dir = data_dir + def set_name_map(self, name_map: dict[str, str]) -> None: """注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。 @@ -346,15 +356,17 @@ class MonitorRuleEngine: return [] # 2. 根据 type 构建命中集 - hit_rows: list[tuple[str, Any, Any, Any, list[str]]] = [] # (symbol,name,price,pct,signals) + # 元组格式: (event_type, symbol, name, price, pct, signals) + hit_rows: list[tuple[str, str, Any, Any, Any, list[str]]] = [] rtype = rule.get("type", "signal") if rtype == "strategy": - # 策略类型: 从 StrategyEngine 读策略的 entry/exit_signals, 按 direction 评估 + # 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件 hit_rows = self._match_strategy(scoped, rule) else: # signal / price / market: 通用条件匹配 - hit_rows = self._match_conditions(scoped, rule) + for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule): + hit_rows.append((rtype, sym, name, price, pct, hit_sigs)) if not hit_rows: return [] @@ -362,26 +374,36 @@ class MonitorRuleEngine: # 3. cooldown 去重 + 生成 events cooldown = rule.get("cooldown_seconds", 3600) severity = rule.get("severity", "info") - message = rule.get("message", "") or self._default_message(rule) - source = rtype if rtype != "strategy" else "strategy" - ev_type = rule.get("direction", "entry") if rtype == "strategy" else rtype + source = rtype events: list[dict] = [] - for sym, name, price, pct, hit_sigs in hit_rows: - key = (rule["id"], sym) + for ev_type, sym, name, price, pct, hit_sigs in hit_rows: + # cooldown 键: 批量事件用特殊键, 单只事件用 (rule_id, symbol) + is_batch = sym == "_batch" + if is_batch: + key = (rule["id"], f"_{ev_type}_batch") + else: + key = (rule["id"], sym) last = self._last_fire.get(key) if last is not None and (now - last) < cooldown: continue # 冷却期内, 跳过 self._last_fire[key] = now - # enriched DataFrame 已 drop name 列 → 从注入的 name_map 回填 (instruments 表) - resolved_name = name if name else self._name_map.get(sym) + + # 批量事件: name 存放预构建的消息文本 + if is_batch: + resolved_name = "" + message = name # name 字段即批量消息 + else: + resolved_name = name if name else self._name_map.get(sym) + message = rule.get("message", "") or self._default_message(rule, ev_type=ev_type, sym=sym, name=resolved_name, pct=pct) + ev = { "ts": int(now * 1000), "rule_id": rule["id"], "rule_name": rule.get("name", ""), "source": source, "type": ev_type, - "symbol": sym, + "symbol": "" if is_batch else sym, "name": resolved_name, "message": message, "price": price, @@ -417,12 +439,12 @@ class MonitorRuleEngine: def _match_strategy( self, df: pl.DataFrame, rule: dict, - ) -> list[tuple[str, Any, Any, Any, list[str]]]: - """策略类型评估: 从 StrategyEngine 读策略信号, 按 direction 用 OR 匹配。 + ) -> list[tuple[str, str, Any, Any, Any, list[str]]]: + """策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。 - direction=entry → 策略 entry_signals - direction=exit → 策略 exit_signals - direction=both → entry + exit 合并 (命中信号名区分来源) + 返回 [(event_type, symbol, name, price, pct, signals)] + event_type: "new_entry" (新入选) | "dropped" (已移出) + 单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch") """ if self._strategy_engine is None: return [] @@ -436,18 +458,100 @@ class MonitorRuleEngine: if s is None: return [] - direction = rule.get("direction", "entry") - # 收集要评估的信号 (OR 组合), 与旧 StrategyMonitorService 行为一致 - sigs: list[str] = [] - if direction in ("entry", "both"): - sigs.extend(s.entry_signals or []) - if direction in ("exit", "both"): - sigs.extend(s.exit_signals or []) - if not sigs: + # 需要历史数据的策略跳过 (实时监控不支持 history loader) + if s.filter_history_fn: + logger.debug("策略 %s 需要历史数据, 跳过实时监控", sid) return [] - # 复用旧的 _check_signals 静态方法 (已支持 signal_/csg_ 前缀) - return StrategyMonitorService._check_signals(df, sigs) + # 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载 + overrides = {} + if self._data_dir: + try: + overrides = _strategy_config.load_override(self._data_dir, sid) + except Exception: + pass + + try: + result = self._strategy_engine.run( + sid, + as_of=_dt.date.today(), + precomputed=df, + overrides=overrides, + ) + except Exception as e: + logger.warning("策略 %s 选股执行失败: %s", sid, e) + return [] + + current_pool: set[str] = {r["symbol"] for r in result.rows} + prev_pool = self._strategy_pools.get(sid) + + # 首次运行: 仅记录当前选股池, 不产生事件 + if prev_pool is None: + self._strategy_pools[sid] = 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[sid] = current_pool + + 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 + + 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, [])) + + return results @staticmethod def _match_conditions( @@ -473,12 +577,11 @@ class MonitorRuleEngine: results.append((sym, name, price, pct, hit_sigs)) return results - def _default_message(self, rule: dict) -> str: - """生成默认 message。策略类型带策略名 + 方向 (对齐 demo 模板格式, 让前端可高亮)。""" + def _default_message(self, rule: dict, ev_type: str = "", sym: str = "", + name: str = "", pct: Any = None) -> str: + """生成默认 message。策略类型按变更方向生成。""" rtype = rule.get("type", "signal") if rtype == "strategy": - direction = rule.get("direction", "entry") - action = {"entry": "买入", "exit": "卖出", "both": "买卖"}.get(direction, "买入") # 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分 sname = "" sid = rule.get("strategy_id") @@ -491,6 +594,20 @@ class MonitorRuleEngine: if not sname: rn = rule.get("name", "") sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略") - return f"策略「{sname}」{action}信号" + + if ev_type == "new_entry": + 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}」变更" + name_map = {"signal": "信号触发", "price": "价格触发", "market": "市场异动"} return name_map.get(rtype, "监控触发") diff --git a/docs/strategy-builder-step1.md b/docs/strategy-builder-step1.md index 4670e6d..9d62891 100644 --- a/docs/strategy-builder-step1.md +++ b/docs/strategy-builder-step1.md @@ -1,73 +1,87 @@ # 步骤 1:根据规则生成完整策略 -你是A股量化策略工程师。用户提供策略信息,你输出完整的 `.py` 策略文件(包含参数、信号、告警、评分)。 +你是A股量化策略工程师。用户提供策略信息,你输出完整的 `.py` 策略文件。 -**核心原则:贴合用户需求,不要强行套用预设字段。** 数据中已有的指标列和信号列可以用,但如果用户需求涉及自定义概念(如"前高""上次涨停价""N日内某事件后X天"),直接在代码中自行计算,不要为了用已有列而歪曲用户本意。 +## 核心约束 -**性能原则:优先使用 Polars 语法。** 单日策略用 `pl.Expr` 组合条件;历史窗口策略优先用 `with_columns`、`over("symbol")`、`group_by`、`join`、`filter` 等向量化写法。只有复杂状态机难以用表达式描述时,才使用 `partition_by("symbol")` + `to_dicts()` 的 Python 循环。 - -## 输入格式 - -用户会提供: -- 策略名称(中文) -- 策略描述(一句话) -- 选股方向:做多 / 做空 / 监控 -- 策略规则(自然语言描述筛选逻辑) +- **只创建这一个 .py 文件,不要修改任何现有文件,不要跨文件引用** +- 只 import polars as pl,不 import 其他模块 +- 贴合用户需求优先:不要为了套模板而歪曲策略含义 ## 选择策略模式 **先分析用户规则,判断使用哪种模式:** ### 模式 A:单日过滤(filter) -当所有条件都是当日指标的比较,且不需要回溯历史时使用。例如: +所有条件都是当日指标的比较,不需要回溯历史。例如: - "收盘价 > ma5 或 ma10" - "RSI < 30" - "放量(量比 > 2)" ### 模式 B:历史窗口(filter_history) -当规则涉及以下任何时序/回溯逻辑时使用: +规则涉及以下任何时序/回溯逻辑时使用: - "最近 N 天内出现过涨停/金叉/某信号" - "涨停后的第 X 天" -- "上次涨停的收盘价"、"前高"、"前低" +- "上次涨停价"、"前高"、"前低" - "连续 N 天阴跌/阳线" - 任何需要多天数据才能判断的条件 -- 任何用户自定义的、需要从历史数据中计算的概念 ## 你必须完成的全部内容 输出完整的 Python 策略文件,包含: -1. META(含 params、scoring) -2. ENTRY_SIGNALS / EXIT_SIGNALS(根据方向和策略逻辑选择) -3. STOP_LOSS / MAX_HOLD_DAYS -4. ALERTS -5. RULES(中文逐条列出核心逻辑) -6. filter() 或 filter_history() 函数 -### 模式 A 模板 +1. **META**:id(name, description, tags, params, scoring, basic_filter, limit 等) +2. **ENTRY_SIGNALS / EXIT_SIGNALS**:根据策略逻辑自行选择合适的信号列(参考下方可用信号表),不要照抄示例 +3. **STOP_LOSS / MAX_HOLD_DAYS**:根据策略类型合理设定,做多止损一般为 -5%~-8%,短线持有 5~20 天 +4. **ALERTS**:列出需要监控提醒的条件 +5. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整 +6. **filter() 或 filter_history()**:核心筛选逻辑 + +## 性能原则 + +- 优先用 Polars 表达式、`with_columns`、`over("symbol")`、`group_by`、`join`、`filter` +- 只有复杂状态机难以用表达式描述时,才用 `partition_by("symbol")` + `to_dicts()` + +--- + +## 模式 A 框架(单日过滤) ```python -"""策略描述""" +"""策略简短描述""" import polars as pl META = { - "id": "english_id", + "id": "ai_xxxxxxxxxxxx", # 使用用户提供的 strategy_id "name": "用户给的名称", "description": "用户给的描述", + "tags": ["根据策略添加标签"], + "basic_filter": { + "price_min": 3, # 根据策略调整 + "price_max": 200, + "market_cap_min": 10e8, + "amount_min": 0.5e8, + "exclude_st": True, + "exclude_new_days": 30, + }, "params": [ - {"id": "param_id", "label": "中文名", "type": "float", "default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1}, + # 只把用户可能调节的阈值放这里;每个参数含 id/label/type/default/min/max/step ], "scoring": { - "momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3, + # 根据策略核心逻辑定制权重,总和 = 1.0 }, "order_by": "score", "descending": True, "limit": 100, } -ENTRY_SIGNALS = ["signal_broken_board_recovery"] -EXIT_SIGNALS = ["signal_ma20_breakdown"] +# 根据策略逻辑选择合适的信号,见下方可用信号表 +ENTRY_SIGNALS = [] +EXIT_SIGNALS = [] + +# 根据策略类型设定 STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 + ALERTS = [] RULES = """ @@ -77,29 +91,35 @@ RULES = """ """ def filter(df: pl.DataFrame, params: dict) -> pl.Expr: - param_val = params.get("param_id", 2.0) - return ( - ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10"))) - & pl.col("signal_broken_board_recovery").fill_null(False) - & (pl.col("vol_ratio_5d") >= param_val) - ) + """策略核心过滤逻辑,返回 Polars 布尔表达式。""" + # 用 params.get("param_id", 默认值) 读取参数 + return pl.col("<字段>") > pl.col("<字段>") # 替换为实际逻辑 ``` -### 模式 B 模板 +## 模式 B 框架(历史窗口) ```python -"""策略描述""" +"""策略简短描述""" import polars as pl META = { - "id": "english_id", + "id": "ai_xxxxxxxxxxxx", "name": "用户给的名称", "description": "用户给的描述", + "tags": ["根据策略添加标签"], + "basic_filter": { + "price_min": 3, + "price_max": 200, + "market_cap_min": 10e8, + "amount_min": 0.5e8, + "exclude_st": True, + "exclude_new_days": 30, + }, "params": [ - {"id": "param_id", "label": "中文名", "type": "float", "default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1}, + # 只把用户可能调节的阈值放这里 ], "scoring": { - "momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3, + # 根据策略核心逻辑定制权重,总和 = 1.0 }, "order_by": "score", "descending": True, @@ -108,10 +128,12 @@ META = { LOOKBACK_DAYS = 8 # 根据策略需要的最大回看天数设置 -ENTRY_SIGNALS = ["signal_broken_board_recovery"] -EXIT_SIGNALS = ["signal_ma20_breakdown"] +ENTRY_SIGNALS = [] +EXIT_SIGNALS = [] + STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 + ALERTS = [] RULES = """ @@ -124,52 +146,60 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: if df.is_empty() or "date" not in df.columns: return df - down_pct = float(params.get("prev_down_pct", -0.02)) - vol_ratio = float(params.get("volume_ratio", 1.2)) - tolerance = float(params.get("reversal_tolerance", 0.005)) latest = df["date"].max() + # 用 shift/over 回溯历史数据,或用 group_by 计算窗口聚合 hist = ( df.sort(["symbol", "date"]) .with_columns([ - pl.col("open").shift(1).over("symbol").alias("_prev_open"), - pl.col("high").shift(1).over("symbol").alias("_prev_high"), pl.col("close").shift(1).over("symbol").alias("_prev_close"), - pl.col("volume").shift(1).over("symbol").alias("_prev_volume"), - pl.col("change_pct").shift(1).over("symbol").alias("_prev_change_pct"), + # ... 根据策略需要添加更多回溯列 ]) ) return hist.filter(pl.col("date") == latest).filter( - (pl.col("_prev_close") < pl.col("_prev_open")) - & (pl.col("_prev_change_pct") <= down_pct) - & (pl.col("close") > pl.col("open")) - & (pl.col("close") > pl.col("_prev_open")) - & (pl.col("close") >= pl.col("_prev_high") * (1 - tolerance)) - & (pl.col("volume") >= pl.col("_prev_volume") * vol_ratio) - & ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10"))) + # 在此编写筛选条件 ) ``` -如果是极复杂状态机,Polars 表达式很难清楚表达时,才使用 `partition_by("symbol")` + `to_dicts()` 逐股票分析。 +--- -## 信号匹配指南(参考) +## 可用指标列(参考) -根据用户的选股方向和策略逻辑,从可用信号中选择合适的买入/卖出信号。信号列仅供参考,不强求使用。 +见 [strategy-guide.md](./strategy-guide.md) 第 3 节。 -| 方向 | 推荐买入信号 | 推荐卖出信号 | -|------|-------------|-------------| -| 做多 | signal_n_day_high, signal_ma20_breakout, signal_ma_golden_5_20, signal_ma_golden_20_60, signal_macd_golden, signal_boll_breakout_upper, signal_limit_up, signal_limit_down_recovery | signal_ma20_breakdown, signal_macd_dead, signal_n_day_low | -| 做空 | signal_n_day_low, signal_boll_breakdown_lower | signal_n_day_high, signal_ma_golden_5_20 | -| 监控 | 两者都选 | 两者都选 | +## 可用信号列(参考) + +以下信号列已预计算,**根据策略含义自行选择匹配的**,不要全部照搬: + +| 列名 | 含义 | 方向 | +|------|------|------| +| signal_ma_golden_5_20 | MA5 上穿 MA20 | 买入 | +| signal_ma_dead_5_20 | MA5 下穿 MA20 | 卖出 | +| signal_ma_golden_20_60 | MA20 上穿 MA60 | 买入 | +| signal_macd_golden | MACD 金叉 | 买入 | +| signal_macd_dead | MACD 死叉 | 卖出 | +| signal_ma20_breakout | 突破 MA20 | 买入 | +| signal_ma20_breakdown | 跌破 MA20 | 卖出 | +| signal_n_day_high | 60日新高 | 买入 | +| signal_n_day_low | 60日新低 | 卖出 | +| signal_boll_breakout_upper | 突破布林上轨 | 中性 | +| signal_boll_breakdown_lower | 跌破布林下轨 | 中性 | +| signal_volume_surge | 放量 | 中性 | +| signal_limit_up | 涨停 | 买入 | +| signal_limit_down | 跌停 | 卖出 | +| signal_limit_down_recovery | 跌停翘板 | 买入 | + +**选信号原则**:选和策略逻辑直接相关的,不要凑数。监控类策略两类都选。 + +--- ## 规则 -1. 用户可能调节的数值阈值通过 `META["params"]` 暴露,filter()/filter_history() 中用 `params.get()` 读取;公式常数、固定窗口边界不必强行参数化 +1. 用户可能调节的阈值才放 `params`;公式常数、固定窗口边界不必参数化 2. 信号列使用 `.fill_null(False)` 处理空值 3. `filter()` 只返回 `pl.Expr`,`filter_history()` 返回筛选后的 `DataFrame` 4. scoring 权重总和 = 1.0 -5. `name` 使用用户输入,`description` 写一句简洁摘要 -6. **必须生成 RULES**:格式 `RULES = """\n1. 规则一\n2. 规则二\n3. 规则三\n"""`,用中文逐条列出核心筛选逻辑(至少 3 条),这是用户审阅策略的唯一依据,务必准确完整 -7. **贴合用户需求**:不要为了使用已有字段而改变用户本意。用户说"前高"就是"前高",需要自己算就自己算;用户说"最近涨停后的收盘价"就从历史数据中找,不要用其他近似值替代 -8. **输出前自我检查**:确认 RULES 已生成、Python 语法正确、括号匹配、引号闭合 -9. **优先 Polars**:不要默认生成逐行/逐股 Python 循环;能用表达式、窗口、聚合、join 完成时就用 Polars 语法 -10. 直接输出 Python 代码,不要解释文字 +5. **必须生成 RULES**:用中文逐条列出核心逻辑(至少 3 条),准确完整 +6. **贴合用户需求**:不为了用已有字段而改变用户本意。用户说"前高"就自己算前高 +7. **输出前自我检查**:确认 RULES 完整、语法正确、括号匹配、引号闭合 +8. **优先 Polars**:不要默认生成逐行/逐股 Python 循环 +9. 直接输出 Python 代码,不要解释文字 diff --git a/docs/strategy-guide.md b/docs/strategy-guide.md index 9403f13..f43b3ac 100644 --- a/docs/strategy-guide.md +++ b/docs/strategy-guide.md @@ -32,27 +32,13 @@ META = { "exclude_new_days": 60, # 排除上市N天内新股 }, - # 策略参数 (Stage 2, filter() 使用, 前端渲染为表单) - # 用户可能调节的阈值通过 params 暴露;公式常数、固定窗口边界不必强行参数化 + # 策略参数 (只把用户可能调节的阈值放这里,公式常数不必参数化) + # 每个参数含 id/label/type/default/min/max/step;select 类型用 options "params": [ - { - "id": "param_id", # 参数ID, filter() 中 params.get("param_id") - "label": "参数显示名", # 前端显示 - "type": "float", # float | int | select - "default": 2.0, # 默认值 - "min": 0.5, # 最小值 (float/int) - "max": 10.0, # 最大值 (float/int) - "step": 0.1, # 步长 - # select 类型用 options: - # "options": ["ma5", "ma10", "ma20", "ma60"], - }, ], - # 评分权重 (用于排序, 权重总和 = 1.0) + # 评分权重 (用于排序, 根据策略核心逻辑定制, 权重总和 = 1.0) "scoring": { - "momentum_60d": 0.4, - "vol_ratio_5d": 0.3, - "change_pct": 0.3, }, "order_by": "score", # 排序字段, 通常用 "score" @@ -60,23 +46,20 @@ META = { "limit": 100, # 最多返回条数 } -# 买入信号 (回测 + 监控用, 对应 enriched 表的信号列名) -ENTRY_SIGNALS = ["signal_broken_board_recovery"] +# 买入信号 (回测 + 监控用, 根据策略逻辑选择合适的信号列) +ENTRY_SIGNALS = [] # 卖出信号 -EXIT_SIGNALS = ["signal_ma20_breakdown"] +EXIT_SIGNALS = [] -# 止损 (负数, 如 -0.08 = -8%) -STOP_LOSS = -0.08 +# 止损 (负数, 根据策略类型合理设定, 如做多短线 -0.05~-0.08) +STOP_LOSS = -0.05 -# 最长持有天数 +# 最长持有天数 (短线 5~20, 中线 20~60) MAX_HOLD_DAYS = 20 # 提醒条件 (监控用) -ALERTS = [ - {"field": "signal_broken_board_recovery", "message": "反包信号"}, - {"field": "rsi_14", "op": ">", "value": 80, "message": "RSI超买预警"}, -] +ALERTS = [] # 策略规则(人类可读,逐条编号,至少 3 条) @@ -94,11 +77,10 @@ def filter(df: pl.DataFrame, params: dict) -> pl.Expr: 返回: Polars 布尔表达式 (pl.Expr) """ - vol_min = params.get("vol_ratio_min", 2.0) + # 用 params.get("param_id", 默认值) 读取参数 return ( - ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10"))) - & pl.col("signal_broken_board_recovery").fill_null(False) - & (pl.col("vol_ratio_5d") >= vol_min) + (pl.col("close") > pl.col("ma5")) + & (pl.col("rsi_14") < 30) ) ``` @@ -164,11 +146,19 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: 以下列在数据中已预计算,可直接引用。**但如果这些列无法满足策略需求,可以不用,自行在 `filter_history()` 中基于 enriched 表的数据(已复权,含所有指标列和信号列)计算任何需要的字段。** +### 通用列 + +| 列名 | 类型 | 说明 | +|------|------|------| +| symbol | string | 股票代码 (如 600519.SH) | +| date | date | 交易日期 | + ### 价格相关 | 列名 | 类型 | 说明 | |------|------|------| -| open, high, low, close | float | OHLCV 开高低收 | +| open, high, low, close | float | OHLCV 开高低收 (前复权) | +| raw_close, raw_high, raw_low | float | 原始未复权价 | | prev_close | float | 昨收价 | | change_pct | float | 涨跌幅 (如 0.032 = +3.2%) | | change_amount | float | 涨跌额 | @@ -218,11 +208,19 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: | consecutive_limit_ups | 连续涨停天数 | | consecutive_limit_downs | 连续跌停天数 | +### 运行时附加列(由引擎从 instruments 表 JOIN) + +| 列名 | 说明 | +|------|------| +| name | 股票名称 | +| total_shares | 总股本 | +| float_shares | 流通股本 | + +(`total_shares` 和 `float_shares` 用于 `basic_filter` 中计算市值:`close * total_shares`) + ## 4. 常用信号列(参考) -信号列是布尔值,使用时用 `.fill_null(False)` 处理空值。同样仅供参考,不要求必须使用。 - -信号列是布尔值,**必须**使用 `.fill_null(False)` 处理空值。 +信号列是布尔值,**必须**使用 `.fill_null(False)` 处理空值。同样仅供参考,根据策略含义自行选择匹配的。 | 列名 | 方向 | 说明 | |------|------|------| @@ -241,8 +239,23 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: | signal_limit_up | 买入 | 涨停 | | signal_limit_down | 卖出 | 跌停 | | signal_limit_down_recovery | 买入 | 跌停翘板 | +| signal_broken_limit_up | 卖出 | 炸板 | -## 5. 规则 +此外,用户自定义信号(`data/user_data/custom_signals/`)以 `csg_` 前缀注入,也可在 filter() 中引用。 + +## 5. 不可用的数据(重要) + +以下数据**不在** enriched DataFrame 中,策略代码中**不能**直接引用: + +| 数据 | 说明 | +|------|------| +| 财务数据 (PE/PB/ROE/净利润/营收/资产负债等) | 存储在独立 financials 表,未 JOIN | +| 扩展数据 (概念/行业/人气排名/资金流向等) | 存储在 ext_data 目录,未 JOIN | +| 盘中实时数据 (分时价/五档盘口等) | 仅前端轮询使用 | + +如需财务或扩展数据作为筛选条件,需先在系统层面完成 JOIN 再提供给策略(当前未实现)。 + +## 6. 规则 1. `filter()` 必须返回 `pl.Expr` (用 `&` `|` 组合布尔表达式);`filter_history()` 返回筛选后的 `DataFrame` 2. 信号列使用 `.fill_null(False)` 处理空值 @@ -254,7 +267,7 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: 8. **贴合用户需求优先**:第3/4节的指标列和信号列仅供参考,能用则用;如果用户需求需要自定义计算(如"前高""上次涨停价""N日内某个事件后X天"),直接在 `filter_history()` 中自行设计和计算,不需要局限于已有列 9. `filter_history()` 中优先用 Polars 向量化语法;仅在复杂状态机无法清晰表达时,才用 `partition_by("symbol")` 逐股票分析 -## 6. 策略示例 +## 7. 策略示例 ### 强势反包 @@ -327,6 +340,6 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: ) ``` -## 7. 完整示例 +## 8. 完整示例 见 [strategy-example.md](./strategy-example.md) — 从零创建强势反包策略的三步完整演示。 diff --git a/frontend/src/components/AlertToast.tsx b/frontend/src/components/AlertToast.tsx index 03600cc..cf2f898 100644 --- a/frontend/src/components/AlertToast.tsx +++ b/frontend/src/components/AlertToast.tsx @@ -74,10 +74,12 @@ const SEVERITY_BAR: Record = { info: 'bg-accent', warn: 'bg-warning', critical: 'bg-danger', } const SOURCE_BADGE: Record = { - strategy: { label: '策略', cls: 'bg-amber-400/15 text-amber-400' }, - 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' }, + strategy: { label: '策略', cls: 'bg-amber-400/15 text-amber-400' }, + 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' }, } // ===== 容器 — 挂在 Layout ===== @@ -102,11 +104,18 @@ export function AlertToastContainer() { return (
- {items.map(item => { + {items + .filter(item => !(item.alert.source === 'strategy' && !item.alert.symbol)) + .map(item => { const ev = item.alert const sev = SEVERITY_BAR[ev.severity ?? 'info'] ?? SEVERITY_BAR.info - const badge = SOURCE_BADGE[ev.source] ?? { label: ev.source, cls: 'bg-elevated text-muted' } + 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' return (
- {/* 底行: 触发消息 + 价格 */} -
- - {ev.message && {ev.message}} - {ev.price != null && {fmtPrice(ev.price)}} -
+ {/* 底行: 策略类型走新格式, 其他走旧格式 */} + {isStrategy ? ( +
+ + + {isNew ? '进入' : '移出'} + + 策略 + 「{sname}」 + + {ev.price != null && {fmtPrice(ev.price)}} +
+ ) : ( +
+ + {ev.message && {ev.message}} + {ev.price != null && {fmtPrice(ev.price)}} +
+ )} ) })} diff --git a/frontend/src/components/screener/StrategyBuilderDialog.tsx b/frontend/src/components/screener/StrategyBuilderDialog.tsx index 795e2dc..8f9bd9c 100644 --- a/frontend/src/components/screener/StrategyBuilderDialog.tsx +++ b/frontend/src/components/screener/StrategyBuilderDialog.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { X, Sparkles, Save, Loader2, ChevronLeft, ChevronRight, AlertTriangle, Settings2 } from 'lucide-react' +import { X, Sparkles, Save, Loader2, ChevronLeft, ChevronRight, AlertTriangle, Settings2, FileText, Copy, Check, Terminal } from 'lucide-react' import { api } from '@/lib/api' import { storage } from '@/lib/storage' +import { cn } from '@/lib/cn' // ===== 工具函数 ===== @@ -76,12 +77,55 @@ const DIRECTIONS = [ // ===== 组件 ===== +const CUSTOM_TEMPLATE = `"""策略简短描述""" +import polars as pl + +META = { + "id": "custom_my_strategy", + "name": "我的策略", + "description": "策略描述", + "tags": ["自定义"], + "basic_filter": { + "price_min": 3, "price_max": 200, + "market_cap_min": 10e8, "amount_min": 0.5e8, + "exclude_st": True, "exclude_new_days": 30, + }, + "params": [], + "scoring": { + "change_pct": 0.5, "vol_ratio_5d": 0.5, + }, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_n_day_high"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 20 +ALERTS = [] + +RULES = """ +1. 规则一 +2. 规则二 +3. 规则三 +""" + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + return ( + (pl.col("close") > pl.col("ma20")) + & (pl.col("volume") > pl.col("vol_ma5") * 1.5) + ) +` + interface Props { open: boolean; onClose: () => void; onSavedId?: (id: string) => void | Promise; mode?: 'create' | 'modify' } export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create' }: Props) { // 根据 mode 选择存储 key const draftStore = mode === 'modify' ? storage.strategyModify : storage.strategyDraft const [step, setStep] = useState(1) + const [tab, setTab] = useState<'ai' | 'custom'>('ai') + const [customCopied, setCustomCopied] = useState(false) const [name, setName] = useState('') const [description, setDescription] = useState('') const [direction, setDirection] = useState('long') @@ -217,23 +261,51 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create className="w-[820px] max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"> {/* 标题 */} -
-
- - - {strategyId ? '修改策略 · ' + (parseMetaField(code, 'name') || strategyId) : '创建策略'} - +
+ {/* 左侧:Tab 切换 */} +
+ +
-
- 1 - - 2 + {/* 中间:标题 */} + + {strategyId ? '修改策略' : '创建策略'} + + {/* 右侧:步骤 + 关闭 */} +
+ {tab === 'ai' && ( +
+ 1 + + 2 +
+ )} +
- +
+ + {/* Tab 描述 */} +
+ {tab === 'ai' ? ( +
+ + 步骤 1 描述策略规则 → 步骤 2 预览代码 → 保存 +
+ ) : ( +
+ + 适合有 Python 基础的开发者,手动编写策略文件进行深度定制和二次开发 +
+ )}
{/* 内容 */}
+ {tab === 'ai' ? (<> {aiStatus && !aiStatus.configured && (
@@ -370,9 +442,47 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create

修改指令可调整参数、信号、告警、评分等任意内容。确认无误后点击「保存策略」。

)} + + ) : ( + /* 自定义编写 */ +
+
+
+ + 自定义策略开发方式 +
+
+

在项目目录 data/strategies/custom/ 下创建 .py 文件。支持两种模式:

+
+
+ + 模式 A:单日过滤filter(df, params) → pl.Expr +
+
+ + 模式 B:历史窗口filter_history(df, params) → pl.DataFrame + LOOKBACK_DAYS +
+
+

完整规范见 docs/strategy-guide.md

+
+
+
+
+ 快速模板 + +
+
{CUSTOM_TEMPLATE}
+
+
+ )}
{/* 底部 */} + {tab === 'ai' && (
@@ -395,6 +505,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create )}
+ )} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0b01506..c043d41 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -110,9 +110,15 @@ function MonitorWidget() { return ( <>
- {events.map((ev, i) => { + {events + .filter((ev: AlertEvent) => !(ev.source === 'strategy' && !ev.symbol)) + .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' return ( )}
- {/* 第二行: 分类标签 + 触发消息 + 时间 */} -
- - {_SOURCE_LABEL[ev.source] ?? ev.source} - - {ev.message && ( - {ev.message} - )} - - {ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''} - -
- {/* 第三行: 命中信号 (买入/卖出触发器) */} - {ev.signals && ev.signals.length > 0 && ( -
- {ev.signals.map((s, j) => ( - {cnSignal(s)} - ))} + {/* 第二行: 策略类型走新格式, 其他走旧格式 */} + {isStrategy ? ( +
+ + {isNew ? '进入' : '移出'} + + 策略 + 「{sname}」 + + + {ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''} +
+ ) : ( + <> +
+ + {_SOURCE_LABEL[ev.source] ?? ev.source} + + {ev.message && ( + {ev.message} + )} + + {ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''} + +
+ {ev.signals && ev.signals.length > 0 && ( +
+ {ev.signals.map((s, j) => ( + {cnSignal(s)} + ))} +
+ )} + )} ) diff --git a/frontend/src/pages/Dev.tsx b/frontend/src/pages/Dev.tsx index 13c2428..2b52161 100644 --- a/frontend/src/pages/Dev.tsx +++ b/frontend/src/pages/Dev.tsx @@ -287,7 +287,7 @@ function SeedPanel() {

监控规则

- 生成多种类型的演示监控规则 (个股信号/价格/市场异动),用于测试监控中心规则列表展示。 + 生成多种类型的演示监控规则 (个股信号/价格/市场异动/策略变更),用于测试监控中心规则列表展示。

diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 0310398..5d4d6e1 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -32,13 +32,13 @@ const SOURCE_BADGE_STYLE: Record = { } /** - * 渲染策略类消息 — 策略名黄色、买入红、卖出绿、其余白色。 + * 渲染策略类消息 — 策略名黄色、新入选绿、移出红、其余白色。 */ function renderMessage(source: string, message: string) { if (source !== 'strategy') { return {message} } - const m = message.match(/^(.*?「)([^」]+)(」)(买入|卖出)(信号.*)$/) + const m = message.match(/^(策略「)([^」]+)(」)(新入选|移出)( .*)$/) if (!m) return {message} const [, pre, strategyName, mid, direction, post] = m return ( @@ -46,7 +46,7 @@ function renderMessage(source: string, message: string) { {pre} {strategyName} {mid} - {direction} + {direction} {post} ) @@ -248,7 +248,9 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs /> ) : (
- {events.map((ev: any, i: number) => { + {events + .filter((ev: any) => !(ev.source === 'strategy' && !ev.symbol)) + .map((ev: any, i: number) => { const sev = SEVERITY_CONFIG[ev.severity ?? 'info'] ?? SEVERITY_CONFIG.info const SevIcon = sev.icon const isNew = ev.ts > enterTs @@ -272,55 +274,110 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
-
- {ev.symbol && (() => { - const board = boardTag(ev.symbol) - return ( - + ) + })()} + {ev.price != null && ( + = 0 ? 'text-danger' : 'text-bear')}> + {_pct >= 0 ? : } + {fmtPrice(ev.price)} )} - {ev.name && {ev.name}} - - ) - })()} - - {(() => { - // 优先用规则名 (如 "策略监控 · 空中加油" → "空中加油"); 退回到 type 标签 - const rn = ev.rule_name ?? '' - const dotIdx = rn.indexOf(' · ') - return dotIdx >= 0 ? rn.slice(dotIdx + 3) : (rn || (TYPE_LABEL[ev.source] ?? ev.source)) - })()} - -
-
- {renderMessage(ev.source, ev.message)} -
-
- {ev.price != null && ( - {fmtPrice(ev.price)} - )} - {ev.change_pct != null && ( - = 0 ? 'text-danger' : 'text-bear')}> - {ev.change_pct >= 0 ? : } - {fmtPct(ev.change_pct)} - - )} -
- {ev.signals && ev.signals.length > 0 && ( -
- {ev.signals.map((s: string, j: number) => ( - {cnSignal(s)} - ))} -
+ {ev.change_pct != null && ( + = 0 ? 'text-danger' : 'text-bear')}> + {fmtPct(_pct)} + + )} + + {sname} + +
+
+ + {isNew ? '进入' : '移出'} + + 策略 + 「{sname}」 +
+ + ) + })() : ( + <> +
+ {ev.symbol && (() => { + const board = boardTag(ev.symbol) + return ( + + ) + })()} + {ev.price != null && ( + = 0 ? 'text-danger' : 'text-bear')}> + {(ev.change_pct ?? 0) >= 0 ? : } + {fmtPrice(ev.price)} + + )} + {ev.change_pct != null && ( + = 0 ? 'text-danger' : 'text-bear')}> + {fmtPct(ev.change_pct)} + + )} + + {(() => { + // 优先用规则名 (如 "策略监控 · 空中加油" → "空中加油"); 退回到 type 标签 + const rn = ev.rule_name ?? '' + const dotIdx = rn.indexOf(' · ') + return dotIdx >= 0 ? rn.slice(dotIdx + 3) : (rn || (TYPE_LABEL[ev.source] ?? ev.source)) + })()} + +
+
+ {renderMessage(ev.source, ev.message)} +
+ {ev.signals && ev.signals.length > 0 && ( +
+ {ev.signals.map((s: string, j: number) => ( + {cnSignal(s)} + ))} +
+ )} + )}
@@ -408,20 +465,6 @@ function RulesList({ rulesQuery, onEdit }: { }) const symbolNames = namesQuery.data?.names ?? {} - // 查策略详情 (建 strategy_id → {entry, exit} signals 映射) - const strategiesQuery = useQuery({ - queryKey: QK.screenerStrategies, - queryFn: () => api.strategyList(), - staleTime: 300000, - }) - const strategySignals = useMemo(() => { - const m: Record = {} - for (const s of strategiesQuery.data?.strategies ?? []) { - m[s.id] = { entry: s.entry_signals ?? [], exit: s.exit_signals ?? [] } - } - return m - }, [strategiesQuery.data]) - const del = useMutation({ mutationFn: api.monitorRuleDelete, onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }), @@ -534,30 +577,10 @@ function RulesList({ rulesQuery, onEdit }: {
- {/* 第二行: 触发条件 (策略类型显示买卖信号) */} + {/* 第二行: 策略类型显示选股池变更监控 */} {r.type === 'strategy' && r.strategy_id ? ( -
- {(() => { - const sigs = strategySignals[r.strategy_id] - const entrySigs = (sigs?.entry ?? []).map(s => cnSignal(s)) - const exitSigs = (sigs?.exit ?? []).map(s => cnSignal(s)) - return ( - <> - {(r.direction === 'entry' || r.direction === 'both') && entrySigs.length > 0 && ( - - - {entrySigs.join('、')} - - )} - {(r.direction === 'exit' || r.direction === 'both') && exitSigs.length > 0 && ( - - - {exitSigs.join('、')} - - )} - - ) - })()} +
+ 选股池变更监控
) : r.conditions.length > 0 && (
diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 410cade..41a04e9 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -562,7 +562,7 @@ export function Screener() {