You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1660 lines
75 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
import {
LineChart as LineChartIcon, Play, Pause, RotateCcw, Plus, Trash2, X, CheckCircle,
XCircle, Clock, Loader, AlertTriangle, Activity,
ChevronDown, RefreshCw, Terminal, Shield, Cpu, Square,
} from 'lucide-react';
import {
paperApi,
type PaperSession,
type PaperTask,
} from '../api/client';
import { Loading, ErrorState } from '../components/common/Loading';
import { ansiToHtml } from '../lib/utils';
// ── Shared styles ────────────────────────────────────────────────────────────
const card: React.CSSProperties = {
background: 'var(--bg1)',
border: '1px solid var(--border)',
borderRadius: 12,
overflow: 'hidden',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 11px',
fontSize: 14,
fontFamily: 'var(--font-mono)',
background: 'var(--bg2)',
border: '1px solid var(--border-md)',
borderRadius: 7,
color: 'var(--text1)',
outline: 'none',
};
const btn = (variant: 'primary' | 'danger' | 'ghost' | 'outline' = 'outline'): React.CSSProperties => ({
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '7px 14px',
fontSize: 13,
fontWeight: 500,
borderRadius: 7,
border: '1px solid',
cursor: 'pointer',
transition: 'all 0.12s',
whiteSpace: 'nowrap' as const,
background: variant === 'primary' ? 'var(--cyan)' : variant === 'danger' ? 'var(--red)' : 'transparent',
color: variant === 'primary' ? '#fff' : variant === 'danger' ? '#fff' : variant === 'ghost' ? 'var(--text3)' : 'var(--text2)',
borderColor: variant === 'primary' ? 'var(--cyan)' : variant === 'danger' ? 'var(--red)' : 'var(--border-md)',
});
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtMoney(v: number | null | undefined, decimals = 2): string {
if (v == null) return '—';
const sign = v >= 0 ? '+' : '';
return `${sign}$${Math.abs(v).toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}`;
}
function fmtPct(v: number | null | undefined, decimals = 2): string {
if (v == null) return '—';
const sign = v >= 0 ? '+' : '';
return `${sign}${v.toFixed(decimals)}%`;
}
function fmtPrice(v: number | null | undefined): string {
if (v == null) return '—';
return `$${v.toFixed(2)}`;
}
function pnlColor(v: number | null | undefined): string {
if (v == null) return 'var(--text3)';
return v >= 0 ? 'var(--green)' : 'var(--red)';
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { color: string; bg: string }> = {
active: { color: 'var(--green)', bg: 'var(--green-dim)' },
paused: { color: 'var(--gold)', bg: 'var(--gold-dim)' },
closed: { color: 'var(--text3)', bg: 'var(--bg2)' },
};
const { color, bg } = map[status] ?? map.closed;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '2px 9px', borderRadius: 20,
fontSize: 11, fontFamily: 'var(--font-mono)', fontWeight: 600, letterSpacing: '0.06em',
background: bg, color,
}}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: color, display: 'inline-block' }} />
{status.toUpperCase()}
</span>
);
}
function TaskStatusIcon({ status }: { status: PaperTask['status'] }) {
if (status === 'running') return <Loader size={13} style={{ color: 'var(--cyan)', animation: 'spin 1s linear infinite' }} />;
if (status === 'completed') return <CheckCircle size={13} style={{ color: 'var(--green)' }} />;
if (status === 'failed') return <XCircle size={13} style={{ color: 'var(--red)' }} />;
return <Clock size={13} style={{ color: 'var(--text3)' }} />;
}
function KillSwitchBadge({ on }: { on: boolean }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '2px 9px', borderRadius: 20,
fontSize: 11, fontFamily: 'var(--font-mono)', fontWeight: 600,
background: on ? 'var(--red-dim)' : 'var(--green-dim)',
color: on ? 'var(--red)' : 'var(--green)',
}}>
<Shield size={10} /> {on ? 'KILL SW ON' : 'OK'}
</span>
);
}
// ── Stat Card ────────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, valueColor }: {
label: string; value: string; sub?: string; valueColor?: string
}) {
return (
<div style={{
background: 'var(--bg2)', borderRadius: 10, padding: '14px 18px',
display: 'flex', flexDirection: 'column', gap: 4,
}}>
<div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
{label}
</div>
<div style={{ fontSize: 20, fontWeight: 700, fontFamily: 'var(--font-mono)', color: valueColor ?? 'var(--text1)' }}>
{value}
</div>
{sub && <div style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>{sub}</div>}
</div>
);
}
// ── Create Session Modal ──────────────────────────────────────────────────────
function CreateSessionModal({ onClose, onCreated }: {
onClose: () => void;
onCreated: (session: { session_id: string; name: string }) => void;
}) {
const [name, setName] = useState('');
const [configSearch, setConfigSearch] = useState('');
const [selectedConfig, setSelectedConfig] = useState('');
const [capital, setCapital] = useState('10000');
const [configOpen, setConfigOpen] = useState(false);
const dropRef = useRef<HTMLDivElement>(null);
const { data: cfgData, isLoading: cfgLoading } = useQuery({
queryKey: ['paper-configs', configSearch],
queryFn: () => paperApi.configs({ pattern: configSearch || undefined, limit: 100 }),
staleTime: 60_000,
});
const qc = useQueryClient();
const { mutate, isPending, error } = useMutation({
mutationFn: () => paperApi.createSession(name.trim(), selectedConfig, parseFloat(capital) || 10000),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['paper-sessions'] });
onCreated({ session_id: data.session_id, name: data.name });
},
});
// Close dropdown on outside click
useEffect(() => {
function handle(e: MouseEvent) {
if (dropRef.current && !dropRef.current.contains(e.target as Node)) {
setConfigOpen(false);
}
}
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, []);
const filtered = cfgData?.configs ?? [];
const canSubmit = name.trim() && selectedConfig && parseFloat(capital) > 0;
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={onClose}>
<div style={{
background: 'var(--bg1)', borderRadius: 14, padding: 28, width: 480,
border: '1px solid var(--border-md)', boxShadow: '0 8px 40px rgba(0,0,0,0.15)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 22 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text1)' }}>New Paper Trading Session</h2>
<button onClick={onClose} style={{ ...btn('ghost'), padding: '4px 8px', border: 'none' }}>
<X size={16} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Session name */}
<div>
<label style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', display: 'block', marginBottom: 6, letterSpacing: '0.06em' }}>
SESSION NAME *
</label>
<input
style={inputStyle}
placeholder="my_session_v1"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
{/* Strategy config */}
<div>
<label style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', display: 'block', marginBottom: 6, letterSpacing: '0.06em' }}>
STRATEGY CONFIG *
</label>
<div ref={dropRef} style={{ position: 'relative' }}>
<div
onClick={() => setConfigOpen(!configOpen)}
style={{
...inputStyle,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
userSelect: 'none',
}}
>
<span style={{ color: selectedConfig ? 'var(--text1)' : 'var(--text3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedConfig || 'Select or type config name…'}
</span>
<ChevronDown size={14} style={{ flexShrink: 0, color: 'var(--text3)' }} />
</div>
{configOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 10,
background: 'var(--bg1)', border: '1px solid var(--border-md)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.12)',
maxHeight: 280, overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--border)' }}>
<input
style={{ ...inputStyle, padding: '5px 9px', fontSize: 13 }}
placeholder="Search configs…"
value={configSearch}
onChange={e => setConfigSearch(e.target.value)}
autoFocus
onClick={e => e.stopPropagation()}
/>
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
{cfgLoading && <div style={{ padding: '12px 14px', color: 'var(--text3)', fontSize: 13 }}>Loading</div>}
{/* Also allow manual entry */}
{configSearch && !filtered.find(c => c.name === configSearch) && (
<div
onClick={() => { setSelectedConfig(configSearch); setConfigOpen(false); }}
style={{
padding: '9px 14px', cursor: 'pointer', fontSize: 13,
fontFamily: 'var(--font-mono)', color: 'var(--cyan)',
borderBottom: '1px solid var(--border)',
}}
>
Use "{configSearch}" directly
</div>
)}
{filtered.map(c => (
<div
key={c.name}
onClick={() => { setSelectedConfig(c.name); setConfigOpen(false); }}
style={{
padding: '8px 14px', cursor: 'pointer',
fontSize: 13, fontFamily: 'var(--font-mono)',
color: c.name === selectedConfig ? 'var(--cyan)' : 'var(--text1)',
background: c.name === selectedConfig ? 'var(--cyan-dim)' : 'transparent',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg2)')}
onMouseLeave={e => (e.currentTarget.style.background = c.name === selectedConfig ? 'var(--cyan-dim)' : 'transparent')}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.name}</span>
{c.id != null && <span style={{ color: 'var(--text3)', fontSize: 11, flexShrink: 0, marginLeft: 8 }}>#{c.id}</span>}
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Capital */}
<div>
<label style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', display: 'block', marginBottom: 6, letterSpacing: '0.06em' }}>
INITIAL CAPITAL ($)
</label>
<input
style={inputStyle}
type="number"
min="100"
step="1000"
value={capital}
onChange={e => setCapital(e.target.value)}
/>
</div>
{error && (
<div style={{ padding: '10px 12px', background: 'var(--red-dim)', borderRadius: 7, fontSize: 13, color: 'var(--red)' }}>
{(error as Error).message}
</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
<button style={btn('outline')} onClick={onClose}>Cancel</button>
<button
style={{ ...btn('primary'), opacity: (!canSubmit || isPending) ? 0.5 : 1 }}
onClick={() => canSubmit && mutate()}
disabled={!canSubmit || isPending}
>
{isPending ? <><Loader size={13} /> Creating</> : <><Plus size={13} /> Create Session</>}
</button>
</div>
</div>
</div>
</div>
);
}
// ── Close Confirm Modal ───────────────────────────────────────────────────────
function CloseConfirmModal({ session, onClose, onConfirm }: {
session: PaperSession;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={onClose}>
<div style={{
background: 'var(--bg1)', borderRadius: 14, padding: 28, width: 420,
border: '1px solid var(--border-md)', boxShadow: '0 8px 40px rgba(0,0,0,0.15)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<AlertTriangle size={18} style={{ color: 'var(--red)' }} />
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>Close Session?</h2>
</div>
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 20 }}>
This will <strong>liquidate all Alpaca positions</strong> and permanently delete session
<strong> "{session.session_name}"</strong> and all its trade history.
This cannot be undone.
</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button style={btn('outline')} onClick={onClose}>Cancel</button>
<button style={btn('danger')} onClick={onConfirm}>
<Trash2 size={13} /> Close & Delete
</button>
</div>
</div>
</div>
);
}
// ── Session List Item ─────────────────────────────────────────────────────────
function SessionItem({ session, selected, onClick }: {
session: PaperSession;
selected: boolean;
onClick: () => void;
}) {
const pnlPct = session.total_pnl_pct;
return (
<div
onClick={onClick}
style={{
padding: '13px 16px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: selected ? 'var(--cyan-dim)' : 'transparent',
borderLeft: `3px solid ${selected ? 'var(--cyan)' : 'transparent'}`,
transition: 'all 0.1s',
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'var(--bg2)'; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text1)' }}>{session.session_name}</span>
<StatusBadge status={session.status} />
</div>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', marginBottom: 6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.config_path.replace('configs/experiments/', '')}
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span style={{ fontSize: 13, fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--text1)' }}>
${session.current_equity.toLocaleString('en-US', { maximumFractionDigits: 0 })}
</span>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: pnlColor(pnlPct) }}>
{fmtPct(pnlPct)}
</span>
{session.kill_switch && <Shield size={11} style={{ color: 'var(--red)' }} />}
{session.latest_date && (
<span style={{ fontSize: 11, color: 'var(--text3)', marginLeft: 'auto' }}>{session.latest_date}</span>
)}
</div>
</div>
);
}
// ── Overview Tab ─────────────────────────────────────────────────────────────
function OverviewTab({ session }: { session: PaperSession }) {
const { data, isLoading } = useQuery({
queryKey: ['paper-equity', session.session_id],
queryFn: () => paperApi.equity(session.session_id),
refetchInterval: 60_000,
});
const snapshots = data?.snapshots ?? [];
const chartData = snapshots.map(s => ({
date: s.date,
equity: s.equity,
pnl: s.total_pnl ?? 0,
drawdown: s.drawdown_pct ?? 0,
}));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Stats grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
<StatCard
label="Equity"
value={`$${session.current_equity.toLocaleString('en-US', { maximumFractionDigits: 0 })}`}
sub={`Initial: $${session.initial_equity.toLocaleString('en-US', { maximumFractionDigits: 0 })}`}
/>
<StatCard
label="Total P&L"
value={fmtMoney(session.total_pnl, 0)}
sub={fmtPct(session.total_pnl_pct)}
valueColor={pnlColor(session.total_pnl)}
/>
<StatCard
label="Max Drawdown"
value={`${session.drawdown_pct.toFixed(2)}%`}
sub={`Peak: $${session.peak_equity.toLocaleString('en-US', { maximumFractionDigits: 0 })}`}
valueColor={session.drawdown_pct > 10 ? 'var(--red)' : session.drawdown_pct > 5 ? 'var(--gold)' : 'var(--green)'}
/>
<StatCard
label="Trades"
value={String(session.trade_count)}
sub={session.latest_date ? `Last: ${session.latest_date}` : 'No trades yet'}
/>
</div>
{/* Risk state */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
<StatCard
label="Kill Switch"
value={session.kill_switch ? 'ON' : 'OFF'}
valueColor={session.kill_switch ? 'var(--red)' : 'var(--green)'}
/>
<StatCard
label="Consec. Losses"
value={String(session.consecutive_losses)}
valueColor={session.consecutive_losses >= 3 ? 'var(--red)' : 'var(--text1)'}
/>
<StatCard
label="Cooldown"
value={session.cooldown_remaining > 0 ? `${session.cooldown_remaining}d` : 'None'}
valueColor={session.cooldown_remaining > 0 ? 'var(--gold)' : 'var(--text1)'}
/>
<StatCard
label="Daily Risk Used"
value={`${(session.daily_new_risk_used * 100).toFixed(1)}%`}
/>
</div>
{/* Equity chart */}
<div style={{ ...card, padding: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text2)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 7 }}>
<Activity size={14} /> Equity Curve
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)', marginLeft: 'auto' }}>
{snapshots.length} days
</span>
</div>
{isLoading && <Loading />}
{!isLoading && chartData.length === 0 && (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text3)', fontSize: 14 }}>
No equity history yet. Run the session to start tracking.
</div>
)}
{chartData.length > 0 && (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={chartData} margin={{ top: 4, right: 16, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fontFamily: 'var(--font-mono)', fill: 'var(--text3)' }}
tickFormatter={v => v.slice(5)}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fontFamily: 'var(--font-mono)', fill: 'var(--text3)' }}
tickFormatter={v => `$${(v / 1000).toFixed(1)}k`}
width={58}
/>
<Tooltip
contentStyle={{
background: 'var(--bg1)', border: '1px solid var(--border-md)',
borderRadius: 8, fontSize: 12, fontFamily: 'var(--font-mono)',
}}
formatter={(v) => [`$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 })}`, 'Equity']}
labelStyle={{ color: 'var(--text2)' }}
/>
<ReferenceLine y={data?.initial_equity} stroke="var(--text3)" strokeDasharray="4 4" />
<Line
type="monotone"
dataKey="equity"
stroke="var(--cyan)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: 'var(--cyan)' }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Daily snapshots table */}
{snapshots.length > 0 && (
<div style={card}>
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)', fontSize: 13, fontWeight: 600, color: 'var(--text2)' }}>
Daily Snapshots (last 20)
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)' }}>
{['Date', 'Equity', 'Cash', 'Mkt Val', 'Daily P&L', 'Total P&L', 'Drawdown', 'Positions'].map((h, hi) => (
<th key={h} style={{ padding: '10px 14px', textAlign: hi === 0 ? 'left' : 'right', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.07em', color: 'var(--text3)', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[...snapshots].reverse().slice(0, 20).map((s, i) => (
<tr key={s.date} style={{ borderTop: '1px solid var(--border)', background: i % 2 === 1 ? 'rgba(0,0,0,0.015)' : 'transparent' }}>
<td style={{ padding: '8px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text2)' }}>{s.date}</td>
<td style={{ padding: '8px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--text1)' }}>${s.equity.toLocaleString('en-US', { maximumFractionDigits: 0 })}</td>
<td style={{ padding: '8px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>${s.cash.toLocaleString('en-US', { maximumFractionDigits: 0 })}</td>
<td style={{ padding: '8px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>${s.market_value.toLocaleString('en-US', { maximumFractionDigits: 0 })}</td>
<td style={{ padding: '8px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: pnlColor(s.daily_pnl) }}>{fmtMoney(s.daily_pnl)}</td>
<td style={{ padding: '8px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: pnlColor(s.total_pnl) }}>{fmtMoney(s.total_pnl)}</td>
<td style={{ padding: '8px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: (s.drawdown_pct ?? 0) > 5 ? 'var(--red)' : 'var(--text2)' }}>{s.drawdown_pct?.toFixed(2) ?? '—'}%</td>
<td style={{ padding: '8px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{s.open_position_count ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ── Positions Tab ─────────────────────────────────────────────────────────────
function PositionsTab({ session }: { session: PaperSession }) {
const { data, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['paper-positions', session.session_id],
queryFn: () => paperApi.positions(session.session_id),
staleTime: 30_000,
});
const positions = data?.positions ?? [];
const brokerAvailable = data?.broker_available;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
{positions.length} open position{positions.length !== 1 ? 's' : ''}
</span>
{brokerAvailable === false && (
<span style={{ fontSize: 12, color: 'var(--gold)', display: 'flex', alignItems: 'center', gap: 5 }}>
<AlertTriangle size={12} /> Alpaca unavailable showing local state only
</span>
)}
<button
style={{ ...btn('outline'), marginLeft: 'auto', padding: '5px 10px' }}
onClick={() => refetch()}
>
<RefreshCw size={13} style={{ animation: isFetching ? 'spin 1s linear infinite' : 'none' }} />
Refresh
</button>
</div>
{isLoading && <Loading />}
{error && <ErrorState error={error as Error} />}
{!isLoading && positions.length === 0 && (
<div style={{ ...card, padding: '40px 24px', textAlign: 'center', color: 'var(--text3)', fontSize: 14 }}>
No open positions
</div>
)}
{positions.length > 0 && (
<div style={card}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border-md)' }}>
{['Symbol', 'Qty', 'Entry', 'Current', 'P&L $', 'P&L %', 'Days', 'Stop', 'Target', 'Direction'].map(h => (
<th key={h} style={{ padding: '11px 14px', textAlign: h === 'Symbol' ? 'left' : 'right', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.07em', color: 'var(--text3)', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{positions.map((p, i) => (
<tr key={p.symbol} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'rgba(0,0,0,0.015)' : 'transparent' }}>
<td style={{ padding: '10px 14px', fontFamily: 'var(--font-mono)', fontWeight: 700, color: p._ghost ? 'var(--gold)' : 'var(--text1)', display: 'flex', alignItems: 'center', gap: 6 }}>
{p.symbol}
{p._ghost && <span style={{ fontSize: 10, color: 'var(--gold)' }}>GHOST</span>}
</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{p.qty ?? '—'}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(p.avg_entry_price)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(p.current_price)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 600, color: pnlColor(p.unrealized_pl) }}>{fmtMoney(p.unrealized_pl)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: pnlColor(p.unrealized_pl_pct) }}>{fmtPct(p.unrealized_pl_pct)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{p.days_held ?? '—'}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--red)' }}>{fmtPrice(p.stop_price)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--green)' }}>{fmtPrice(p.target_price)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{p.trade_direction ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ── Trades Tab ────────────────────────────────────────────────────────────────
function TradesTab({ session }: { session: PaperSession }) {
const [lastN, setLastN] = useState<string>('');
const { data, isLoading, error } = useQuery({
queryKey: ['paper-trades', session.session_id, lastN],
queryFn: () => paperApi.trades(session.session_id, lastN ? parseInt(lastN) : undefined),
});
const trades = data?.trades ?? [];
const wins = trades.filter(t => t.net_pnl > 0).length;
const totalPnl = trades.reduce((s, t) => s + (t.net_pnl ?? 0), 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
{data?.total ?? 0} total trades
{trades.length > 0 && ` · Win Rate: ${((wins / trades.length) * 100).toFixed(0)}%`}
{trades.length > 0 && ` · P&L: `}
{trades.length > 0 && <span style={{ color: pnlColor(totalPnl), fontFamily: 'var(--font-mono)' }}>{fmtMoney(totalPnl, 0)}</span>}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>Show last:</span>
{[10, 20, 50, 100].map(n => (
<button
key={n}
onClick={() => setLastN(String(n))}
style={{
...btn('outline'),
padding: '4px 10px',
fontSize: 12,
background: lastN === String(n) ? 'var(--cyan-dim)' : 'transparent',
color: lastN === String(n) ? 'var(--cyan)' : 'var(--text3)',
borderColor: lastN === String(n) ? 'var(--cyan)' : 'var(--border-md)',
}}
>
{n}
</button>
))}
<button
onClick={() => setLastN('')}
style={{
...btn('outline'),
padding: '4px 10px',
fontSize: 12,
background: !lastN ? 'var(--cyan-dim)' : 'transparent',
color: !lastN ? 'var(--cyan)' : 'var(--text3)',
borderColor: !lastN ? 'var(--cyan)' : 'var(--border-md)',
}}
>
All
</button>
</div>
</div>
{isLoading && <Loading />}
{error && <ErrorState error={error as Error} />}
{!isLoading && trades.length === 0 && (
<div style={{ ...card, padding: '40px 24px', textAlign: 'center', color: 'var(--text3)', fontSize: 14 }}>
No trades recorded yet
</div>
)}
{trades.length > 0 && (
<div style={card}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border-md)' }}>
{['Symbol', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'Shares', 'Net P&L', 'R', 'Days', 'Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', textAlign: h === 'Symbol' || h === 'Reason' ? 'left' : 'right', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.07em', color: 'var(--text3)', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{trades.map((t, i) => (
<tr key={t.trade_id} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'rgba(0,0,0,0.015)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--text1)' }}>{t.symbol}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{t.entry_date ?? '—'}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{t.exit_date}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(t.entry_price)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(t.exit_price)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.shares}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 600, color: pnlColor(t.net_pnl) }}>{fmtMoney(t.net_pnl)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: pnlColor(t.r_multiple) }}>
{t.r_multiple >= 0 ? '+' : ''}{t.r_multiple.toFixed(2)}R
</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.holding_days}d</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{t.exit_reason}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ── Run Tab ───────────────────────────────────────────────────────────────────
function RunTab({ session, onTaskLaunched }: {
session: PaperSession;
onTaskLaunched: (task: PaperTask) => void;
}) {
const [date, setDate] = useState('');
const [force, setForce] = useState(false);
const [runningOp, setRunningOp] = useState<string | null>(null);
const qc = useQueryClient();
const execOp = async (op: 'run' | 'run-close' | 'run-open' | 'run-all') => {
setRunningOp(op);
try {
let task: PaperTask;
const d = date || null;
if (op === 'run') task = await paperApi.run(session.session_id, d, force);
else if (op === 'run-close') task = await paperApi.runClose(session.session_id, d, force);
else if (op === 'run-open') task = await paperApi.runOpen(session.session_id, d, force);
else {
const result = await paperApi.runAll();
task = result.tasks[0];
}
onTaskLaunched(task);
qc.invalidateQueries({ queryKey: ['paper-tasks'] });
} catch (err) {
alert((err as Error).message);
} finally {
setRunningOp(null);
}
};
const isActive = session.status === 'active';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{!isActive && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px', background: 'var(--gold-dim)', borderRadius: 9, color: 'var(--gold)', fontSize: 13 }}>
<AlertTriangle size={14} />
Session is {session.status}. Resume to run operations.
</div>
)}
{session.kill_switch && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px', background: 'var(--red-dim)', borderRadius: 9, color: 'var(--red)', fontSize: 13 }}>
<Shield size={14} />
Kill switch is active all trading halted.
</div>
)}
{/* Options */}
<div style={{ ...card, padding: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text2)', marginBottom: 16 }}>Options</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end' }}>
<div>
<label style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)', display: 'block', marginBottom: 5, letterSpacing: '0.07em', textTransform: 'uppercase' }}>
Target Date (YYYY-MM-DD)
</label>
<input
style={inputStyle}
type="date"
value={date}
onChange={e => setDate(e.target.value)}
placeholder="Leave empty for today"
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--text2)', cursor: 'pointer', paddingBottom: 2, fontFamily: 'var(--font-mono)' }}>
<input
type="checkbox"
checked={force}
onChange={e => setForce(e.target.checked)}
style={{ width: 15, height: 15 }}
/>
--force (re-run even if already processed)
</label>
</div>
</div>
{/* Run operations */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
{/* run */}
<div style={{ ...card, padding: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text1)', marginBottom: 6 }}>run</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 14, lineHeight: 1.5 }}>
: exit + +
</div>
<button
style={{ ...btn('primary'), width: '100%', justifyContent: 'center', opacity: (!isActive || !!runningOp) ? 0.5 : 1 }}
onClick={() => execOp('run')}
disabled={!isActive || !!runningOp}
>
{runningOp === 'run' ? <><Loader size={13} /> Running</> : <><Play size={13} /> Run Daily</>}
</button>
</div>
{/* run-close */}
<div style={{ ...card, padding: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text1)', marginBottom: 6 }}>run-close</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 14, lineHeight: 1.5 }}>
: same-day MOC
</div>
<button
style={{ ...btn('outline'), width: '100%', justifyContent: 'center', opacity: (!isActive || !!runningOp) ? 0.5 : 1 }}
onClick={() => execOp('run-close')}
disabled={!isActive || !!runningOp}
>
{runningOp === 'run-close' ? <><Loader size={13} /> Running</> : <><Play size={13} /> Run Close</>}
</button>
</div>
{/* run-open */}
<div style={{ ...card, padding: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text1)', marginBottom: 6 }}>run-open</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 14, lineHeight: 1.5 }}>
: exit + after-close
</div>
<button
style={{ ...btn('outline'), width: '100%', justifyContent: 'center', opacity: (!isActive || !!runningOp) ? 0.5 : 1 }}
onClick={() => execOp('run-open')}
disabled={!isActive || !!runningOp}
>
{runningOp === 'run-open' ? <><Loader size={13} /> Running</> : <><Play size={13} /> Run Open</>}
</button>
</div>
{/* run-all */}
<div style={{ ...card, padding: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text1)', marginBottom: 6 }}>run-all</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 14, lineHeight: 1.5 }}>
</div>
<button
style={{ ...btn('outline'), width: '100%', justifyContent: 'center', opacity: !!runningOp ? 0.5 : 1 }}
onClick={() => execOp('run-all')}
disabled={!!runningOp}
>
{runningOp === 'run-all' ? <><Loader size={13} /> Running</> : <><Play size={13} /> Run All Sessions</>}
</button>
</div>
</div>
</div>
);
}
// ── Tasks Tab ─────────────────────────────────────────────────────────────────
function TasksTab({ session, highlightTaskId }: {
session: PaperSession;
highlightTaskId?: string | null;
}) {
const [selectedTask, setSelectedTask] = useState<string | null>(highlightTaskId ?? null);
const { data, isLoading } = useQuery({
queryKey: ['paper-tasks', session.session_name],
queryFn: () => paperApi.tasks(session.session_name),
refetchInterval: 3000,
});
const { data: logData } = useQuery({
queryKey: ['paper-task-log', selectedTask],
queryFn: () => selectedTask ? paperApi.taskLog(selectedTask) : null,
enabled: !!selectedTask,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'running' ? 2000 : false;
},
});
const tasks = data?.tasks ?? [];
useEffect(() => {
if (highlightTaskId) setSelectedTask(highlightTaskId);
}, [highlightTaskId]);
return (
<div style={{ display: 'flex', gap: 16, minHeight: 400 }}>
{/* Task list */}
<div style={{ width: 280, flexShrink: 0, ...card, alignSelf: 'flex-start' }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontSize: 13, fontWeight: 600, color: 'var(--text2)' }}>
Recent Tasks
</div>
{isLoading && <div style={{ padding: 16 }}><Loading /></div>}
{tasks.length === 0 && !isLoading && (
<div style={{ padding: '20px 16px', fontSize: 13, color: 'var(--text3)', textAlign: 'center' }}>
No tasks yet
</div>
)}
{tasks.map(t => (
<div
key={t.task_id}
onClick={() => setSelectedTask(t.task_id)}
style={{
padding: '11px 16px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: selectedTask === t.task_id ? 'var(--cyan-dim)' : 'transparent',
borderLeft: `3px solid ${selectedTask === t.task_id ? 'var(--cyan)' : 'transparent'}`,
}}
onMouseEnter={e => { if (selectedTask !== t.task_id) e.currentTarget.style.background = 'var(--bg2)'; }}
onMouseLeave={e => { if (selectedTask !== t.task_id) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 4 }}>
<TaskStatusIcon status={t.status} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>
{t.operation}
</span>
</div>
<div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>
{t.started_at ? new Date(t.started_at).toLocaleString() : t.created_at}
</div>
{t.error && (
<div style={{ fontSize: 11, color: 'var(--red)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.error}
</div>
)}
</div>
))}
</div>
{/* Log viewer */}
<div style={{ flex: 1, background: '#0d1117', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.3)', display: 'flex', flexDirection: 'column' }}>
{!selectedTask && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.25)', fontSize: 14, gap: 8 }}>
<Terminal size={16} /> Select a task to view its log
</div>
)}
{selectedTask && (
<>
{/* Terminal title bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', background: '#161b22', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 8 }}>
{logData && <TaskStatusIcon status={logData.status as PaperTask['status']} />}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'rgba(255,255,255,0.4)' }}>
{tasks.find(t => t.task_id === selectedTask)?.operation ?? selectedTask}
</span>
{logData?.status === 'running' && (
<span style={{ fontSize: 11, color: 'var(--cyan)', fontFamily: 'var(--font-mono)' }}>(live)</span>
)}
</div>
</div>
<pre
className="terminal-scroll"
style={{
flex: 1,
padding: '16px 20px',
fontSize: 12,
fontFamily: 'var(--font-mono)',
lineHeight: 1.7,
color: 'rgba(230,237,243,0.85)',
background: 'transparent',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
minHeight: 200,
maxHeight: 500,
margin: 0,
}}
dangerouslySetInnerHTML={{
__html: logData?.log
? ansiToHtml(logData.log)
: `<span style="color:rgba(255,255,255,0.25);font-style:italic">${logData?.status === 'running' ? '(waiting for output...)' : 'No output'}</span>`,
}}
/>
</>
)}
</div>
</div>
);
}
// ── Session Detail ────────────────────────────────────────────────────────────
type TabId = 'overview' | 'positions' | 'trades' | 'run' | 'tasks';
function SessionDetail({ session, onRefresh }: {
session: PaperSession;
onRefresh: () => void;
}) {
const [tab, setTab] = useState<TabId>('overview');
const [showClose, setShowClose] = useState(false);
const [latestTask, setLatestTask] = useState<PaperTask | null>(null);
const qc = useQueryClient();
const pauseMut = useMutation({
mutationFn: () => paperApi.pauseSession(session.session_id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['paper-sessions'] }); onRefresh(); },
});
const resumeMut = useMutation({
mutationFn: () => paperApi.resumeSession(session.session_id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['paper-sessions'] }); onRefresh(); },
});
const closeMut = useMutation({
mutationFn: () => paperApi.closeSession(session.session_id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['paper-sessions'] }); setShowClose(false); },
});
const TABS: { id: TabId; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'positions', label: 'Positions' },
{ id: 'trades', label: 'Trades' },
{ id: 'run', label: 'Run' },
{ id: 'tasks', label: 'Tasks' },
];
function handleTaskLaunched(task: PaperTask) {
setLatestTask(task);
setTab('tasks');
}
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 24px', borderBottom: '1px solid var(--border)',
background: 'var(--bg1)', flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text1)' }}>{session.session_name}</h2>
<StatusBadge status={session.status} />
{session.kill_switch && <KillSwitchBadge on />}
</div>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', marginTop: 3 }}>
{session.config_path} · ID: {session.session_id} · Created {session.created_at.slice(0, 10)}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{session.status === 'active' && (
<button
style={btn('outline')}
onClick={() => pauseMut.mutate()}
disabled={pauseMut.isPending}
>
<Pause size={13} /> Pause
</button>
)}
{session.status === 'paused' && (
<button
style={btn('primary')}
onClick={() => resumeMut.mutate()}
disabled={resumeMut.isPending}
>
<Play size={13} /> Resume
</button>
)}
<button style={btn('danger')} onClick={() => setShowClose(true)}>
<Trash2 size={13} /> Close
</button>
</div>
</div>
{/* Tabs */}
<div style={{
display: 'flex', gap: 0,
borderBottom: '1px solid var(--border)',
background: 'var(--bg1)', flexShrink: 0, paddingLeft: 24,
}}>
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
style={{
padding: '11px 18px',
fontSize: 13, fontWeight: 500,
border: 'none', borderBottom: `2px solid ${tab === t.id ? 'var(--cyan)' : 'transparent'}`,
background: 'transparent',
color: tab === t.id ? 'var(--cyan)' : 'var(--text3)',
cursor: 'pointer',
transition: 'all 0.1s',
marginBottom: -1,
}}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
<div style={{ flex: 1, padding: '22px 24px', overflowY: 'auto' }}>
{tab === 'overview' && <OverviewTab session={session} />}
{tab === 'positions' && <PositionsTab session={session} />}
{tab === 'trades' && <TradesTab session={session} />}
{tab === 'run' && <RunTab session={session} onTaskLaunched={handleTaskLaunched} />}
{tab === 'tasks' && <TasksTab session={session} highlightTaskId={latestTask?.task_id} />}
</div>
{showClose && (
<CloseConfirmModal
session={session}
onClose={() => setShowClose(false)}
onConfirm={() => closeMut.mutate()}
/>
)}
</div>
);
}
// ── Auto Daemon Badge (sidebar) ───────────────────────────────────────────────
function AutoDaemonBadge() {
const { data } = useQuery({
queryKey: ['paper-auto-status'],
queryFn: () => paperApi.autoStatus(),
refetchInterval: 5000,
staleTime: 4000,
});
if (!data) return null;
return (
<span style={{
marginLeft: 'auto',
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 20,
fontSize: 10, fontFamily: 'var(--font-mono)', fontWeight: 600,
background: data.running ? 'var(--green-dim)' : 'var(--bg2)',
color: data.running ? 'var(--green)' : 'var(--text3)',
}}>
<span style={{
width: 5, height: 5, borderRadius: '50%',
background: data.running ? 'var(--green)' : 'var(--text3)',
display: 'inline-block',
animation: data.running ? 'pulse 1.5s ease-in-out infinite' : 'none',
}} />
{data.running ? 'ON' : 'OFF'}
</span>
);
}
// ── Auto Daemon Panel ────────────────────────────────────────────────────────
function fmtCountdown(secs: number): string {
if (secs <= 0) return 'now';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, '0')}s`;
return `${s}s`;
}
function AutoDaemonPanel({ sessions }: { sessions: string[] }) {
const [showLog, setShowLog] = useState(false);
const [sessionFilter, setSessionFilter] = useState<string[]>([]);
const [dryRun, setDryRun] = useState(false);
const [sessionDropOpen, setSessionDropOpen] = useState(false);
const logRef = useRef<HTMLPreElement>(null);
const qc = useQueryClient();
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ['paper-auto-status'],
queryFn: () => paperApi.autoStatus(),
refetchInterval: 5000,
});
const { data: logData, refetch: refetchLog } = useQuery({
queryKey: ['paper-auto-log'],
queryFn: () => paperApi.autoLog(300),
enabled: showLog,
refetchInterval: showLog ? 3000 : false,
});
// Auto-scroll log to bottom
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logData?.log]);
const startMut = useMutation({
mutationFn: () => paperApi.autoStart(sessionFilter.length > 0 ? sessionFilter : [], dryRun),
onSuccess: () => qc.invalidateQueries({ queryKey: ['paper-auto-status'] }),
onError: (e) => alert((e as Error).message),
});
const stopMut = useMutation({
mutationFn: () => paperApi.autoStop(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['paper-auto-status'] }),
onError: (e) => alert((e as Error).message),
});
const running = data?.running ?? false;
const pid = data?.pid;
const schedule = data?.schedule ?? [];
// Live countdown — re-render every second
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
const nextEvent = schedule.find(e => !e.past);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Status + controls */}
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Status card */}
<div style={{
...card, padding: '16px 20px', flex: '0 0 auto', minWidth: 220,
borderLeft: `3px solid ${running ? 'var(--green)' : 'var(--border-md)'}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Cpu size={14} style={{ color: running ? 'var(--green)' : 'var(--text3)' }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: running ? 'var(--green)' : 'var(--text3)' }}>
{running ? 'RUNNING' : 'STOPPED'}
</span>
{running && (
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>PID {pid}</span>
)}
<button
style={{ marginLeft: 'auto', ...btn('ghost'), padding: '2px 5px', border: 'none' }}
onClick={() => { refetch(); if (showLog) refetchLog(); }}
>
<RotateCcw size={12} style={{ animation: isFetching ? 'spin 1s linear infinite' : 'none' }} />
</button>
</div>
{running && nextEvent && (
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>
Next: <span style={{ color: 'var(--cyan)' }}>{nextEvent.label}</span>
<br />
<span style={{ color: 'var(--text3)' }}>
{nextEvent.et_time} · in{' '}
<span style={{ color: 'var(--gold)', fontWeight: 600 }}>
{fmtCountdown(nextEvent.wait_secs - tick)}
</span>
</span>
</div>
)}
{!running && (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Auto daemon not running</div>
)}
</div>
{/* Controls */}
<div style={{ ...card, padding: '16px 20px', flex: 1, minWidth: 280 }}>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', marginBottom: 12, letterSpacing: '0.07em', textTransform: 'uppercase' }}>
Controls
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Session filter */}
{!running && (
<>
<div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 5, fontFamily: 'var(--font-mono)' }}>
Sessions ( active )
</div>
<div style={{ position: 'relative' }}>
<div
onClick={() => setSessionDropOpen(!sessionDropOpen)}
style={{ ...inputStyle, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 13 }}
>
<span style={{ color: sessionFilter.length ? 'var(--text1)' : 'var(--text3)' }}>
{sessionFilter.length ? sessionFilter.join(', ') : 'All active sessions'}
</span>
<ChevronDown size={13} style={{ color: 'var(--text3)', flexShrink: 0 }} />
</div>
{sessionDropOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 20,
background: 'var(--bg1)', border: '1px solid var(--border-md)',
borderRadius: 7, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
}}>
<div
onClick={() => { setSessionFilter([]); setSessionDropOpen(false); }}
style={{ padding: '8px 12px', fontSize: 13, cursor: 'pointer', color: !sessionFilter.length ? 'var(--cyan)' : 'var(--text2)', fontFamily: 'var(--font-mono)', borderBottom: '1px solid var(--border)' }}
>
All active sessions
</div>
{sessions.map(s => (
<div
key={s}
onClick={() => {
setSessionFilter(prev =>
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
);
}}
style={{
padding: '8px 12px', fontSize: 13, cursor: 'pointer', fontFamily: 'var(--font-mono)',
display: 'flex', alignItems: 'center', gap: 8,
color: sessionFilter.includes(s) ? 'var(--cyan)' : 'var(--text1)',
background: sessionFilter.includes(s) ? 'var(--cyan-dim)' : 'transparent',
}}
>
<input type="checkbox" readOnly checked={sessionFilter.includes(s)} style={{ width: 13, height: 13 }} />
{s}
</div>
))}
</div>
)}
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>
<input type="checkbox" checked={dryRun} onChange={e => setDryRun(e.target.checked)} style={{ width: 14, height: 14 }} />
--dry-run ( )
</label>
</>
)}
<div style={{ display: 'flex', gap: 8 }}>
{!running && (
<button
style={{ ...btn('primary'), flex: 1, justifyContent: 'center', opacity: startMut.isPending ? 0.5 : 1 }}
onClick={() => startMut.mutate()}
disabled={startMut.isPending}
>
{startMut.isPending
? <><Loader size={13} /> Starting</>
: <><Play size={13} /> Start Auto Daemon</>}
</button>
)}
{running && (
<button
style={{ ...btn('danger'), flex: 1, justifyContent: 'center', opacity: stopMut.isPending ? 0.5 : 1 }}
onClick={() => stopMut.mutate()}
disabled={stopMut.isPending}
>
{stopMut.isPending
? <><Loader size={13} /> Stopping</>
: <><Square size={13} /> Stop Daemon</>}
</button>
)}
<button
style={{ ...btn('outline'), opacity: isLoading ? 0.5 : 1 }}
onClick={() => setShowLog(!showLog)}
>
<Terminal size={13} /> {showLog ? 'Hide Log' : 'View Log'}
</button>
</div>
</div>
</div>
{/* Schedule */}
<div style={{ ...card, flex: '0 0 auto', minWidth: 300 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontSize: 12, fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--text2)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Today's Schedule (ET)
</div>
{schedule.map(ev => (
<div key={ev.name} style={{
padding: '10px 16px',
borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 10,
opacity: ev.past ? 0.45 : 1,
}}>
<div style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: ev.past ? 'var(--text3)' : running ? 'var(--green)' : 'var(--border-md)',
}} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: 'var(--text1)' }}>{ev.label}</div>
<div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{ev.et_time}</div>
</div>
<div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: ev.past ? 'var(--text3)' : 'var(--gold)' }}>
{ev.past ? '' : fmtCountdown(ev.wait_secs - tick)}
</div>
</div>
))}
</div>
</div>
{/* Log viewer */}
{showLog && (
<div style={{ background: '#0d1117', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.3)' }}>
{/* Terminal title bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', background: '#161b22', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'rgba(255,255,255,0.4)', marginLeft: 8 }}>
paper_auto.log
</span>
{logData?.source && logData.source !== 'none' && !logData.tty && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontFamily: 'var(--font-mono)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 380 }}>
· {logData.source}
</span>
)}
{logData && !logData.tty && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontFamily: 'var(--font-mono)' }}>
· {logData.lines} lines
</span>
)}
{running && !logData?.tty && (
<span style={{ fontSize: 11, color: 'var(--cyan)', fontFamily: 'var(--font-mono)' }}>(live)</span>
)}
{logData?.tty && (
<span style={{ fontSize: 11, color: 'var(--gold)', fontFamily: 'var(--font-mono)' }}>
(terminal output — cannot capture)
</span>
)}
</div>
<pre
ref={logRef}
className="terminal-scroll"
style={{
padding: '16px 20px', fontSize: 12, fontFamily: 'var(--font-mono)', lineHeight: 1.7,
color: logData?.tty ? 'var(--gold)' : 'rgba(230,237,243,0.85)',
background: 'transparent',
overflowY: 'auto', maxHeight: 480, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
margin: 0,
}}
dangerouslySetInnerHTML={{
__html: logData?.log
? (logData.tty ? logData.log : ansiToHtml(logData.log))
: `<span style="color:rgba(255,255,255,0.25);font-style:italic">${isLoading ? 'Loading' : 'No log output yet.'}</span>`,
}}
/>
</div>
)}
</div>
);
}
// ── Main Page ────────────────────────────────────────────────────────────────
export function PaperTradingPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState<'session' | 'auto'>('session');
const [showCreate, setShowCreate] = useState(false);
const qc = useQueryClient();
const { data, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['paper-sessions'],
queryFn: () => paperApi.sessions(),
refetchInterval: 30_000,
});
const sessions = data?.sessions ?? [];
const selected = sessions.find(s => s.session_id === selectedId || s.session_name === selectedId) ?? null;
// Auto-select first session
useEffect(() => {
if (!selectedId && sessions.length > 0) {
setSelectedId(sessions[0].session_id);
}
}, [sessions]);
function handleCreated(s: { session_id: string; name: string }) {
setShowCreate(false);
qc.invalidateQueries({ queryKey: ['paper-sessions'] });
setSelectedId(s.session_id);
}
const activeCount = sessions.filter(s => s.status === 'active').length;
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }} className="fade-up">
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:0.4 } }
.terminal-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.2) transparent; }
.terminal-scroll::-webkit-scrollbar { width: 7px; }
.terminal-scroll::-webkit-scrollbar-track { background: transparent; }
.terminal-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
.terminal-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.32); }
`}</style>
{/* Session sidebar */}
<div style={{
width: 280,
flexShrink: 0,
background: 'var(--bg1)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
}}>
{/* Sidebar header */}
<div style={{ padding: '20px 16px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<LineChartIcon size={16} style={{ color: 'var(--green)' }} />
<h1 style={{ fontSize: 17, fontWeight: 700, color: 'var(--text1)' }}>Paper Trading</h1>
</div>
<button
onClick={() => refetch()}
style={{ ...btn('ghost'), padding: '4px 6px', border: 'none' }}
>
<RotateCcw size={13} style={{ animation: isFetching ? 'spin 1s linear infinite' : 'none' }} />
</button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>
{sessions.length} sessions · {activeCount} active
</span>
<button
style={{ ...btn('primary'), marginLeft: 'auto', padding: '5px 10px', fontSize: 12 }}
onClick={() => { setView('session'); setShowCreate(true); }}
>
<Plus size={12} /> New
</button>
</div>
</div>
{/* Auto daemon button */}
<div
onClick={() => setView(view === 'auto' ? 'session' : 'auto')}
style={{
padding: '11px 16px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 8,
background: view === 'auto' ? 'var(--cyan-dim)' : 'transparent',
borderLeft: `3px solid ${view === 'auto' ? 'var(--cyan)' : 'transparent'}`,
transition: 'all 0.1s',
}}
onMouseEnter={e => { if (view !== 'auto') e.currentTarget.style.background = 'var(--bg2)'; }}
onMouseLeave={e => { if (view !== 'auto') e.currentTarget.style.background = 'transparent'; }}
>
<Cpu size={14} style={{ color: view === 'auto' ? 'var(--cyan)' : 'var(--text3)' }} />
<span style={{ fontSize: 14, fontWeight: 500, color: view === 'auto' ? 'var(--cyan)' : 'var(--text2)' }}>
Auto Daemon
</span>
<AutoDaemonBadge />
</div>
{/* Session list */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{isLoading && <div style={{ padding: 20 }}><Loading /></div>}
{error && <div style={{ padding: 16 }}><ErrorState error={error as Error} /></div>}
{!isLoading && sessions.length === 0 && (
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--text3)', fontSize: 13, lineHeight: 1.7 }}>
No sessions yet.<br />
<button style={{ color: 'var(--cyan)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13 }} onClick={() => setShowCreate(true)}>
Create your first session
</button>
</div>
)}
{sessions.map(s => (
<SessionItem
key={s.session_id}
session={s}
selected={view === 'session' && s.session_id === selected?.session_id}
onClick={() => { setView('session'); setSelectedId(s.session_id); }}
/>
))}
</div>
</div>
{/* Main content area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg)' }}>
{/* Auto daemon view */}
{view === 'auto' && (
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 20 }}>
<Cpu size={16} style={{ color: 'var(--cyan)' }} />
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>Auto Daemon</h2>
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
ET 장 스케줄 자동 실행
</span>
</div>
<AutoDaemonPanel
sessions={sessions.filter(s => s.status === 'active').map(s => s.session_name)}
/>
</div>
)}
{/* Session view */}
{view === 'session' && !selected && !isLoading && (
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 16, color: 'var(--text3)',
}}>
<LineChartIcon size={40} style={{ opacity: 0.3 }} />
<div style={{ fontSize: 16, fontWeight: 500 }}>Select a session</div>
<div style={{ fontSize: 13 }}>
or{' '}
<button
style={{ color: 'var(--cyan)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13 }}
onClick={() => setShowCreate(true)}
>
create a new one
</button>
</div>
</div>
)}
{view === 'session' && selected && (
<SessionDetail
key={selected.session_id}
session={selected}
onRefresh={() => {
qc.invalidateQueries({ queryKey: ['paper-sessions'] });
qc.invalidateQueries({ queryKey: ['paper-session', selected.session_id] });
}}
/>
)}
</div>
{showCreate && (
<CreateSessionModal
onClose={() => setShowCreate(false)}
onCreated={handleCreated}
/>
)}
</div>
);
}