From fbcfd31d6a7daa1c16458475365d978411c1015e Mon Sep 17 00:00:00 2001 From: shy3130 Date: Wed, 8 Jul 2026 22:17:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/strategy.py | 245 ++++++++++++++---- backend/app/services/ai_provider.py | 134 ++++++++-- backend/app/strategy/ai_generator.py | 39 ++- backend/app/strategy/prompt_builder.py | 18 +- .../prompts/strategy-guide-compact.md | 126 +++++++++ .../app/strategy/prompts/strategy-guide.md | 2 +- backend/tests/test_ai_generator_prompt.py | 29 +++ backend/tests/test_ai_provider.py | 38 ++- backend/tests/test_strategy_build_stream.py | 68 +++++ backend/tests/test_strategy_code_save.py | 130 ++++++++++ docs/strategy.md | 16 +- .../screener/StrategyBuilderDialog.tsx | 182 +++++++++---- .../screener/StrategyPoolDialog.tsx | 105 +++++++- .../screener/StrategySettingsDialog.tsx | 31 ++- frontend/src/lib/api.ts | 78 +++++- frontend/src/lib/storage.ts | 6 +- frontend/src/pages/Screener.tsx | 2 +- 17 files changed, 1083 insertions(+), 166 deletions(-) create mode 100644 backend/app/strategy/prompts/strategy-guide-compact.md create mode 100644 backend/tests/test_ai_generator_prompt.py create mode 100644 backend/tests/test_strategy_build_stream.py create mode 100644 backend/tests/test_strategy_code_save.py 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" />
-
-
- 快速模板 - +
+