Spaces:
Running
Running
| import { useState, useEffect } from 'react'; | |
| import { pinescriptAPI } from '../api/client'; | |
| import { formatCurrency, detectCurrencyFromTicker } from '../utils/currencyUtils'; | |
| import TickerSearch from '../components/TickerSearch'; | |
| /* ── SVG Icons ─────────────────────────────────────────────────────────── */ | |
| const CodeIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fillRule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>; | |
| const TemplateIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" /></svg>; | |
| const PlayIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" /></svg>; | |
| const CopyIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" /><path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" /></svg>; | |
| const DownloadIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /></svg>; | |
| const CheckIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>; | |
| const XIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /></svg>; | |
| const CATEGORY_COLORS: Record<string, string> = { | |
| 'Momentum': '#3b82f6', | |
| 'Mean Reversion': '#a855f7', | |
| 'Volatility': '#f59e0b', | |
| 'Trend Following': '#10b981', | |
| 'Oscillator': '#ec4899', | |
| 'Statistical': '#6366f1', | |
| 'Risk-Managed': '#14b8a6', | |
| 'Advanced': '#f97316', | |
| 'Intraday': '#06b6d4', | |
| 'Multi-Signal': '#8b5cf6', | |
| 'Hedging': '#ef4444', | |
| 'Forex': '#22d3ee', | |
| 'Crypto': '#d97706', | |
| 'Commodities': '#a3e635', | |
| 'Futures': '#c084fc', | |
| }; | |
| export default function PineScriptLab() { | |
| const [mode, setMode] = useState<'generate' | 'templates'>('templates'); | |
| const [description, setDescription] = useState(''); | |
| const [code, setCode] = useState(''); | |
| const [templates, setTemplates] = useState<any[]>([]); | |
| const [backtestTicker, setBacktestTicker] = useState(''); | |
| const [backtestPeriod, setBacktestPeriod] = useState('3y'); | |
| const [loading, setLoading] = useState(false); | |
| const [generating, setGenerating] = useState(false); | |
| const [results, setResults] = useState<any>(null); | |
| const [validation, setValidation] = useState<any>(null); | |
| const [error, setError] = useState(''); | |
| const [copied, setCopied] = useState(false); | |
| useEffect(() => { | |
| loadTemplates(); | |
| }, []); | |
| const loadTemplates = async () => { | |
| try { | |
| const { data } = await pinescriptAPI.templates(); | |
| setTemplates(data.templates || []); | |
| } catch (err) { | |
| console.error('Failed to load templates'); | |
| } | |
| }; | |
| const handleGenerate = async () => { | |
| if (!description.trim()) return; | |
| setGenerating(true); | |
| setError(''); | |
| try { | |
| const { data } = await pinescriptAPI.generate({ description }); | |
| setCode(data.code || ''); | |
| setValidation(null); | |
| setResults(null); | |
| } catch (err: any) { | |
| setError(err.response?.data?.detail || 'Generation failed'); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| }; | |
| const handleTemplateSelect = async (templateId: string) => { | |
| setGenerating(true); | |
| setError(''); | |
| try { | |
| const { data } = await pinescriptAPI.generateFromTemplate({ template_id: templateId }); | |
| setCode(data.code || ''); | |
| setValidation(null); | |
| setResults(null); | |
| } catch (err: any) { | |
| setError(err.response?.data?.detail || 'Template load failed'); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| }; | |
| const handleValidate = async () => { | |
| if (!code.trim()) return; | |
| try { | |
| const { data } = await pinescriptAPI.validate({ code }); | |
| setValidation(data); | |
| } catch (err) { | |
| setError('Validation failed'); | |
| } | |
| }; | |
| const handleBacktest = async () => { | |
| if (!code.trim()) return; | |
| setLoading(true); | |
| setError(''); | |
| try { | |
| const { data } = await pinescriptAPI.backtest({ | |
| code, ticker: backtestTicker, period: backtestPeriod, | |
| }); | |
| setResults(data); | |
| } catch (err: any) { | |
| setError(err.response?.data?.detail || 'Backtest failed'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleCopy = () => { | |
| navigator.clipboard.writeText(code); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| const handleDownload = () => { | |
| const blob = new Blob([code], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'strategy.pine'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| const metricColor = (val: number, positive: boolean = true) => { | |
| if (positive) return val >= 0 ? 'var(--accent-green)' : '#ef4444'; | |
| return val >= 0 ? '#ef4444' : 'var(--accent-green)'; | |
| }; | |
| return ( | |
| <div className="page-container" style={{ padding: '1.5rem 2rem' }}> | |
| {/* Header */} | |
| <div style={{ marginBottom: '1.5rem' }}> | |
| <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| <CodeIcon /> Pine Script Lab | |
| </h1> | |
| <p style={{ color: 'var(--text-muted)', margin: '0.25rem 0 0', fontSize: '0.85rem' }}> | |
| Generate, validate, and backtest TradingView Pine Script v5 strategies | |
| </p> | |
| </div> | |
| <div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}> | |
| {/* ── Left Panel: Code Generation ────────────────────────────── */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| {/* Mode Toggle */} | |
| <div style={{ display: 'flex', gap: '0.25rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', padding: '0.2rem' }}> | |
| <button | |
| onClick={() => setMode('templates')} | |
| style={{ | |
| flex: 1, padding: '0.45rem', border: 'none', borderRadius: '0.4rem', cursor: 'pointer', | |
| fontSize: '0.8rem', fontWeight: 600, transition: 'all 0.2s', | |
| background: mode === 'templates' ? 'var(--accent-green)' : 'transparent', | |
| color: mode === 'templates' ? '#fff' : 'var(--text-muted)', | |
| }} | |
| > | |
| <TemplateIcon /> Templates | |
| </button> | |
| <button | |
| onClick={() => setMode('generate')} | |
| style={{ | |
| flex: 1, padding: '0.45rem', border: 'none', borderRadius: '0.4rem', cursor: 'pointer', | |
| fontSize: '0.8rem', fontWeight: 600, transition: 'all 0.2s', | |
| background: mode === 'generate' ? 'var(--accent-green)' : 'transparent', | |
| color: mode === 'generate' ? '#fff' : 'var(--text-muted)', | |
| }} | |
| > | |
| AI Generate | |
| </button> | |
| </div> | |
| {/* Template Browser */} | |
| {mode === 'templates' && ( | |
| <div style={{ display: 'grid', gap: '0.4rem', maxHeight: 220, overflow: 'auto', gridTemplateColumns: '1fr 1fr' }}> | |
| {templates.map((t: any) => ( | |
| <button | |
| key={t.id} | |
| onClick={() => handleTemplateSelect(t.id)} | |
| style={{ | |
| padding: '0.6rem 0.75rem', borderRadius: '0.5rem', cursor: 'pointer', | |
| border: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)', | |
| textAlign: 'left', transition: 'all 0.15s', | |
| color: 'var(--text-primary)', | |
| }} | |
| onMouseOver={e => (e.currentTarget.style.borderColor = 'var(--accent-green)')} | |
| onMouseOut={e => (e.currentTarget.style.borderColor = 'var(--border-subtle)')} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.2rem' }}> | |
| <span style={{ fontSize: '0.8rem', fontWeight: 600 }}>{t.name}</span> | |
| <span style={{ | |
| fontSize: '0.55rem', padding: '0.1rem 0.35rem', borderRadius: 20, | |
| background: `${CATEGORY_COLORS[t.category] || '#6b7280'}22`, | |
| color: CATEGORY_COLORS[t.category] || '#6b7280', fontWeight: 600, | |
| }}>{t.category}</span> | |
| </div> | |
| <div style={{ fontSize: '0.65rem', color: 'var(--text-muted)', lineHeight: 1.3 }}> | |
| {t.description.length > 80 ? t.description.slice(0, 80) + '...' : t.description} | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* NL Generator */} | |
| {mode === 'generate' && ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> | |
| <textarea | |
| value={description} | |
| onChange={e => setDescription(e.target.value)} | |
| placeholder="Describe your strategy in natural language... e.g., 'Create a strategy that buys when RSI is below 30 and the price is above the 200-day SMA, sells when RSI goes above 70'" | |
| style={{ | |
| width: '100%', minHeight: 100, padding: '0.75rem', borderRadius: '0.5rem', | |
| border: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)', | |
| color: 'var(--text-primary)', fontSize: '0.8rem', resize: 'vertical', | |
| fontFamily: 'inherit', lineHeight: 1.5, | |
| }} | |
| /> | |
| <button | |
| onClick={handleGenerate} | |
| disabled={generating || !description.trim()} | |
| style={{ | |
| padding: '0.55rem 1rem', borderRadius: '0.5rem', border: 'none', | |
| background: 'var(--accent-green)', color: '#fff', fontWeight: 600, | |
| fontSize: '0.8rem', cursor: generating ? 'wait' : 'pointer', | |
| opacity: generating || !description.trim() ? 0.6 : 1, | |
| }} | |
| > | |
| {generating ? 'Generating...' : 'Generate Pine Script'} | |
| </button> | |
| </div> | |
| )} | |
| {/* Code Editor */} | |
| <div style={{ position: 'relative', flex: 1 }}> | |
| <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.3rem' }}> | |
| <span style={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', color: 'var(--text-muted)' }}>Pine Script v5 Code</span> | |
| <div style={{ display: 'flex', gap: '0.3rem' }}> | |
| {validation && ( | |
| <span style={{ | |
| display: 'flex', alignItems: 'center', gap: '0.2rem', fontSize: '0.65rem', fontWeight: 600, | |
| color: validation.valid ? 'var(--accent-green)' : '#ef4444', | |
| }}> | |
| {validation.valid ? <><CheckIcon /> Valid</> : <><XIcon /> {validation.errors?.length} Error(s)</>} | |
| </span> | |
| )} | |
| <button onClick={handleValidate} disabled={!code} style={{ padding: '0.2rem 0.5rem', borderRadius: '0.3rem', border: '1px solid var(--border-subtle)', background: 'transparent', color: 'var(--text-muted)', fontSize: '0.65rem', cursor: 'pointer' }}>Validate</button> | |
| <button onClick={handleCopy} disabled={!code} style={{ padding: '0.2rem 0.5rem', borderRadius: '0.3rem', border: '1px solid var(--border-subtle)', background: 'transparent', color: 'var(--text-muted)', fontSize: '0.65rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.2rem' }}> | |
| <CopyIcon /> {copied ? 'Copied' : 'Copy'} | |
| </button> | |
| <button onClick={handleDownload} disabled={!code} style={{ padding: '0.2rem 0.5rem', borderRadius: '0.3rem', border: '1px solid var(--border-subtle)', background: 'transparent', color: 'var(--text-muted)', fontSize: '0.65rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.2rem' }}> | |
| <DownloadIcon /> .pine | |
| </button> | |
| </div> | |
| </div> | |
| <textarea | |
| value={code} | |
| onChange={e => { setCode(e.target.value); setValidation(null); }} | |
| style={{ | |
| width: '100%', minHeight: 280, padding: '0.75rem', borderRadius: '0.5rem', | |
| border: '1px solid var(--border-subtle)', background: 'var(--bg-tertiary, #0d1117)', | |
| color: 'var(--text-primary)', fontSize: '0.75rem', fontFamily: '"JetBrains Mono", "Fira Code", monospace', | |
| lineHeight: 1.6, resize: 'vertical', tabSize: 4, | |
| }} | |
| placeholder="// Your Pine Script v5 code will appear here..." | |
| spellCheck={false} | |
| /> | |
| </div> | |
| {/* Validation Errors */} | |
| {validation && !validation.valid && ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}> | |
| {(validation.errors || []).map((e: any, i: number) => ( | |
| <div key={i} style={{ fontSize: '0.7rem', padding: '0.3rem 0.5rem', borderRadius: '0.3rem', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.2)' }}> | |
| {e.line > 0 && `Line ${e.line}: `}{e.message} | |
| </div> | |
| ))} | |
| {(validation.warnings || []).map((w: any, i: number) => ( | |
| <div key={i} style={{ fontSize: '0.7rem', padding: '0.3rem 0.5rem', borderRadius: '0.3rem', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', border: '1px solid rgba(245,158,11,0.2)' }}> | |
| {w.line > 0 && `Line ${w.line}: `}{w.message} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* ── Right Panel: Backtest Results ──────────────────────────── */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| {/* Backtest Controls */} | |
| <div className="card" style={{ padding: '0.75rem 1rem' }}> | |
| <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}> | |
| <TickerSearch | |
| value={backtestTicker} | |
| onChange={setBacktestTicker} | |
| placeholder="Backtest ticker..." | |
| style={{ width: 200 }} | |
| /> | |
| <select value={backtestPeriod} onChange={e => setBacktestPeriod(e.target.value)} style={{ | |
| padding: '0.45rem 0.6rem', borderRadius: '0.4rem', | |
| border: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)', | |
| color: 'var(--text-primary)', fontSize: '0.8rem', | |
| }}> | |
| <option value="1y">1 Year</option> | |
| <option value="2y">2 Years</option> | |
| <option value="3y">3 Years</option> | |
| <option value="5y">5 Years</option> | |
| </select> | |
| <button | |
| onClick={handleBacktest} | |
| disabled={loading || !code.trim()} | |
| style={{ | |
| display: 'flex', alignItems: 'center', gap: '0.3rem', | |
| padding: '0.45rem 1rem', borderRadius: '0.4rem', border: 'none', | |
| background: 'var(--accent-green)', color: '#fff', fontWeight: 600, | |
| fontSize: '0.8rem', cursor: loading ? 'wait' : 'pointer', | |
| opacity: loading || !code.trim() ? 0.6 : 1, | |
| }} | |
| > | |
| <PlayIcon /> {loading ? 'Running...' : 'Run Backtest'} | |
| </button> | |
| </div> | |
| </div> | |
| {error && ( | |
| <div style={{ padding: '0.5rem 0.75rem', borderRadius: '0.4rem', background: 'rgba(239,68,68,0.1)', color: '#ef4444', fontSize: '0.75rem', border: '1px solid rgba(239,68,68,0.2)' }}> | |
| {error} | |
| </div> | |
| )} | |
| {/* Results */} | |
| {results && ( | |
| <> | |
| {/* Key Metrics */} | |
| <div style={{ display: 'grid', gap: '0.5rem', gridTemplateColumns: 'repeat(3, 1fr)' }}> | |
| {[ | |
| { label: 'Net Profit', value: `${results.net_profit_pct >= 0 ? '+' : ''}${results.net_profit_pct.toFixed(2)}%`, color: metricColor(results.net_profit_pct) }, | |
| { label: 'Sharpe Ratio', value: results.sharpe_ratio.toFixed(2), color: metricColor(results.sharpe_ratio) }, | |
| { label: 'Max Drawdown', value: `${results.max_drawdown_pct.toFixed(2)}%`, color: '#ef4444' }, | |
| { label: 'Win Rate', value: `${(results.win_rate * 100).toFixed(1)}%`, color: metricColor(results.win_rate - 0.5) }, | |
| { label: 'Profit Factor', value: results.profit_factor === Infinity ? 'N/A' : results.profit_factor.toFixed(2), color: metricColor(results.profit_factor - 1) }, | |
| { label: 'Total Trades', value: results.total_trades, color: 'var(--text-primary)' }, | |
| ].map((m, i) => ( | |
| <div key={i} className="card" style={{ padding: '0.6rem 0.75rem', textAlign: 'center' }}> | |
| <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)', fontWeight: 600, letterSpacing: '0.03em' }}>{m.label}</div> | |
| <div style={{ fontSize: '1.15rem', fontWeight: 700, color: m.color }}>{m.value}</div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Secondary Metrics */} | |
| <div className="card" style={{ padding: '0.75rem 1rem' }}> | |
| <div style={{ display: 'grid', gap: '0.5rem', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', fontSize: '0.75rem' }}> | |
| <div><span style={{ color: 'var(--text-muted)' }}>Sortino: </span><strong>{results.sortino_ratio.toFixed(2)}</strong></div> | |
| <div><span style={{ color: 'var(--text-muted)' }}>Ann. Return: </span><strong style={{ color: metricColor(results.annualized_return_pct) }}>{results.annualized_return_pct.toFixed(2)}%</strong></div> | |
| <div><span style={{ color: 'var(--text-muted)' }}>Avg Win: </span><strong style={{ color: 'var(--accent-green)' }}>{results.avg_win_pct.toFixed(2)}%</strong></div> | |
| <div><span style={{ color: 'var(--text-muted)' }}>Avg Loss: </span><strong style={{ color: '#ef4444' }}>{results.avg_loss_pct.toFixed(2)}%</strong></div> | |
| <div><span style={{ color: 'var(--text-muted)' }}>Final Equity: </span><strong>{formatCurrency(results.final_equity, detectCurrencyFromTicker(backtestTicker))}</strong></div> | |
| <div><span style={{ color: 'var(--text-muted)' }}>Trading Days: </span><strong>{results.trading_days}</strong></div> | |
| </div> | |
| </div> | |
| {/* Equity Curve */} | |
| <div className="card" style={{ padding: '1rem' }}> | |
| <div style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', color: 'var(--text-muted)', marginBottom: '0.5rem' }}> | |
| Equity Curve | |
| </div> | |
| {results.equity_curve && results.equity_curve.length > 0 && ( | |
| <svg viewBox={`0 0 ${results.equity_curve.length} 100`} style={{ width: '100%', height: 120 }} preserveAspectRatio="none"> | |
| {(() => { | |
| const eqData = results.equity_curve; | |
| const minEq = Math.min(...eqData.map((d: any) => d.equity)); | |
| const maxEq = Math.max(...eqData.map((d: any) => d.equity)); | |
| const range = maxEq - minEq || 1; | |
| const points = eqData.map((d: any, i: number) => | |
| `${i},${100 - ((d.equity - minEq) / range) * 90 - 5}` | |
| ).join(' '); | |
| const fillPoints = `0,100 ${points} ${eqData.length - 1},100`; | |
| const isProfit = eqData[eqData.length - 1].equity >= eqData[0].equity; | |
| return ( | |
| <> | |
| <defs> | |
| <linearGradient id="eqGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor={isProfit ? '#10b981' : '#ef4444'} stopOpacity="0.3" /> | |
| <stop offset="100%" stopColor={isProfit ? '#10b981' : '#ef4444'} stopOpacity="0.02" /> | |
| </linearGradient> | |
| </defs> | |
| <polygon points={fillPoints} fill="url(#eqGrad)" /> | |
| <polyline points={points} fill="none" stroke={isProfit ? '#10b981' : '#ef4444'} strokeWidth="1.5" /> | |
| </> | |
| ); | |
| })()} | |
| </svg> | |
| )} | |
| </div> | |
| {/* Trade Log */} | |
| <div className="card" style={{ padding: 0, overflow: 'hidden', maxHeight: 250, overflowY: 'auto' }}> | |
| <div style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', color: 'var(--text-muted)', padding: '0.6rem 0.75rem', background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-subtle)' }}> | |
| Trade Log (Last {results.trades?.length || 0}) | |
| </div> | |
| <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}> | |
| <tbody> | |
| {(results.trades || []).map((t: any, i: number) => ( | |
| <tr key={i} style={{ borderBottom: '1px solid var(--border-subtle)' }}> | |
| <td style={{ padding: '0.35rem 0.75rem' }}> | |
| <span style={{ | |
| fontSize: '0.6rem', fontWeight: 700, padding: '0.1rem 0.35rem', borderRadius: 3, | |
| background: t.type === 'ENTRY' ? 'rgba(16,185,129,0.15)' : 'rgba(239,68,68,0.15)', | |
| color: t.type === 'ENTRY' ? '#10b981' : '#ef4444', | |
| }}>{t.type}</span> | |
| </td> | |
| <td style={{ padding: '0.35rem 0.5rem', color: 'var(--text-muted)' }}>{t.date}</td> | |
| <td style={{ padding: '0.35rem 0.5rem' }}>{formatCurrency(t.price, detectCurrencyFromTicker(backtestTicker))}</td> | |
| {t.pnl_pct !== undefined && ( | |
| <td style={{ padding: '0.35rem 0.5rem', fontWeight: 600, color: t.pnl_pct >= 0 ? 'var(--accent-green)' : '#ef4444' }}> | |
| {t.pnl_pct >= 0 ? '+' : ''}{t.pnl_pct}% | |
| </td> | |
| )} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </> | |
| )} | |
| {/* Empty State */} | |
| {!results && ( | |
| <div style={{ textAlign: 'center', padding: '3rem 2rem', color: 'var(--text-muted)', flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}> | |
| <PlayIcon /> | |
| <p style={{ fontSize: '0.85rem', marginTop: '0.5rem' }}>Select a template or generate code, then click Run Backtest</p> | |
| <p style={{ fontSize: '0.7rem' }}>Results will show equity curve, metrics, and trade log</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |