|
|
|
|
@ -0,0 +1,913 @@
|
|
|
|
|
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 {
|
|
|
|
|
Zap, Play, Square, Plus, Trash2, Pause, RotateCcw,
|
|
|
|
|
TrendingUp, TrendingDown, ChevronDown, PlayCircle,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
import {
|
|
|
|
|
orbTradingApi,
|
|
|
|
|
type OrbSession,
|
|
|
|
|
type OrbScheduleEvent,
|
|
|
|
|
type OrbStrategyInfo,
|
|
|
|
|
} from '../api/client';
|
|
|
|
|
import { Loading, ErrorState } from '../components/common/Loading';
|
|
|
|
|
|
|
|
|
|
// ── 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)',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function fmt(n: number, dp = 2) { return n.toFixed(dp); }
|
|
|
|
|
function fmtPct(n: number) { return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`; }
|
|
|
|
|
function fmtDollars(n: number) {
|
|
|
|
|
return `${n >= 0 ? '+' : ''}$${Math.abs(n).toFixed(2)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Scheduler Panel ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function SchedulerPanel({ selectedSessions }: { selectedSessions: string[] }) {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const [showLog, setShowLog] = useState(false);
|
|
|
|
|
const logRef = useRef<HTMLPreElement>(null);
|
|
|
|
|
|
|
|
|
|
const { data, isLoading } = useQuery({
|
|
|
|
|
queryKey: ['orb-auto-status'],
|
|
|
|
|
queryFn: orbTradingApi.autoStatus,
|
|
|
|
|
refetchInterval: 5000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const startMut = useMutation({
|
|
|
|
|
mutationFn: () => orbTradingApi.autoStart(selectedSessions.length > 0 ? selectedSessions : []),
|
|
|
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-auto-status'] }),
|
|
|
|
|
onError: (e: Error) => alert(`스케줄러 시작 실패: ${e.message}`),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const stopMut = useMutation({
|
|
|
|
|
mutationFn: orbTradingApi.autoStop,
|
|
|
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-auto-status'] }),
|
|
|
|
|
onError: (e: Error) => alert(`스케줄러 중지 실패: ${e.message}`),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const logLines = data?.log_tail ?? [];
|
|
|
|
|
|
|
|
|
|
// Auto-scroll to bottom when new log lines arrive (must be before early return)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (showLog && logRef.current) {
|
|
|
|
|
logRef.current.scrollTop = logRef.current.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
}, [logLines.length, showLog]);
|
|
|
|
|
|
|
|
|
|
if (isLoading) return <div style={{ padding: 20 }}><Loading /></div>;
|
|
|
|
|
|
|
|
|
|
const running = data?.running ?? false;
|
|
|
|
|
const schedule = data?.schedule ?? [];
|
|
|
|
|
|
|
|
|
|
// Find next upcoming event
|
|
|
|
|
const nextEvent = schedule.find(e => !e.done && e.wait_secs > 0);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ ...card, marginBottom: 24 }}>
|
|
|
|
|
<div style={{
|
|
|
|
|
padding: '14px 20px',
|
|
|
|
|
background: running ? 'color-mix(in srgb, var(--green) 8%, transparent)' : 'var(--bg2)',
|
|
|
|
|
borderBottom: '1px solid var(--border)',
|
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
|
|
|
<div style={{
|
|
|
|
|
width: 8, height: 8, borderRadius: '50%',
|
|
|
|
|
background: running ? 'var(--green)' : 'var(--text3)',
|
|
|
|
|
animation: running ? 'pulse-line 2s infinite' : 'none',
|
|
|
|
|
}} />
|
|
|
|
|
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text1)' }}>
|
|
|
|
|
ORB 자동 트레이딩 스케줄러
|
|
|
|
|
</span>
|
|
|
|
|
{running && data?.dry_run && (
|
|
|
|
|
<span style={{ fontSize: 11, color: 'var(--orange)', background: 'color-mix(in srgb, var(--orange) 12%, transparent)', padding: '2px 8px', borderRadius: 4 }}>
|
|
|
|
|
DRY RUN
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{running && !data?.dry_run && (
|
|
|
|
|
<span style={{ fontSize: 11, color: 'var(--green)', background: 'color-mix(in srgb, var(--green) 12%, transparent)', padding: '2px 8px', borderRadius: 4 }}>
|
|
|
|
|
LIVE
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
|
|
<button
|
|
|
|
|
style={btn(running ? 'danger' : 'primary')}
|
|
|
|
|
onClick={() => running ? stopMut.mutate() : startMut.mutate()}
|
|
|
|
|
disabled={startMut.isPending || stopMut.isPending}
|
|
|
|
|
>
|
|
|
|
|
{running ? <><Square size={13} /> 중지</> : <><Play size={13} /> 시작</>}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Schedule timeline — per-session rows */}
|
|
|
|
|
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
|
|
|
|
|
{(() => {
|
|
|
|
|
// Group events by session
|
|
|
|
|
const sessionNames = Array.from(new Set(schedule.map((e: OrbScheduleEvent) => e.session))).filter(Boolean) as string[];
|
|
|
|
|
|
|
|
|
|
const renderSessionRow = (sessionName: string) => {
|
|
|
|
|
const sessionEvs = schedule.filter((e: OrbScheduleEvent) => e.session === sessionName);
|
|
|
|
|
const breakouts = sessionEvs.filter((e: OrbScheduleEvent) => e.kind === 'breakout');
|
|
|
|
|
const displayed = sessionEvs.filter((ev: OrbScheduleEvent) => {
|
|
|
|
|
if (['orb_monitor', 'orb_detect', 'stop_check', 'eod_exit', 'post_close'].includes(ev.kind)) return true;
|
|
|
|
|
if (ev.kind === 'breakout') return ev === breakouts[0] || ev === breakouts[breakouts.length - 1];
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
const isNext = (ev: OrbScheduleEvent) => nextEvent && ev.name === nextEvent.name;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={sessionName} style={{ marginBottom: sessionNames.length > 1 ? 8 : 0 }}>
|
|
|
|
|
{sessionNames.length > 1 && (
|
|
|
|
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 4, fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
{sessionName}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ display: 'flex', gap: 3, minWidth: 'max-content', flexWrap: 'nowrap' }}>
|
|
|
|
|
{displayed.map((ev: OrbScheduleEvent) => (
|
|
|
|
|
<div
|
|
|
|
|
key={ev.name}
|
|
|
|
|
title={`[${ev.session}] ${ev.label}\n${ev.et_time}${ev.done ? ' ✓' : ev.wait_secs > 0 ? ` — ${fmtCountdown(ev.wait_secs)}` : ''}`}
|
|
|
|
|
style={{
|
|
|
|
|
padding: '3px 7px',
|
|
|
|
|
borderRadius: 5,
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontFamily: 'var(--font-mono)',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
background: ev.done
|
|
|
|
|
? 'color-mix(in srgb, var(--green) 15%, transparent)'
|
|
|
|
|
: isNext(ev)
|
|
|
|
|
? 'color-mix(in srgb, var(--cyan) 20%, transparent)'
|
|
|
|
|
: ev.kind === 'orb_monitor'
|
|
|
|
|
? 'color-mix(in srgb, var(--yellow) 8%, transparent)'
|
|
|
|
|
: 'var(--bg2)',
|
|
|
|
|
color: ev.done ? 'var(--green)' : isNext(ev) ? 'var(--cyan)' : ev.kind === 'orb_monitor' ? 'color-mix(in srgb, var(--yellow) 70%, var(--text3))' : 'var(--text3)',
|
|
|
|
|
border: `1px solid ${ev.done ? 'var(--green)' : isNext(ev) ? 'var(--cyan)' : ev.kind === 'orb_monitor' ? 'color-mix(in srgb, var(--yellow) 30%, transparent)' : 'var(--border)'}`,
|
|
|
|
|
opacity: ev.kind === 'orb_monitor' && !ev.done && !isNext(ev) ? 0.65 : 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{ev.et_time.replace(' ET', '')}
|
|
|
|
|
{ev.done && ' ✓'}
|
|
|
|
|
{ev.kind === 'breakout' && ev === breakouts[0] && breakouts.length > 1 && (
|
|
|
|
|
<span style={{ color: 'var(--text3)', marginLeft: 2 }}>
|
|
|
|
|
~{breakouts[breakouts.length - 1]?.et_time?.replace(' ET', '')}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return sessionNames.length > 0 ? sessionNames.map(renderSessionRow) : (
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>(스케줄 없음)</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
{nextEvent && (
|
|
|
|
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text2)' }}>
|
|
|
|
|
다음: <strong style={{ color: 'var(--cyan)' }}>{nextEvent.label}</strong>
|
|
|
|
|
{' — '}<Countdown seconds={nextEvent.wait_secs} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Log */}
|
|
|
|
|
<div>
|
|
|
|
|
<button
|
|
|
|
|
style={{ ...btn('ghost'), width: '100%', justifyContent: 'space-between', borderRadius: 0, padding: '8px 20px' }}
|
|
|
|
|
onClick={() => setShowLog(v => !v)}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ fontSize: 12 }}>
|
|
|
|
|
스케줄러 로그
|
|
|
|
|
{logLines.length > 0 && (
|
|
|
|
|
<span style={{ marginLeft: 8, color: 'var(--text3)', fontWeight: 400 }}>
|
|
|
|
|
({logLines.length}줄)
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronDown size={13} style={{ transform: showLog ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }} />
|
|
|
|
|
</button>
|
|
|
|
|
{showLog && (
|
|
|
|
|
<pre ref={logRef} style={{
|
|
|
|
|
margin: 0, padding: '10px 20px', maxHeight: 480, overflowY: 'auto',
|
|
|
|
|
fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text2)',
|
|
|
|
|
background: 'var(--bg2)', whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
|
|
|
|
}}>
|
|
|
|
|
{logLines.length > 0 ? logLines.join('\n') : '(로그 없음)'}
|
|
|
|
|
</pre>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmtCountdown(seconds: number): string {
|
|
|
|
|
if (seconds <= 0) return 'now';
|
|
|
|
|
const h = Math.floor(seconds / 3600);
|
|
|
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
|
|
|
const s = Math.floor(seconds % 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 Countdown({ seconds }: { seconds: number }) {
|
|
|
|
|
const [remaining, setRemaining] = useState(seconds);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setRemaining(seconds);
|
|
|
|
|
const iv = setInterval(() => setRemaining(r => Math.max(0, r - 1)), 1000);
|
|
|
|
|
return () => clearInterval(iv);
|
|
|
|
|
}, [seconds]);
|
|
|
|
|
return <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--cyan)' }}>{fmtCountdown(remaining)}</span>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Session Card ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function SessionCard({
|
|
|
|
|
session,
|
|
|
|
|
selected,
|
|
|
|
|
onSelect,
|
|
|
|
|
onDelete,
|
|
|
|
|
onPause,
|
|
|
|
|
onResume,
|
|
|
|
|
onRunToday,
|
|
|
|
|
runTodayPending,
|
|
|
|
|
}: {
|
|
|
|
|
session: OrbSession;
|
|
|
|
|
selected: boolean;
|
|
|
|
|
onSelect: () => void;
|
|
|
|
|
onDelete: () => void;
|
|
|
|
|
onPause: () => void;
|
|
|
|
|
onResume: () => void;
|
|
|
|
|
onRunToday: () => void;
|
|
|
|
|
runTodayPending: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const totalReturn = session.total_return_pct ?? 0;
|
|
|
|
|
const equity = session.current_equity ?? session.initial_equity;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
onClick={onSelect}
|
|
|
|
|
style={{
|
|
|
|
|
...card,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
border: `1px solid ${selected ? 'var(--cyan)' : 'var(--border)'}`,
|
|
|
|
|
background: selected ? 'color-mix(in srgb, var(--cyan) 5%, var(--bg1))' : 'var(--bg1)',
|
|
|
|
|
transition: 'all 0.12s',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ padding: '14px 16px' }}>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text1)', marginBottom: 2 }}>
|
|
|
|
|
{session.session_name}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
{session.status === 'active' ? '● active' : session.status === 'paused' ? '⏸ paused' : session.status}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ textAlign: 'right' }}>
|
|
|
|
|
<div style={{ fontSize: 18, fontWeight: 700, color: totalReturn >= 0 ? 'var(--green)' : 'var(--red)', fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
{fmtPct(totalReturn)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>${fmt(equity)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
|
|
|
{session.config_path.split('/').pop()}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* "지금 시작" — only when this session hasn't run today */}
|
|
|
|
|
{session.ran_today === false && (
|
|
|
|
|
<div style={{ marginBottom: 8 }}>
|
|
|
|
|
<button
|
|
|
|
|
style={{
|
|
|
|
|
...btn('outline'),
|
|
|
|
|
width: '100%', justifyContent: 'center',
|
|
|
|
|
borderColor: 'var(--cyan)', color: 'var(--cyan)',
|
|
|
|
|
background: 'color-mix(in srgb, var(--cyan) 6%, transparent)',
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
}}
|
|
|
|
|
onClick={e => { e.stopPropagation(); onRunToday(); }}
|
|
|
|
|
disabled={runTodayPending}
|
|
|
|
|
title="오늘 ORB 감지를 지금 즉시 실행 (스케줄러 실행 중이어야 함)"
|
|
|
|
|
>
|
|
|
|
|
<PlayCircle size={13} />
|
|
|
|
|
{runTodayPending ? '감지 중...' : '지금 시작 (현재 가격 기준)'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
|
|
|
|
{session.status === 'active' ? (
|
|
|
|
|
<button style={btn('ghost')} onClick={onPause} title="일시 정지">
|
|
|
|
|
<Pause size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<button style={btn('ghost')} onClick={onResume} title="재개">
|
|
|
|
|
<RotateCcw size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button style={btn('danger')} onClick={onDelete} title="세션 삭제">
|
|
|
|
|
<Trash2 size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Create Session Modal ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function StrategyBadge({ label, value }: { label: string; value: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<span style={{
|
|
|
|
|
display: 'inline-block', padding: '2px 7px', borderRadius: 5, background: 'var(--bg2)',
|
|
|
|
|
fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text3)',
|
|
|
|
|
border: '1px solid var(--border)', whiteSpace: 'nowrap',
|
|
|
|
|
}}>
|
|
|
|
|
{label}: <span style={{ color: 'var(--text2)' }}>{value}</span>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CreateSessionModal({ onClose }: { onClose: () => void }) {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const [name, setName] = useState('');
|
|
|
|
|
const [selectedSlug, setSelectedSlug] = useState('');
|
|
|
|
|
const [capital, setCapital] = useState('10000');
|
|
|
|
|
|
|
|
|
|
const { data: stratData, isLoading: straLoading } = useQuery({
|
|
|
|
|
queryKey: ['orb-strategies'],
|
|
|
|
|
queryFn: orbTradingApi.strategies,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const strategies: OrbStrategyInfo[] = stratData?.strategies ?? [];
|
|
|
|
|
|
|
|
|
|
// Auto-select first strategy when list loads
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedSlug && strategies.length > 0) {
|
|
|
|
|
setSelectedSlug(strategies[0].slug);
|
|
|
|
|
}
|
|
|
|
|
}, [strategies, selectedSlug]);
|
|
|
|
|
|
|
|
|
|
const selected = strategies.find(s => s.slug === selectedSlug) ?? null;
|
|
|
|
|
|
|
|
|
|
const createMut = useMutation({
|
|
|
|
|
mutationFn: () => {
|
|
|
|
|
if (!selected) throw new Error('전략을 선택하세요');
|
|
|
|
|
return orbTradingApi.createSession(name, selected.config_path, parseFloat(capital));
|
|
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['orb-sessions'] });
|
|
|
|
|
onClose();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{
|
|
|
|
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 50,
|
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
}} onClick={onClose}>
|
|
|
|
|
<div style={{ ...card, width: 520, padding: 24, maxHeight: '90vh', overflowY: 'auto' }}
|
|
|
|
|
onClick={e => e.stopPropagation()}>
|
|
|
|
|
<h3 style={{ margin: '0 0 18px', fontSize: 16, color: 'var(--text1)' }}>ORB 세션 생성</h3>
|
|
|
|
|
|
|
|
|
|
{/* Session name */}
|
|
|
|
|
<div style={{ marginBottom: 14 }}>
|
|
|
|
|
<label style={{ fontSize: 12, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>세션 이름</label>
|
|
|
|
|
<input style={inputStyle} value={name} onChange={e => setName(e.target.value)} placeholder="예: v55_live" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Strategy selector */}
|
|
|
|
|
<div style={{ marginBottom: 6 }}>
|
|
|
|
|
<label style={{ fontSize: 12, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>전략 선택</label>
|
|
|
|
|
{straLoading ? (
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text3)', padding: '8px 0' }}>전략 목록 로딩 중...</div>
|
|
|
|
|
) : (
|
|
|
|
|
<select
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
value={selectedSlug}
|
|
|
|
|
onChange={e => setSelectedSlug(e.target.value)}
|
|
|
|
|
>
|
|
|
|
|
<option value="">— 전략 선택 —</option>
|
|
|
|
|
{strategies.map(s => (
|
|
|
|
|
<option key={s.slug} value={s.slug}>
|
|
|
|
|
{s.name}{s.builtin ? '' : ' ✦'}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Strategy detail card */}
|
|
|
|
|
{selected && (
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 8,
|
|
|
|
|
padding: '10px 14px', marginBottom: 14, fontSize: 12,
|
|
|
|
|
}}>
|
|
|
|
|
{selected.description && (
|
|
|
|
|
<div style={{ color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
|
|
|
|
{selected.description}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
|
|
|
|
{selected.orb_minutes != null && (
|
|
|
|
|
<StrategyBadge label="ORB" value={`${selected.orb_minutes}분`} />
|
|
|
|
|
)}
|
|
|
|
|
{selected.sim_bar_minutes != null && (
|
|
|
|
|
<StrategyBadge label="Bar" value={`${selected.sim_bar_minutes}분`} />
|
|
|
|
|
)}
|
|
|
|
|
{selected.entry_direction && (
|
|
|
|
|
<StrategyBadge
|
|
|
|
|
label="방향"
|
|
|
|
|
value={selected.entry_direction === 'both' ? 'Long+Short' : selected.entry_direction === 'long_only' ? 'Long only' : selected.entry_direction}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{selected.risk_per_trade_pct != null && (
|
|
|
|
|
<StrategyBadge label="리스크" value={`${(selected.risk_per_trade_pct * 100).toFixed(2)}%`} />
|
|
|
|
|
)}
|
|
|
|
|
{selected.atr_stop_multiplier != null && (
|
|
|
|
|
<StrategyBadge label="ATR스톱" value={`×${selected.atr_stop_multiplier}`} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ marginTop: 7, fontSize: 10, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
{selected.config_path}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Capital */}
|
|
|
|
|
<div style={{ marginBottom: 20 }}>
|
|
|
|
|
<label style={{ fontSize: 12, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
|
|
|
|
초기 자본 (USD) — 복리 모드 강제 적용
|
|
|
|
|
</label>
|
|
|
|
|
<input style={inputStyle} type="number" value={capital} onChange={e => setCapital(e.target.value)} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{createMut.isError && (
|
|
|
|
|
<div style={{ color: 'var(--red)', fontSize: 12, marginBottom: 12 }}>
|
|
|
|
|
{(createMut.error as Error).message}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
|
|
|
<button style={btn('ghost')} onClick={onClose}>취소</button>
|
|
|
|
|
<button
|
|
|
|
|
style={btn('primary')}
|
|
|
|
|
onClick={() => createMut.mutate()}
|
|
|
|
|
disabled={!name || !selected || createMut.isPending}
|
|
|
|
|
>
|
|
|
|
|
{createMut.isPending ? '생성 중...' : '세션 생성'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Session Detail ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function PositionsPanel({ sessionId }: { sessionId: string }) {
|
|
|
|
|
const { data, isLoading } = useQuery({
|
|
|
|
|
queryKey: ['orb-positions', sessionId],
|
|
|
|
|
queryFn: () => orbTradingApi.positions(sessionId),
|
|
|
|
|
refetchInterval: 30000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isLoading) return <Loading />;
|
|
|
|
|
const positions = data?.positions ?? [];
|
|
|
|
|
if (positions.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
|
|
|
|
|
오늘 오픈 포지션 없음 ({data?.date ?? '—'})
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
|
|
|
|
|
{['티커', '방향', '진입가', '수량', '현재가', '변동%', '현재 스톱', 'R', '미실현 P&L', '트레일'].map(h => (
|
|
|
|
|
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500, color: 'var(--text3)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{h}</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{positions.map(pos => (
|
|
|
|
|
<tr key={pos.ticker} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text1)' }}>{pos.ticker}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px' }}>
|
|
|
|
|
<span style={{
|
|
|
|
|
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
|
|
|
|
color: pos.direction === 'long' ? 'var(--green)' : 'var(--red)',
|
|
|
|
|
}}>
|
|
|
|
|
{pos.direction === 'long' ? <TrendingUp size={11} /> : <TrendingDown size={11} />}
|
|
|
|
|
{pos.direction.toUpperCase()}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(pos.entry_price)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{pos.shares}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(pos.current_price)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: pos.change_pct == null ? 'var(--text3)' : pos.change_pct >= 0 ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{pos.change_pct == null ? '—' : `${pos.change_pct >= 0 ? '+' : ''}${pos.change_pct.toFixed(2)}%`}
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(pos.current_stop)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: pos.r_multiple >= 0 ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{pos.r_multiple >= 0 ? '+' : ''}{fmt(pos.r_multiple)}R
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: pos.unrealized_pnl >= 0 ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{fmtDollars(pos.unrealized_pnl)}
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', color: pos.trailing_active ? 'var(--cyan)' : 'var(--text3)' }}>
|
|
|
|
|
{pos.trailing_active ? '● 활성' : '—'}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CandidatesPanel({ sessionId }: { sessionId: string }) {
|
|
|
|
|
const { data } = useQuery({
|
|
|
|
|
queryKey: ['orb-candidates', sessionId],
|
|
|
|
|
queryFn: () => orbTradingApi.candidates(sessionId),
|
|
|
|
|
refetchInterval: 60000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const candidates = data?.candidates ?? [];
|
|
|
|
|
if (candidates.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
|
|
|
|
|
오늘 ORB 후보 없음
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
|
|
|
|
|
{['티커', '방향', '돌파 레벨', 'ORB High', 'ORB Low', 'ATR', 'RVOL', 'Gap%', '점수', '상태'].map(h => (
|
|
|
|
|
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500, color: 'var(--text3)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{h}</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{candidates.map((c: any) => (
|
|
|
|
|
<tr key={c.ticker} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text1)' }}>{c.ticker}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px' }}>
|
|
|
|
|
<span style={{ fontSize: 11, fontWeight: 600, color: c.direction === 'bullish' ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{c.direction === 'bullish' ? '▲ LONG' : '▼ SHORT'}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', fontWeight: 600 }}>${fmt(c.breakout_level)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(c.orb_high)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(c.orb_low)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(c.atr, 3)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{fmt(c.rvol, 1)}x</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: c.gap_pct >= 0 ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{fmtPct(c.gap_pct * 100)}
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{fmt(c.composite_score, 3)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px' }}>
|
|
|
|
|
<span style={{
|
|
|
|
|
fontSize: 11, fontWeight: 500,
|
|
|
|
|
color: c.status === 'filled' ? 'var(--green)' : c.status === 'timeout' ? 'var(--red)' : c.status === 'pending' ? 'var(--cyan)' : 'var(--text3)',
|
|
|
|
|
}}>
|
|
|
|
|
{c.status}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TradesPanel({ sessionId }: { sessionId: string }) {
|
|
|
|
|
const { data } = useQuery({
|
|
|
|
|
queryKey: ['orb-trades', sessionId],
|
|
|
|
|
queryFn: () => orbTradingApi.trades(sessionId, 100),
|
|
|
|
|
refetchInterval: 60000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const trades = data?.trades ?? [];
|
|
|
|
|
if (trades.length === 0) {
|
|
|
|
|
return <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>트레이드 이력 없음</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
|
|
|
|
|
{['날짜', '티커', '방향', '진입가', '청산가', '수량', 'P&L', 'R', '사유'].map(h => (
|
|
|
|
|
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500, color: 'var(--text3)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{h}</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{trades.map((t: any) => (
|
|
|
|
|
<tr key={t.trade_id} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.date}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text1)' }}>{t.ticker}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px' }}>
|
|
|
|
|
<span style={{ fontSize: 11, fontWeight: 600, color: t.direction === 'long' ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{t.direction.toUpperCase()}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(t.entry_price)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(t.exit_price)}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{t.shares}</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', fontWeight: 600, color: t.pnl >= 0 ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{fmtDollars(t.pnl)}
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: t.r_multiple >= 0 ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{t.r_multiple >= 0 ? '+' : ''}{fmt(t.r_multiple)}R
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ padding: '8px 12px' }}>
|
|
|
|
|
<span style={{
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
color: t.exit_reason === 'close' ? 'var(--text3)' : t.exit_reason === 'stop_loss' ? 'var(--red)' : t.exit_reason === 'trailing_stop' ? 'var(--cyan)' : 'var(--orange)',
|
|
|
|
|
}}>
|
|
|
|
|
{t.exit_reason}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EquityPanel({ sessionId, initialEquity }: { sessionId: string; initialEquity: number }) {
|
|
|
|
|
const { data } = useQuery({
|
|
|
|
|
queryKey: ['orb-equity', sessionId],
|
|
|
|
|
queryFn: () => orbTradingApi.equity(sessionId),
|
|
|
|
|
refetchInterval: 60000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const snapshots = data?.snapshots ?? [];
|
|
|
|
|
if (snapshots.length === 0) {
|
|
|
|
|
return <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>에쿼티 데이터 없음</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chartData = [
|
|
|
|
|
{ date: '시작', equity: initialEquity },
|
|
|
|
|
...snapshots.map((s: any) => ({ date: s.date, equity: s.equity, pnl: s.daily_pnl })),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const latest = snapshots[snapshots.length - 1];
|
|
|
|
|
const totalReturn = ((latest.equity - initialEquity) / initialEquity) * 100;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: '16px 20px' }}>
|
|
|
|
|
<div style={{ display: 'flex', gap: 20, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}>총 수익률</div>
|
|
|
|
|
<div style={{ fontSize: 22, fontWeight: 700, fontFamily: 'var(--font-mono)', color: totalReturn >= 0 ? 'var(--green)' : 'var(--red)' }}>
|
|
|
|
|
{fmtPct(totalReturn)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}>현재 자산</div>
|
|
|
|
|
<div style={{ fontSize: 22, fontWeight: 700, fontFamily: 'var(--font-mono)', color: 'var(--text1)' }}>
|
|
|
|
|
${fmt(latest.equity)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}>최대 낙폭</div>
|
|
|
|
|
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: 'var(--font-mono)', color: 'var(--red)' }}>
|
|
|
|
|
{fmt(Math.min(...snapshots.map((s: any) => s.drawdown_pct)))}%
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}>총 트레이드</div>
|
|
|
|
|
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: 'var(--font-mono)', color: 'var(--text1)' }}>
|
|
|
|
|
{snapshots.reduce((a: number, s: any) => a + s.trades_taken, 0)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
|
|
|
<LineChart data={chartData}>
|
|
|
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
|
|
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: 'var(--text3)' }} interval="preserveStartEnd" />
|
|
|
|
|
<YAxis tick={{ fontSize: 10, fill: 'var(--text3)' }} domain={['auto', 'auto']} tickFormatter={v => `$${v.toFixed(0)}`} />
|
|
|
|
|
<Tooltip
|
|
|
|
|
formatter={(v) => [`$${(v as number).toFixed(2)}`, '에쿼티']}
|
|
|
|
|
contentStyle={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }}
|
|
|
|
|
/>
|
|
|
|
|
<ReferenceLine y={initialEquity} stroke="var(--text3)" strokeDasharray="4 2" />
|
|
|
|
|
<Line dataKey="equity" stroke="var(--cyan)" strokeWidth={2} dot={false} />
|
|
|
|
|
</LineChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
type TabKey = 'positions' | 'candidates' | 'trades' | 'equity';
|
|
|
|
|
|
|
|
|
|
export function OrbTradingPage() {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
|
const [activeTab, setActiveTab] = useState<TabKey>('positions');
|
|
|
|
|
|
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
|
|
|
queryKey: ['orb-sessions'],
|
|
|
|
|
queryFn: orbTradingApi.sessions,
|
|
|
|
|
refetchInterval: 60000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const pauseMut = useMutation({
|
|
|
|
|
mutationFn: orbTradingApi.pauseSession,
|
|
|
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-sessions'] }),
|
|
|
|
|
});
|
|
|
|
|
const resumeMut = useMutation({
|
|
|
|
|
mutationFn: orbTradingApi.resumeSession,
|
|
|
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-sessions'] }),
|
|
|
|
|
});
|
|
|
|
|
const runTodayMut = useMutation({
|
|
|
|
|
mutationFn: orbTradingApi.runToday,
|
|
|
|
|
onSuccess: (result) => {
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['orb-sessions'] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['orb-auto-status'] });
|
|
|
|
|
alert(`${result.session_name}: ${result.note}`);
|
|
|
|
|
},
|
|
|
|
|
onError: (e: Error) => alert(`지금 시작 실패: ${e.message}`),
|
|
|
|
|
});
|
|
|
|
|
const deleteMut = useMutation({
|
|
|
|
|
mutationFn: orbTradingApi.closeSession,
|
|
|
|
|
onSuccess: (result, id) => {
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['orb-sessions'] });
|
|
|
|
|
if (selectedId === id) setSelectedId(null);
|
|
|
|
|
if (result.close_errors && result.close_errors.length > 0) {
|
|
|
|
|
alert(
|
|
|
|
|
`세션 삭제 완료 (포지션 ${result.positions_closed}개 청산)\n\n` +
|
|
|
|
|
`청산 실패 (수동 처리 필요):\n${result.close_errors.join('\n')}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onError: (e: Error) => alert(`세션 삭제 실패: ${e.message}`),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sessions = data?.sessions ?? [];
|
|
|
|
|
const selectedSession = sessions.find(s => s.session_id === selectedId || s.session_name === selectedId);
|
|
|
|
|
|
|
|
|
|
// Collect active session names for scheduler
|
|
|
|
|
const activeSessions = sessions.filter(s => s.status === 'active').map(s => s.session_name);
|
|
|
|
|
|
|
|
|
|
if (isLoading) return <div style={{ padding: 32 }}><Loading /></div>;
|
|
|
|
|
if (error) return <div style={{ padding: 32 }}><ErrorState error={error} /></div>;
|
|
|
|
|
|
|
|
|
|
const tabs: { key: TabKey; label: string }[] = [
|
|
|
|
|
{ key: 'positions', label: '포지션' },
|
|
|
|
|
{ key: 'candidates', label: 'ORB 후보' },
|
|
|
|
|
{ key: 'trades', label: '트레이드' },
|
|
|
|
|
{ key: 'equity', label: '에쿼티' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: '24px 28px', maxWidth: 1300, margin: '0 auto' }}>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
|
|
|
<Zap size={20} color="var(--cyan)" />
|
|
|
|
|
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--text1)' }}>
|
|
|
|
|
ORB 인트라데이 트레이딩
|
|
|
|
|
</h1>
|
|
|
|
|
</div>
|
|
|
|
|
<button style={btn('primary')} onClick={() => setShowCreate(true)}>
|
|
|
|
|
<Plus size={14} /> 세션 생성
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Auto Scheduler */}
|
|
|
|
|
<SchedulerPanel selectedSessions={activeSessions} />
|
|
|
|
|
|
|
|
|
|
{/* Sessions grid */}
|
|
|
|
|
<div style={{ marginBottom: 24 }}>
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
|
|
|
|
세션 ({sessions.length})
|
|
|
|
|
</div>
|
|
|
|
|
{sessions.length === 0 ? (
|
|
|
|
|
<div style={{ ...card, padding: '28px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
|
|
|
|
|
세션이 없습니다. 위의 "세션 생성" 버튼으로 시작하세요.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
|
|
|
|
|
{sessions.map(session => (
|
|
|
|
|
<SessionCard
|
|
|
|
|
key={session.session_id}
|
|
|
|
|
session={session}
|
|
|
|
|
selected={selectedId === session.session_id || selectedId === session.session_name}
|
|
|
|
|
onSelect={() => setSelectedId(session.session_id)}
|
|
|
|
|
onDelete={() => {
|
|
|
|
|
if (window.confirm(`"${session.session_name}" 세션을 삭제하시겠습니까? 오픈 포지션이 청산됩니다.`)) {
|
|
|
|
|
deleteMut.mutate(session.session_id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onPause={() => pauseMut.mutate(session.session_id)}
|
|
|
|
|
onResume={() => resumeMut.mutate(session.session_id)}
|
|
|
|
|
onRunToday={() => {
|
|
|
|
|
if (window.confirm(`"${session.session_name}" 세션을 현재 가격 기준으로 지금 시작하시겠습니까?\n(스케줄러가 실행 중이어야 합니다)`)) {
|
|
|
|
|
runTodayMut.mutate(session.session_id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
runTodayPending={runTodayMut.isPending && runTodayMut.variables === session.session_id}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Session detail */}
|
|
|
|
|
{selectedSession && (
|
|
|
|
|
<div style={card}>
|
|
|
|
|
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
|
|
|
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text1)' }}>
|
|
|
|
|
{selectedSession.session_name}
|
|
|
|
|
</span>
|
|
|
|
|
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
${fmt(selectedSession.initial_equity)} → ${fmt(selectedSession.current_equity ?? selectedSession.initial_equity)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Tabs */}
|
|
|
|
|
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', background: 'var(--bg2)' }}>
|
|
|
|
|
{tabs.map(tab => (
|
|
|
|
|
<button
|
|
|
|
|
key={tab.key}
|
|
|
|
|
onClick={() => setActiveTab(tab.key)}
|
|
|
|
|
style={{
|
|
|
|
|
padding: '10px 18px', fontSize: 13, fontWeight: 500, cursor: 'pointer',
|
|
|
|
|
background: 'none', border: 'none', borderBottom: `2px solid ${activeTab === tab.key ? 'var(--cyan)' : 'transparent'}`,
|
|
|
|
|
color: activeTab === tab.key ? 'var(--cyan)' : 'var(--text3)',
|
|
|
|
|
transition: 'all 0.12s',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{tab.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{activeTab === 'positions' && <PositionsPanel sessionId={selectedSession.session_id} />}
|
|
|
|
|
{activeTab === 'candidates' && <CandidatesPanel sessionId={selectedSession.session_id} />}
|
|
|
|
|
{activeTab === 'trades' && <TradesPanel sessionId={selectedSession.session_id} />}
|
|
|
|
|
{activeTab === 'equity' && (
|
|
|
|
|
<EquityPanel sessionId={selectedSession.session_id} initialEquity={selectedSession.initial_equity} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showCreate && <CreateSessionModal onClose={() => setShowCreate(false)} />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|