diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index d24b6c5..6dae646 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -188,6 +188,7 @@ def _strategy_detail( "description": description or s.meta.get("description", ""), "tags": s.meta.get("tags", []), "source": s.source, + "research_only": s.meta.get("research_only", False), "execution_backend": s.execution_backend, "asset_types": s.meta.get("asset_types", ["stock"]), "timeframes": s.meta.get("timeframes", ["1d"]), @@ -307,14 +308,17 @@ def list_strategies( request: Request, asset_type: str | None = None, timeframe: str | None = None, + include_research: bool = False, ): engine = _get_engine(request) data_dir = _data_dir(request) all_overrides = strategy_config.list_overrides(data_dir) result = [] - for meta in engine.list_strategies(): - if meta.get("research_only"): + # include_research=True 时返回 research_only 草稿(供前端「草稿」分区展示/发布)。 + # 默认 False 保持既有行为: 草稿不进公开列表。 + for meta in engine.list_strategies(include_research=include_research): + if meta.get("research_only") and not include_research: continue if asset_type and asset_type not in meta.get("asset_types", ["stock"]): continue @@ -1133,7 +1137,7 @@ def publish_ai_strategy(strategy_id: str, request: Request): except Exception as e: _restore_strategy_file(path, previous_code) engine.reload() - raise ValueError(f"策略发布失败: {e}") from e + raise HTTPException(status_code=500, detail=f"策略发布失败: {e}") from e _invalidate_strategy_runtime(request) return {"ok": True, "strategy_id": sid} diff --git a/frontend/src/components/screener/StrategyBuilderDialog.tsx b/frontend/src/components/screener/StrategyBuilderDialog.tsx index 473593b..539443b 100644 --- a/frontend/src/components/screener/StrategyBuilderDialog.tsx +++ b/frontend/src/components/screener/StrategyBuilderDialog.tsx @@ -180,7 +180,7 @@ MATRIX_STRATEGY = CustomMatrixStrategy() interface Props { open: boolean onClose: () => void - onSavedId?: (id: string) => void | Promise + onSavedId?: (id: string, researchOnly?: boolean) => void | Promise mode?: 'create' | 'modify' existingStrategyIds?: ReadonlySet } @@ -374,7 +374,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create const target = mode === 'modify' ? source : (tab === 'custom' ? 'custom' : 'ai') const id = resolveStrategyId(target) setStrategyId(id); setSource(target) - await api.strategySaveCodeV2({ + const savedResult = await api.strategySaveCodeV2({ strategy_id: id, code: draftCode, target_source: target, @@ -387,7 +387,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create const genRules = parseRules(draftCode) const finalRules = (genRules || rules).trim() if (finalRules) { const saved = storage.strategyRules.get({}); saved[id] = finalRules; storage.strategyRules.set(saved) } - await onSavedId?.(id) + await onSavedId?.(id, savedResult.research_only) setTimeout(() => onClose(), 1000) } catch (e: any) { setError(String(e?.message ?? '保存失败')) } setSaving(false) diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx index 4b6277c..19e12ff 100644 --- a/frontend/src/components/screener/StrategyPoolDialog.tsx +++ b/frontend/src/components/screener/StrategyPoolDialog.tsx @@ -54,13 +54,15 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { const [importing, setImporting] = useState(false) const [importError, setImportError] = useState('') const [importMsg, setImportMsg] = useState('') + const [publishingId, setPublishingId] = useState(null) const fileInputRef = useRef(null) const loadStrategies = useCallback(async () => { setLoading(true) try { // 不按周期过滤: 日线+分钟策略合并展示, 分钟策略以徽章区分 - const d = await api.strategyList(undefined, 'all') + // include_research=true 同时拉取 research_only 草稿, 供 AI 标签「草稿」分区展示/发布 + const d = await api.strategyList(undefined, 'all', true) setAllStrategies(d.strategies) } catch { setAllStrategies([]) @@ -86,10 +88,16 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { const invalidPoolCount = draftPool.length - validDraft.length const available = useMemo( - () => allStrategies.filter(s => !draftPool.includes(s.id)), + () => allStrategies.filter(s => !s.research_only && !draftPool.includes(s.id)), [allStrategies, draftPool] ) + // research_only 草稿(AI 来源)单独列出, 供「发布」操作; 不进待选列表 + const drafts = useMemo( + () => allStrategies.filter(s => s.research_only), + [allStrategies] + ) + // 按 Tab 分组过滤待选 const filteredAvailable = useMemo(() => { if (activeTab === 'all') return available @@ -124,6 +132,20 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { }) }, [filteredAvailable]) + // 发布 research_only 草稿 → 刷新后进入公开列表 + const handlePublish = useCallback(async (id: string) => { + setPublishingId(id); setImportError(''); setImportMsg('') + try { + await api.strategyPublish(id) + await loadStrategies() + setImportMsg(`已发布: ${id}`) + } catch (e: any) { + setImportError(String(e?.message ?? '发布失败')) + } finally { + setPublishingId(null) + } + }, [loadStrategies]) + const handleImportFile = useCallback(async (file: File) => { setImporting(true); setImportError(''); setImportMsg('') try { @@ -143,7 +165,10 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { }) await loadStrategies() setActiveTab(result.source === 'ai' ? 'ai' : 'custom') - setImportMsg(`已导入到${result.source === 'ai' ? 'AI' : '自定义'}策略: ${result.strategy_id}`) + const srcLabel = result.source === 'ai' + ? (result.research_only ? 'AI 草稿(发布后可用)' : 'AI 策略') + : '自定义策略' + setImportMsg(`已导入到${srcLabel}: ${result.strategy_id}`) } catch (e: any) { setImportError(String(e?.message ?? '导入失败')) } finally { @@ -243,33 +268,69 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { 本组全加 -
- {filteredAvailable.length === 0 ? ( -
- {available.length === 0 ? '全部已加入策略池' : '此分组无待选策略'} +
+ {activeTab === 'ai' && drafts.length > 0 && ( +
+
+ 草稿 + {drafts.length} 个待发布 +
+
+ {drafts.map(s => ( +
+ + + {s.name} {s.id} + + {s.description} + + +
+ ))} +
- ) : filteredAvailable.map(s => ( - - ))} + + {SOURCE_LABEL[s.source] ?? '内置'} + + {s.timeframes?.includes('1m') && ( + 分钟 + )} + + + ))} +
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a0f2938..d9ec91a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -717,6 +717,7 @@ export interface StrategyDetail { description: string tags: string[] source: 'builtin' | 'custom' | 'ai' | 'composite' + research_only?: boolean execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite' | 'minute_filter' asset_types: string[] timeframes: string[] @@ -764,6 +765,7 @@ export interface StrategyCodeSaveResult { source: 'ai' | 'custom' | 'composite' path: string meta: Record + research_only?: boolean } // ===== Custom Signals (自定义信号) ===== @@ -3253,10 +3255,11 @@ export const api = { }, // ===== Strategy Engine ===== - strategyList: (assetType?: 'stock' | 'etf', timeframe: '1d' | '1m' | 'all' = '1d') => { + strategyList: (assetType?: 'stock' | 'etf', timeframe: '1d' | '1m' | 'all' = '1d', includeResearch = false) => { const params = new URLSearchParams() if (assetType) params.set('asset_type', assetType) if (timeframe && timeframe !== 'all') params.set('timeframe', timeframe) + if (includeResearch) params.set('include_research', 'true') const qs = params.toString() return request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>( `/api/strategies${qs ? `?${qs}` : ''}`, @@ -3266,6 +3269,10 @@ export const api = { strategyGet: (id: string) => request(`/api/strategies/${id}`), + /** 发布 research_only 的 AI 草稿策略(翻转为公开) */ + strategyPublish: (strategyId: string) => + request<{ ok: boolean; strategy_id: string }>(`/api/strategies/${encodeURIComponent(strategyId)}/publish`, { method: 'POST' }), + strategyRun: (strategyId: string, params?: Record, asOf?: string, pool?: string[]) => request('/api/strategies/run', { method: 'POST', diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index af6f19e..d221ff9 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -1100,7 +1100,12 @@ export function Screener() { onClose={() => setShowBuilder(false)} mode={builderMode} existingStrategyIds={allStrategyIds} - onSavedId={async id => { + onSavedId={async (id, researchOnly) => { + if (researchOnly) { + // AI 策略保存为 research_only 草稿, 不进入策略池, 提示用户去策略池发布 + toast('AI 策略已保存为草稿,请在策略池「AI」标签发布后使用', 'success') + return + } const data = await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 }) if (!data.presets.some(s => s.id === id)) { throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`)