初始化工程

This commit is contained in:
2026-07-01 22:07:55 +08:00
parent 9bb6d7a654
commit fc2b0944d2
248 changed files with 65020 additions and 316 deletions
View File
+154
View File
@@ -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"))
)
+71
View File
@@ -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
+207
View File
@@ -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()
+453
View File
@@ -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()))
+613
View File
@@ -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, "监控触发")
+241
View File
@@ -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
+65
View File
@@ -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 代码。"""