diff --git a/backend/app/api/screener.py b/backend/app/api/screener.py index d35faef..3023d20 100644 --- a/backend/app/api/screener.py +++ b/backend/app/api/screener.py @@ -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") diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index b355209..9497881 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -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") diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py index 4db6a5e..a76e6a8 100644 --- a/backend/app/strategy/ai_generator.py +++ b/backend/app/strategy/ai_generator.py @@ -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"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化 diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 67ef2ad..1f27e62 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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: diff --git a/frontend/src/components/screener/StrategySettingsDialog.tsx b/frontend/src/components/screener/StrategySettingsDialog.tsx index f4eb336..24b235c 100644 --- a/frontend/src/components/screener/StrategySettingsDialog.tsx +++ b/frontend/src/components/screener/StrategySettingsDialog.tsx @@ -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 {resetting ? '重置中…' : '重置默认'} {(detail?.source === 'ai' || detail?.source === 'custom') && ( - setShowDeleteConfirm(true)} + { setDeleteError(''); setShowDeleteConfirm(true) }} className="text-[10px] text-muted/40 hover:text-danger transition-colors">删除策略 )} @@ -591,6 +596,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif 删除后无法恢复,策略文件、配置和关联数据将被永久清除。 + {deleteError && ( + + {deleteError} + + )} setShowDeleteConfirm(false)} className="flex-1 h-8 rounded-lg border border-border text-xs text-secondary hover:text-foreground">取消 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 43c7b16..b3a406e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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('/api/screener/run_preset', { method: 'POST', diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 2970546..3db438b 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -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({