diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py
index 38f9d64..9223a4c 100644
--- a/backend/app/api/strategy.py
+++ b/backend/app/api/strategy.py
@@ -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 traversal:strategy_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}")
diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py
index 203f711..5e2b1ba 100644
--- a/backend/app/services/ai_provider.py
+++ b/backend/app/services/ai_provider.py
@@ -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(" 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],
*,
diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py
index 6109d32..dd2bd63 100644
--- a/backend/app/strategy/ai_generator.py
+++ b/backend/app/strategy/ai_generator.py
@@ -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 等未列出的危险模块。
diff --git a/backend/app/strategy/prompt_builder.py b/backend/app/strategy/prompt_builder.py
index 75433fa..0b11d99 100644
--- a/backend/app/strategy/prompt_builder.py
+++ b/backend/app/strategy/prompt_builder.py
@@ -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:
diff --git a/backend/app/strategy/prompts/strategy-guide-compact.md b/backend/app/strategy/prompts/strategy-guide-compact.md
new file mode 100644
index 0000000..1035fc9
--- /dev/null
+++ b/backend/app/strategy/prompts/strategy-guide-compact.md
@@ -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 中,策略代码不能直接引用:财务数据、扩展数据、概念/行业/人气排名/资金流向、盘中分时价、五档盘口。
diff --git a/backend/app/strategy/prompts/strategy-guide.md b/backend/app/strategy/prompts/strategy-guide.md
index 92b73a7..0d04c99 100644
--- a/backend/app/strategy/prompts/strategy-guide.md
+++ b/backend/app/strategy/prompts/strategy-guide.md
@@ -1,6 +1,6 @@
# 策略开发指南
-本文档是策略开发的完整参考。人类开发者参考它编写策略,AI 读取它生成策略代码。
+本文档是策略开发的完整参考。人类开发者参考它编写策略;AI 运行时使用同目录下的 `strategy-guide-compact.md` 精简指南生成策略代码。
## 1. 策略文件格式
diff --git a/backend/tests/test_ai_generator_prompt.py b/backend/tests/test_ai_generator_prompt.py
new file mode 100644
index 0000000..c4e91c7
--- /dev/null
+++ b/backend/tests/test_ai_generator_prompt.py
@@ -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
diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py
index 3db10c7..52dc35b 100644
--- a/backend/tests/test_ai_provider.py
+++ b/backend/tests/test_ai_provider.py
@@ -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="
Gateway Timeout
",
+ 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): 请求参数无效, 请检查模型名称和上下文长度"
diff --git a/backend/tests/test_strategy_build_stream.py b/backend/tests/test_strategy_build_stream.py
new file mode 100644
index 0000000..a43768d
--- /dev/null
+++ b/backend/tests/test_strategy_build_stream.py
@@ -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"]
diff --git a/backend/tests/test_strategy_code_save.py b/backend/tests/test_strategy_code_save.py
new file mode 100644
index 0000000..dd2e419
--- /dev/null
+++ b/backend/tests/test_strategy_code_save.py
@@ -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")
diff --git a/docs/strategy.md b/docs/strategy.md
index 4ebdf34..d2b3c1e 100644
--- a/docs/strategy.md
+++ b/docs/strategy.md
@@ -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 等),完整规范见该文档。
diff --git a/frontend/src/components/screener/StrategyBuilderDialog.tsx b/frontend/src/components/screener/StrategyBuilderDialog.tsx
index 9c0e1f7..2e7c49c 100644
--- a/frontend/src/components/screener/StrategyBuilderDialog.tsx
+++ b/frontend/src/components/screener/StrategyBuilderDialog.tsx
@@ -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
{/* 左侧:Tab 切换 */}
-
@@ -444,36 +499,51 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
) : (
/* 自定义编写 */
-
-
-
- 自定义策略开发方式
-
-
-
在项目目录 data/strategies/custom/ 下创建 .py 文件。支持两种模式:
-
-
-
- 模式 A:单日过滤 — filter(df, params) → pl.Expr
-
-
-
- 模式 B:历史窗口 — filter_history(df, params) → pl.DataFrame + LOOKBACK_DAYS
-
-
-
完整规范见 backend/app/strategy/prompts/strategy-guide.md
-
+
+ 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" />
+ 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" />
-
-
-
快速模板
-
{ navigator.clipboard.writeText(CUSTOM_TEMPLATE); setCustomCopied(true); setTimeout(() => setCustomCopied(false), 2000) }}
+
+
+
+
+ 自定义策略代码
+ {validated && 已校验}
+
+
{ 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 ? : }
- {customCopied ? '已复制' : '复制模板'}
+ {customCopied ? '已复制' : '复制代码'}
+
+
+
)}
diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx
index c58ca35..8a57bd2 100644
--- a/frontend/src/components/screener/StrategyPoolDialog.tsx
+++ b/frontend/src/components/screener/StrategyPoolDialog.tsx
@@ -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
(() => [...pool])
const [allStrategies, setAllStrategies] = useState([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('all')
+ const [importing, setImporting] = useState(false)
+ const [importError, setImportError] = useState('')
+ const [importMsg, setImportMsg] = useState('')
+ const fileInputRef = useRef(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()
@@ -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 (
{validDraft.length} / {allStrategies.length}
{invalidPoolCount > 0 && {invalidPoolCount} 个失效}
-
-
-
+
+ {
+ const file = e.target.files?.[0]
+ if (file) void handleImportFile(file)
+ }}
+ />
+ 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 ? : }
+ 导入策略
+
+
+
+
+
+ {(importError || importMsg) && (
+
+ {importError || importMsg}
+
+ )}
+
{loading ? (
@@ -150,7 +227,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
hover:bg-accent/8 transition-colors cursor-pointer group text-left"
>
- {s.name}
+
+ {s.name} {s.id}
+
{s.description}
@@ -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)' }}
>
- {s?.name ?? id}
+
+ {s?.name ?? id} {id}
+
{SOURCE_LABEL[src] ?? '内置'}
diff --git a/frontend/src/components/screener/StrategySettingsDialog.tsx b/frontend/src/components/screener/StrategySettingsDialog.tsx
index 4405c69..2332a29 100644
--- a/frontend/src/components/screener/StrategySettingsDialog.tsx
+++ b/frontend/src/components/screener/StrategySettingsDialog.tsx
@@ -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 && {{ builtin: '内置', custom: '自定义', ai: 'AI' }[detail.source] ?? detail.source}}
{strategyId}
-
+
+ {detail && (detail.source === 'ai' || detail.source === 'custom') && (
+
+
+ 下载策略
+
+ )}
+
+
{/* 内容 */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 341ae81..ac6ef72 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -395,6 +395,27 @@ export interface StrategyDetail {
limit: number
}
+export interface StrategyBuildResult {
+ code: string
+ meta: Record
+ 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
+}
+
// ===== 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) =>
- request<{ code: string; meta: Record; valid: boolean; error: string | null }>(
+ request(
'/api/strategies/build',
{ method: 'POST', body: JSON.stringify({ step, ...payload }) },
),
+ async *strategyBuildStream(step: number, payload: Record): AsyncGenerator {
+ 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('/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('/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', {
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index 2af0d08..19c9303 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -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>('strategy-rules'),
diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx
index bc75d0c..dfff8ef 100644
--- a/frontend/src/pages/Screener.tsx
+++ b/frontend/src/pages/Screener.tsx
@@ -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')