优化策略监控与 AI 生成流程

- 策略监控从全市场信号扫描改为选股池变更对比(进入/移出)
- 触发记录/看板/弹窗三处统一策略通知格式
- AI 策略模板去掉硬编码信号/止损/评分,补全数据字典
- 创建策略对话框改为 AI生成/自定义编写 双 Tab
- 自定义 Tab 含文件路径说明+模板代码一键复制
This commit is contained in:
shy3130
2026-06-22 17:42:04 +08:00
parent c874b50321
commit 70e17a9fb5
13 changed files with 670 additions and 289 deletions
+22 -7
View File
@@ -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,
})
+27 -4
View File
@@ -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}
+1
View File
@@ -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:
+9 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -32,27 +32,13 @@ META = {
"exclude_new_days": 60, # 排除上市N天内新股
},
# 策略参数 (Stage 2, filter() 使用, 前端渲染为表单)
# 用户可能调节的阈值通过 params 暴露;公式常数、固定窗口边界不必强行参数化
# 策略参数 (只把用户可能调节的阈值放这里,公式常数不必参数化)
# 每个参数含 id/label/type/default/min/max/stepselect 类型用 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) — 从零创建强势反包策略的三步完整演示。
+34 -12
View File
@@ -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>
+40 -19
View File
@@ -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>
)
+1 -1
View File
@@ -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
View File
@@ -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">
+1 -1
View File
@@ -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" />