Merge pull request #80 from shy3130/fix/ai-strategy-save-target

fix(strategy): keep AI-generated strategies in AI pool
This commit is contained in:
wshy
2026-07-08 18:39:50 +08:00
committed by GitHub
4 changed files with 205 additions and 20 deletions
+115 -6
View File
@@ -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:
@@ -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, "<strategy>", "exec")
assert '"id": "ai_inserted"' in code
assert '"name": "中文名"' in code
assert '"description": "描述"' in code
@@ -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) }
+2 -2
View File
@@ -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 ?? '' }),
}),
}