quanthedge / frontend /src /pages /PineScriptLab.tsx
jashdoshi77's picture
added more tickersfor pine
8577e65
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...&#10;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>
);
}