import { useState, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { X, Sparkles, Save, Loader2, ChevronLeft, ChevronRight, AlertTriangle, Settings2, FileText, Copy, Check, Terminal } from 'lucide-react' import { api } from '@/lib/api' import { storage } from '@/lib/storage' import { cn } from '@/lib/cn' // ===== 工具函数 ===== function parsePyValue(v: string): any { const s = v.trim() if (s === 'True') return true if (s === 'False') return false if (s === 'None') return null return JSON.parse(s) } function slugId(): string { return 'ai_' + Date.now().toString(36) } function parseParams(code: string): any[] { const m = code.match(/"params"\s*:\s*\[([^\]]*)\]/) if (!m) return [] const inner = m[1] const blocks = inner.match(/\{[^}]+\}/g) ?? [] return blocks.map(b => { const get = (key: string) => { const re = new RegExp('"' + key + '"\\s*:\\s*(.+?)\\s*[,}]') const r = b.match(re) return r ? r[1].trim().replace(/^"(.*)"$/, '$1') : '' } const id = get('id'); if (!id) return null const type = get('type') return { id, type, label: get('label'), default: parsePyValue(get('default') || 'null'), min: parsePyValue(get('min') || 'null'), max: parsePyValue(get('max') || 'null'), step: parsePyValue(get('step') || 'null') } }).filter(Boolean) } function parseStringList(code: string, key: string): string[] { const re = new RegExp(key + '\\s*=\\s*\\[([^\\]]+)\\]') const m = code.match(re) if (!m) return [] const items = m[1].match(/"([^"]+)"/g) return items ? items.map(x => x.replace(/"/g, '')) : [] } function parseScoring(code: string): Record { const m = code.match(/"scoring"\s*:\s*\{([^}]+)\}/) if (!m) return {} const items = m[1].match(/"([^"]+)"\s*:\s*([0-9.]+)/g) if (!items) return {} const result: Record = {} for (const item of items) { const p = item.match(/"([^"]+)"\s*:\s*([0-9.]+)/) if (p) result[p[1]] = parseFloat(p[2]) } return result } function parseRules(code: string): string { const m = code.match(/RULES\s*=\s*"""\s*([\s\S]*?)\s*"""/) return m ? m[1].trim() : '' } function parseMetaField(code: string, field: string): string { const m = code.match(new RegExp('"' + field + '"\\s*:\\s*"([^"]+)"')) return m ? m[1] : '' } // ===== 常量 ===== const DIRECTIONS = [ { value: 'long', label: '做多' }, { value: 'short', label: '做空' }, { value: 'monitor', label: '监控' }, ] // ===== 组件 ===== const CUSTOM_TEMPLATE = `"""策略简短描述""" import polars as pl META = { "id": "custom_my_strategy", "name": "我的策略", "description": "策略描述", "tags": ["自定义"], "basic_filter": { "price_min": 3, "price_max": 200, "market_cap_min": 10e8, "amount_min": 0.5e8, "exclude_st": True, "exclude_new_days": 30, }, "params": [], "scoring": { "change_pct": 0.5, "vol_ratio_5d": 0.5, }, "order_by": "score", "descending": True, "limit": 100, } ENTRY_SIGNALS = ["signal_n_day_high"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 ALERTS = [] RULES = """ 1. 规则一 2. 规则二 3. 规则三 """ def filter(df: pl.DataFrame, params: dict) -> pl.Expr: return ( (pl.col("close") > pl.col("ma20")) & (pl.col("volume") > pl.col("vol_ma5") * 1.5) ) ` interface Props { open: boolean; onClose: () => void; onSavedId?: (id: string) => void | Promise; mode?: 'create' | 'modify' } export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create' }: Props) { // 根据 mode 选择存储 key const draftStore = mode === 'modify' ? storage.strategyModify : storage.strategyDraft const [step, setStep] = useState(1) const [tab, setTab] = useState<'ai' | 'custom'>('ai') const [customCopied, setCustomCopied] = useState(false) const [name, setName] = useState('') const [description, setDescription] = useState('') const [direction, setDirection] = useState('long') const [rules, setRules] = useState('') const [code, setCode] = useState('') const [instruction, setInstruction] = useState('') const [previewTab, setPreviewTab] = useState<'params' | 'code'>('params') const [strategyId, setStrategyId] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [aiStatus, setAiStatus] = useState<{ configured: boolean } | null>(null) const [checkedAi, setCheckedAi] = useState(false) const [loaded, setLoaded] = useState(false) // 打开时恢复草稿 useEffect(() => { if (!open) { setLoaded(false); return } const d = draftStore.get(null) if (d) { setStep(d.step ?? 1); setName(d.name ?? ''); setDescription(d.description ?? '') setDirection(d.direction ?? 'long') setRules(d.rules ?? ''); setCode(d.code ?? ''); setStrategyId(d.strategyId ?? '') } setLoaded(true) }, [open]) // 打开时检查 AI 状态 useEffect(() => { if (!open || checkedAi) return api.strategyAiStatus().then(s => { setAiStatus(s); setCheckedAi(true) }).catch(() => setAiStatus({ configured: false })) }, [open, checkedAi]) // 持久化 const persist = useCallback(() => { if (!name && !rules && !code) { draftStore.set(null) } else { draftStore.set({ name, description, direction, rules, code, step, strategyId }) } }, [name, description, direction, rules, code, step, strategyId]) useEffect(() => { if (loaded) persist() }, [loaded, persist]) const clearDraft = () => { setName(''); setDescription(''); setDirection('long') setRules(''); setCode(''); setStep(1); setError(''); setInstruction('') setStrategyId('') } const handleClose = () => { if (name || rules || code) persist(); onClose() } // 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() 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 } setCode(res.code); setStep(2) const genDesc = parseMetaField(res.code, 'description') 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 || '生成失败')) } finally { setLoading(false) } } // Step 2: AI 修改 const handleModify = async () => { if (!instruction.trim() || !code) return setLoading(true); setError('') try { const res = await api.strategyBuild(2, { current_code: code, instruction: instruction.trim() }) 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) } } // 手动保存 const handleSave = async () => { 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 genRules = parseRules(code) const finalRules = (genRules || rules).trim() if (finalRules) { const saved = storage.strategyRules.get({}); saved[id] = finalRules; storage.strategyRules.set(saved) } await onSavedId?.(id) clearDraft() setTimeout(() => onClose(), 1000) } catch (e: any) { setError(String(e?.message ?? '保存失败')) } setSaving(false) } if (!open) return null const params = parseParams(code) const entrySignals = parseStringList(code, 'ENTRY_SIGNALS') const exitSignals = parseStringList(code, 'EXIT_SIGNALS') const scoring = parseScoring(code) const stopMatch = code.match(/STOP_LOSS\s*=\s*(-?[0-9.]+|None)/) const codeStopLoss = stopMatch ? (stopMatch[1] === 'None' ? null : parseFloat(stopMatch[1])) : null const holdMatch = code.match(/MAX_HOLD_DAYS\s*=\s*(-?[0-9.]+|None)/) const codeHoldDays = holdMatch ? (holdMatch[1] === 'None' ? null : parseInt(holdMatch[1])) : null const hasParams = params.length > 0 || entrySignals.length > 0 || exitSignals.length > 0 || Object.keys(scoring).length > 0 return ( { if (e.target === e.currentTarget) handleClose() }}> {/* 标题 */}
{/* 左侧:Tab 切换 */}
{/* 中间:标题 */} {strategyId ? '修改策略' : '创建策略'} {/* 右侧:步骤 + 关闭 */}
{tab === 'ai' && (
1 2
)}
{/* Tab 描述 */}
{tab === 'ai' ? (
步骤 1 描述策略规则 → 步骤 2 预览代码 → 保存
) : (
适合有 Python 基础的开发者,手动编写策略文件进行深度定制和二次开发
)}
{/* 内容 */}
{tab === 'ai' ? (<> {aiStatus && !aiStatus.configured && (
AI API Key 未配置,无法生成策略。填写的内容会自动保存。
)} {step === 1 ? ( <>
setName(e.target.value)} placeholder="策略名称,如:强势反包" className="w-full h-9 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm font-medium text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" /> setDescription(e.target.value)} placeholder="一句话描述,如:筛选前日阴线下跌、今日放量阳线反包的短线强势股" className="w-full h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
选股方向
{DIRECTIONS.map(d => ( ))}
策略规则