mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
优化策略监控与 AI 生成流程
- 策略监控从全市场信号扫描改为选股池变更对比(进入/移出) - 触发记录/看板/弹窗三处统一策略通知格式 - AI 策略模板去掉硬编码信号/止损/评分,补全数据字典 - 创建策略对话框改为 AI生成/自定义编写 双 Tab - 自定义 Tab 含文件路径说明+模板代码一键复制
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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代码,不要输出其他内容
|
||||
|
||||
--- 策略开发指南 ---
|
||||
|
||||
|
||||
+150
-33
@@ -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, "监控触发")
|
||||
|
||||
+100
-70
@@ -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 代码,不要解释文字
|
||||
|
||||
+51
-38
@@ -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) — 从零创建强势反包策略的三步完整演示。
|
||||
|
||||
@@ -74,10 +74,12 @@ const SEVERITY_BAR: Record<string, string> = {
|
||||
info: 'bg-accent', warn: 'bg-warning', critical: 'bg-danger',
|
||||
}
|
||||
const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
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 (
|
||||
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 w-[320px] pointer-events-none">
|
||||
<AnimatePresence>
|
||||
{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 (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
@@ -139,12 +148,25 @@ export function AlertToastContainer() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 底行: 触发消息 + 价格 */}
|
||||
<div className="mt-1 flex items-center gap-2 pl-0.5">
|
||||
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
|
||||
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
|
||||
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
|
||||
</div>
|
||||
{/* 底行: 策略类型走新格式, 其他走旧格式 */}
|
||||
{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>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center gap-2 pl-0.5">
|
||||
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
|
||||
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
|
||||
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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<void>; 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">
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Sparkles className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{strategyId ? '修改策略 · ' + (parseMetaField(code, 'name') || strategyId) : '创建策略'}
|
||||
</span>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center px-5 py-2.5 border-b border-border/50">
|
||||
{/* 左侧:Tab 切换 */}
|
||||
<div className="flex rounded-lg bg-elevated p-0.5 w-fit">
|
||||
<button onClick={() => setTab('ai')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'ai' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-foreground')}>
|
||||
<Sparkles className="h-3 w-3 inline mr-1" />AI 生成
|
||||
</button>
|
||||
<button onClick={() => setTab('custom')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'custom' ? 'bg-accent/15 text-accent' : 'text-muted hover:text-foreground')}>
|
||||
<FileText className="h-3 w-3 inline mr-1" />自定义编写
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={'w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 1 ? 'bg-amber-400/20 text-amber-400' : 'bg-emerald-400/20 text-emerald-400')}>1</span>
|
||||
<span className="text-muted/30">—</span>
|
||||
<span className={'w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 2 ? 'bg-amber-400/20 text-amber-400' : 'bg-border/50 text-muted')}>2</span>
|
||||
{/* 中间:标题 */}
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{strategyId ? '修改策略' : '创建策略'}
|
||||
</span>
|
||||
{/* 右侧:步骤 + 关闭 */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{tab === 'ai' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={'w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 1 ? 'bg-amber-400/20 text-amber-400' : 'bg-emerald-400/20 text-emerald-400')}>1</span>
|
||||
<span className="text-muted/20 text-[10px]">—</span>
|
||||
<span className={'w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 2 ? 'bg-amber-400/20 text-amber-400' : 'bg-border/50 text-muted')}>2</span>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleClose} className="p-1.5 rounded-lg hover:bg-elevated"><X className="h-4 w-4 text-muted" /></button>
|
||||
</div>
|
||||
<button onClick={handleClose} className="p-1.5 rounded-lg hover:bg-elevated"><X className="h-4 w-4 text-muted" /></button>
|
||||
</div>
|
||||
|
||||
{/* Tab 描述 */}
|
||||
<div className="px-5 py-2 border-b border-border/30 bg-elevated/30">
|
||||
{tab === 'ai' ? (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Sparkles className="h-3.5 w-3.5 text-amber-400 shrink-0" />
|
||||
<span className="text-amber-400/80">步骤 1 描述策略规则 → 步骤 2 预览代码 → 保存</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Terminal className="h-3.5 w-3.5 text-accent shrink-0" />
|
||||
<span className="text-muted">适合有 Python 基础的开发者,手动编写策略文件进行深度定制和二次开发</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-4">
|
||||
{tab === 'ai' ? (<>
|
||||
{aiStatus && !aiStatus.configured && (
|
||||
<div className="rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 flex items-center gap-3">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
|
||||
@@ -370,9 +442,47 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
|
||||
<p className="text-[10px] text-muted/40">修改指令可调整参数、信号、告警、评分等任意内容。确认无误后点击「保存策略」。</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* 自定义编写 */
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-border/40 bg-elevated/50 p-4 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-foreground">自定义策略开发方式</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-[11px] text-secondary leading-relaxed">
|
||||
<p>在项目目录 <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">data/strategies/custom/</code> 下创建 <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">.py</code> 文件。支持两种模式:</p>
|
||||
<div className="space-y-1 pl-1">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-accent/60 shrink-0" />
|
||||
<span><strong className="text-foreground/80">模式 A:单日过滤</strong> — <code className="text-[10px] font-mono text-foreground/80">filter(df, params) → pl.Expr</code></span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-amber-400/60 shrink-0" />
|
||||
<span><strong className="text-foreground/80">模式 B:历史窗口</strong> — <code className="text-[10px] font-mono text-foreground/80">filter_history(df, params) → pl.DataFrame</code> + <code className="text-[10px] font-mono text-foreground/80">LOOKBACK_DAYS</code></span>
|
||||
</div>
|
||||
</div>
|
||||
<p>完整规范见 <span className="text-accent">docs/strategy-guide.md</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">快速模板</span>
|
||||
<button onClick={() => { navigator.clipboard.writeText(CUSTOM_TEMPLATE); setCustomCopied(true); setTimeout(() => setCustomCopied(false), 2000) }}
|
||||
className={cn('inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium transition-all cursor-pointer', customCopied ? 'bg-emerald-400/10 text-emerald-400' : 'bg-elevated text-muted hover:text-foreground hover:bg-accent/10')}>
|
||||
{customCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{customCopied ? '已复制' : '复制模板'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="rounded-xl border border-border/40 bg-base p-4 text-[10px] leading-relaxed font-mono text-foreground/70 overflow-auto max-h-[400px]">{CUSTOM_TEMPLATE}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
{tab === 'ai' && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-border/50 bg-surface/50">
|
||||
<button onClick={clearDraft} className="text-[10px] text-muted/40 hover:text-danger transition-colors">重新创建</button>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -395,6 +505,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -110,9 +110,15 @@ function MonitorWidget() {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{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 (
|
||||
<motion.div
|
||||
key={`${ev.ts}-${i}`}
|
||||
@@ -150,25 +156,40 @@ function MonitorWidget() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 第二行: 分类标签 + 触发消息 + 时间 */}
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
|
||||
{_SOURCE_LABEL[ev.source] ?? ev.source}
|
||||
</span>
|
||||
{ev.message && (
|
||||
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
|
||||
)}
|
||||
<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((s, j) => (
|
||||
<span key={j} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(s)}</span>
|
||||
))}
|
||||
{/* 第二行: 策略类型走新格式, 其他走旧格式 */}
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
|
||||
{_SOURCE_LABEL[ev.source] ?? ev.source}
|
||||
</span>
|
||||
{ev.message && (
|
||||
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
|
||||
)}
|
||||
<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((s, j) => (
|
||||
<span key={j} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(s)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -287,7 +287,7 @@ function SeedPanel() {
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">监控规则</h3>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
生成多种类型的演示监控规则 (个股信号/价格/市场异动),用于测试监控中心规则列表展示。
|
||||
生成多种类型的演示监控规则 (个股信号/价格/市场异动/策略变更),用于测试监控中心规则列表展示。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
+111
-88
@@ -32,13 +32,13 @@ const SOURCE_BADGE_STYLE: Record<string, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染策略类消息 — 策略名黄色、买入红、卖出绿、其余白色。
|
||||
* 渲染策略类消息 — 策略名黄色、新入选绿、移出红、其余白色。
|
||||
*/
|
||||
function renderMessage(source: string, message: string) {
|
||||
if (source !== 'strategy') {
|
||||
return <span className="text-secondary">{message}</span>
|
||||
}
|
||||
const m = message.match(/^(.*?「)([^」]+)(」)(买入|卖出)(信号.*)$/)
|
||||
const m = message.match(/^(策略「)([^」]+)(」)(新入选|移出)( .*)$/)
|
||||
if (!m) return <span className="text-foreground">{message}</span>
|
||||
const [, pre, strategyName, mid, direction, post] = m
|
||||
return (
|
||||
@@ -46,7 +46,7 @@ function renderMessage(source: string, message: string) {
|
||||
<span className="text-foreground/80">{pre}</span>
|
||||
<span className="text-amber-400 font-medium">{strategyName}</span>
|
||||
<span className="text-foreground/80">{mid}</span>
|
||||
<span className={direction === '买入' ? 'text-danger font-medium' : 'text-bear font-medium'}>{direction}</span>
|
||||
<span className={direction === '新入选' ? 'text-emerald-400 font-medium' : 'text-danger font-medium'}>{direction}</span>
|
||||
<span className="text-foreground/80">{post}</span>
|
||||
</>
|
||||
)
|
||||
@@ -248,7 +248,9 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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
|
||||
<SevIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ev.symbol && (() => {
|
||||
const board = boardTag(ev.symbol)
|
||||
return (
|
||||
<button
|
||||
onClick={() => setPreviewEv(ev)}
|
||||
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
|
||||
title="点击查看日K"
|
||||
>
|
||||
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
|
||||
{board && (
|
||||
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
|
||||
{board.label}
|
||||
{ev.source === 'strategy' ? (() => {
|
||||
const sm = ev.message?.match(/策略「([^」]+)」/)
|
||||
const sname = sm ? sm[1] : ''
|
||||
const isNew = ev.type === 'new_entry'
|
||||
const _pct = ev.change_pct ?? 0
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ev.symbol && (() => {
|
||||
const board = boardTag(ev.symbol)
|
||||
return (
|
||||
<button
|
||||
onClick={() => setPreviewEv(ev)}
|
||||
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
|
||||
title="点击查看日K"
|
||||
>
|
||||
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
|
||||
{board && (
|
||||
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
)}
|
||||
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{ev.price != null && (
|
||||
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono', _pct >= 0 ? 'text-danger' : 'text-bear')}>
|
||||
{_pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
|
||||
{fmtPrice(ev.price)}
|
||||
</span>
|
||||
)}
|
||||
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE[ev.source] ?? 'bg-elevated text-muted border-border')}>
|
||||
{(() => {
|
||||
// 优先用规则名 (如 "策略监控 · 空中加油" → "空中加油"); 退回到 type 标签
|
||||
const rn = ev.rule_name ?? ''
|
||||
const dotIdx = rn.indexOf(' · ')
|
||||
return dotIdx >= 0 ? rn.slice(dotIdx + 3) : (rn || (TYPE_LABEL[ev.source] ?? ev.source))
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
{ev.price != null && (
|
||||
<span className="text-[11px] font-mono text-foreground/60">{fmtPrice(ev.price)}</span>
|
||||
)}
|
||||
{ev.change_pct != null && (
|
||||
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono font-medium',
|
||||
ev.change_pct >= 0 ? 'text-danger' : 'text-bear')}>
|
||||
{ev.change_pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
|
||||
{fmtPct(ev.change_pct)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ev.signals && ev.signals.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{ev.signals.map((s: string, j: number) => (
|
||||
<span key={j} className="rounded bg-accent/8 px-1.5 py-0.5 text-[9px] text-accent/70">{cnSignal(s)}</span>
|
||||
))}
|
||||
</div>
|
||||
{ev.change_pct != null && (
|
||||
<span className={cn('text-[11px] font-mono font-medium',
|
||||
_pct >= 0 ? 'text-danger' : 'text-bear')}>
|
||||
{fmtPct(_pct)}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE.strategy)}>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
})() : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ev.symbol && (() => {
|
||||
const board = boardTag(ev.symbol)
|
||||
return (
|
||||
<button
|
||||
onClick={() => setPreviewEv(ev)}
|
||||
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
|
||||
title="点击查看日K"
|
||||
>
|
||||
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
|
||||
{board && (
|
||||
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
)}
|
||||
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{ev.price != null && (
|
||||
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono', (ev.change_pct ?? 0) >= 0 ? 'text-danger' : 'text-bear')}>
|
||||
{(ev.change_pct ?? 0) >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
|
||||
{fmtPrice(ev.price)}
|
||||
</span>
|
||||
)}
|
||||
{ev.change_pct != null && (
|
||||
<span className={cn('text-[11px] font-mono font-medium',
|
||||
ev.change_pct >= 0 ? 'text-danger' : 'text-bear')}>
|
||||
{fmtPct(ev.change_pct)}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE[ev.source] ?? 'bg-elevated text-muted border-border')}>
|
||||
{(() => {
|
||||
// 优先用规则名 (如 "策略监控 · 空中加油" → "空中加油"); 退回到 type 标签
|
||||
const rn = ev.rule_name ?? ''
|
||||
const dotIdx = rn.indexOf(' · ')
|
||||
return dotIdx >= 0 ? rn.slice(dotIdx + 3) : (rn || (TYPE_LABEL[ev.source] ?? ev.source))
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
|
||||
</div>
|
||||
{ev.signals && ev.signals.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{ev.signals.map((s: string, j: number) => (
|
||||
<span key={j} className="rounded bg-accent/8 px-1.5 py-0.5 text-[9px] text-accent/70">{cnSignal(s)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
@@ -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<string, { entry: string[]; exit: string[] }> = {}
|
||||
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 }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行: 触发条件 (策略类型显示买卖信号) */}
|
||||
{/* 第二行: 策略类型显示选股池变更监控 */}
|
||||
{r.type === 'strategy' && r.strategy_id ? (
|
||||
<div className="mt-0.5 flex items-center gap-2 pl-0.5 flex-wrap">
|
||||
{(() => {
|
||||
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 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-[9px] text-danger">买</span>
|
||||
<span className="text-[9px] text-accent/80">{entrySigs.join('、')}</span>
|
||||
</span>
|
||||
)}
|
||||
{(r.direction === 'exit' || r.direction === 'both') && exitSigs.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-[9px] text-bear">卖</span>
|
||||
<span className="text-[9px] text-accent/80">{exitSigs.join('、')}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<div className="mt-0.5 flex items-center gap-2 pl-0.5">
|
||||
<span className="text-[9px] text-secondary">选股池变更监控</span>
|
||||
</div>
|
||||
) : r.conditions.length > 0 && (
|
||||
<div className="mt-0.5 flex items-center gap-1 pl-0.5">
|
||||
|
||||
@@ -562,7 +562,7 @@ export function Screener() {
|
||||
<button
|
||||
onClick={() => { setBuilderMode('create'); setShowBuilder(true) }}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
|
||||
border border-amber-400/30 bg-amber-400/8 text-xs font-medium text-amber-400
|
||||
text-xs font-medium text-amber-400 border border-amber-400/20 bg-amber-400/5
|
||||
hover:bg-amber-400/15 transition-colors cursor-pointer"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
|
||||
Reference in New Issue
Block a user