Spaces:
Running
Running
| import { useState, useCallback } from 'react'; | |
| import { ReactFlow, addEdge, applyNodeChanges, applyEdgeChanges, Background, Controls, MiniMap, Panel, type Node, type Edge, type Connection } from '@xyflow/react'; | |
| import '@xyflow/react/dist/style.css'; | |
| import { strategyAPI } from '../api/client'; | |
| const NODE_TYPES = [ | |
| { type: 'data', label: 'Data Source', color: '#005241', config: { tickers: ['AAPL', 'MSFT', 'GOOGL'] } }, | |
| { type: 'indicator', label: 'Indicator', color: '#0a8f5c', config: { signal_type: 'momentum' } }, | |
| { type: 'factor', label: 'Factor Model', color: '#b8860b', config: { factors: ['momentum', 'value'] } }, | |
| { type: 'condition', label: 'Condition', color: '#c23030', config: { rule_type: 'threshold', threshold: 0.2, operator: 'gt' } }, | |
| { type: 'allocation', label: 'Allocation', color: '#1a5276', config: { method: 'equal_weight', max_position_pct: 0.1 } }, | |
| { type: 'risk', label: 'Risk Mgmt', color: '#6b21a8', config: { stop_loss_pct: 0.05, take_profit_pct: 0.15 } }, | |
| ]; | |
| export default function StrategyBuilder() { | |
| const [nodes, setNodes] = useState<Node[]>([]); | |
| const [edges, setEdges] = useState<Edge[]>([]); | |
| const [strategyConfig, setStrategyConfig] = useState<any>(null); | |
| const [strategies, setStrategies] = useState<any[]>([]); | |
| const [saving, setSaving] = useState(false); | |
| const [strategyName, setStrategyName] = useState(''); | |
| const onNodesChange = useCallback((changes: any) => setNodes(nds => applyNodeChanges(changes, nds)), []); | |
| const onEdgesChange = useCallback((changes: any) => setEdges(eds => applyEdgeChanges(changes, eds)), []); | |
| const onConnect = useCallback((connection: Connection) => setEdges(eds => addEdge({...connection, id: `e-${Date.now()}`}, eds)), []); | |
| const addNode = (nodeType: typeof NODE_TYPES[0]) => { | |
| const id = `node-${Date.now()}`; | |
| const newNode: Node = { | |
| id, | |
| position: { x: 100 + Math.random() * 400, y: 100 + Math.random() * 300 }, | |
| data: { label: nodeType.label }, | |
| style: { | |
| background: nodeType.color + '22', | |
| border: `2px solid ${nodeType.color}`, | |
| borderRadius: '12px', | |
| padding: '12px 20px', | |
| color: 'var(--text-primary)', | |
| fontSize: '13px', | |
| fontWeight: '600', | |
| minWidth: '160px', | |
| textAlign: 'center' as const, | |
| }, | |
| type: 'default', | |
| }; | |
| (newNode as any)._type = nodeType.type; | |
| (newNode as any)._config = { ...nodeType.config }; | |
| setNodes(nds => [...nds, newNode]); | |
| }; | |
| const convertToConfig = async () => { | |
| const graph = { | |
| nodes: nodes.map(n => ({ | |
| id: n.id, | |
| type: (n as any)._type || 'data', | |
| label: String(n.data?.label || ''), | |
| position: n.position, | |
| config: (n as any)._config || {}, | |
| })), | |
| edges: edges.map(e => ({ | |
| id: e.id, | |
| source: e.source, | |
| target: e.target, | |
| })), | |
| }; | |
| try { | |
| const { data } = await strategyAPI.convertGraph(graph); | |
| setStrategyConfig(data.config); | |
| } catch (e) { console.error(e); } | |
| }; | |
| const saveStrategy = async () => { | |
| if (!strategyConfig || !strategyName) return; | |
| setSaving(true); | |
| try { | |
| await strategyAPI.create({ | |
| name: strategyName, | |
| description: 'Created via Visual Strategy Builder', | |
| strategy_type: 'custom', | |
| config: strategyConfig, | |
| }); | |
| setStrategyName(''); | |
| const { data } = await strategyAPI.list(); | |
| setStrategies(data.strategies || []); | |
| } catch (e) { console.error(e); } finally { setSaving(false); } | |
| }; | |
| useState(() => { | |
| strategyAPI.list().then(({ data }) => setStrategies(data.strategies || [])).catch(console.error); | |
| }); | |
| return ( | |
| <div className="page animate-fade-in"> | |
| <div className="page-header"> | |
| <h1>Strategy <span className="text-gradient">Builder</span></h1> | |
| <p>Drag-and-drop visual strategy construction with node graph</p> | |
| </div> | |
| <div className="grid-2" style={{gridTemplateColumns:'1fr 320px'}}> | |
| {/* Canvas */} | |
| <div className="card" style={{padding:0,overflow:'hidden'}}> | |
| <div style={{height:'500px'}}> | |
| <ReactFlow | |
| nodes={nodes} edges={edges} | |
| onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} | |
| fitView | |
| style={{background:'var(--bg-secondary)'}} | |
| > | |
| <Background color="var(--border-color)" gap={20} /> | |
| <Controls /> | |
| <MiniMap nodeColor="var(--accent)" style={{background:'var(--bg-tertiary)'}} /> | |
| <Panel position="top-left"> | |
| <div style={{display:'flex',gap:'0.375rem',flexWrap:'wrap',maxWidth:'400px'}}> | |
| {NODE_TYPES.map(nt => ( | |
| <button key={nt.type} className="btn btn-secondary btn-sm" onClick={() => addNode(nt)} | |
| style={{fontSize:'0.7rem',borderColor:nt.color+'66'}}> | |
| {nt.label} | |
| </button> | |
| ))} | |
| </div> | |
| </Panel> | |
| </ReactFlow> | |
| </div> | |
| </div> | |
| {/* Sidebar */} | |
| <div style={{display:'flex',flexDirection:'column',gap:'1rem'}}> | |
| <div className="card"> | |
| <h3 style={{marginBottom:'0.75rem',fontSize:'0.95rem'}}>Build Strategy</h3> | |
| <p style={{fontSize:'0.8rem',color:'var(--text-secondary)',marginBottom:'1rem'}}> | |
| Add nodes from the palette, connect them, then generate the config. | |
| </p> | |
| <p style={{fontSize:'0.72rem',color:'var(--text-muted)',marginBottom:'1rem',display:'flex',alignItems:'center',gap:'0.35rem'}}> | |
| <kbd style={{padding:'0.1rem 0.35rem',borderRadius:4,border:'1px solid var(--border-color)',background:'var(--bg-tertiary)',fontSize:'0.65rem',fontFamily:'var(--font-mono)'}}>⌫</kbd> Press Backspace to delete a selected node | |
| </p> | |
| <button className="btn btn-primary" style={{width:'100%',marginBottom:'0.75rem'}} onClick={convertToConfig} disabled={nodes.length === 0}> | |
| Generate Config | |
| </button> | |
| {strategyConfig && ( | |
| <div> | |
| <div className="form-group"> | |
| <label>Strategy Name</label> | |
| <input className="input" value={strategyName} onChange={e => setStrategyName(e.target.value)} placeholder="My Strategy" /> | |
| </div> | |
| <button className="btn btn-primary" style={{width:'100%'}} onClick={saveStrategy} disabled={saving || !strategyName}> | |
| {saving ? <div className="spinner" /> : 'Save Strategy'} | |
| </button> | |
| <details style={{marginTop:'1rem'}}> | |
| <summary style={{cursor:'pointer',fontSize:'0.8rem',color:'var(--text-muted)'}}>View Config JSON</summary> | |
| <pre style={{marginTop:'0.5rem',padding:'0.75rem',background:'var(--bg-tertiary)',borderRadius:'var(--radius-sm)',fontSize:'0.7rem',overflowX:'auto',maxHeight:200,color:'var(--accent)',fontFamily:'var(--font-mono)'}}> | |
| {JSON.stringify(strategyConfig, null, 2)} | |
| </pre> | |
| </details> | |
| </div> | |
| )} | |
| </div> | |
| <div className="card"> | |
| <h3 style={{marginBottom:'0.75rem',fontSize:'0.95rem'}}>Saved Strategies ({strategies.length})</h3> | |
| {strategies.length > 0 ? strategies.slice(0, 5).map(s => ( | |
| <div key={s.id} style={{padding:'0.5rem 0',borderBottom:'1px solid var(--border-subtle)',fontSize:'0.8rem'}}> | |
| <div style={{fontWeight:600}}>{s.name}</div> | |
| <div style={{color:'var(--text-muted)',fontSize:'0.7rem'}}> | |
| {s.strategy_type} · v{s.version} · {s.status} | |
| </div> | |
| </div> | |
| )) : <p style={{fontSize:'0.8rem',color:'var(--text-muted)'}}>No strategies yet</p>} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |