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