mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
fix: 修复自定义策略删除导致策略池清空及删除失败静默问题
- bug2(删除后策略全没): prune 仅在拉取成功且非空时执行,加载中/失败/空列表不碰池
根因: 删除触发 engine.reload() 重扫所有文件,任一文件 import 失败被静默跳过,
前端拿到残缺列表后 prune 把池中有效 ID 永久清除并写 localStorage
- bug1(自定义策略删不掉): handleDelete 不再静默吞错误,失败时显式提示并保持弹窗打开
- ai_save 守卫回归: 放宽前缀允许 ai_/custom_,保留 path traversal 字符白名单(安全核心未动)
修复「AI 修改 custom 策略」因 strategy_id 非 ai_ 前缀被 400 拒绝
- 引擎加载失败可见化: _load_all 收集 load_errors,/api/screener/strategies 返回,
前端 toast 提示具体失败文件,不再静默消失
- 同步补全 ai_generator _SYSTEM_PREFIX 三条铁律(与 bbc92c4 docs 一致)
This commit is contained in:
@@ -199,8 +199,12 @@ def strategies(request: Request):
|
||||
desc = (overrides.get("description") or meta.get("description", "")) if overrides else meta.get("description", "")
|
||||
presets.append({"id": sid, "name": name, "description": desc, "source": meta.get("source", "custom")})
|
||||
seen_ids.add(sid)
|
||||
# 暴露加载失败的策略,让前端可见(避免"策略静默消失"误判为正常)
|
||||
load_errors = engine.load_errors() if engine else []
|
||||
else:
|
||||
load_errors = []
|
||||
|
||||
return {"presets": presets}
|
||||
return {"presets": presets, "load_errors": load_errors}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import asdict
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -377,7 +378,16 @@ 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 = out_dir / f"{req.strategy_id}.py"
|
||||
# 防 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"
|
||||
previous_code = path.read_text(encoding="utf-8") if path.exists() else None
|
||||
path.write_text(req.code, encoding="utf-8")
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@ GUIDE_PATH = Path(__file__).resolve().parent.parent.parent.parent / "docs" / "st
|
||||
|
||||
_SYSTEM_PREFIX = """你是A股量化策略设计专家。根据用户描述的需求,参考下方的《策略开发指南》生成一个完整的策略Python文件。
|
||||
|
||||
核心约束:
|
||||
- 只创建这一个 .py 文件,不要修改任何现有文件,不要跨文件引用
|
||||
- 只 import polars as pl,不 import 其他模块
|
||||
文件与范围铁律(不可违反):
|
||||
1. 只创建这一个策略文件:只生成一个 .py 文件,绝不创建多文件、不拆分模块、不跨文件引用
|
||||
2. 绝不触碰项目源码:不要写任何会修改 backend/、docs/、frontend/ 等现有文件的代码;不要 import os/sys/pathlib 等文件系统模块
|
||||
3. 不得放入内置策略目录:AI 生成的策略只属于 data/strategies/ai/,文件名/ID 用 ai_ 前缀;内置目录 backend/app/strategy/builtin/ 由项目维护,AI 不得染指
|
||||
4. 只 import polars as pl,不 import 其他模块
|
||||
|
||||
要求:
|
||||
1. 用户可能调整的策略阈值通过 META["params"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化
|
||||
|
||||
@@ -80,6 +80,7 @@ class StrategyEngine:
|
||||
self._loader = enriched_loader
|
||||
self._history_loader = enriched_history_loader
|
||||
self._strategies: dict[str, StrategyDef] = {}
|
||||
self._load_errors: list[dict] = [] # 加载失败的策略 [{file, error}]
|
||||
self._strategy_dirs = strategy_dirs or []
|
||||
self._load_all()
|
||||
|
||||
@@ -89,6 +90,7 @@ class StrategyEngine:
|
||||
|
||||
def _load_all(self) -> None:
|
||||
self._strategies.clear()
|
||||
self._load_errors = []
|
||||
for d in self._strategy_dirs:
|
||||
if not d.exists():
|
||||
continue
|
||||
@@ -100,7 +102,13 @@ class StrategyEngine:
|
||||
self._strategies[s.meta["id"]] = s
|
||||
logger.debug("loaded strategy: %s (%s)", s.meta["id"], s.source)
|
||||
except Exception as e:
|
||||
# 不再静默吞掉: 记录失败项, 供前端可见(避免"策略静默消失"误判)。
|
||||
logger.warning("load strategy %s failed: %s", f.name, e)
|
||||
self._load_errors.append({"file": f.name, "error": str(e)})
|
||||
|
||||
def load_errors(self) -> list[dict]:
|
||||
"""返回最近一次 _load_all 中加载失败的策略 [{file, error}]。"""
|
||||
return list(self._load_errors)
|
||||
|
||||
@staticmethod
|
||||
def _load_file(path: Path) -> StrategyDef:
|
||||
|
||||
@@ -217,6 +217,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
const [editingScoring, setEditingScoring] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
|
||||
// 辅助:更新 basicFilter 某个 key
|
||||
const setBF = useCallback((key: string, value: any) => {
|
||||
@@ -303,12 +304,16 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
const handleDelete = async () => {
|
||||
if (!strategyId) return
|
||||
setDeleting(true)
|
||||
setDeleteError('')
|
||||
try {
|
||||
await api.strategyDelete(strategyId)
|
||||
onDeleted?.()
|
||||
onClose()
|
||||
} catch { /* ignore */ }
|
||||
finally { setDeleting(false); setShowDeleteConfirm(false) }
|
||||
setShowDeleteConfirm(false)
|
||||
} catch (e: any) {
|
||||
// request() 已弹 toast, 这里再在确认弹窗内显式提示, 并保持弹窗打开让用户知晓删除失败。
|
||||
setDeleteError(String(e?.message ?? '删除失败,请重试'))
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
if (!strategyId) return null
|
||||
@@ -546,7 +551,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
<RotateCcw className="h-3.5 w-3.5" />{resetting ? '重置中…' : '重置默认'}
|
||||
</button>
|
||||
{(detail?.source === 'ai' || detail?.source === 'custom') && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)}
|
||||
<button onClick={() => { setDeleteError(''); setShowDeleteConfirm(true) }}
|
||||
className="text-[10px] text-muted/40 hover:text-danger transition-colors">删除策略</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -591,6 +596,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
<div className="text-[11px] text-danger/70 bg-danger/[0.04] rounded-lg px-3 py-2 border border-danger/10">
|
||||
删除后无法恢复,策略文件、配置和关联数据将被永久清除。
|
||||
</div>
|
||||
{deleteError && (
|
||||
<div className="text-[11px] text-danger bg-danger/10 rounded-lg px-3 py-2 border border-danger/20">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 h-8 rounded-lg border border-border text-xs text-secondary hover:text-foreground">取消</button>
|
||||
|
||||
@@ -242,6 +242,11 @@ export interface ScreenerStrategy {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface StrategyLoadError {
|
||||
file: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface ScreenerResult {
|
||||
as_of: string
|
||||
strategy: string | null
|
||||
@@ -1080,7 +1085,7 @@ export const api = {
|
||||
: '/api/watchlist/enriched',
|
||||
),
|
||||
|
||||
screenerStrategies: () => request<{ presets: ScreenerStrategy[] }>('/api/screener/strategies'),
|
||||
screenerStrategies: () => request<{ presets: ScreenerStrategy[]; load_errors?: StrategyLoadError[] }>('/api/screener/strategies'),
|
||||
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
|
||||
request<ScreenerResult>('/api/screener/run_preset', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
|
||||
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
|
||||
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -146,11 +147,23 @@ export function Screener() {
|
||||
|
||||
// 策略列表加载后,自动清除池中失效的自定义策略(如本地开发残留的、
|
||||
// 当前后端已不存在的策略 ID),避免"策略池"对话框持续显示失效项。
|
||||
// availableStrategyIds 初始为空集合时跳过,防止首次渲染误清整个池。
|
||||
// 关键: 仅当本次拉取成功且返回非空列表时才 prune。
|
||||
// 拉取中/失败/返回空(如引擎 reload 瞬时把某策略跳过)时一律不碰池,
|
||||
// 否则会把用户池里仍有效的 ID 永久清空并写入 localStorage,导致卡片全没。
|
||||
useEffect(() => {
|
||||
if (availableStrategyIds.size === 0) return
|
||||
if (strategies.isError) return // 拉取失败: 不 prune
|
||||
if (!strategies.isSuccess) return // 加载中: 不 prune
|
||||
if (availableStrategyIds.size === 0) return // 空列表: 不 prune
|
||||
prune(availableStrategyIds)
|
||||
}, [availableStrategyIds, prune])
|
||||
}, [availableStrategyIds, prune, strategies.isError, strategies.isSuccess])
|
||||
|
||||
// 策略文件加载失败时提示用户(避免"策略静默消失"被误判为正常)
|
||||
const loadErrors = strategies.data?.load_errors ?? []
|
||||
useEffect(() => {
|
||||
for (const e of loadErrors) {
|
||||
toast(`策略「${e.file}」加载失败:${e.error}`, 'error')
|
||||
}
|
||||
}, [loadErrors])
|
||||
|
||||
// 进入页面自动跑策略池中的策略,获取命中数
|
||||
const runAll = useMutation({
|
||||
|
||||
Reference in New Issue
Block a user