初始化工程
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""AI 策略生成器 — 读取策略开发文档 + 调用 LLM 生成策略代码。
|
||||
|
||||
职责: 接收用户自然语言描述 → 读取 docs/strategy-guide.md → 调用 LLM → 返回策略代码。
|
||||
不知道: 引擎内部、API、前端、配置持久化、回测。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 策略开发文档路径
|
||||
GUIDE_PATH = Path(__file__).resolve().parent.parent.parent.parent / "docs" / "strategy-guide.md"
|
||||
|
||||
_SYSTEM_PREFIX = """你是A股量化策略设计专家。根据用户描述的需求,参考下方的《策略开发指南》生成一个完整的策略Python文件。
|
||||
|
||||
核心约束:
|
||||
- 只创建这一个 .py 文件,不要修改任何现有文件,不要跨文件引用
|
||||
- 只 import polars as pl,不 import 其他模块
|
||||
|
||||
要求:
|
||||
1. 用户可能调整的策略阈值通过 META["params"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化
|
||||
2. 遵循指南中的文件结构,但优先贴合用户规则,不要为了套模板歪曲策略含义
|
||||
3. ENTRY_SIGNALS/EXIT_SIGNALS 根据策略逻辑自行选择匹配的信号列,不要照搬示例
|
||||
4. scoring 权重根据策略核心逻辑定制,总和 = 1.0
|
||||
5. 优先使用 Polars 表达式、窗口函数、聚合和 with_columns/filter 实现,避免逐行/逐股 Python 循环;只有表达式难以描述的复杂状态机才使用 partition_by/to_dicts
|
||||
6. 直接输出Python代码,不要输出其他内容
|
||||
|
||||
--- 策略开发指南 ---
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class AIStrategyGenerator:
|
||||
"""AI 策略生成器"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._guide_cache: str | None = None
|
||||
|
||||
def _get_guide(self) -> str:
|
||||
if self._guide_cache is None:
|
||||
if GUIDE_PATH.exists():
|
||||
self._guide_cache = GUIDE_PATH.read_text(encoding="utf-8")
|
||||
else:
|
||||
logger.warning("strategy-guide.md not found at %s", GUIDE_PATH)
|
||||
self._guide_cache = ""
|
||||
return self._guide_cache
|
||||
|
||||
async def generate(self, user_prompt: str) -> dict:
|
||||
"""根据用户描述生成策略代码
|
||||
|
||||
Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
|
||||
"""
|
||||
guide = self._get_guide()
|
||||
|
||||
# 调用 LLM
|
||||
code = await self._call_llm(user_prompt, guide)
|
||||
|
||||
# 验证
|
||||
try:
|
||||
self._validate_safety(code)
|
||||
except ValueError as e:
|
||||
return {"code": code, "meta": {}, "valid": False, "error": str(e)}
|
||||
|
||||
# 试加载获取 META
|
||||
try:
|
||||
meta = self._extract_meta(code)
|
||||
except Exception as e:
|
||||
return {"code": code, "meta": {}, "valid": False, "error": f"解析META失败: {e}"}
|
||||
|
||||
return {"code": code, "meta": meta, "valid": True, "error": None}
|
||||
|
||||
async def _call_llm(self, user_prompt: str, guide: str) -> str:
|
||||
"""调用 OpenAI 兼容 API(流式,避免 CDN 长连接超时)"""
|
||||
from openai import AsyncOpenAI
|
||||
from app import secrets_store
|
||||
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
raise RuntimeError("AI API Key 未配置,请在设置页面配置")
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=ai_key,
|
||||
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
|
||||
timeout=180.0,
|
||||
max_retries=2,
|
||||
)
|
||||
# 使用流式请求:CDN 收到首个 token 后会持续转发,不会因等待超时
|
||||
stream = await client.chat.completions.create(
|
||||
model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PREFIX + guide},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=3000,
|
||||
stream=True,
|
||||
)
|
||||
chunks: list[str] = []
|
||||
async for chunk in stream:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
chunks.append(delta.content)
|
||||
content = "".join(chunks).strip()
|
||||
# 提取代码块
|
||||
if "```python" in content:
|
||||
content = content.split("```python", 1)[1].split("```", 1)[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```", 1)[1].split("```", 1)[0].strip()
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _validate_safety(code: str) -> None:
|
||||
"""AST 级安全检查"""
|
||||
tree = ast.parse(code)
|
||||
|
||||
forbidden_modules = {"os", "sys", "subprocess", "socket", "shutil",
|
||||
"pathlib", "http", "urllib", "requests", "httpx"}
|
||||
forbidden_calls = {"open", "exec", "eval", "compile", "__import__",
|
||||
"globals", "locals", "vars", "dir", "getattr",
|
||||
"setattr", "delattr", "type", "input"}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.split(".")[0] not in ("polars",):
|
||||
if alias.name.split(".")[0] in forbidden_modules:
|
||||
raise ValueError(f"禁止 import {alias.name}")
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.split(".")[0] not in ("polars",):
|
||||
if node.module.split(".")[0] in forbidden_modules:
|
||||
raise ValueError(f"禁止 from {node.module} import")
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id in forbidden_calls:
|
||||
raise ValueError(f"禁止调用 {node.func.id}()")
|
||||
|
||||
@staticmethod
|
||||
def _extract_meta(code: str) -> dict:
|
||||
"""从代码字符串中提取 META 字典(不执行代码)"""
|
||||
tree = ast.parse(code)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "META":
|
||||
# 找到 META 赋值,用 compile+eval 安全提取
|
||||
# 只允许字面量
|
||||
meta_node = node.value
|
||||
code_obj = compile(ast.Expression(meta_node), "<meta>", "eval")
|
||||
return eval(code_obj, {"__builtins__": {}}) # noqa: S307
|
||||
return {}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""布林突破 — 突破布林上轨 + 放量"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "boll_breakout",
|
||||
"name": "布林突破",
|
||||
"description": "突破布林上轨 + 放量, 强势加速信号",
|
||||
"tags": ["布林", "突破"],
|
||||
"params": [
|
||||
{"id": "vol_ratio_min", "label": "最低量比", "type": "float",
|
||||
"default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
|
||||
],
|
||||
"scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_boll_breakout_upper"]
|
||||
EXIT_SIGNALS = ["signal_boll_breakdown_lower"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_min = params.get("vol_ratio_min", 1.5)
|
||||
return (
|
||||
pl.col("signal_boll_breakout_upper").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""断板反包 — 涨停 + 放量 + 涨幅 >3%"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "broken_board_recovery",
|
||||
"name": "断板反包",
|
||||
"description": "连板≥2后断板1-2天, 出现放量反包信号",
|
||||
"tags": ["涨停", "反包"],
|
||||
"params": [
|
||||
{"id": "vol_ratio_min", "label": "最低量比", "type": "float",
|
||||
"default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
|
||||
{"id": "change_pct_min", "label": "最低涨幅", "type": "float",
|
||||
"default": 0.03, "min": 0.01, "max": 0.10, "step": 0.01},
|
||||
],
|
||||
"scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_limit_up"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_min = params.get("vol_ratio_min", 1.5)
|
||||
chg_min = params.get("change_pct_min", 0.03)
|
||||
return (
|
||||
pl.col("signal_limit_up").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
& (pl.col("change_pct") > chg_min)
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""均线多头 — MA5>MA10>MA20>MA60 + 短期动量为正"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "bullish_alignment",
|
||||
"name": "均线多头",
|
||||
"description": "MA5>MA10>MA20>MA60多头排列 + 短期动量为正",
|
||||
"tags": ["均线", "多头"],
|
||||
"params": [],
|
||||
"scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_ma_golden_5_20", "signal_ma_golden_20_60"]
|
||||
EXIT_SIGNALS = ["signal_ma_dead_5_20", "signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
return (
|
||||
(pl.col("ma5") > pl.col("ma10"))
|
||||
& (pl.col("ma10") > pl.col("ma20"))
|
||||
& (pl.col("ma20") > pl.col("ma60"))
|
||||
& (pl.col("momentum_20d") > 0)
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""连板股 — 涨停且连续涨停≥2天"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "consecutive_limit_ups",
|
||||
"name": "连板股",
|
||||
"description": "当日涨停且连续涨停≥2天, 强势追涨",
|
||||
"tags": ["涨停", "连板"],
|
||||
"params": [
|
||||
{"id": "min_boards", "label": "最少连板数", "type": "int",
|
||||
"default": 2, "min": 1, "max": 20, "step": 1},
|
||||
],
|
||||
"scoring": {"consecutive_limit_ups": 0.5, "change_pct": 0.3, "amount": 0.2},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_limit_up"]
|
||||
EXIT_SIGNALS = []
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 5
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
min_boards = params.get("min_boards", 2)
|
||||
return (
|
||||
pl.col("signal_limit_up").fill_null(False)
|
||||
& (pl.col("consecutive_limit_ups") >= min_boards)
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""高换手拉升 — 换手率 > 5% 且涨幅 > 3%, 资金活跃"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "high_turnover_surge",
|
||||
"name": "高换手拉升",
|
||||
"description": "换手率 > 5% 且涨幅 > 3%, 资金活跃",
|
||||
"tags": ["换手率", "放量", "资金"],
|
||||
"params": [
|
||||
{"id": "min_turnover", "label": "最低换手率%", "type": "float",
|
||||
"default": 5.0, "min": 1.0, "max": 20.0, "step": 0.5},
|
||||
{"id": "min_change", "label": "最低涨幅%", "type": "float",
|
||||
"default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
|
||||
],
|
||||
"scoring": {"turnover_rate": 0.4, "change_pct": 0.3, "momentum_5d": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 50,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_volume_surge"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
min_to = params.get("min_turnover", 5.0) / 100.0
|
||||
min_chg = params.get("min_change", 3.0) / 100.0
|
||||
return (
|
||||
(pl.col("turnover_rate") > min_to)
|
||||
& (pl.col("change_pct") > min_chg)
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""连板接力 — 近2日涨停且今日涨幅 > 5%, 连板股追踪"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "limit_up_momentum",
|
||||
"name": "连板接力",
|
||||
"description": "连板股 + 今日涨幅 > 5%, 连板接力追踪",
|
||||
"tags": ["涨停", "连板", "接力"],
|
||||
"params": [
|
||||
{"id": "min_change", "label": "最低涨幅%", "type": "float",
|
||||
"default": 5.0, "min": 2.0, "max": 15.0, "step": 0.5},
|
||||
{"id": "min_boards", "label": "最少连板", "type": "int",
|
||||
"default": 1, "min": 1, "max": 10, "step": 1},
|
||||
],
|
||||
"scoring": {"consecutive_limit_ups": 0.4, "change_pct": 0.3, "amount": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 50,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_limit_up"]
|
||||
EXIT_SIGNALS = []
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 5
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
min_chg = params.get("min_change", 5.0) / 100.0
|
||||
min_boards = params.get("min_boards", 1)
|
||||
return (
|
||||
(pl.col("change_pct") > min_chg)
|
||||
& (pl.col("consecutive_limit_ups") >= min_boards)
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""低波动龙头 — 正动量 + 低波动 + MA20上方"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "low_volatility_leader",
|
||||
"name": "低波动龙头",
|
||||
"description": "20日动量为正 + 年化波动 < 30% + MA20上方",
|
||||
"tags": ["低波动", "龙头"],
|
||||
"params": [
|
||||
{"id": "vol_max", "label": "最大年化波动", "type": "float",
|
||||
"default": 0.30, "min": 0.05, "max": 1.0, "step": 0.01},
|
||||
],
|
||||
"scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_ma20_breakout"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 30
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_max = params.get("vol_max", 0.30)
|
||||
return (
|
||||
(pl.col("momentum_20d") > 0)
|
||||
& (pl.col("annual_vol_20d") < vol_max)
|
||||
& (pl.col("close") > pl.col("ma20"))
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""MA金叉 — MA5上穿MA20 + 量能配合 + MA60上方"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "ma_golden_cross",
|
||||
"name": "MA 金叉",
|
||||
"description": "MA5上穿MA20当日触发, 量能配合",
|
||||
"tags": ["均线", "金叉"],
|
||||
"params": [
|
||||
{"id": "vol_ratio_min", "label": "最低量比", "type": "float",
|
||||
"default": 1.2, "min": 0.5, "max": 5.0, "step": 0.1},
|
||||
],
|
||||
"scoring": {"momentum_20d": 0.5, "vol_ratio_5d": 0.3, "change_pct": 0.2},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
||||
EXIT_SIGNALS = ["signal_ma_dead_5_20"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_min = params.get("vol_ratio_min", 1.2)
|
||||
return (
|
||||
pl.col("signal_ma_golden_5_20").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
& (pl.col("close") > pl.col("ma60"))
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""MACD金叉放量 — MACD金叉当日 + 量能放大"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "macd_golden",
|
||||
"name": "MACD 金叉放量",
|
||||
"description": "MACD金叉当日 + 量能放大",
|
||||
"tags": ["MACD", "金叉", "放量"],
|
||||
"params": [
|
||||
{"id": "vol_ratio_min", "label": "最低量比", "type": "float",
|
||||
"default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
|
||||
],
|
||||
"scoring": {"momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_macd_golden"]
|
||||
EXIT_SIGNALS = ["signal_macd_dead"]
|
||||
STOP_LOSS = -0.07
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_min = params.get("vol_ratio_min", 1.5)
|
||||
return (
|
||||
pl.col("signal_macd_golden").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""新低反转 — 60日新低后收阳放量"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "n_day_low_reversal",
|
||||
"name": "新低反转",
|
||||
"description": "触及60日新低后当日收阳放量, 反转信号",
|
||||
"tags": ["反转", "新低"],
|
||||
"params": [
|
||||
{"id": "vol_ratio_min", "label": "最低量比", "type": "float",
|
||||
"default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
|
||||
],
|
||||
"scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_n_day_low"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_min = params.get("vol_ratio_min", 1.5)
|
||||
return (
|
||||
pl.col("signal_n_day_low").fill_null(False)
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""逼近涨停 — 涨幅 > 7% 且距涨停 < 3%, 盘后选股"""
|
||||
import polars as pl
|
||||
|
||||
|
||||
def _limit_pct() -> pl.Expr:
|
||||
"""根据板块和 ST 动态计算涨跌幅限制 (小数)。
|
||||
创业板(300/301)/科创板(688): 20%
|
||||
北交所(.BJ): 30%
|
||||
ST: 5%
|
||||
主板: 10%
|
||||
"""
|
||||
is_st = pl.col("name").str.contains("(?i)ST").fill_null(False)
|
||||
is_cyb = pl.col("symbol").str.starts_with("300") | pl.col("symbol").str.starts_with("301")
|
||||
is_kcb = pl.col("symbol").str.starts_with("688")
|
||||
is_bj = pl.col("symbol").str.contains(r"\.BJ$")
|
||||
return (
|
||||
pl.when(is_st).then(0.05)
|
||||
.when(is_cyb | is_kcb).then(0.20)
|
||||
.when(is_bj).then(0.30)
|
||||
.otherwise(0.10)
|
||||
)
|
||||
|
||||
|
||||
META = {
|
||||
"id": "near_limit_up",
|
||||
"name": "逼近涨停",
|
||||
"description": "涨幅 > 7% 且距涨停 < 3%, 追涨信号",
|
||||
"tags": ["涨停", "追涨"],
|
||||
"params": [
|
||||
{"id": "min_change", "label": "最低涨幅%", "type": "float",
|
||||
"default": 7.0, "min": 3.0, "max": 15.0, "step": 1.0},
|
||||
{"id": "limit_gap", "label": "距涨停空间%", "type": "float",
|
||||
"default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
|
||||
],
|
||||
"scoring": {"change_pct": 0.5, "amount": 0.3, "momentum_5d": 0.2},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 50,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 5
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
min_chg = params.get("min_change", 7.0) / 100.0
|
||||
gap = params.get("limit_gap", 3.0) / 100.0
|
||||
lp = _limit_pct()
|
||||
return (
|
||||
(pl.col("change_pct") > min_chg)
|
||||
& (pl.col("change_pct") < lp - gap)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""超跌反弹 — RSI14 < 30 + 收阳 + 放量"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "oversold_bounce",
|
||||
"name": "超跌反弹",
|
||||
"description": "RSI14 < 30超卖区 + 当日收阳 + 放量, 抄底信号",
|
||||
"tags": ["超跌", "反弹", "RSI"],
|
||||
"params": [
|
||||
{"id": "rsi_max", "label": "RSI上限", "type": "float",
|
||||
"default": 30.0, "min": 10.0, "max": 50.0, "step": 1.0},
|
||||
{"id": "vol_ratio_min", "label": "最低量比", "type": "float",
|
||||
"default": 1.2, "min": 0.5, "max": 5.0, "step": 0.1},
|
||||
],
|
||||
"scoring": {"change_pct": 0.3, "vol_ratio_5d": 0.3, "momentum_5d": 0.2, "rsi_14": 0.2},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = [
|
||||
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
|
||||
]
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
rsi_max = params.get("rsi_max", 30.0)
|
||||
vol_min = params.get("vol_ratio_min", 1.2)
|
||||
return (
|
||||
(pl.col("rsi_14") < rsi_max)
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""超跌反弹 — RSI14 < 30 + 涨幅 > 1% + 站上 MA5, 超卖反弹信号"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "oversold_reversal",
|
||||
"name": "超跌反转",
|
||||
"description": "RSI14 < 30超卖 + 涨幅 > 1% + 站上MA5, 超卖反转信号",
|
||||
"tags": ["超跌", "反弹", "RSI"],
|
||||
"params": [
|
||||
{"id": "rsi_max", "label": "RSI上限", "type": "float",
|
||||
"default": 30.0, "min": 10.0, "max": 50.0, "step": 1.0},
|
||||
{"id": "min_change", "label": "最低涨幅%", "type": "float",
|
||||
"default": 1.0, "min": 0.5, "max": 5.0, "step": 0.5},
|
||||
],
|
||||
"scoring": {"change_pct": 0.4, "rsi_14": 0.3, "vol_ratio_5d": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 50,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = [
|
||||
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
|
||||
]
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
rsi_max = params.get("rsi_max", 30.0)
|
||||
min_chg = params.get("min_change", 1.0) / 100.0
|
||||
return (
|
||||
(pl.col("rsi_14") < rsi_max)
|
||||
& (pl.col("change_pct") > min_chg)
|
||||
& (pl.col("close") > pl.col("ma5"))
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""均线回踩反弹 — 价格在 MA20 附近(±2%)且 MA 多头排列, 回踩买入"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "pullback_ma20_bounce",
|
||||
"name": "均线回踩反弹",
|
||||
"description": "价格在MA20附近(±2%)且MA5>MA20>MA60多头排列, 回踩买入",
|
||||
"tags": ["回踩", "均线", "反弹"],
|
||||
"params": [
|
||||
{"id": "ma_proximity", "label": "MA偏离度%", "type": "float",
|
||||
"default": 2.0, "min": 0.5, "max": 5.0, "step": 0.5},
|
||||
],
|
||||
"scoring": {"momentum_60d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 50,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
proximity = params.get("ma_proximity", 2.0) / 100.0
|
||||
return (
|
||||
(pl.col("close") > pl.col("ma20") * (1 - proximity))
|
||||
& (pl.col("close") < pl.col("ma20") * (1 + proximity))
|
||||
& (pl.col("ma5") > pl.col("ma20"))
|
||||
& (pl.col("ma20") > pl.col("ma60"))
|
||||
& (pl.col("change_pct") > 0)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""缩量回踩 — 回踩MA20附近 + 缩量 + 中期趋势向上"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "pullback_to_support",
|
||||
"name": "缩量回踩",
|
||||
"description": "回踩MA20附近 + 缩量 + 中期趋势向上",
|
||||
"tags": ["回踩", "支撑"],
|
||||
"params": [
|
||||
{"id": "ma_proximity", "label": "均线偏离度", "type": "float",
|
||||
"default": 0.02, "min": 0.01, "max": 0.05, "step": 0.005},
|
||||
{"id": "vol_ratio_max", "label": "最大量比", "type": "float",
|
||||
"default": 0.8, "min": 0.2, "max": 1.5, "step": 0.1},
|
||||
],
|
||||
"scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
proximity = params.get("ma_proximity", 0.02)
|
||||
vol_max = params.get("vol_ratio_max", 0.8)
|
||||
return (
|
||||
(pl.col("close") > pl.col("ma20") * (1 - proximity))
|
||||
& (pl.col("close") < pl.col("ma20") * (1 + proximity))
|
||||
& (pl.col("vol_ratio_5d") < vol_max)
|
||||
& (pl.col("close") > pl.col("ma60"))
|
||||
& (pl.col("momentum_20d") > 0)
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""强势高开 — 高开 > 3% 且保持上涨, 集合竞价强势"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "strong_open",
|
||||
"name": "强势高开",
|
||||
"description": "高开 > 3% 且收盘高于开盘价, 集合竞价强势",
|
||||
"tags": ["高开", "强势"],
|
||||
"params": [
|
||||
{"id": "min_open_gap", "label": "最低高开%", "type": "float",
|
||||
"default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
|
||||
{"id": "min_change", "label": "最低涨幅%", "type": "float",
|
||||
"default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
|
||||
],
|
||||
"scoring": {"change_pct": 0.4, "amplitude": 0.2, "amount": 0.4},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 50,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
min_gap = params.get("min_open_gap", 3.0) / 100.0
|
||||
min_chg = params.get("min_change", 3.0) / 100.0
|
||||
return (
|
||||
(pl.col("open") > pl.col("prev_close") * (1 + min_gap))
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
& (pl.col("change_pct") > min_chg)
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""趋势突破 — MA60上方 + 60日新高 + 放量"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "trend_breakout",
|
||||
"name": "趋势突破",
|
||||
"description": "MA60上方 + 60日新高 + 量能 ≥ 2倍均量",
|
||||
"tags": ["趋势", "突破", "放量"],
|
||||
"basic_filter": {
|
||||
"price_min": 5,
|
||||
"price_max": 200,
|
||||
"market_cap_min": 20e8,
|
||||
"amount_min": 1e8,
|
||||
"exclude_st": True,
|
||||
"exclude_new_days": 60,
|
||||
},
|
||||
"params": [
|
||||
{"id": "vol_ratio_min", "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},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_n_day_high"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.08
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = [
|
||||
{"field": "signal_volume_surge", "message": "放量异动"},
|
||||
]
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_min = params.get("vol_ratio_min", 2.0)
|
||||
return (
|
||||
(pl.col("close") > pl.col("ma60"))
|
||||
& pl.col("signal_n_day_high").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""量价齐升 — 突破MA20 + 放量 + 收阳"""
|
||||
import polars as pl
|
||||
|
||||
META = {
|
||||
"id": "volume_price_surge",
|
||||
"name": "量价齐升",
|
||||
"description": "突破MA20 + 放量 + 收阳",
|
||||
"tags": ["量价", "突破"],
|
||||
"params": [
|
||||
{"id": "vol_ratio_min", "label": "最低量比", "type": "float",
|
||||
"default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1},
|
||||
],
|
||||
"scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
ENTRY_SIGNALS = ["signal_ma20_breakout"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||
vol_min = params.get("vol_ratio_min", 2.0)
|
||||
return (
|
||||
pl.col("signal_ma20_breakout").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= vol_min)
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""策略配置持久化 — 读写用户覆盖值。
|
||||
|
||||
职责: 将每个策略的用户定制设置(基础参数、策略参数、评分、买卖信号)持久化到 JSON。
|
||||
不知道: 引擎、AI、前端、回测。
|
||||
存储: data/user_data/strategy_overrides/{strategy_id}.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _overrides_dir(data_dir: Path) -> Path:
|
||||
d = data_dir / "user_data" / "strategy_overrides"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _path(data_dir: Path, strategy_id: str) -> Path:
|
||||
return _overrides_dir(data_dir) / f"{strategy_id}.json"
|
||||
|
||||
|
||||
def load_override(data_dir: Path, strategy_id: str) -> dict:
|
||||
"""读取策略的用户覆盖配置,不存在返回空 dict"""
|
||||
p = _path(data_dir, strategy_id)
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
# 清理 basic_filter 中值为 None/空的键(避免固化无意义的空值)
|
||||
bf = data.get("basic_filter")
|
||||
if isinstance(bf, dict):
|
||||
cleaned = {k: v for k, v in bf.items() if v is not None}
|
||||
if cleaned:
|
||||
data["basic_filter"] = cleaned
|
||||
else:
|
||||
del data["basic_filter"]
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning("load override %s failed: %s", strategy_id, e)
|
||||
return {}
|
||||
|
||||
|
||||
def save_override(data_dir: Path, strategy_id: str, overrides: dict) -> None:
|
||||
"""保存策略的用户覆盖配置(全量覆盖写)"""
|
||||
p = _path(data_dir, strategy_id)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(overrides, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def delete_override(data_dir: Path, strategy_id: str) -> None:
|
||||
"""删除策略的用户覆盖配置(重置为默认值)"""
|
||||
p = _path(data_dir, strategy_id)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
def list_overrides(data_dir: Path) -> dict[str, dict]:
|
||||
"""返回所有策略的覆盖配置 {strategy_id: overrides}"""
|
||||
d = _overrides_dir(data_dir)
|
||||
result: dict[str, dict] = {}
|
||||
for f in d.glob("*.json"):
|
||||
try:
|
||||
sid = f.stem
|
||||
result[sid] = json.loads(f.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
return result
|
||||
@@ -0,0 +1,207 @@
|
||||
"""自定义信号 — 用户用「字段 + 运算符 + 值」组合出的布尔信号。
|
||||
|
||||
职责:
|
||||
- 从 data/user_data/custom_signals/*.json 加载信号定义
|
||||
- 把每个信号的 conditions 编译成一条 Polars 布尔表达式(AND 组合)
|
||||
- 供 pipeline 在 compute_signals / compute_enriched_today 末尾注入为列
|
||||
|
||||
不知道: 引擎、AI、API、回测、监控。纯函数 + 模块级缓存。
|
||||
|
||||
设计:
|
||||
- 信号列名加前缀 ``csg_`` 避免与内置 ``signal_`` 列冲突。
|
||||
- 回测/选股/监控都按列名找信号,因此注入列后零特殊处理即可三处生效。
|
||||
- 字段白名单 + 固定运算符集,杜绝任意表达式注入。
|
||||
- 第一版只支持 AND(多条件同时满足)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ────────────────────────────────────────────────
|
||||
PREFIX = "csg_" # 自定义信号列名前缀
|
||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
OPS = {">", ">=", "<", "<=", "==", "!="}
|
||||
|
||||
# 字段白名单:只允许这些列出现在条件里(防注入)。均为数值型。
|
||||
# 与 ENRICHED_COLUMNS 的数值列保持一致,排除 symbol/date/name 等非数值列。
|
||||
ALLOWED_FIELDS: frozenset[str] = frozenset({
|
||||
# 行情
|
||||
"open", "high", "low", "close", "volume", "amount", "turnover_rate",
|
||||
"consecutive_limit_ups", "consecutive_limit_downs",
|
||||
# 基础
|
||||
"prev_close", "change_pct", "change_amount", "amplitude",
|
||||
# 均线 / 指数均线
|
||||
"ma5", "ma10", "ma20", "ma30", "ma60",
|
||||
"ema5", "ema10", "ema20", "ema30", "ema60",
|
||||
# MACD / BOLL / KDJ / ATR
|
||||
"macd_dif", "macd_dea", "macd_hist",
|
||||
"boll_upper", "boll_lower",
|
||||
"kdj_k", "kdj_d", "kdj_j",
|
||||
"atr_14",
|
||||
# 量价 / 极值 / 动量 / 波动率 / RSI
|
||||
"vol_ma5", "vol_ma10", "vol_ratio_5d",
|
||||
"high_60d", "low_60d",
|
||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
||||
"annual_vol_20d",
|
||||
"rsi_6", "rsi_14", "rsi_24",
|
||||
})
|
||||
|
||||
# 运算符 → Polars 表达式构造器(输入 col_expr, value)
|
||||
_OP_BUILDERS = {
|
||||
">": lambda c, v: c > v,
|
||||
">=": lambda c, v: c >= v,
|
||||
"<": lambda c, v: c < v,
|
||||
"<=": lambda c, v: c <= v,
|
||||
"==": lambda c, v: c == v,
|
||||
"!=": lambda c, v: c != v,
|
||||
}
|
||||
|
||||
|
||||
# ── 持久化(镜像 strategy/config.py 的写法)──────────────
|
||||
def _dir(data_dir: Path) -> Path:
|
||||
d = data_dir / "user_data" / "custom_signals"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _path(data_dir: Path, signal_id: str) -> Path:
|
||||
return _dir(data_dir) / f"{signal_id}.json"
|
||||
|
||||
|
||||
def load_all(data_dir: Path) -> list[dict]:
|
||||
"""读取全部自定义信号定义。损坏的文件被跳过。"""
|
||||
d = _dir(data_dir)
|
||||
out: list[dict] = []
|
||||
for f in sorted(d.glob("*.json")):
|
||||
try:
|
||||
out.append(json.loads(f.read_text(encoding="utf-8")))
|
||||
except Exception as e:
|
||||
logger.warning("custom signal load failed %s: %s", f.name, e)
|
||||
return out
|
||||
|
||||
|
||||
def save_one(data_dir: Path, sig: dict) -> None:
|
||||
p = _path(data_dir, sig["id"])
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(sig, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def delete_one(data_dir: Path, signal_id: str) -> bool:
|
||||
p = _path(data_dir, signal_id)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── 校验 ────────────────────────────────────────────────
|
||||
def _parse_right(right: str) -> tuple[str, object]:
|
||||
"""解析右值。返回 ('field', colname) 或 ('const', float)。"""
|
||||
if isinstance(right, (int, float)):
|
||||
return ("const", float(right))
|
||||
if not isinstance(right, str):
|
||||
raise ValueError(f"非法右值: {right!r}")
|
||||
if right.startswith("field:"):
|
||||
col = right[len("field:"):]
|
||||
if col not in ALLOWED_FIELDS:
|
||||
raise ValueError(f"右值字段不在白名单: {col}")
|
||||
return ("field", col)
|
||||
# 纯数字
|
||||
try:
|
||||
return ("const", float(right))
|
||||
except ValueError:
|
||||
raise ValueError(f"非法右值(应为 field:xxx 或数字): {right!r}")
|
||||
|
||||
|
||||
def validate(sig: dict) -> None:
|
||||
"""校验一个信号定义,非法则抛 ValueError(含中文信息)。"""
|
||||
sid = sig.get("id", "")
|
||||
if not isinstance(sid, str) or not ID_RE.match(sid):
|
||||
raise ValueError(f"信号 id 非法(仅小写字母数字下划线,1-40字符): {sid!r}")
|
||||
if not isinstance(sig.get("name"), str) or not sig["name"].strip():
|
||||
raise ValueError("信号 name 不能为空")
|
||||
if sig.get("kind") not in ("entry", "exit", "both"):
|
||||
raise ValueError("kind 必须是 entry / exit / both")
|
||||
conds = sig.get("conditions")
|
||||
if not isinstance(conds, list) or len(conds) == 0:
|
||||
raise ValueError("conditions 不能为空")
|
||||
if len(conds) > 8:
|
||||
raise ValueError("conditions 最多 8 条")
|
||||
for i, c in enumerate(conds):
|
||||
if not isinstance(c, dict):
|
||||
raise ValueError(f"第 {i+1} 个条件格式错误")
|
||||
left = c.get("left", "")
|
||||
if left not in ALLOWED_FIELDS:
|
||||
raise ValueError(f"第 {i+1} 个条件: 字段 {left!r} 不在白名单")
|
||||
if c.get("op") not in OPS:
|
||||
raise ValueError(f"第 {i+1} 个条件: 运算符 {c.get('op')!r} 非法")
|
||||
_parse_right(c.get("right")) # 会校验右值字段/数字
|
||||
|
||||
|
||||
# ── 编译为 Polars 表达式 ─────────────────────────────────
|
||||
def column_name(signal_id: str) -> str:
|
||||
"""信号 id → DataFrame 列名(加前缀)。"""
|
||||
return f"{PREFIX}{signal_id}"
|
||||
|
||||
|
||||
def build_expressions(signals: list[dict]) -> dict[str, pl.Expr]:
|
||||
"""把多个自定义信号编译成 {column_name: pl.Expr}。
|
||||
|
||||
- 只处理 enabled != False 的信号。
|
||||
- 单个信号内多条件用 ``&`` 串联(AND)。
|
||||
- 编译失败的信号被跳过并告警(不影响其它信号)。
|
||||
"""
|
||||
out: dict[str, pl.Expr] = {}
|
||||
for sig in signals:
|
||||
if sig.get("enabled") is False:
|
||||
continue
|
||||
try:
|
||||
conds = sig["conditions"]
|
||||
col_name = column_name(sig["id"])
|
||||
parts: list[pl.Expr] = []
|
||||
for c in conds:
|
||||
left = c["left"]
|
||||
op = c["op"]
|
||||
kind, val = _parse_right(c["right"])
|
||||
right_expr = pl.col(val) if kind == "field" else val
|
||||
parts.append(_OP_BUILDERS[op](pl.col(left), right_expr))
|
||||
combined = parts[0]
|
||||
for p in parts[1:]:
|
||||
combined = combined & p
|
||||
out[col_name] = combined
|
||||
except Exception as e:
|
||||
logger.warning("custom signal compile failed %s: %s", sig.get("id"), e)
|
||||
return out
|
||||
|
||||
|
||||
def inject(df: pl.DataFrame, exprs: dict[str, pl.Expr]) -> pl.DataFrame:
|
||||
"""把编译好的信号表达式作为列加入 df。仅添加 df 已含其依赖列的信号。"""
|
||||
if df.is_empty() or not exprs:
|
||||
return df
|
||||
cols = set(df.columns)
|
||||
add: dict[str, pl.Expr] = {}
|
||||
for name, expr in exprs.items():
|
||||
# 提取该表达式引用的所有字段列,缺失则跳过(避免运行时报错)
|
||||
needed = _expr_root_columns(expr)
|
||||
if needed.issubset(cols):
|
||||
add[name] = expr
|
||||
if add:
|
||||
df = df.with_columns([e.alias(n) for n, e in add.items()])
|
||||
return df
|
||||
|
||||
|
||||
def _expr_root_columns(expr: pl.Expr) -> set[str]:
|
||||
"""尽力提取表达式里出现的列名。失败则返回空集(保守跳过)。"""
|
||||
try:
|
||||
# Polars 的 meta.root_names() 返回表达式引用的根列名
|
||||
names = expr.meta.root_names()
|
||||
return set(names)
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -0,0 +1,453 @@
|
||||
"""策略引擎 — 加载、执行、评分。
|
||||
|
||||
职责: 从文件系统加载策略 Python 模块,执行两阶段过滤(基础+策略),
|
||||
通用评分排序。
|
||||
不知道: AI、API、前端、配置持久化、回测。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import polars as pl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 引擎级默认基础过滤 — 策略未定义 BASIC_FILTER 时兜底
|
||||
DEFAULT_BASIC_FILTER: dict = {
|
||||
"price_min": 3,
|
||||
"price_max": 300,
|
||||
"market_cap_min": 10e8,
|
||||
"float_cap_min": None,
|
||||
"float_cap_max": None,
|
||||
"amount_min": 0.2e8,
|
||||
"amount_max": None,
|
||||
"turnover_min": None,
|
||||
"turnover_max": None,
|
||||
"exclude_st": True,
|
||||
"exclude_new_days": 30,
|
||||
"boards": ["沪主板", "深主板", "创业板", "科创板", "北交所"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyDef:
|
||||
"""加载后的策略定义(只读数据 + filter 函数引用)"""
|
||||
meta: dict
|
||||
basic_filter: dict
|
||||
entry_signals: list[str]
|
||||
exit_signals: list[str]
|
||||
stop_loss: float | None
|
||||
trailing_stop: float | None
|
||||
trailing_take_profit_activate: float | None
|
||||
trailing_take_profit_drawdown: float | None
|
||||
max_hold_days: int | None
|
||||
alerts: list[dict]
|
||||
filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None
|
||||
filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None
|
||||
lookback_days: int
|
||||
source: str # "builtin" | "custom" | "ai"
|
||||
file_path: Path | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyResult:
|
||||
"""策略执行结果"""
|
||||
as_of: date
|
||||
strategy_id: str
|
||||
rows: list[dict] = field(default_factory=list)
|
||||
total: int = 0
|
||||
elapsed_ms: float = 0.0
|
||||
scores: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
class StrategyEngine:
|
||||
"""策略引擎 — 策略加载 + 执行 + 评分"""
|
||||
|
||||
def __init__(self, enriched_loader: Callable[[date], pl.DataFrame],
|
||||
enriched_history_loader: Callable[[date, int], pl.DataFrame] | None = None,
|
||||
strategy_dirs: list[Path] | None = None):
|
||||
"""
|
||||
Args:
|
||||
enriched_loader: (date) -> pl.DataFrame, 加载指定日期的 enriched 数据
|
||||
strategy_dirs: 策略文件搜索目录列表
|
||||
"""
|
||||
self._loader = enriched_loader
|
||||
self._history_loader = enriched_history_loader
|
||||
self._strategies: dict[str, StrategyDef] = {}
|
||||
self._strategy_dirs = strategy_dirs or []
|
||||
self._load_all()
|
||||
|
||||
# ================================================================
|
||||
# 加载
|
||||
# ================================================================
|
||||
|
||||
def _load_all(self) -> None:
|
||||
self._strategies.clear()
|
||||
for d in self._strategy_dirs:
|
||||
if not d.exists():
|
||||
continue
|
||||
for f in sorted(d.glob("*.py")):
|
||||
if f.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
s = self._load_file(f)
|
||||
self._strategies[s.meta["id"]] = s
|
||||
logger.debug("loaded strategy: %s (%s)", s.meta["id"], s.source)
|
||||
except Exception as e:
|
||||
logger.warning("load strategy %s failed: %s", f.name, e)
|
||||
|
||||
@staticmethod
|
||||
def _load_file(path: Path) -> StrategyDef:
|
||||
"""从 Python 文件加载策略定义"""
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValueError(f"cannot load module from {path}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
meta = getattr(mod, "META", {})
|
||||
meta.setdefault("id", path.stem)
|
||||
meta.setdefault("name", path.stem)
|
||||
meta.setdefault("description", "")
|
||||
meta.setdefault("tags", [])
|
||||
meta.setdefault("params", [])
|
||||
meta.setdefault("scoring", {})
|
||||
meta.setdefault("order_by", "score")
|
||||
meta.setdefault("descending", True)
|
||||
meta.setdefault("limit", 100)
|
||||
|
||||
# 合并默认基础过滤
|
||||
bf = {**DEFAULT_BASIC_FILTER}
|
||||
strat_bf = getattr(mod, "BASIC_FILTER", None)
|
||||
if strat_bf:
|
||||
bf.update(strat_bf)
|
||||
# meta 里的 basic_filter 也合并(优先级最高)
|
||||
meta_bf = meta.get("basic_filter")
|
||||
if meta_bf:
|
||||
bf.update(meta_bf)
|
||||
|
||||
source = "custom"
|
||||
if "builtin" in str(path).replace("\\", "/"):
|
||||
source = "builtin"
|
||||
elif "/ai/" in str(path).replace("\\", "/") or "\\ai\\" in str(path):
|
||||
source = "ai"
|
||||
|
||||
return StrategyDef(
|
||||
meta=meta,
|
||||
basic_filter=bf,
|
||||
entry_signals=getattr(mod, "ENTRY_SIGNALS", []),
|
||||
exit_signals=getattr(mod, "EXIT_SIGNALS", []),
|
||||
stop_loss=getattr(mod, "STOP_LOSS", None),
|
||||
trailing_stop=getattr(mod, "TRAILING_STOP", None),
|
||||
trailing_take_profit_activate=getattr(mod, "TRAILING_TAKE_PROFIT_ACTIVATE", None),
|
||||
trailing_take_profit_drawdown=getattr(mod, "TRAILING_TAKE_PROFIT_DRAWDOWN", None),
|
||||
max_hold_days=getattr(mod, "MAX_HOLD_DAYS", None),
|
||||
alerts=getattr(mod, "ALERTS", []),
|
||||
filter_fn=getattr(mod, "filter", None),
|
||||
filter_history_fn=getattr(mod, "filter_history", None),
|
||||
lookback_days=int(getattr(mod, "LOOKBACK_DAYS", meta.get("lookback_days", 1)) or 1),
|
||||
source=source,
|
||||
file_path=path,
|
||||
)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""热重载所有策略"""
|
||||
self._load_all()
|
||||
|
||||
# ================================================================
|
||||
# 查询
|
||||
# ================================================================
|
||||
|
||||
def list_strategies(self) -> list[dict]:
|
||||
"""返回所有策略的元信息"""
|
||||
result = []
|
||||
for s in self._strategies.values():
|
||||
result.append({**s.meta, "source": s.source})
|
||||
return result
|
||||
|
||||
def get(self, strategy_id: str) -> StrategyDef:
|
||||
s = self._strategies.get(strategy_id)
|
||||
if not s:
|
||||
raise ValueError(f"unknown strategy: {strategy_id}")
|
||||
return s
|
||||
|
||||
def has(self, strategy_id: str) -> bool:
|
||||
return strategy_id in self._strategies
|
||||
|
||||
# ================================================================
|
||||
# 执行
|
||||
# ================================================================
|
||||
|
||||
def run(
|
||||
self,
|
||||
strategy_id: str,
|
||||
as_of: date,
|
||||
pool: list[str] | None = None,
|
||||
params: dict | None = None,
|
||||
overrides: dict | None = None,
|
||||
precomputed: pl.DataFrame | None = None,
|
||||
precomputed_history: pl.DataFrame | None = None,
|
||||
) -> StrategyResult:
|
||||
"""执行策略: 基础过滤 → 策略过滤 → 评分排序
|
||||
|
||||
Args:
|
||||
strategy_id: 策略 ID
|
||||
as_of: 选股日期
|
||||
pool: 限定股票池
|
||||
params: 策略参数 (用户在设置面板调的值)
|
||||
overrides: 用户覆盖配置 (basic_filter/scoring/stop_loss 等)
|
||||
precomputed: 已加载的 enriched 数据 (run_all 场景复用)
|
||||
precomputed_history: 已加载的历史窗口数据 (run_all 场景复用)
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
s = self.get(strategy_id)
|
||||
params = params or {}
|
||||
overrides = overrides or {}
|
||||
|
||||
# 加载数据。普通策略只读目标日期;声明 filter_history 的策略读取历史窗口。
|
||||
if s.filter_history_fn:
|
||||
if precomputed_history is not None and not precomputed_history.is_empty():
|
||||
df = precomputed_history
|
||||
elif self._history_loader:
|
||||
df = self._history_loader(as_of, max(1, s.lookback_days))
|
||||
else:
|
||||
logger.warning("strategy %s requires history loader", strategy_id)
|
||||
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
|
||||
if df.is_empty():
|
||||
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
|
||||
df = s.filter_history_fn(df, params)
|
||||
if df.is_empty():
|
||||
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
|
||||
if "date" in df.columns:
|
||||
df = df.filter(pl.col("date") == as_of)
|
||||
elif precomputed is not None and not precomputed.is_empty():
|
||||
df = precomputed
|
||||
else:
|
||||
df = self._loader(as_of)
|
||||
if df.is_empty():
|
||||
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
|
||||
|
||||
# 基础过滤: 策略默认 basic_filter 兜底, 用户 override 优先覆盖。
|
||||
# 这样策略文件里写的 exclude_st/price_min 等默认值即使前端没保存也能生效。
|
||||
bf = dict(s.basic_filter) if s.basic_filter else {}
|
||||
if overrides and overrides.get("basic_filter"):
|
||||
bf.update(overrides["basic_filter"])
|
||||
|
||||
# Stage 1: 基础过滤(enabled 默认开启; 显式 enabled=false 才跳过)
|
||||
if bf and bf.get("enabled", True):
|
||||
df = self._apply_basic_filter(df, bf)
|
||||
|
||||
# Pool 过滤
|
||||
if pool:
|
||||
df = df.filter(pl.col("symbol").is_in(pool))
|
||||
|
||||
# Stage 2: 策略过滤
|
||||
if s.filter_fn:
|
||||
expr = s.filter_fn(df, params)
|
||||
df = df.filter(expr)
|
||||
|
||||
# Stage 3: 评分
|
||||
scoring = s.meta.get("scoring", {})
|
||||
scoring_overrides = overrides.get("scoring")
|
||||
if scoring_overrides:
|
||||
scoring = {**scoring, **scoring_overrides}
|
||||
df = self._apply_scoring(df, scoring)
|
||||
|
||||
# 排序 + 限制
|
||||
limit = s.meta.get("limit", 100)
|
||||
order_desc = s.meta.get("descending", True)
|
||||
if "score" in df.columns:
|
||||
df = df.sort("score", descending=order_desc)
|
||||
elif s.meta.get("order_by") and s.meta["order_by"] != "score":
|
||||
ob = s.meta["order_by"]
|
||||
if ob in df.columns:
|
||||
df = df.sort(ob, descending=order_desc)
|
||||
df = df.head(limit)
|
||||
|
||||
# 输出
|
||||
rows = _sanitize(df.to_dicts())
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
|
||||
scores: dict[str, float] = {}
|
||||
if "score" in df.columns:
|
||||
for r in df.iter_rows(named=True):
|
||||
scores[r["symbol"]] = float(r.get("score") or 0)
|
||||
|
||||
return StrategyResult(
|
||||
as_of=as_of,
|
||||
strategy_id=strategy_id,
|
||||
rows=rows,
|
||||
total=len(rows),
|
||||
elapsed_ms=elapsed,
|
||||
scores=scores,
|
||||
)
|
||||
|
||||
def run_all(self, as_of: date, params_map: dict | None = None,
|
||||
overrides_map: dict | None = None) -> dict[str, StrategyResult]:
|
||||
"""批量执行所有策略 (enriched 只加载一次,基础过滤按策略分组缓存,历史数据共享)"""
|
||||
df = self._loader(as_of)
|
||||
params_map = params_map or {}
|
||||
overrides_map = overrides_map or {}
|
||||
|
||||
# 历史策略: 找最大 lookback,一次加载共享
|
||||
history_strats = [(sid, s) for sid, s in self._strategies.items() if s.filter_history_fn]
|
||||
if history_strats and self._history_loader:
|
||||
max_lookback = max(s.lookback_days for _, s in history_strats)
|
||||
shared_history = self._history_loader(as_of, max(1, max_lookback))
|
||||
else:
|
||||
shared_history = None
|
||||
|
||||
# 按 basic_filter hash 分组,避免重复过滤
|
||||
bf_cache: dict[str, pl.DataFrame] = {}
|
||||
results: dict[str, StrategyResult] = {}
|
||||
|
||||
for sid, strat in self._strategies.items():
|
||||
try:
|
||||
bf_key = _dict_hash(strat.basic_filter)
|
||||
if bf_key not in bf_cache:
|
||||
if strat.basic_filter.get("enabled", True):
|
||||
bf_cache[bf_key] = self._apply_basic_filter(df, strat.basic_filter)
|
||||
else:
|
||||
bf_cache[bf_key] = df
|
||||
base = bf_cache[bf_key]
|
||||
|
||||
# 从已过滤的 base 执行 (filter_history 策略使用共享历史)
|
||||
results[sid] = self.run(
|
||||
sid, as_of,
|
||||
params=params_map.get(sid),
|
||||
overrides=overrides_map.get(sid),
|
||||
precomputed=base,
|
||||
precomputed_history=shared_history,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("run strategy %s failed: %s", sid, e)
|
||||
|
||||
return results
|
||||
|
||||
# ================================================================
|
||||
# 内部: 基础过滤
|
||||
# ================================================================
|
||||
|
||||
@staticmethod
|
||||
def _basic_filter_expr(df: pl.DataFrame, bf: dict) -> pl.Expr | None:
|
||||
"""构建基础过滤表达式。回测可复用为买入候选 mask,不删除行情行。"""
|
||||
exprs: list[pl.Expr] = []
|
||||
if bf.get("price_min") is not None:
|
||||
exprs.append(pl.col("close") >= bf["price_min"])
|
||||
if bf.get("price_max") is not None:
|
||||
exprs.append(pl.col("close") <= bf["price_max"])
|
||||
if bf.get("market_cap_min") is not None and "total_shares" in df.columns:
|
||||
exprs.append(
|
||||
pl.col("close") * pl.col("total_shares") >= bf["market_cap_min"]
|
||||
)
|
||||
if bf.get("market_cap_max") is not None and "total_shares" in df.columns:
|
||||
exprs.append(
|
||||
pl.col("close") * pl.col("total_shares") <= bf["market_cap_max"]
|
||||
)
|
||||
# 流通市值
|
||||
if bf.get("float_cap_min") is not None and "float_shares" in df.columns:
|
||||
exprs.append(
|
||||
pl.col("close") * pl.col("float_shares") >= bf["float_cap_min"]
|
||||
)
|
||||
if bf.get("float_cap_max") is not None and "float_shares" in df.columns:
|
||||
exprs.append(
|
||||
pl.col("close") * pl.col("float_shares") <= bf["float_cap_max"]
|
||||
)
|
||||
if bf.get("amount_min") is not None:
|
||||
exprs.append(pl.col("amount") >= bf["amount_min"])
|
||||
if bf.get("amount_max") is not None:
|
||||
exprs.append(pl.col("amount") <= bf["amount_max"])
|
||||
# 换手率
|
||||
if bf.get("turnover_min") is not None and "turnover_rate" in df.columns:
|
||||
exprs.append(pl.col("turnover_rate") >= bf["turnover_min"])
|
||||
if bf.get("turnover_max") is not None and "turnover_rate" in df.columns:
|
||||
exprs.append(pl.col("turnover_rate") <= bf["turnover_max"])
|
||||
if bf.get("exclude_st") and "name" in df.columns:
|
||||
exprs.append(~pl.col("name").str.contains("(?i)ST|\\*ST|退"))
|
||||
# 板块过滤
|
||||
boards = bf.get("boards")
|
||||
if boards and isinstance(boards, list) and len(boards) > 0:
|
||||
board_exprs: list[pl.Expr] = []
|
||||
for b in boards:
|
||||
if b == "沪主板":
|
||||
board_exprs.append(pl.col("symbol").str.starts_with("60"))
|
||||
elif b == "深主板":
|
||||
board_exprs.append(
|
||||
pl.col("symbol").str.starts_with("00")
|
||||
| pl.col("symbol").str.starts_with("001")
|
||||
)
|
||||
elif b == "创业板":
|
||||
board_exprs.append(
|
||||
pl.col("symbol").str.starts_with("300")
|
||||
| pl.col("symbol").str.starts_with("301")
|
||||
)
|
||||
elif b == "科创板":
|
||||
board_exprs.append(pl.col("symbol").str.starts_with("688"))
|
||||
elif b == "北交所":
|
||||
board_exprs.append(pl.col("symbol").str.contains(r"\.BJ$"))
|
||||
if board_exprs:
|
||||
exprs.append(pl.any_horizontal(board_exprs))
|
||||
if exprs:
|
||||
return pl.all_horizontal(exprs)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _apply_basic_filter(df: pl.DataFrame, bf: dict) -> pl.DataFrame:
|
||||
"""Stage 1: 基础参数过滤"""
|
||||
expr = StrategyEngine._basic_filter_expr(df, bf)
|
||||
if expr is not None:
|
||||
return df.filter(expr)
|
||||
return df
|
||||
|
||||
# ================================================================
|
||||
# 内部: 评分
|
||||
# ================================================================
|
||||
|
||||
@staticmethod
|
||||
def _apply_scoring(df: pl.DataFrame, weights: dict) -> pl.DataFrame:
|
||||
"""通用评分: min-max 归一化 → 加权求和 → 0~100 分"""
|
||||
if not weights:
|
||||
return df
|
||||
total_weight = sum(weights.values())
|
||||
if total_weight <= 0:
|
||||
return df
|
||||
|
||||
score_parts: list[pl.Expr] = []
|
||||
for col, weight in weights.items():
|
||||
if col not in df.columns:
|
||||
continue
|
||||
w = weight / total_weight
|
||||
col_min = pl.col(col).min()
|
||||
col_range = pl.col(col).max() - col_min
|
||||
normalized = pl.when(col_range > 0).then(
|
||||
(pl.col(col) - col_min) / col_range
|
||||
).otherwise(pl.lit(0.5))
|
||||
score_parts.append(normalized * w)
|
||||
|
||||
if not score_parts:
|
||||
return df
|
||||
|
||||
score_expr = score_parts[0]
|
||||
for part in score_parts[1:]:
|
||||
score_expr = score_expr + part
|
||||
return df.with_columns((score_expr * 100).alias("score"))
|
||||
|
||||
|
||||
def _sanitize(rows: list[dict]) -> list[dict]:
|
||||
for r in rows:
|
||||
for k, v in list(r.items()):
|
||||
if isinstance(v, float) and (v != v or abs(v) == float("inf")):
|
||||
r[k] = None
|
||||
return rows
|
||||
|
||||
|
||||
def _dict_hash(d: dict) -> str:
|
||||
"""用于 basic_filter 分组缓存"""
|
||||
return str(sorted(d.items()))
|
||||
@@ -0,0 +1,613 @@
|
||||
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。
|
||||
|
||||
职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。
|
||||
不知道: 策略加载逻辑、AI、API、配置持久化、回测。
|
||||
依赖: 外部调用 on_quote_update() 传入实时数据。
|
||||
|
||||
本模块含两个评估器:
|
||||
1. StrategyMonitorService — 旧的策略监控 (type=strategy),第二步迁移到 MonitorRuleEngine
|
||||
2. MonitorRuleEngine — 通用规则引擎,覆盖 signal/price/market/strategy 四类,
|
||||
支持 scope (symbols/all/sector) + 多条件 AND/OR + cooldown 去重
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
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__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyAlert:
|
||||
"""策略告警"""
|
||||
type: str # "entry" | "exit" | "alert"
|
||||
strategy_id: str
|
||||
symbol: str
|
||||
name: str | None
|
||||
message: str
|
||||
price: float | None = None
|
||||
change_pct: float | None = None
|
||||
signals: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class StrategyMonitorService:
|
||||
"""策略实时监控服务"""
|
||||
|
||||
def __init__(self, alert_handler: Callable[[StrategyAlert], None] | None = None):
|
||||
"""
|
||||
Args:
|
||||
alert_handler: 告警回调 (如推 SSE)
|
||||
"""
|
||||
self._alert_handler = alert_handler
|
||||
# strategy_id → 监控配置
|
||||
self._watching: dict[str, dict] = {}
|
||||
|
||||
def start(self, strategy_id: str, config: dict) -> None:
|
||||
"""开始监控一个策略
|
||||
|
||||
config: {
|
||||
"entry_signals": ["signal_n_day_high", ...],
|
||||
"exit_signals": ["signal_ma20_breakdown", ...],
|
||||
"alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}],
|
||||
}
|
||||
"""
|
||||
self._watching[strategy_id] = config
|
||||
logger.info("strategy monitor started: %s", strategy_id)
|
||||
|
||||
def stop(self, strategy_id: str) -> None:
|
||||
self._watching.pop(strategy_id, None)
|
||||
logger.info("strategy monitor stopped: %s", strategy_id)
|
||||
|
||||
def stop_all(self) -> None:
|
||||
self._watching.clear()
|
||||
|
||||
@property
|
||||
def watching(self) -> dict[str, dict]:
|
||||
return dict(self._watching)
|
||||
|
||||
def on_quote_update(self, df: pl.DataFrame) -> list[StrategyAlert]:
|
||||
"""行情更新后调用。向量化检查所有监控策略。
|
||||
|
||||
Args:
|
||||
df: 实时 enriched 数据 (~5500行)
|
||||
Returns:
|
||||
触发的告警列表
|
||||
"""
|
||||
if not self._watching or df.is_empty():
|
||||
return []
|
||||
|
||||
all_alerts: list[StrategyAlert] = []
|
||||
|
||||
for strategy_id, cfg in self._watching.items():
|
||||
# 买入信号
|
||||
entry_sigs = cfg.get("entry_signals", [])
|
||||
if entry_sigs:
|
||||
for sym, name, price, pct, hit_sigs in self._check_signals(df, entry_sigs):
|
||||
alert = StrategyAlert(
|
||||
type="entry",
|
||||
strategy_id=strategy_id,
|
||||
symbol=sym,
|
||||
name=name,
|
||||
message=f"买入信号触发",
|
||||
price=price,
|
||||
change_pct=pct,
|
||||
signals=hit_sigs,
|
||||
)
|
||||
all_alerts.append(alert)
|
||||
self._emit(alert)
|
||||
|
||||
# 卖出信号
|
||||
exit_sigs = cfg.get("exit_signals", [])
|
||||
if exit_sigs:
|
||||
for sym, name, price, pct, hit_sigs in self._check_signals(df, exit_sigs):
|
||||
alert = StrategyAlert(
|
||||
type="exit",
|
||||
strategy_id=strategy_id,
|
||||
symbol=sym,
|
||||
name=name,
|
||||
message=f"卖出信号触发",
|
||||
price=price,
|
||||
change_pct=pct,
|
||||
signals=hit_sigs,
|
||||
)
|
||||
all_alerts.append(alert)
|
||||
self._emit(alert)
|
||||
|
||||
# 提醒条件
|
||||
for alert_cfg in cfg.get("alerts", []):
|
||||
for sym, name, price, pct in self._check_alert(df, alert_cfg):
|
||||
alert = StrategyAlert(
|
||||
type="alert",
|
||||
strategy_id=strategy_id,
|
||||
symbol=sym,
|
||||
name=name,
|
||||
message=alert_cfg.get("message", "提醒"),
|
||||
price=price,
|
||||
change_pct=pct,
|
||||
)
|
||||
all_alerts.append(alert)
|
||||
self._emit(alert)
|
||||
|
||||
return all_alerts
|
||||
|
||||
def _emit(self, alert: StrategyAlert) -> None:
|
||||
if self._alert_handler:
|
||||
try:
|
||||
self._alert_handler(alert)
|
||||
except Exception as e:
|
||||
logger.warning("alert handler failed: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _check_signals(
|
||||
df: pl.DataFrame,
|
||||
signals: list[str],
|
||||
) -> list[tuple[str, str | None, float | None, float | None, list[str]]]:
|
||||
"""检查信号列,返回 [(symbol, name, price, change_pct, [hit_signals])]。
|
||||
支持内置 signal_ 与自定义 csg_ 前缀。"""
|
||||
cols = set(df.columns)
|
||||
resolved: list[tuple[str, str]] = [] # (原值, 列名)
|
||||
for s in signals:
|
||||
col = s if (s.startswith("signal_") or s.startswith("csg_")) else f"signal_{s}"
|
||||
if col in cols:
|
||||
resolved.append((s, col))
|
||||
if not resolved:
|
||||
return []
|
||||
|
||||
mask = pl.any_horizontal(pl.col(c).fill_null(False) for _, c in resolved)
|
||||
hit_df = df.filter(mask)
|
||||
|
||||
results = []
|
||||
for row in hit_df.iter_rows(named=True):
|
||||
sym = row.get("symbol", "")
|
||||
name = row.get("name")
|
||||
price = row.get("close")
|
||||
pct = row.get("change_pct")
|
||||
hit_sigs = [orig for orig, col in resolved if row.get(col)]
|
||||
results.append((sym, name, price, pct, hit_sigs))
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _check_alert(
|
||||
df: pl.DataFrame,
|
||||
alert: dict,
|
||||
) -> list[tuple[str, str | None, float | None, float | None]]:
|
||||
"""检查阈值型提醒条件"""
|
||||
field = alert.get("field", "")
|
||||
if field not in df.columns:
|
||||
return []
|
||||
|
||||
if "op" in alert:
|
||||
# 阈值比较
|
||||
op = alert["op"]
|
||||
value = alert["value"]
|
||||
col = pl.col(field)
|
||||
ops = {
|
||||
">": col > value,
|
||||
">=": col >= value,
|
||||
"<": col < value,
|
||||
"<=": col <= value,
|
||||
}
|
||||
expr = ops.get(op)
|
||||
if expr is None:
|
||||
return []
|
||||
else:
|
||||
# 信号列 (布尔)
|
||||
expr = pl.col(field).fill_null(False)
|
||||
|
||||
hit_df = df.filter(expr)
|
||||
results = []
|
||||
for row in hit_df.iter_rows(named=True):
|
||||
results.append((
|
||||
row.get("symbol", ""),
|
||||
row.get("name"),
|
||||
row.get("close"),
|
||||
row.get("change_pct"),
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 通用监控规则引擎 MonitorRuleEngine
|
||||
# ================================================================
|
||||
|
||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
||||
|
||||
|
||||
def _is_signal_field(field: str) -> bool:
|
||||
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
|
||||
|
||||
|
||||
def _build_condition_mask(df: pl.DataFrame, conditions: list[dict], logic: str) -> pl.DataFrame:
|
||||
"""根据 conditions + logic 构建过滤后的命中 DataFrame。
|
||||
|
||||
conditions: [{"field","op","value"?}] — op=truth 为布尔信号, 否则阈值比较
|
||||
logic: "and" | "or"
|
||||
返回命中行 (含 symbol/name/close/change_pct + 各信号列)
|
||||
"""
|
||||
cols = set(df.columns)
|
||||
parts: list[pl.Expr] = []
|
||||
for c in conditions:
|
||||
field = c["field"]
|
||||
if field not in cols:
|
||||
return df.head(0) # 字段缺失,无法判定 → 空结果
|
||||
op = c["op"]
|
||||
if op == "truth":
|
||||
parts.append(pl.col(field).fill_null(False))
|
||||
elif op in _OP_BUILDERS:
|
||||
parts.append(_OP_BUILDERS[op](pl.col(field), c["value"]))
|
||||
else:
|
||||
return df.head(0)
|
||||
if not parts:
|
||||
return df.head(0)
|
||||
if logic == "or":
|
||||
mask = pl.any_horizontal(parts)
|
||||
else:
|
||||
mask = pl.all_horizontal(parts)
|
||||
return df.filter(mask)
|
||||
|
||||
|
||||
class MonitorRuleEngine:
|
||||
"""通用监控规则引擎 — 接收实时行情 DataFrame,评估所有规则,返回 AlertEvent。
|
||||
|
||||
与 StrategyMonitorService 的区别:
|
||||
- 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config
|
||||
- 支持 scope (symbols/all/sector) 过滤作用域
|
||||
- 支持 conditions + logic (AND/OR) 任意组合
|
||||
- ★ cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发
|
||||
"""
|
||||
|
||||
def __init__(self, alert_handler: Callable[[dict], None] | None = None):
|
||||
self._alert_handler = alert_handler
|
||||
self._rules: dict[str, dict] = {} # rule_id → rule
|
||||
# (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。
|
||||
self._last_fire: dict[tuple[str, str], float] = {}
|
||||
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 规则据此跑选股。"""
|
||||
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 字段。
|
||||
|
||||
enriched DataFrame 在 pipeline 计算后不含 name 列 (见 indicators/pipeline.py),
|
||||
触发时从 instruments 表预构建此映射, 保证 AlertEvent.name 有值。
|
||||
"""
|
||||
self._name_map = name_map or {}
|
||||
|
||||
# ── 规则管理 ───────────────────────────────────────
|
||||
def set_rules(self, rules: list[dict]) -> None:
|
||||
"""批量设置规则 (覆盖)。用于启动时 reload。"""
|
||||
self._rules = {}
|
||||
for r in rules:
|
||||
if r.get("enabled") is not False:
|
||||
self._rules[r["id"]] = r
|
||||
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
|
||||
|
||||
def add_rule(self, rule: dict) -> None:
|
||||
if rule.get("enabled") is not False:
|
||||
self._rules[rule["id"]] = rule
|
||||
else:
|
||||
self._rules.pop(rule["id"], None)
|
||||
|
||||
def remove_rule(self, rule_id: str) -> None:
|
||||
self._rules.pop(rule_id, None)
|
||||
# 清理对应的 cooldown 记录
|
||||
self._last_fire = {k: v for k, v in self._last_fire.items() if k[0] != rule_id}
|
||||
|
||||
def clear(self) -> None:
|
||||
self._rules.clear()
|
||||
self._last_fire.clear()
|
||||
|
||||
@property
|
||||
def rules(self) -> dict[str, dict]:
|
||||
return dict(self._rules)
|
||||
|
||||
@property
|
||||
def rule_count(self) -> int:
|
||||
return len(self._rules)
|
||||
|
||||
# ── 评估 ───────────────────────────────────────────
|
||||
def evaluate(self, df: pl.DataFrame) -> list[dict]:
|
||||
"""行情更新后评估所有规则。
|
||||
|
||||
Args:
|
||||
df: 实时 enriched 数据 (~5500行, 含 signal_/csg_/指标列)
|
||||
Returns:
|
||||
触发的 AlertEvent dict 列表 (含 ts/rule_id/source/type/symbol/...)
|
||||
"""
|
||||
if not self._rules or df.is_empty():
|
||||
return []
|
||||
|
||||
now = time.time()
|
||||
events: list[dict] = []
|
||||
|
||||
for rule_id, rule in self._rules.items():
|
||||
try:
|
||||
events.extend(self._evaluate_rule(df, rule, now))
|
||||
except Exception as e:
|
||||
logger.warning("规则评估失败 %s: %s", rule_id, e)
|
||||
|
||||
return events
|
||||
|
||||
def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]:
|
||||
"""评估单条规则,返回触发的 events。"""
|
||||
# 1. 按 scope 过滤作用域
|
||||
scoped = self._apply_scope(df, rule)
|
||||
if scoped.is_empty():
|
||||
return []
|
||||
|
||||
# 2. 根据 type 构建命中集
|
||||
# 元组格式: (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":
|
||||
# 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件
|
||||
hit_rows = self._match_strategy(scoped, rule)
|
||||
else:
|
||||
# signal / price / market: 通用条件匹配
|
||||
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 []
|
||||
|
||||
# 3. cooldown 去重 + 生成 events
|
||||
cooldown = rule.get("cooldown_seconds", 3600)
|
||||
severity = rule.get("severity", "info")
|
||||
source = rtype
|
||||
|
||||
events: list[dict] = []
|
||||
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
|
||||
|
||||
# 批量事件: 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": "" if is_batch else sym,
|
||||
"name": resolved_name,
|
||||
"message": message,
|
||||
"price": price,
|
||||
"change_pct": pct,
|
||||
"signals": hit_sigs,
|
||||
"severity": severity,
|
||||
}
|
||||
events.append(ev)
|
||||
if self._alert_handler:
|
||||
try:
|
||||
self._alert_handler(ev)
|
||||
except Exception as e:
|
||||
logger.warning("alert handler failed: %s", e)
|
||||
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _apply_scope(df: pl.DataFrame, rule: dict) -> pl.DataFrame:
|
||||
"""按 scope 过滤 DataFrame。"""
|
||||
scope = rule.get("scope", "symbols")
|
||||
if scope == "all":
|
||||
return df
|
||||
if scope == "symbols":
|
||||
syms = rule.get("symbols", [])
|
||||
if not syms:
|
||||
return df.head(0)
|
||||
return df.filter(pl.col("symbol").is_in(syms))
|
||||
if scope == "sector":
|
||||
# sector 过滤: 需 df 含板块列 (后续接入 ext_data JOIN)
|
||||
# 当前先返回全量, sector 精确过滤第二步完善
|
||||
return df
|
||||
return df
|
||||
|
||||
def _match_strategy(
|
||||
self, df: pl.DataFrame, rule: dict,
|
||||
) -> list[tuple[str, str, Any, Any, Any, list[str]]]:
|
||||
"""策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。
|
||||
|
||||
返回 [(event_type, symbol, name, price, pct, signals)]
|
||||
event_type: "new_entry" (新入选) | "dropped" (已移出)
|
||||
单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch")
|
||||
"""
|
||||
if self._strategy_engine is None:
|
||||
return []
|
||||
sid = rule.get("strategy_id")
|
||||
if not sid:
|
||||
return []
|
||||
try:
|
||||
s = self._strategy_engine.get(sid)
|
||||
except Exception:
|
||||
return []
|
||||
if s is None:
|
||||
return []
|
||||
|
||||
# 需要历史数据的策略跳过 (实时监控不支持 history loader)
|
||||
if s.filter_history_fn:
|
||||
logger.debug("策略 %s 需要历史数据, 跳过实时监控", sid)
|
||||
return []
|
||||
|
||||
# 运行策略选股: 复用当前 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(
|
||||
df: pl.DataFrame, rule: dict,
|
||||
) -> list[tuple[str, Any, Any, Any, list[str]]]:
|
||||
"""按 conditions + logic 匹配,返回命中行 [(symbol,name,price,pct,signals)]。"""
|
||||
conditions = rule.get("conditions", [])
|
||||
logic = rule.get("logic", "and")
|
||||
if not conditions:
|
||||
return []
|
||||
hit_df = _build_condition_mask(df, conditions, logic)
|
||||
results = []
|
||||
for row in hit_df.iter_rows(named=True):
|
||||
sym = row.get("symbol", "")
|
||||
name = row.get("name")
|
||||
price = row.get("close")
|
||||
pct = row.get("change_pct")
|
||||
# 收集命中的信号列名 (仅 op=truth 且为真的)
|
||||
hit_sigs = [
|
||||
c["field"] for c in conditions
|
||||
if c.get("op") == "truth" and row.get(c["field"])
|
||||
]
|
||||
results.append((sym, name, price, pct, hit_sigs))
|
||||
return results
|
||||
|
||||
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":
|
||||
# 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分
|
||||
sname = ""
|
||||
sid = rule.get("strategy_id")
|
||||
if sid and self._strategy_engine is not None:
|
||||
try:
|
||||
s = self._strategy_engine.get(sid)
|
||||
sname = s.meta.get("name", "") or s.meta.get("id", "")
|
||||
except Exception: # noqa: BLE001
|
||||
sname = ""
|
||||
if not sname:
|
||||
rn = rule.get("name", "")
|
||||
sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略")
|
||||
|
||||
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, "监控触发")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""监控规则 — 统一的 MonitorRule 模型,覆盖策略/个股信号/个股价格/市场异动四类。
|
||||
|
||||
职责:
|
||||
- 从 data/user_data/monitor_rules/*.json 加载规则定义
|
||||
- 校验规则字段合法性
|
||||
- 提供 CRUD (load_all / save_one / delete_one)
|
||||
|
||||
不知道: 行情评估引擎、API、告警落盘。纯函数 + 文件存储。
|
||||
|
||||
设计 (镜像 custom_signals.py 的写法):
|
||||
- 一对象一文件 + glob 全扫 + 全量重写
|
||||
- 字段白名单复用 custom_signals.ALLOWED_FIELDS (阈值条件) + 信号列清单 (布尔条件)
|
||||
- id 正则与 custom_signals 一致,保证可纳入同一索引体系
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.strategy.custom_signals import ALLOWED_FIELDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ────────────────────────────────────────────────
|
||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market"}
|
||||
SCOPES = {"symbols", "all", "sector"}
|
||||
LOGICS = {"and", "or"}
|
||||
DIRECTIONS = {"entry", "exit", "both"}
|
||||
SEVERITIES = {"info", "warn", "critical"}
|
||||
OPS = {">", ">=", "<", "<=", "==", "!="}
|
||||
|
||||
# 布尔信号列前缀 (op=truth 时 field 取这些)
|
||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
||||
|
||||
|
||||
# ── 持久化 (镜像 custom_signals.py) ─────────────────────
|
||||
def _dir(data_dir: Path) -> Path:
|
||||
d = data_dir / "user_data" / "monitor_rules"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _path(data_dir: Path, rule_id: str) -> Path:
|
||||
return _dir(data_dir) / f"{rule_id}.json"
|
||||
|
||||
|
||||
def load_all(data_dir: Path) -> list[dict]:
|
||||
"""读取全部监控规则。损坏的文件被跳过。"""
|
||||
d = _dir(data_dir)
|
||||
out: list[dict] = []
|
||||
for f in sorted(d.glob("*.json")):
|
||||
try:
|
||||
out.append(json.loads(f.read_text(encoding="utf-8")))
|
||||
except Exception as e:
|
||||
logger.warning("monitor rule load failed %s: %s", f.name, e)
|
||||
return out
|
||||
|
||||
|
||||
def load_one(data_dir: Path, rule_id: str) -> dict | None:
|
||||
p = _path(data_dir, rule_id)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("monitor rule load failed %s: %s", rule_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def save_one(data_dir: Path, rule: dict) -> None:
|
||||
p = _path(data_dir, rule["id"])
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(rule, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def delete_one(data_dir: Path, rule_id: str) -> bool:
|
||||
p = _path(data_dir, rule_id)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── 校验 ────────────────────────────────────────────────
|
||||
def _is_signal_field(field: str) -> bool:
|
||||
"""判断 field 是否为布尔信号列 (signal_ / csg_ 前缀)。"""
|
||||
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
|
||||
|
||||
|
||||
def validate(rule: dict) -> None:
|
||||
"""校验一条监控规则,非法则抛 ValueError (含中文信息)。"""
|
||||
rid = rule.get("id", "")
|
||||
if not isinstance(rid, str) or not ID_RE.match(rid):
|
||||
raise ValueError(f"规则 id 非法 (仅小写字母数字下划线, 1-40字符): {rid!r}")
|
||||
if not isinstance(rule.get("name"), str) or not rule["name"].strip():
|
||||
raise ValueError("规则 name 不能为空")
|
||||
if rule.get("type") not in RULE_TYPES:
|
||||
raise ValueError(f"type 必须是 {RULE_TYPES} 之一")
|
||||
|
||||
# 策略类型: 需要 strategy_id + direction,conditions 可空
|
||||
if rule.get("type") == "strategy":
|
||||
if not rule.get("strategy_id"):
|
||||
raise ValueError("策略类型规则必须指定 strategy_id")
|
||||
if rule.get("direction", "entry") not in DIRECTIONS:
|
||||
raise ValueError(f"direction 必须是 {DIRECTIONS} 之一")
|
||||
else:
|
||||
# 信号/价格/市场类型: 需要 conditions
|
||||
conds = rule.get("conditions")
|
||||
if not isinstance(conds, list) or len(conds) == 0:
|
||||
raise ValueError("conditions 不能为空")
|
||||
if len(conds) > 8:
|
||||
raise ValueError("conditions 最多 8 条")
|
||||
if rule.get("logic", "and") not in LOGICS:
|
||||
raise ValueError(f"logic 必须是 {LOGICS} 之一")
|
||||
for i, c in enumerate(conds):
|
||||
if not isinstance(c, dict):
|
||||
raise ValueError(f"第 {i+1} 个条件格式错误")
|
||||
field = c.get("field", "")
|
||||
op = c.get("op", "")
|
||||
if op == "truth":
|
||||
# 布尔信号: field 必须是 signal_/csg_ 前缀
|
||||
if not _is_signal_field(field):
|
||||
raise ValueError(f"第 {i+1} 个条件: op=truth 时 field 必须是信号列 (signal_/csg_ 前缀): {field!r}")
|
||||
elif op in OPS:
|
||||
# 阈值比较: field 必须在白名单, 需要 value
|
||||
if field not in ALLOWED_FIELDS:
|
||||
raise ValueError(f"第 {i+1} 个条件: 阈值字段 {field!r} 不在白名单")
|
||||
if not isinstance(c.get("value"), (int, float)):
|
||||
raise ValueError(f"第 {i+1} 个条件: value 必须是数字")
|
||||
else:
|
||||
raise ValueError(f"第 {i+1} 个条件: op {op!r} 非法 (应为 truth 或 {OPS})")
|
||||
|
||||
# scope 校验
|
||||
if rule.get("scope", "symbols") not in SCOPES:
|
||||
raise ValueError(f"scope 必须是 {SCOPES} 之一")
|
||||
if rule.get("scope") == "symbols":
|
||||
syms = rule.get("symbols")
|
||||
if not isinstance(syms, list) or len(syms) == 0:
|
||||
raise ValueError("scope=symbols 时 symbols 不能为空")
|
||||
|
||||
# 其余枚举
|
||||
if rule.get("severity", "info") not in SEVERITIES:
|
||||
raise ValueError(f"severity 必须是 {SEVERITIES} 之一")
|
||||
cd = rule.get("cooldown_seconds", 3600)
|
||||
if not isinstance(cd, int) or cd < 0:
|
||||
raise ValueError("cooldown_seconds 必须是非负整数")
|
||||
|
||||
|
||||
def normalize(rule: dict) -> dict:
|
||||
"""补全默认字段,返回规范化后的规则 (不校验)。"""
|
||||
r = dict(rule)
|
||||
r.setdefault("enabled", True)
|
||||
r.setdefault("scope", "symbols")
|
||||
r.setdefault("symbols", [])
|
||||
r.setdefault("sector", None)
|
||||
r.setdefault("strategy_id", None)
|
||||
r.setdefault("direction", "entry")
|
||||
r.setdefault("conditions", [])
|
||||
r.setdefault("logic", "and")
|
||||
r.setdefault("cooldown_seconds", 3600)
|
||||
r.setdefault("severity", "info")
|
||||
r.setdefault("message", "")
|
||||
r.setdefault("webhook_url", "")
|
||||
r.setdefault("webhook_enabled", False)
|
||||
r.setdefault("created_at", datetime.now(timezone.utc).isoformat())
|
||||
return r
|
||||
|
||||
|
||||
# 策略监控自动迁移的规则 id 前缀 (固定, 保证幂等)
|
||||
STRATEGY_RULE_PREFIX = "mr_strategy_"
|
||||
|
||||
|
||||
def strategy_rule_id(strategy_id: str) -> str:
|
||||
"""策略监控规则 id = mr_strategy_{strategy_id}。"""
|
||||
return f"{STRATEGY_RULE_PREFIX}{strategy_id}"
|
||||
|
||||
|
||||
def migrate_strategy_monitors(data_dir: Path, strategy_ids: list[str], strategy_names: dict[str, str]) -> list[dict]:
|
||||
"""把 preferences.strategy_monitor_ids 里的策略,同步生成/更新 type=strategy 规则。
|
||||
|
||||
幂等: 已存在的策略规则会被更新 (方向/名称),不会重复创建。
|
||||
已从 strategy_ids 移除的策略, 其规则会被停用 (enabled=False) 而非删除 (保留历史触发记录的关联)。
|
||||
|
||||
Args:
|
||||
data_dir: 数据目录
|
||||
strategy_ids: 当前监控池中的策略 id 列表
|
||||
strategy_names: {strategy_id: 策略名} 用于规则显示名
|
||||
Returns:
|
||||
本次生成/更新的规则列表
|
||||
"""
|
||||
desired = set(strategy_ids)
|
||||
existing = load_all(data_dir)
|
||||
# 已存在的策略规则 {strategy_id: rule}
|
||||
existing_strategy_rules: dict[str, dict] = {}
|
||||
for r in existing:
|
||||
rid = r.get("id", "")
|
||||
if rid.startswith(STRATEGY_RULE_PREFIX):
|
||||
sid = rid[len(STRATEGY_RULE_PREFIX):]
|
||||
if sid:
|
||||
existing_strategy_rules[sid] = r
|
||||
|
||||
touched: list[dict] = []
|
||||
# 1. 为当前监控池的策略 upsert 规则
|
||||
for sid in desired:
|
||||
rule_id = strategy_rule_id(sid)
|
||||
name = strategy_names.get(sid, sid)
|
||||
rule = existing_strategy_rules.get(sid)
|
||||
if rule is None:
|
||||
rule = normalize({
|
||||
"id": rule_id,
|
||||
"name": f"策略监控 · {name}",
|
||||
"type": "strategy",
|
||||
"scope": "all",
|
||||
"strategy_id": sid,
|
||||
"direction": "entry",
|
||||
"conditions": [],
|
||||
"cooldown_seconds": 3600,
|
||||
"enabled": True,
|
||||
})
|
||||
else:
|
||||
rule = dict(rule)
|
||||
rule["enabled"] = True
|
||||
rule["strategy_id"] = sid
|
||||
rule["name"] = f"策略监控 · {name}"
|
||||
rule.setdefault("scope", "all")
|
||||
rule.setdefault("direction", "entry")
|
||||
save_one(data_dir, rule)
|
||||
touched.append(rule)
|
||||
|
||||
# 2. 不在监控池的策略 → 停用其规则 (不删除)
|
||||
for sid, rule in existing_strategy_rules.items():
|
||||
if sid not in desired and rule.get("enabled") is not False:
|
||||
rule = dict(rule)
|
||||
rule["enabled"] = False
|
||||
save_one(data_dir, rule)
|
||||
|
||||
return touched
|
||||
@@ -0,0 +1,65 @@
|
||||
"""策略提示词组装器 — 两步定制流程的提示词生成。
|
||||
|
||||
职责: 加载对应步骤的 Markdown 指南,拼接用户输入,组装 LLM 提示词。
|
||||
不知道: LLM 调用、API、前端、引擎执行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
|
||||
_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def _load_doc(name: str) -> str:
|
||||
if name not in _cache:
|
||||
path = _DOCS_DIR / name
|
||||
_cache[name] = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
return _cache[name]
|
||||
|
||||
|
||||
DIRECTION_CN = {"long": "做多", "short": "做空", "monitor": "监控"}
|
||||
|
||||
|
||||
def build_step1(name: str, description: str, direction: str, rules: str, strategy_id: str = "") -> str:
|
||||
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警)
|
||||
|
||||
注意: strategy-guide.md 已在 ai_generator.py 的 system prompt 中加载,
|
||||
此处不再重复加载以节省 token。
|
||||
"""
|
||||
guide = _load_doc("strategy-builder-step1.md")
|
||||
|
||||
id_line = f"\n策略ID(必须使用此ID):{strategy_id}" if strategy_id else ""
|
||||
|
||||
return f"""{guide}
|
||||
|
||||
---
|
||||
|
||||
请根据以下用户输入生成完整策略代码:
|
||||
|
||||
策略名称:{name}{id_line}
|
||||
策略描述:{description}
|
||||
选股方向:{DIRECTION_CN.get(direction, direction)}
|
||||
策略规则:
|
||||
{rules}
|
||||
|
||||
只输出 Python 代码。"""
|
||||
|
||||
|
||||
def build_step2(current_code: str, instruction: str) -> str:
|
||||
"""步骤2:修改策略任意部分"""
|
||||
guide = _load_doc("strategy-builder-step2.md")
|
||||
|
||||
return f"""{guide}
|
||||
|
||||
---
|
||||
|
||||
当前策略代码:
|
||||
```python
|
||||
{current_code}
|
||||
```
|
||||
|
||||
用户修改指令:
|
||||
{instruction}
|
||||
|
||||
只输出修改后的完整 Python 代码。"""
|
||||
Reference in New Issue
Block a user