Merge fix/strategy-delete-bugs: 自定义策略删除相关 bug 修复

This commit is contained in:
shy3130
2026-07-02 22:28:16 +08:00
7 changed files with 64 additions and 12 deletions
+5 -1
View File
@@ -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")
+11 -1
View File
@@ -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 traversalstrategy_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")
+5 -3
View File
@@ -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"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化
+8
View File
@@ -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>
+6 -1
View File
@@ -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',
+16 -3
View File
@@ -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({