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
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>
|
|
);
|
|
}
|