mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
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:
+115
-6
@@ -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) }
|
||||
|
||||
@@ -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 ?? '' }),
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user