diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index f5bb519..38f9d64 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -4,21 +4,22 @@ """ from __future__ import annotations +import ast +import json import math import re from dataclasses import asdict from datetime import date from pathlib import Path -from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from app.strategy import config as strategy_config -from app.strategy.engine import StrategyEngine, StrategyDef from app.strategy.ai_generator import AIStrategyGenerator +from app.strategy.engine import StrategyDef, StrategyEngine +from app.strategy.monitor import StrategyMonitorService from app.strategy.prompt_builder import build_step1, build_step2 -from app.strategy.monitor import StrategyMonitorService, StrategyAlert router = APIRouter(prefix="/api/strategies", tags=["strategies"]) @@ -124,6 +125,8 @@ class AIGenerateRequest(BaseModel): class AISaveRequest(BaseModel): code: str strategy_id: str + name: str = "" + description: str = "" class MonitorStartRequest(BaseModel): @@ -285,6 +288,100 @@ class BuildRequest(BaseModel): instruction: str = "" +def _py_string(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _find_meta_dict(code: str) -> ast.Dict: + tree = ast.parse(code) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "META": + if not isinstance(node.value, ast.Dict): + raise ValueError("META 必须是字面量字典") + return node.value + raise ValueError("找不到 META 字典") + + +def _set_meta_string_field(block: str, field: str, value: str) -> str: + pattern = re.compile( + rf"(?m)^(\s*[\"']{re.escape(field)}[\"']\s*:\s*)([\"'])(?:\\.|[^\n\\])*?\2" + ) + next_block, count = pattern.subn( + lambda m: f"{m.group(1)}{_py_string(value)}", + block, + count=1, + ) + if count: + return next_block + + lines = block.splitlines(keepends=True) + key_indent = None + for line in lines: + m = re.match(r"^(\s*)[\"'][^\"']+[\"']\s*:", line) + if m: + key_indent = m.group(1) + break + if key_indent is None: + first_indent = re.match(r"^(\s*)", lines[0] if lines else "") + key_indent = (first_indent.group(1) if first_indent else "") + " " + + insert_at = len(lines) + for i in range(len(lines) - 1, -1, -1): + if lines[i].lstrip().startswith("}"): + insert_at = i + break + for i in range(insert_at - 1, -1, -1): + if not lines[i].strip(): + continue + body = lines[i].rstrip("\r\n") + if body.rstrip() and not body.rstrip().endswith((",", "{")): + newline = lines[i][len(body):] + lines[i] = body.rstrip() + "," + newline + break + lines.insert(insert_at, f'{key_indent}"{field}": {_py_string(value)},\n') + return "".join(lines) + + +def _normalize_strategy_meta(code: str, strategy_id: str, + name: str | None = None, + description: str | None = None) -> str: + """Force persisted strategy identity to match the caller-owned identity.""" + meta_node = _find_meta_dict(code) + lines = code.splitlines(keepends=True) + start = meta_node.lineno - 1 + end = meta_node.end_lineno or meta_node.lineno + block = "".join(lines[start:end]) + + fields = {"id": strategy_id} + if name: + fields["name"] = name + if description: + fields["description"] = description + for field, value in fields.items(): + block = _set_meta_string_field(block, field, value) + + lines[start:end] = block.splitlines(keepends=True) + return "".join(lines) + + +def _normalize_build_result(result: dict, strategy_id: str, name: str = "", + description: str = "") -> dict: + if not result.get("valid") or not strategy_id: + return result + try: + code = _normalize_strategy_meta( + result.get("code", ""), + strategy_id, + name.strip() or None, + description.strip() or None, + ) + return {**result, "code": code, "meta": AIStrategyGenerator._extract_meta(code)} + except Exception as e: + return {**result, "valid": False, "error": f"规范化 META 失败: {e}"} + + @router.get("/ai/status") def ai_status(request: Request): """Check whether the selected AI provider is configured.""" @@ -305,7 +402,6 @@ def ai_status(request: Request): @router.get("/{strategy_id}/source") def get_strategy_source(strategy_id: str, request: Request): """获取策略源文件内容(用于 AI 修改)""" - from pathlib import Path # 先查 StrategyEngine 获取文件路径 engine = _get_engine(request) @@ -357,6 +453,10 @@ async def build_strategy(req: BuildRequest, request: Request): result = await gen.generate(prompt) except RuntimeError 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: + result = _normalize_build_result(result, req.strategy_id) return result @@ -388,8 +488,18 @@ async def ai_save(req: AISaveRequest, request: Request): 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, + ) + 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(req.code, encoding="utf-8") + path.write_text(code, encoding="utf-8") # 热重载,并确认保存的策略真的被引擎加载。 engine = _get_engine(request) @@ -410,7 +520,6 @@ async def ai_save(req: AISaveRequest, request: Request): @router.delete("/{strategy_id}") def delete_strategy(strategy_id: str, request: Request): """删除自定义策略 — 清除 .py 文件 + overrides + 热重载。内置策略不可删除。""" - from pathlib import Path engine = _get_engine(request) try: diff --git a/backend/tests/test_ai_strategy_meta_normalize.py b/backend/tests/test_ai_strategy_meta_normalize.py new file mode 100644 index 0000000..d6ad8a7 --- /dev/null +++ b/backend/tests/test_ai_strategy_meta_normalize.py @@ -0,0 +1,78 @@ +"""AI 策略 META 规范化回归测试。""" +from __future__ import annotations + +from app.api.strategy import _normalize_build_result, _normalize_strategy_meta + +RAW_CODE = '''"""模型返回的策略""" +import polars as pl + +META = { + "id": "custom_wrong_id", + "name": "English Placeholder", + "description": "model desc", + "tags": ["AI"], + "params": [], + "scoring": {}, +} + +ENTRY_SIGNALS = [] +EXIT_SIGNALS = [] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 20 +ALERTS = [] + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + return pl.lit(True) +''' + + +def test_normalize_strategy_meta_forces_ai_id_and_chinese_name(): + code = _normalize_strategy_meta( + RAW_CODE, + "ai_test123", + name="断板反包", + description="中文描述", + ) + + assert '"id": "ai_test123"' in code + assert '"name": "断板反包"' in code + assert '"description": "中文描述"' in code + assert "custom_wrong_id" not in code + assert "English Placeholder" not in code + + +def test_normalize_build_result_updates_code_and_meta(): + result = {"code": RAW_CODE, "meta": {}, "valid": True, "error": None} + + normalized = _normalize_build_result( + result, + "ai_from_frontend", + name="中文策略名", + description="前端描述", + ) + + assert normalized["valid"] is True + assert normalized["meta"]["id"] == "ai_from_frontend" + assert normalized["meta"]["name"] == "中文策略名" + assert normalized["meta"]["description"] == "前端描述" + assert '"id": "ai_from_frontend"' in normalized["code"] + + +def test_normalize_strategy_meta_inserts_missing_name_fields(): + raw = '''import polars as pl + +META = { + "id": "wrong", + "tags": [] +} + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + return pl.lit(True) +''' + + code = _normalize_strategy_meta(raw, "ai_inserted", name="中文名", description="描述") + + compile(code, "", "exec") + assert '"id": "ai_inserted"' in code + assert '"name": "中文名"' in code + assert '"description": "描述"' in code diff --git a/frontend/src/components/screener/StrategyBuilderDialog.tsx b/frontend/src/components/screener/StrategyBuilderDialog.tsx index 01f9f42..9c0e1f7 100644 --- a/frontend/src/components/screener/StrategyBuilderDialog.tsx +++ b/frontend/src/components/screener/StrategyBuilderDialog.tsx @@ -178,13 +178,18 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create const handleClose = () => { if (name || rules || code) persist(); onClose() } + const resolveStrategyId = () => { + if (mode === 'create' && strategyId && !strategyId.startsWith('ai_')) return slugId() + return strategyId || slugId() + } + // Step 1: 生成 const handleGenerate = async () => { if (!name.trim() || !rules.trim()) return if (!aiStatus?.configured) { setError('AI 未配置,请在设置页面配置 API Key'); return } setLoading(true); setError('') try { - const id = strategyId || slugId() + 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 } @@ -193,8 +198,6 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create const genRules = parseRules(res.code) if (genDesc) setDescription(genDesc) if (genRules) setRules(genRules) - await api.strategySaveCode(id, res.code) - if (genRules) { const sr = storage.strategyRules.get({}); sr[id] = genRules; storage.strategyRules.set(sr) } } catch (e: any) { const msg = String(e?.message ?? '') setError(msg.includes('API Key') || msg.includes('api_key') ? 'AI API Key 未配置或无效' : (msg || '生成失败')) @@ -206,18 +209,13 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create if (!instruction.trim() || !code) return setLoading(true); setError('') try { - const res = await api.strategyBuild(2, { current_code: code, instruction: instruction.trim() }) + 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) if (genDesc) setDescription(genDesc) if (updatedRules) setRules(updatedRules) - const idMatch = res.code.match(/"id"\s*:\s*"([^"]+)"/) - if (idMatch) { - await api.strategySaveCode(idMatch[1], res.code) - const sr = storage.strategyRules.get({}); sr[idMatch[1]] = updatedRules; storage.strategyRules.set(sr) - } } catch (e: any) { setError(String(e?.message ?? '修改失败')) } finally { setLoading(false) } } @@ -227,9 +225,9 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create if (!code) return setSaving(true) try { - const idMatch = code.match(/"id"\s*:\s*"([^"]+)"/) - const id = idMatch?.[1] || strategyId || slugId() - await api.strategySaveCode(id, code) + const id = resolveStrategyId() + setStrategyId(id) + await api.strategySaveCode(id, code, { name: name.trim(), description: description.trim() }) const genRules = parseRules(code) const finalRules = (genRules || rules).trim() if (finalRules) { const saved = storage.strategyRules.get({}); saved[id] = finalRules; storage.strategyRules.set(saved) } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 638d56e..341ae81 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1905,10 +1905,10 @@ export const api = { ), /** 保存 AI 生成的策略文件 */ - strategySaveCode: (strategyId: string, code: string) => + strategySaveCode: (strategyId: string, code: string, meta?: { name?: string; description?: string }) => request<{ ok: boolean; path: string }>('/api/strategies/ai/save', { method: 'POST', - body: JSON.stringify({ strategy_id: strategyId, code }), + body: JSON.stringify({ strategy_id: strategyId, code, name: meta?.name ?? '', description: meta?.description ?? '' }), }), }