feat: 优化策略创建流程

This commit is contained in:
shy3130
2026-07-08 22:17:11 +08:00
parent 7e8b45fd0c
commit fbcfd31d6a
17 changed files with 1083 additions and 166 deletions
+201 -44
View File
@@ -11,8 +11,10 @@ import re
from dataclasses import asdict
from datetime import date
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.strategy import config as strategy_config
@@ -129,6 +131,24 @@ class AISaveRequest(BaseModel):
description: str = ""
class StrategyCodeValidateRequest(BaseModel):
code: str
strategy_id: str = ""
name: str = ""
description: str = ""
strict: bool = True
class StrategyCodeSaveRequest(BaseModel):
code: str
strategy_id: str
target_source: Literal["ai", "custom"] = "custom"
mode: Literal["create", "update"] = "create"
name: str = ""
description: str = ""
strict: bool = True
class MonitorStartRequest(BaseModel):
strategy_id: str
@@ -382,6 +402,119 @@ def _normalize_build_result(result: dict, strategy_id: str, name: str = "",
return {**result, "valid": False, "error": f"规范化 META 失败: {e}"}
def _validate_strategy_id(strategy_id: str) -> str:
sid = (strategy_id or "").strip()
if not re.fullmatch(r"[A-Za-z0-9_-]+", sid):
raise ValueError("strategy_id 仅允许字母、数字、下划线、短横线")
return sid
def _target_dir(data_dir: Path, source: str) -> Path:
if source not in {"ai", "custom"}:
raise ValueError("target_source 必须是 ai 或 custom")
return data_dir / "strategies" / source
def _prepare_strategy_code(req: StrategyCodeValidateRequest | StrategyCodeSaveRequest) -> dict:
sid = _validate_strategy_id(req.strategy_id) if req.strategy_id else ""
code = req.code
if sid:
current_meta = AIStrategyGenerator._extract_meta(code)
needs_normalize = (
current_meta.get("id") != sid
or bool(req.name.strip())
or bool(req.description.strip())
)
if needs_normalize:
code = _normalize_strategy_meta(
code,
sid,
req.name.strip() or None,
req.description.strip() or None,
)
if req.strict:
AIStrategyGenerator._validate_safety(code)
meta = AIStrategyGenerator._extract_meta(code)
return {"code": code, "meta": meta}
def _restore_strategy_file(path: Path, previous_code: str | None) -> None:
if previous_code is None:
path.unlink(missing_ok=True)
else:
path.write_text(previous_code, encoding="utf-8")
def _save_strategy_code(req: StrategyCodeSaveRequest, request: Request, *, legacy_ai_path: bool = False) -> dict:
sid = _validate_strategy_id(req.strategy_id)
if legacy_ai_path:
if not (sid.startswith("ai_") or sid.startswith("custom_")):
raise ValueError("策略 ID 必须以 ai_ 或 custom_ 开头")
engine = _get_engine(request)
data_dir = _data_dir(request)
existing: StrategyDef | None = None
try:
existing = engine.get(sid)
except ValueError:
existing = None
if not legacy_ai_path and req.mode == "create":
if req.target_source == "ai" and not sid.startswith("ai_"):
raise ValueError("AI 策略 ID 必须以 ai_ 开头")
if req.target_source == "custom" and not sid.startswith("custom_"):
raise ValueError("自定义策略 ID 必须以 custom_ 开头")
if legacy_ai_path:
out_dir = _target_dir(data_dir, "ai")
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{sid}.py"
expected_source = "ai"
elif req.mode == "update":
if existing is None:
raise ValueError(f"策略 {sid} 不存在")
if existing.source == "builtin":
raise ValueError("内置策略不可覆盖,请另存为自定义策略")
path = existing.file_path
expected_source = existing.source
else:
if existing is not None:
raise ValueError(f"策略 {sid} 已存在,请改用修改模式或换一个策略 ID")
source_dir = "ai" if legacy_ai_path else req.target_source
out_dir = _target_dir(data_dir, source_dir)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{sid}.py"
expected_source = "ai" if legacy_ai_path else req.target_source
if path is None:
raise ValueError("策略源文件不存在")
path.parent.mkdir(parents=True, exist_ok=True)
prepared = _prepare_strategy_code(req)
previous_code = path.read_text(encoding="utf-8") if path.exists() else None
path.write_text(prepared["code"], encoding="utf-8")
try:
engine.reload()
loaded = engine.get(sid)
if loaded.file_path is None or loaded.file_path.resolve() != path.resolve():
raise ValueError("策略加载到了非预期文件,请检查是否存在重复 strategy_id")
if loaded.source != expected_source:
raise ValueError(f"策略来源异常: 期望 {expected_source}, 实际 {loaded.source}")
except Exception as e:
_restore_strategy_file(path, previous_code)
engine.reload()
raise ValueError(f"策略保存失败: {e}") from e
return {
"ok": True,
"strategy_id": sid,
"source": expected_source,
"path": str(path),
"meta": prepared["meta"],
}
@router.get("/ai/status")
def ai_status(request: Request):
"""Check whether the selected AI provider is configured."""
@@ -434,6 +567,14 @@ async def ai_test(request: Request):
return {"ok": False, "error": str(e)}
def _build_prompt(req: BuildRequest) -> str:
if req.step == 1:
return build_step1(req.name, req.description, req.direction, req.rules, req.strategy_id)
if req.step == 2:
return build_step2(req.current_code, req.instruction)
raise ValueError(f"无效步骤: {req.step}")
@router.post("/build")
async def build_strategy(req: BuildRequest, request: Request):
"""两步策略构建。
@@ -442,17 +583,13 @@ async def build_strategy(req: BuildRequest, request: Request):
"""
gen = AIStrategyGenerator()
if req.step == 1:
prompt = build_step1(req.name, req.description, req.direction, req.rules, req.strategy_id)
elif req.step == 2:
prompt = build_step2(req.current_code, req.instruction)
else:
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
try:
prompt = _build_prompt(req)
result = await gen.generate(prompt)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if req.step == 1:
result = _normalize_build_result(result, req.strategy_id, req.name, req.description)
elif req.strategy_id:
@@ -460,6 +597,35 @@ async def build_strategy(req: BuildRequest, request: Request):
return result
@router.post("/build/stream")
async def build_strategy_stream(req: BuildRequest, request: Request):
try:
prompt = _build_prompt(req)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
async def event_generator():
gen = AIStrategyGenerator()
chunks: list[str] = []
yield json.dumps({"type": "meta", "strategy_id": req.strategy_id, "step": req.step}, ensure_ascii=False) + "\n"
try:
async for chunk in gen.stream(prompt):
chunks.append(chunk)
yield json.dumps({"type": "delta", "content": chunk}, ensure_ascii=False) + "\n"
result = gen.validate_code("".join(chunks))
if req.step == 1:
result = _normalize_build_result(result, req.strategy_id, req.name, req.description)
elif req.strategy_id:
result = _normalize_build_result(result, req.strategy_id)
yield json.dumps({"type": "result", **result}, ensure_ascii=False) + "\n"
except RuntimeError as e:
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
except Exception as e:
yield json.dumps({"type": "error", "message": f"AI生成失败: {e}"}, ensure_ascii=False) + "\n"
return StreamingResponse(event_generator(), media_type="application/x-ndjson")
@router.post("/ai/generate")
async def ai_generate(req: AIGenerateRequest, request: Request):
@@ -473,48 +639,39 @@ async def ai_generate(req: AIGenerateRequest, request: Request):
return result
@router.post("/code/validate")
def validate_strategy_code(req: StrategyCodeValidateRequest, request: Request):
try:
prepared = _prepare_strategy_code(req)
return {"valid": True, "error": None, **prepared}
except Exception as e:
return {"valid": False, "error": str(e), "code": req.code, "meta": {}}
@router.post("/code/save")
def save_strategy_code(req: StrategyCodeSaveRequest, request: Request):
try:
return _save_strategy_code(req, request)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/ai/save")
async def ai_save(req: AISaveRequest, request: Request):
data_dir = _data_dir(request)
out_dir = data_dir / "strategies" / "ai"
out_dir.mkdir(parents=True, exist_ok=True)
# 防 path traversalstrategy_id 仅允许字母/数字/下划线/短横线。
# 安全性由字符白名单保证(杜绝 / \ .. 等路径分隔/穿越符),文件落点已被
# out_dir 锁死在 data/strategies/ai/。前缀只影响 source 标记,允许
# ai_ 与 custom_,以兼容「AI 修改 custom 策略」流程(Screener.tsx onAiModify)。
sid = req.strategy_id or ""
if not re.fullmatch(r"[A-Za-z0-9_-]+", sid):
raise HTTPException(status_code=400, detail="strategy_id 仅允许字母、数字、下划线、短横线")
if not (sid.startswith("ai_") or sid.startswith("custom_")):
raise HTTPException(status_code=400, detail="策略 ID 必须以 ai_ 或 custom_ 开头")
path = out_dir / f"{sid}.py"
try:
code = _normalize_strategy_meta(
req.code,
sid,
req.name.strip() or None,
req.description.strip() or None,
save_req = StrategyCodeSaveRequest(
code=req.code,
strategy_id=req.strategy_id,
target_source="ai",
mode="create",
name=req.name,
description=req.description,
strict=True,
)
result = _save_strategy_code(save_req, request, legacy_ai_path=True)
return {"ok": True, "path": result["path"]}
except Exception as e:
raise HTTPException(status_code=400, detail=f"策略 META 无效: {e}") from e
previous_code = path.read_text(encoding="utf-8") if path.exists() else None
path.write_text(code, encoding="utf-8")
# 热重载,并确认保存的策略真的被引擎加载。
engine = _get_engine(request)
engine.reload()
if not engine.has(req.strategy_id):
if previous_code is None:
path.unlink(missing_ok=True)
else:
path.write_text(previous_code, encoding="utf-8")
engine.reload()
raise HTTPException(
status_code=400,
detail=f"策略保存成功但加载失败: {req.strategy_id},请检查代码语法和 META.id 是否一致",
)
return {"ok": True, "path": str(path)}
raise HTTPException(status_code=400, detail=str(e)) from e
@router.delete("/{strategy_id}")
+116 -18
View File
@@ -144,12 +144,17 @@ async def _run_openai_once(
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
resp = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
)
try:
resp = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
)
except Exception as exc:
if _is_openai_transport_error(exc):
raise RuntimeError(_format_openai_error(exc)) from exc
raise
if not resp.choices:
return ""
return (resp.choices[0].message.content or "").strip()
@@ -167,18 +172,23 @@ async def _stream_openai(
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
stream = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
try:
stream = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
except Exception as exc:
if _is_openai_transport_error(exc):
raise RuntimeError(_format_openai_error(exc)) from exc
raise
def _openai_client(api_key: str, timeout: float):
@@ -189,11 +199,99 @@ def _openai_client(api_key: str, timeout: float):
api_key=api_key,
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
timeout=timeout,
max_retries=2,
max_retries=0,
default_headers={"User-Agent": user_agent},
)
def _is_openai_transport_error(exc: Exception) -> bool:
try:
import openai
except ImportError:
openai = None
if openai is not None and isinstance(exc, openai.APIError):
return True
try:
import httpx
except ImportError:
return False
return isinstance(exc, httpx.HTTPError)
def _format_openai_error(exc: Exception) -> str:
status = getattr(exc, "status_code", None)
response = getattr(exc, "response", None)
if status is None and response is not None:
status = getattr(response, "status_code", None)
class_name = exc.__class__.__name__
if "Timeout" in class_name:
return "AI 服务请求超时, 请稍后重试或检查 AI Base URL / 网络"
if "Connection" in class_name:
return "AI 服务连接失败, 请检查 AI Base URL / 网络"
detail = _openai_error_detail(exc)
status_messages = {
400: "请求参数无效, 请检查模型名称和上下文长度",
401: "API Key 无效或无权限, 请检查设置页配置",
403: "AI 服务拒绝访问, 请检查账号权限或网关配置",
404: "模型或接口地址不存在, 请检查 AI Base URL 和模型名称",
408: "AI 服务请求超时, 请稍后重试",
429: "AI 服务限流或额度不足, 请稍后重试或检查额度",
500: "AI 服务内部错误, 请稍后重试",
502: "AI 网关返回错误, 请稍后重试或检查 AI Base URL",
503: "AI 服务暂时不可用, 请稍后重试",
504: "AI 上游服务超时, 请稍后重试或检查 AI Base URL / 网络",
}
message = status_messages.get(status) or detail or "请稍后重试或检查 AI 服务配置"
if status:
return f"AI 服务请求失败({status}): {message}"
return f"AI 服务请求失败: {message}"
def _openai_error_detail(exc: Exception) -> str:
body = getattr(exc, "body", None)
if isinstance(body, dict):
error = body.get("error")
if isinstance(error, dict):
text = error.get("message") or error.get("code") or error.get("type")
return _compact_error_text(str(text or ""))
if isinstance(error, str):
return _compact_error_text(error)
response = getattr(exc, "response", None)
content_type = ""
text = ""
if response is not None:
content_type = response.headers.get("content-type", "").lower()
try:
text = response.text
except Exception:
text = ""
if not text and isinstance(body, str):
text = body
if not text:
text = str(exc)
if _looks_like_html(text, content_type):
return ""
return _compact_error_text(text)
def _looks_like_html(text: str, content_type: str) -> bool:
sample = text.lstrip()[:200].lower()
return "html" in content_type or sample.startswith("<!doctype html") or sample.startswith("<html")
def _compact_error_text(text: str) -> str:
text = _ANSI_RE.sub("", text)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:500]
async def _run_codex_cli(
messages: Sequence[Message],
*,
+30 -9
View File
@@ -7,14 +7,12 @@ from __future__ import annotations
import ast
import logging
import re
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
# 策略开发文档路径(随 backend/app 打包进 Docker避免 .dockerignore 排除 docs/ 导致运行时缺失
GUIDE_PATH = Path(__file__).resolve().parent / "prompts" / "strategy-guide.md"
# 策略开发精简指南路径 (随 backend/app 打包进 Docker, 避免 .dockerignore 排除 docs/ 导致运行时缺失)
GUIDE_PATH = Path(__file__).resolve().parent / "prompts" / "strategy-guide-compact.md"
_SYSTEM_PREFIX = """你是A股量化策略设计专家。根据用户描述的需求,参考下方的《策略开发指南》生成一个完整的策略Python文件。
@@ -48,7 +46,7 @@ class AIStrategyGenerator:
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)
logger.warning("strategy guide not found at %s", GUIDE_PATH)
self._guide_cache = ""
return self._guide_cache
@@ -61,6 +59,25 @@ class AIStrategyGenerator:
# 调用 LLM
code = await self._call_llm(user_prompt, guide)
return self.validate_code(code)
async def stream(self, user_prompt: str):
"""Yield generated strategy code deltas from the configured AI provider."""
from app.services.ai_provider import stream_ai_text
guide = self._get_guide()
async for chunk in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PREFIX + guide},
{"role": "user", "content": user_prompt},
],
temperature=0.3,
max_tokens=3000,
):
yield chunk
def validate_code(self, code: str) -> dict:
code = self._extract_code_block(code)
# 验证
try:
@@ -88,12 +105,16 @@ class AIStrategyGenerator:
temperature=0.3,
max_tokens=3000,
)
return self._extract_code_block(content)
@staticmethod
def _extract_code_block(content: str) -> str:
# Extract fenced code if the model wrapped the answer in Markdown.
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
return content.split("```python", 1)[1].split("```", 1)[0].strip()
if "```" in content:
return content.split("```", 1)[1].split("```", 1)[0].strip()
return content.strip()
# import 白名单: 策略文件只允许 polars (见 strategy-guide.md「只 import polars」)。
# 白名单而非黑名单 — 黑名单挡不住 ctypes/importlib/builtins/pickle 等未列出的危险模块。
+8 -10
View File
@@ -25,18 +25,12 @@ 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
注意: 生成规范已在 ai_generator.py 的 system prompt 中加载,
此处只拼用户输入以降低网关超时概率
"""
guide = _load_doc("strategy-builder-step1.md")
id_line = f"\n策略ID(必须使用此ID):{strategy_id}" if strategy_id else ""
return f"""{guide}
---
请根据以下用户输入生成完整策略代码:
return f"""请根据以下用户输入生成完整策略代码:
策略名称:{name}{id_line}
策略描述:{description}
@@ -44,7 +38,11 @@ def build_step1(name: str, description: str, direction: str, rules: str, strateg
策略规则:
{rules}
输出 Python 代码。"""
输出要求:
1. 严格遵循系统提示中的策略文件结构和安全限制。
2. 根据规则自行判断使用 filter() 或 filter_history()。
3. 生成完整 META、ENTRY_SIGNALS、EXIT_SIGNALS、STOP_LOSS、MAX_HOLD_DAYS、ALERTS、RULES 和筛选函数。
4. 只输出 Python 代码。"""
def build_step2(current_code: str, instruction: str) -> str:
@@ -0,0 +1,126 @@
# AI 策略生成精简指南
只生成一个完整 Python 策略文件,直接输出代码,不要输出 Markdown 解释。
## 必须遵守
1.`import polars as pl`,禁止 import 其他模块。
2. AI 策略只属于 `data/strategies/ai/``META.id` 使用用户给定的 `ai_` ID。
3. 不要读写文件,不要使用 `open/exec/eval/compile/__import__/globals/locals/vars/dir/getattr/setattr/delattr/type/input`
4. `META.params` 只放用户可能调整的阈值;公式常数和固定窗口边界不必参数化。
5. `META.scoring` 权重总和必须为 1.0。
6. `ENTRY_SIGNALS` / `EXIT_SIGNALS` 只选和策略逻辑直接相关的信号,不要凑数。
7. `RULES` 用中文逐条列出核心逻辑,至少 3 条。
8. 优先 Polars 表达式、`with_columns``over("symbol")``group_by``join``filter`,避免逐行循环。
## 文件结构
```python
"""策略简短描述"""
import polars as pl
META = {
"id": "ai_xxxxxxxxxxxx",
"name": "策略中文名",
"description": "一句话说明策略逻辑",
"tags": ["标签"],
"basic_filter": {
"price_min": 3,
"price_max": 200,
"market_cap_min": 10e8,
"amount_min": 0.5e8,
"exclude_st": True,
"exclude_new_days": 30,
},
"params": [],
"scoring": {},
"order_by": "score",
"descending": True,
"limit": 100,
}
ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 规则一
2. 规则二
3. 规则三
"""
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return pl.lit(True)
```
## 何时使用 filter_history
普通 `filter()` 只判断当日数据。规则涉及以下场景时必须使用 `filter_history(df, params) -> pl.DataFrame`
- 最近 N 天内出现过某事件。
- 涨停后的第 X 天、上次涨停价、前高、前低。
- 连续 N 天阴跌/阳线等时序逻辑。
- 任何需要多日数据才能判断的条件。
历史窗口策略要声明:
```python
LOOKBACK_DAYS = 8
def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame:
if df.is_empty() or "date" not in df.columns:
return df
hist = df.sort(["symbol", "date"]).with_columns([
pl.col("close").shift(1).over("symbol").alias("_prev_close"),
])
return hist.filter(pl.col("close") > pl.col("_prev_close"))
```
`filter_history()` 必须返回所有匹配行,不要只过滤最新日期;回测需要全区间命中。
## 常用字段
通用:`symbol`, `date`, `name`
价格:`open`, `high`, `low`, `close`, `raw_close`, `raw_high`, `raw_low`, `prev_close`, `change_pct`, `change_amount`, `amount`, `amplitude`
均线:`ma5`, `ma10`, `ma20`, `ma30`, `ma60`, `ema5`, `ema10`, `ema20`, `ema30`, `ema60`
技术指标:`macd_dif`, `macd_dea`, `macd_hist`, `boll_upper`, `boll_lower`, `kdj_k`, `kdj_d`, `kdj_j`, `rsi_6`, `rsi_14`, `rsi_24`, `atr_14`
量能:`volume`, `vol_ma5`, `vol_ma10`, `vol_ratio_5d`, `turnover_rate`
动量与波动:`momentum_5d`, `momentum_10d`, `momentum_20d`, `momentum_30d`, `momentum_60d`, `annual_vol_20d`, `high_60d`, `low_60d`
涨跌停:`consecutive_limit_ups`, `consecutive_limit_downs`
市值相关:`total_shares`, `float_shares`,可用 `close * total_shares` 估算总市值。
## 常用信号列
信号列是布尔值,使用时加 `.fill_null(False)`
- `signal_ma_golden_5_20`: MA5 上穿 MA20
- `signal_ma_dead_5_20`: MA5 下穿 MA20
- `signal_ma_golden_20_60`: MA20 上穿 MA60
- `signal_macd_golden`: MACD 金叉
- `signal_macd_dead`: MACD 死叉
- `signal_ma20_breakout`: 突破 MA20
- `signal_ma20_breakdown`: 跌破 MA20
- `signal_n_day_high`: 60 日新高
- `signal_n_day_low`: 60 日新低
- `signal_boll_breakout_upper`: 突破布林上轨
- `signal_boll_breakdown_lower`: 跌破布林下轨
- `signal_volume_surge`: 放量
- `signal_limit_up`: 涨停
- `signal_limit_down`: 跌停
- `signal_limit_down_recovery`: 跌停翘板
- `signal_broken_limit_up`: 炸板
涨跌停策略优先使用稳定列 `consecutive_limit_ups >= 1`
## 不可直接引用的数据
以下数据不在 enriched DataFrame 中,策略代码不能直接引用:财务数据、扩展数据、概念/行业/人气排名/资金流向、盘中分时价、五档盘口。
@@ -1,6 +1,6 @@
# 策略开发指南
本文档是策略开发的完整参考。人类开发者参考它编写策略AI 读取它生成策略代码。
本文档是策略开发的完整参考。人类开发者参考它编写策略AI 运行时使用同目录下的 `strategy-guide-compact.md` 精简指南生成策略代码。
## 1. 策略文件格式
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from app.strategy.ai_generator import GUIDE_PATH, AIStrategyGenerator
from app.strategy.prompt_builder import build_step1
def test_ai_strategy_generator_uses_compact_guide():
assert GUIDE_PATH.name == "strategy-guide-compact.md"
guide = AIStrategyGenerator()._get_guide()
assert "AI 策略生成精简指南" in guide
assert "策略示例" not in guide
assert len(guide) < 5000
def test_build_step1_keeps_user_prompt_compact():
prompt = build_step1(
"测试策略",
"测试描述",
"long",
"1. 收盘价站上 MA20\n2. 成交量放大\n3. RSI 不过热",
"ai_test",
)
assert "# 步骤 1:根据规则生成完整策略" not in prompt
assert "模式 A 框架" not in prompt
assert "策略ID(必须使用此ID):ai_test" in prompt
assert len(prompt) < 1000
+37 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from app.services.ai_provider import normalize_openai_base_url
import httpx
import openai
from app.services.ai_provider import _format_openai_error, normalize_openai_base_url
def test_normalize_openai_base_url_adds_v1_for_root_gateway():
@@ -13,3 +16,36 @@ def test_normalize_openai_base_url_preserves_v1_base():
def test_normalize_openai_base_url_strips_chat_completions_path():
assert normalize_openai_base_url("http://ai.zedbox.cn:8080/v1/chat/completions") == "http://ai.zedbox.cn:8080/v1"
def test_format_openai_error_hides_html_gateway_body():
response = httpx.Response(
504,
headers={"content-type": "text/html; charset=utf-8"},
text="<!DOCTYPE html><html><body><h1>Gateway Timeout</h1></body></html>",
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.InternalServerError("gateway timeout", response=response, body=response.text)
message = _format_openai_error(exc)
assert message == "AI 服务请求失败(504): AI 上游服务超时, 请稍后重试或检查 AI Base URL / 网络"
assert "html" not in message.lower()
assert "Gateway Timeout" not in message
def test_format_openai_error_uses_status_message_when_available():
response = httpx.Response(
400,
json={"error": {"message": "model context length exceeded"}},
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.BadRequestError(
"bad request",
response=response,
body={"error": {"message": "model context length exceeded"}},
)
message = _format_openai_error(exc)
assert message == "AI 服务请求失败(400): 请求参数无效, 请检查模型名称和上下文长度"
@@ -0,0 +1,68 @@
from __future__ import annotations
import json
import pytest
from app.api.strategy import BuildRequest, build_strategy_stream
from app.strategy.ai_generator import AIStrategyGenerator
STREAM_CODE = '''"""测试策略"""
import polars as pl
META = {
"id": "wrong",
"name": "旧名",
"description": "旧描述",
"tags": ["测试"],
"params": [],
"scoring": {},
}
ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 测试规则一
2. 测试规则二
3. 测试规则三
"""
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return pl.lit(True)
'''
@pytest.mark.asyncio
async def test_build_strategy_stream_yields_delta_and_normalized_result(monkeypatch):
async def fake_stream(self, prompt):
yield STREAM_CODE[:40]
yield STREAM_CODE[40:]
monkeypatch.setattr(AIStrategyGenerator, "stream", fake_stream)
req = BuildRequest(
step=1,
name="新策略",
description="新描述",
direction="long",
rules="1. 规则一\n2. 规则二\n3. 规则三",
strategy_id="ai_streamed",
)
response = await build_strategy_stream(req, None)
body = b""
async for chunk in response.body_iterator:
body += chunk.encode("utf-8") if isinstance(chunk, str) else chunk
events = [json.loads(line) for line in body.decode("utf-8").splitlines()]
assert [event["type"] for event in events] == ["meta", "delta", "delta", "result"]
result = events[-1]
assert result["valid"] is True
assert result["meta"]["id"] == "ai_streamed"
assert result["meta"]["name"] == "新策略"
assert '"id": "ai_streamed"' in result["code"]
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
from types import SimpleNamespace
import polars as pl
import pytest
from app.api.strategy import (
StrategyCodeSaveRequest,
StrategyCodeValidateRequest,
_prepare_strategy_code,
_save_strategy_code,
)
from app.strategy.engine import StrategyEngine
def _code(strategy_id: str, name: str = "测试策略") -> str:
return f'''"""测试策略"""
import polars as pl
META = {{
"id": "{strategy_id}",
"name": "{name}",
"description": "测试描述",
"tags": ["测试"],
"params": [],
"scoring": {{}},
}}
ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 测试规则一
2. 测试规则二
3. 测试规则三
"""
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return pl.lit(True)
'''
def _request(tmp_path):
ai_dir = tmp_path / "strategies" / "ai"
custom_dir = tmp_path / "strategies" / "custom"
engine = StrategyEngine(
enriched_loader=lambda _date: pl.DataFrame(),
strategy_dirs=[custom_dir, ai_dir],
)
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo, strategy_engine=engine)))
def test_prepare_strategy_code_rejects_forbidden_import():
req = StrategyCodeValidateRequest(
strategy_id="custom_bad",
code='''import os\nMETA = {"id": "custom_bad"}\n''',
)
with pytest.raises(ValueError, match="禁止 import os"):
_prepare_strategy_code(req)
def test_save_strategy_code_creates_ai_strategy_in_ai_dir(tmp_path):
request = _request(tmp_path)
req = StrategyCodeSaveRequest(
strategy_id="ai_saved",
target_source="ai",
mode="create",
code=_code("wrong"),
name="AI 策略",
)
result = _save_strategy_code(req, request)
assert result["ok"] is True
assert result["source"] == "ai"
assert (tmp_path / "strategies" / "ai" / "ai_saved.py").exists()
loaded = request.app.state.strategy_engine.get("ai_saved")
assert loaded.source == "ai"
assert loaded.file_path == tmp_path / "strategies" / "ai" / "ai_saved.py"
def test_save_strategy_code_creates_custom_strategy_in_custom_dir(tmp_path):
request = _request(tmp_path)
req = StrategyCodeSaveRequest(
strategy_id="custom_saved",
target_source="custom",
mode="create",
code=_code("wrong"),
name="自定义策略",
)
result = _save_strategy_code(req, request)
assert result["ok"] is True
assert result["source"] == "custom"
assert (tmp_path / "strategies" / "custom" / "custom_saved.py").exists()
loaded = request.app.state.strategy_engine.get("custom_saved")
assert loaded.source == "custom"
assert loaded.file_path == tmp_path / "strategies" / "custom" / "custom_saved.py"
def test_save_strategy_code_updates_existing_source_file(tmp_path):
request = _request(tmp_path)
create = StrategyCodeSaveRequest(
strategy_id="custom_update",
target_source="custom",
mode="create",
code=_code("custom_update", "旧名称"),
)
_save_strategy_code(create, request)
update = StrategyCodeSaveRequest(
strategy_id="custom_update",
target_source="ai",
mode="update",
code=_code("custom_update", "新名称"),
)
result = _save_strategy_code(update, request)
assert result["source"] == "custom"
custom_path = tmp_path / "strategies" / "custom" / "custom_update.py"
assert custom_path.exists()
assert not (tmp_path / "strategies" / "ai" / "custom_update.py").exists()
assert '"name": "新名称"' in custom_path.read_text(encoding="utf-8")
+8 -8
View File
@@ -33,7 +33,7 @@
### 🤖 方式二:AI 生成
一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件:
一句话描述思路,LLM 读取精简运行时指南生成完整策略文件:
1. **配置 AI 接口**(留空即关闭,见 [configuration.md → AI](./configuration.md#ai可选)):
```ini
@@ -43,21 +43,21 @@
AI_MODEL=deepseek-chat
```
2. 在选股页打开「AI 策略生成器」,用自然语言描述你的策略思路
3. LLM 生成完整策略代码,经 `ast` 安全校验(禁止 import os/sys/subprocess 等危险模块)后
4. 落入 `data/strategies/ai/`,文件名/ID 用 `ai_` 前缀
3. 前端流式接收生成代码,后端经 `ast` 安全校验(禁止 import os/sys/subprocess 等危险模块)后返回结果
4. 保存后落入 `data/strategies/ai/`,文件名/ID 用 `ai_` 前缀
生成策略会读取 `backend/app/strategy/prompts/` 下的提示词文档:
生成策略相关提示词位于 `backend/app/strategy/prompts/`:
- `strategy-guide.md` — 完整策略开发规范(作为 LLM system prompt)
- `strategy-builder-step1.md` — 步骤 1 提示词模板(规则 → 完整代码)
- `strategy-guide-compact.md` — AI 运行时精简指南(用于降低长请求超时概率)
- `strategy-guide.md` — 完整策略开发规范(供人工开发和详细参考)
- `strategy-builder-step2.md` — 步骤 2 提示词模板(修改已有策略)
- `strategy-example.md` — 从零创建强势反包策略的三步演示
> 💡 **文件与范围铁律**:AI 生成的策略只生成一个 `.py` 文件,只 `import polars as pl`,绝不修改 `backend/`、`docs/`、`frontend/` 等现有文件。
### 📝 方式三:代码迁移
### 📝 方式三:自定义编写 / 代码迁移
参照开发指南把已有策略改写为 Polars 文件,放入 `data/strategies/custom/`,引擎自动发现。
可以在选股页「自定义编写」中直接编辑策略代码并保存,新建自定义策略会落入 `data/strategies/custom/`,文件名/ID 用 `custom_` 前缀。也可以手动把已有策略改写为 Polars 文件后放入该目录,引擎自动发现。
手写策略需遵循 [`strategy-guide.md`](../backend/app/strategy/prompts/strategy-guide.md) 的文件结构(META / basic_filter / scoring / ENTRY_SIGNALS / filter 等),完整规范见该文档。
@@ -15,8 +15,8 @@ function parsePyValue(v: string): any {
return JSON.parse(s)
}
function slugId(): string {
return 'ai_' + Date.now().toString(36)
function slugId(prefix: 'ai' | 'custom' = 'ai'): string {
return prefix + '_' + Date.now().toString(36)
}
function parseParams(code: string): any[] {
@@ -134,6 +134,8 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
const [instruction, setInstruction] = useState('')
const [previewTab, setPreviewTab] = useState<'params' | 'code'>('params')
const [strategyId, setStrategyId] = useState('')
const [source, setSource] = useState<'ai' | 'custom'>('ai')
const [validated, setValidated] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -150,6 +152,8 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
setStep(d.step ?? 1); setName(d.name ?? ''); setDescription(d.description ?? '')
setDirection(d.direction ?? 'long')
setRules(d.rules ?? ''); setCode(d.code ?? ''); setStrategyId(d.strategyId ?? '')
setSource((d as any).source ?? (d.strategyId?.startsWith('custom_') ? 'custom' : 'ai'))
if (mode === 'modify') setTab('custom')
}
setLoaded(true)
}, [open])
@@ -165,37 +169,48 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
if (!name && !rules && !code) {
draftStore.set(null)
} else {
draftStore.set({ name, description, direction, rules, code, step, strategyId })
draftStore.set({ name, description, direction, rules, code, step, strategyId, source } as any)
}
}, [name, description, direction, rules, code, step, strategyId])
}, [name, description, direction, rules, code, step, strategyId, source])
useEffect(() => { if (loaded) persist() }, [loaded, persist])
const clearDraft = () => {
setName(''); setDescription(''); setDirection('long')
setRules(''); setCode(''); setStep(1); setError(''); setInstruction('')
setStrategyId('')
setStrategyId(''); setSource('ai'); setValidated(false)
}
const handleClose = () => { if (name || rules || code) persist(); onClose() }
const resolveStrategyId = () => {
if (mode === 'create' && strategyId && !strategyId.startsWith('ai_')) return slugId()
return strategyId || slugId()
const resolveStrategyId = (target: 'ai' | 'custom' = source) => {
if (mode === 'modify' && strategyId) return strategyId
if (strategyId && strategyId.startsWith(target + '_')) return strategyId
return slugId(target)
}
// Step 1: 生成
const handleGenerate = async () => {
if (!name.trim() || !rules.trim()) return
if (!aiStatus?.configured) { setError('AI 未配置,请在设置页面配置 API Key'); return }
setLoading(true); setError('')
setLoading(true); setError(''); setCode(''); setValidated(false)
try {
const id = resolveStrategyId()
setStrategyId(id)
const res = await api.strategyBuild(1, { name: name.trim(), description: description.trim(), direction, rules: rules.trim(), strategy_id: id })
if (!res.valid) { setError(res.error ?? '生成失败'); return }
setCode(res.code); setStep(2)
const genDesc = parseMetaField(res.code, 'description')
const genRules = parseRules(res.code)
const id = resolveStrategyId('ai')
setStrategyId(id); setSource('ai'); setPreviewTab('code')
let finalResult: any = null
for await (const evt of api.strategyBuildStream(1, { name: name.trim(), description: description.trim(), direction, rules: rules.trim(), strategy_id: id })) {
if (evt.type === 'delta') {
setCode(prev => prev + evt.content)
} else if (evt.type === 'error') {
throw new Error(evt.message)
} else if (evt.type === 'result') {
finalResult = evt
}
}
if (!finalResult) throw new Error('AI 未返回策略结果')
if (!finalResult.valid) { setError(finalResult.error ?? '生成失败'); return }
setCode(finalResult.code); setStep(2); setValidated(true)
const genDesc = parseMetaField(finalResult.code, 'description')
const genRules = parseRules(finalResult.code)
if (genDesc) setDescription(genDesc)
if (genRules) setRules(genRules)
} catch (e: any) {
@@ -207,28 +222,68 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
// Step 2: AI 修改
const handleModify = async () => {
if (!instruction.trim() || !code) return
setLoading(true); setError('')
setLoading(true); setError(''); setValidated(false)
try {
const res = await api.strategyBuild(2, { current_code: code, instruction: instruction.trim(), strategy_id: strategyId })
if (!res.valid) { setError(res.error ?? '修改失败'); return }
setCode(res.code); setInstruction('')
const genDesc = parseMetaField(res.code, 'description')
const updatedRules = parseRules(res.code)
let draft = ''
let finalResult: any = null
for await (const evt of api.strategyBuildStream(2, { current_code: code, instruction: instruction.trim(), strategy_id: strategyId })) {
if (evt.type === 'delta') {
draft += evt.content
setCode(draft)
} else if (evt.type === 'error') {
throw new Error(evt.message)
} else if (evt.type === 'result') {
finalResult = evt
}
}
if (!finalResult) throw new Error('AI 未返回策略结果')
if (!finalResult.valid) { setError(finalResult.error ?? '修改失败'); return }
setCode(finalResult.code); setInstruction(''); setValidated(true)
const genDesc = parseMetaField(finalResult.code, 'description')
const updatedRules = parseRules(finalResult.code)
if (genDesc) setDescription(genDesc)
if (updatedRules) setRules(updatedRules)
} catch (e: any) { setError(String(e?.message ?? '修改失败')) }
finally { setLoading(false) }
}
// 手动保存
const handleSave = async () => {
if (!code) return
setSaving(true)
const handleValidateCode = async () => {
const draftCode = code
if (!draftCode.trim()) return
setLoading(true); setError('')
try {
const id = resolveStrategyId()
const id = strategyId || resolveStrategyId(tab === 'custom' ? 'custom' : 'ai')
setStrategyId(id)
await api.strategySaveCode(id, code, { name: name.trim(), description: description.trim() })
const genRules = parseRules(code)
const res = await api.strategyValidateCode({ code: draftCode, strategy_id: id, name: name.trim(), description: description.trim(), strict: true })
if (!res.valid) { setValidated(false); setError(res.error ?? '代码校验失败'); return }
setCode(res.code); setValidated(true)
const genDesc = parseMetaField(res.code, 'description')
const genRules = parseRules(res.code)
if (genDesc) setDescription(genDesc)
if (genRules) setRules(genRules)
} catch (e: any) { setError(String(e?.message ?? '代码校验失败')) }
finally { setLoading(false) }
}
// 保存
const handleSave = async () => {
const draftCode = code
if (!draftCode) return
setSaving(true); setError('')
try {
const target = mode === 'modify' ? source : (tab === 'custom' ? 'custom' : 'ai')
const id = resolveStrategyId(target)
setStrategyId(id); setSource(target)
await api.strategySaveCodeV2({
strategy_id: id,
code: draftCode,
target_source: target,
mode: mode === 'modify' ? 'update' : 'create',
name: name.trim(),
description: description.trim(),
strict: true,
})
const genRules = parseRules(draftCode)
const finalRules = (genRules || rules).trim()
if (finalRules) { const saved = storage.strategyRules.get({}); saved[id] = finalRules; storage.strategyRules.set(saved) }
await onSavedId?.(id)
@@ -262,10 +317,10 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
<div className="grid grid-cols-[1fr_auto_1fr] items-center px-5 py-2.5 border-b border-border/50">
{/* 左侧:Tab 切换 */}
<div className="flex rounded-lg bg-elevated p-0.5 w-fit">
<button onClick={() => setTab('ai')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'ai' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-foreground')}>
<button onClick={() => { setTab('ai'); if (mode === 'create') setSource('ai') }} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'ai' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-foreground')}>
<Sparkles className="h-3 w-3 inline mr-1" />AI
</button>
<button onClick={() => setTab('custom')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'custom' ? 'bg-accent/15 text-accent' : 'text-muted hover:text-foreground')}>
<button onClick={() => { setTab('custom'); if (mode === 'create') { setSource('custom'); if (!code) setCode(CUSTOM_TEMPLATE) } }} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'custom' ? 'bg-accent/15 text-accent' : 'text-muted hover:text-foreground')}>
<FileText className="h-3 w-3 inline mr-1" />
</button>
</div>
@@ -444,36 +499,51 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
) : (
/* 自定义编写 */
<div className="space-y-4">
<div className="rounded-xl border border-border/40 bg-elevated/50 p-4 space-y-2.5">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="space-y-1.5 text-[11px] text-secondary leading-relaxed">
<p> <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">data/strategies/custom/</code> <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">.py</code> </p>
<div className="space-y-1 pl-1">
<div className="flex items-start gap-1.5">
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-accent/60 shrink-0" />
<span><strong className="text-foreground/80"> A</strong> <code className="text-[10px] font-mono text-foreground/80">filter(df, params) pl.Expr</code></span>
</div>
<div className="flex items-start gap-1.5">
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-amber-400/60 shrink-0" />
<span><strong className="text-foreground/80"> B</strong> <code className="text-[10px] font-mono text-foreground/80">filter_history(df, params) pl.DataFrame</code> + <code className="text-[10px] font-mono text-foreground/80">LOOKBACK_DAYS</code></span>
</div>
</div>
<p> <span className="text-accent">backend/app/strategy/prompts/strategy-guide.md</span></p>
</div>
<div className="grid grid-cols-2 gap-2">
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="策略名称,如:我的反包策略"
className="h-9 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm font-medium text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
<input type="text" value={description} onChange={e => setDescription(e.target.value)} placeholder="一句话描述策略逻辑"
className="h-9 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<button onClick={() => { navigator.clipboard.writeText(CUSTOM_TEMPLATE); setCustomCopied(true); setTimeout(() => setCustomCopied(false), 2000) }}
<div className="rounded-xl border border-border/40 bg-elevated/50 p-4 space-y-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
{validated && <span className="text-[10px] text-emerald-400"></span>}
</div>
<button onClick={() => { navigator.clipboard.writeText(code || CUSTOM_TEMPLATE); setCustomCopied(true); setTimeout(() => setCustomCopied(false), 2000) }}
className={cn('inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium transition-all cursor-pointer', customCopied ? 'bg-emerald-400/10 text-emerald-400' : 'bg-elevated text-muted hover:text-foreground hover:bg-accent/10')}>
{customCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{customCopied ? '已复制' : '复制模板'}
{customCopied ? '已复制' : '复制代码'}
</button>
</div>
<textarea
value={code}
onChange={e => { setCode(e.target.value); setValidated(false) }}
spellCheck={false}
className="w-full h-[420px] rounded-xl border border-border/40 bg-base p-4 text-[11px] leading-relaxed font-mono text-foreground/80 resize-none focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
<div className="text-[11px] text-muted leading-relaxed">
<code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">data/strategies/custom/</code>
</div>
{error && <div className="text-[11px] text-danger bg-danger/10 border border-danger/20 rounded-lg px-3 py-2">{error}</div>}
<div className="flex items-center justify-end gap-2">
<button onClick={() => { setCode(CUSTOM_TEMPLATE); setStrategyId(''); setSource('custom'); setValidated(false) }}
className="h-8 px-3 rounded-lg border border-border text-xs text-secondary hover:text-foreground">
使
</button>
<button onClick={handleValidateCode} disabled={loading || !code.trim()}
className="h-8 px-3 rounded-lg border border-accent/30 bg-accent/10 text-accent text-xs font-medium hover:bg-accent/15 disabled:opacity-40 flex items-center gap-1.5">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
<button onClick={handleSave} disabled={saving || loading || !code.trim()}
className="h-8 px-3 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-50 flex items-center gap-1.5">
<Save className="h-3.5 w-3.5" />
{saving ? '保存中...' : mode === 'modify' ? '保存修改' : '保存自定义策略'}
</button>
</div>
<pre className="rounded-xl border border-border/40 bg-base p-4 text-[10px] leading-relaxed font-mono text-foreground/70 overflow-auto max-h-[400px]">{CUSTOM_TEMPLATE}</pre>
</div>
</div>
)}
@@ -1,6 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'
import { X, Plus, GripVertical } from 'lucide-react'
import { X, Plus, GripVertical, Upload, Loader2 } from 'lucide-react'
import { api, type StrategyDetail } from '@/lib/api'
interface Props {
@@ -32,19 +32,41 @@ const TABS: { id: SourceTab; label: string }[] = [
{ id: 'ai', label: 'AI' },
]
function parseMetaId(code: string): string {
const m = code.match(/["']id["']\s*:\s*["']([A-Za-z0-9_-]+)["']/)
return m ? m[1] : ''
}
function fileStem(name: string): string {
return name.replace(/\.py$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '')
}
export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
// 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交
const [draftPool, setDraftPool] = useState<string[]>(() => [...pool])
const [allStrategies, setAllStrategies] = useState<StrategyDetail[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<SourceTab>('all')
const [importing, setImporting] = useState(false)
const [importError, setImportError] = useState('')
const [importMsg, setImportMsg] = useState('')
const fileInputRef = useRef<HTMLInputElement | null>(null)
const loadStrategies = useCallback(async () => {
setLoading(true)
try {
const d = await api.strategyList()
setAllStrategies(d.strategies)
} catch {
setAllStrategies([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
api.strategyList()
.then(d => setAllStrategies(d.strategies))
.catch(() => setAllStrategies([]))
.finally(() => setLoading(false))
}, [])
loadStrategies()
}, [loadStrategies])
const stratMap = useMemo(() => {
const m = new Map<string, StrategyDetail>()
@@ -81,6 +103,35 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
setDraftPool(newOrder)
}, [])
const handleImportFile = useCallback(async (file: File) => {
setImporting(true); setImportError(''); setImportMsg('')
try {
if (!file.name.toLowerCase().endsWith('.py')) throw new Error('只能导入 .py 策略文件')
const code = await file.text()
const rawId = parseMetaId(code) || fileStem(file.name)
if (!rawId) throw new Error('无法识别策略 ID,请检查 META.id 或文件名')
const target: 'ai' | 'custom' = rawId.startsWith('ai_') ? 'ai' : 'custom'
const strategyId = target === 'ai'
? rawId
: (rawId.startsWith('custom_') ? rawId : `custom_${rawId}`)
const result = await api.strategySaveCodeV2({
strategy_id: strategyId,
code,
target_source: target,
mode: 'create',
strict: true,
})
await loadStrategies()
setActiveTab(result.source)
setImportMsg(`已导入到${result.source === 'ai' ? 'AI' : '自定义'}策略: ${result.strategy_id}`)
} catch (e: any) {
setImportError(String(e?.message ?? '导入失败'))
} finally {
setImporting(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}, [loadStrategies])
return (
<AnimatePresence>
<motion.div
@@ -103,11 +154,37 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
<span className="text-muted font-normal text-xs">{validDraft.length} / {allStrategies.length}</span>
{invalidPoolCount > 0 && <span className="ml-2 text-[10px] text-danger">{invalidPoolCount} </span>}
</span>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".py,text/x-python,text/plain"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) void handleImportFile(file)
}}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn border border-accent/30 bg-accent/10 text-accent text-xs font-medium hover:bg-accent/15 disabled:opacity-50 transition-colors cursor-pointer"
>
{importing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
</button>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
</div>
{(importError || importMsg) && (
<div className={`mx-4 mt-2 px-3 py-2 rounded-btn border text-[11px] shrink-0 ${importError ? 'border-danger/20 bg-danger/10 text-danger' : 'border-emerald-400/20 bg-emerald-400/10 text-emerald-400'}`}>
{importError || importMsg}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
@@ -150,7 +227,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
hover:bg-accent/8 transition-colors cursor-pointer group text-left"
>
<span className="flex-1 min-w-0">
<span className="text-[12px] text-foreground group-hover:text-accent transition-colors block truncate">{s.name}</span>
<span className="text-[12px] text-foreground group-hover:text-accent transition-colors block truncate">
{s.name} <span className="text-[10px] text-muted font-mono">{s.id}</span>
</span>
<span className="text-[10px] text-muted truncate block">{s.description}</span>
</span>
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[s.source] ?? SOURCE_CLS.builtin}`}>
@@ -194,7 +273,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
whileDrag={{ scale: 1.02, zIndex: 50, boxShadow: '0 4px 12px rgba(0,0,0,0.2)' }}
>
<GripVertical className="h-3.5 w-3.5 text-accent/40 group-hover:text-accent/70 shrink-0" />
<span className="flex-1 min-w-0 text-[12px] text-foreground truncate">{s?.name ?? id}</span>
<span className="flex-1 min-w-0 text-[12px] text-foreground truncate">
{s?.name ?? id} <span className="text-[10px] text-muted font-mono">{id}</span>
</span>
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[src] ?? SOURCE_CLS.builtin}`}>
{SOURCE_LABEL[src] ?? '内置'}
</span>
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
@@ -317,6 +317,20 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
} finally { setDeleting(false) }
}
const handleDownload = async () => {
if (!strategyId || !detail || (detail.source !== 'ai' && detail.source !== 'custom')) return
const src = await api.strategyGetSource(strategyId)
const blob = new Blob([src.code], { type: 'text/x-python;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${strategyId}.py`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
if (!strategyId) return null
return (
@@ -335,7 +349,20 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI' }[detail.source] ?? detail.source}</span>}
<span className="text-[10px] text-muted/40 font-mono">{strategyId}</span>
</div>
<button aria-label="关闭" onClick={onClose} className="p-1.5 rounded-lg hover:bg-elevated transition-colors cursor-pointer"><X className="h-4 w-4 text-muted" /></button>
<div className="flex items-center gap-2">
{detail && (detail.source === 'ai' || detail.source === 'custom') && (
<button
aria-label="下载策略"
title="下载策略"
onClick={handleDownload}
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border/60 bg-surface text-xs text-secondary hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button aria-label="关闭" onClick={onClose} className="p-1.5 rounded-lg hover:bg-elevated transition-colors cursor-pointer"><X className="h-4 w-4 text-muted" /></button>
</div>
</div>
{/* 内容 */}
+77 -1
View File
@@ -395,6 +395,27 @@ export interface StrategyDetail {
limit: number
}
export interface StrategyBuildResult {
code: string
meta: Record<string, any>
valid: boolean
error: string | null
}
export type StrategyBuildStreamEvent =
| { type: 'meta'; strategy_id?: string; step?: number }
| { type: 'delta'; content: string }
| ({ type: 'result' } & StrategyBuildResult)
| { type: 'error'; message: string }
export interface StrategyCodeSaveResult {
ok: boolean
strategy_id: string
source: 'ai' | 'custom'
path: string
meta: Record<string, any>
}
// ===== Custom Signals (自定义信号) =====
export interface CustomSignalCondition {
left: string // 字段名
@@ -1899,11 +1920,66 @@ export const api = {
strategyGetSource: (id: string) =>
request<{ code: string; source: string }>(`/api/strategies/${id}/source`),
strategyBuild: (step: number, payload: Record<string, any>) =>
request<{ code: string; meta: Record<string, any>; valid: boolean; error: string | null }>(
request<StrategyBuildResult>(
'/api/strategies/build',
{ method: 'POST', body: JSON.stringify({ step, ...payload }) },
),
async *strategyBuildStream(step: number, payload: Record<string, any>): AsyncGenerator<StrategyBuildStreamEvent> {
const res = await fetch('/api/strategies/build/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ step, ...payload }),
})
if (!res.ok) {
let detail = ''
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
const msg = detail || `${res.status} ${res.statusText}`
toast(msg, 'error')
throw new Error(msg)
}
if (!res.body) throw new Error('响应无 body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
const s = line.trim()
if (!s) continue
try { yield JSON.parse(s) } catch { /* ignore */ }
}
}
if (buf.trim()) {
try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
}
},
strategyValidateCode: (payload: { code: string; strategy_id?: string; name?: string; description?: string; strict?: boolean }) =>
request<StrategyBuildResult>('/api/strategies/code/validate', {
method: 'POST',
body: JSON.stringify(payload),
}),
strategySaveCodeV2: (payload: {
strategy_id: string
code: string
target_source: 'ai' | 'custom'
mode: 'create' | 'update'
name?: string
description?: string
strict?: boolean
}) =>
request<StrategyCodeSaveResult>('/api/strategies/code/save', {
method: 'POST',
body: JSON.stringify(payload),
}),
/** 保存 AI 生成的策略文件 */
strategySaveCode: (strategyId: string, code: string, meta?: { name?: string; description?: string }) =>
request<{ ok: boolean; path: string }>('/api/strategies/ai/save', {
+3 -3
View File
@@ -70,13 +70,13 @@ export const storage = {
limitLadderSealMode: kv<'vol' | 'amount'>('limit-ladder-seal-mode'),
/** 策略创建草稿(新建专用) */
strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-draft'),
strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string; source?: 'ai' | 'custom' } | null>('strategy-draft'),
/** 策略修改草稿(AI修改专用,不影响创建按钮) */
strategyModify: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-modify'),
strategyModify: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string; source?: 'ai' | 'custom' } | null>('strategy-modify'),
/** 策略构建器草稿(旧版兼容,逐渐废弃) */
strategyBuilderDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-builder-draft'),
strategyBuilderDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string; source?: 'ai' | 'custom' } | null>('strategy-builder-draft'),
/** 已保存策略的原始规则(策略ID → 规则文本) */
strategyRules: kv<Record<string, string>>('strategy-rules'),
+1 -1
View File
@@ -863,7 +863,7 @@ export function Screener() {
description: detail.description ?? '',
direction: 'long',
rules: storage.strategyRules.get({})[settingsStrategyId] ?? '',
code: src.code, step: 2, strategyId: settingsStrategyId,
code: src.code, step: 2, strategyId: settingsStrategyId, source: src.source as any,
})
setSettingsStrategyId(null)
setBuilderMode('modify')