백테스팅 적용
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
@@ -1,27 +1,40 @@
|
||||
/**
|
||||
* BacktestHistoryPage — 전략편집기와 동일한 3열 + 하단 패널 레이아웃
|
||||
* BacktestHistoryPage — Golden Analysis Command Center (참조 UI 3열)
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
loadBacktestResults,
|
||||
deleteBacktestResult,
|
||||
loadPaperTrades,
|
||||
loadPaperSummary,
|
||||
loadStrategies,
|
||||
type BacktestResultRecord,
|
||||
type BacktestAnalysis,
|
||||
type BacktestSignal,
|
||||
} from '../utils/backendApi';
|
||||
import type { Theme } from '../types';
|
||||
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
||||
import { BacktestResultDashboard } from './backtest/BacktestResultDashboard';
|
||||
import BacktestSparkline from './backtest/BacktestSparkline';
|
||||
import BuilderPageShell from './layout/BuilderPageShell';
|
||||
import { normalizeEpochSec, normalizeEpochSecOptional } from '../utils/backtestUiUtils';
|
||||
import BacktestExecutionList, { type ExecutionListTab } from './backtest/BacktestExecutionList';
|
||||
import BacktestAnalysisChart from './backtest/BacktestAnalysisChart';
|
||||
import BacktestKpiPanel from './backtest/BacktestKpiPanel';
|
||||
import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard';
|
||||
import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
|
||||
import {
|
||||
readStoredSize,
|
||||
storeSize,
|
||||
usePanelResize,
|
||||
} from './strategyEditor/usePanelResize';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
|
||||
const pct = (v: number) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%';
|
||||
const LEFT_KEY = 'btd-left-width';
|
||||
const RIGHT_KEY = 'btd-right-width';
|
||||
const LEFT_DEFAULT = 380;
|
||||
const RIGHT_DEFAULT = 440;
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
function readMinWidth(key: string, fallback: number): number {
|
||||
return Math.max(readStoredSize(key, fallback), fallback);
|
||||
}
|
||||
|
||||
function parseDetail(r: BacktestResultRecord) {
|
||||
let analysis: BacktestAnalysis | null = null;
|
||||
@@ -33,186 +46,252 @@ function parseDetail(r: BacktestResultRecord) {
|
||||
return { analysis, signals };
|
||||
}
|
||||
|
||||
interface TimelineItemProps {
|
||||
r: BacktestResultRecord;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function TimelineItem({ r, active, onClick }: TimelineItemProps) {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const positive = ret >= 0;
|
||||
|
||||
const sparkCurve = useMemo(() => {
|
||||
const { analysis, signals } = parseDetail(r);
|
||||
const cap = analysis?.initialCapital ?? 10_000_000;
|
||||
return buildEquityFromSignals(signals, cap, r.symbol.replace(/^KRW-/, '')).curve;
|
||||
}, [r]);
|
||||
|
||||
return (
|
||||
<button type="button" className={`btd-timeline-item${active ? ' btd-timeline-item--active' : ''}`} onClick={onClick}>
|
||||
<span className="btd-timeline-node" />
|
||||
<div className="btd-timeline-card">
|
||||
<div className="btd-timeline-top">
|
||||
<span className="btd-timeline-name">{r.strategyName || '전략 없음'}</span>
|
||||
<BacktestSparkline curve={sparkCurve} positive={positive} />
|
||||
</div>
|
||||
<span className="btd-timeline-date">{fmtDate(r.createdAt)}</span>
|
||||
<span className={`btd-timeline-roi${positive ? ' up' : ' down'}`}>
|
||||
ROI {pct(ret)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||
const [tab, setTab] = useState<ExecutionListTab>('backtest');
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
||||
const [liveItems, setLiveItems] = useState<LiveExecutionItem[]>([]);
|
||||
const [selectedBacktest, setSelectedBacktest] = useState<BacktestResultRecord | null>(null);
|
||||
const [selectedLive, setSelectedLive] = useState<LiveExecutionItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||||
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
||||
const leftRef = useRef(leftWidth);
|
||||
const rightRef = useRef(rightWidth);
|
||||
leftRef.current = leftWidth;
|
||||
rightRef.current = rightWidth;
|
||||
|
||||
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v));
|
||||
const onRightSplit = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const startX = e.clientX;
|
||||
const start = rightRef.current;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.classList.add('btd-splitter--active');
|
||||
const splitter = e.currentTarget;
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
setRightWidth(Math.min(600, Math.max(360, start - delta)));
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
splitter.releasePointerCapture(ev.pointerId);
|
||||
splitter.classList.remove('btd-splitter--active');
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
storeSize(RIGHT_KEY, rightRef.current);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const list = await loadBacktestResults();
|
||||
const [list, trades, summary, strategies] = await Promise.all([
|
||||
loadBacktestResults(),
|
||||
loadPaperTrades(),
|
||||
loadPaperSummary(),
|
||||
loadStrategies(),
|
||||
]);
|
||||
const strategyNames = Object.fromEntries(strategies.map(s => [s.id, s.name]));
|
||||
const live = buildLiveExecutionItems(trades, summary, strategyNames);
|
||||
setRecords(list);
|
||||
setSelected(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
|
||||
setLiveItems(live);
|
||||
setSelectedBacktest(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
|
||||
setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null));
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void fetchList(); }, [fetchList]);
|
||||
useEffect(() => { void fetchAll(); }, [fetchAll]);
|
||||
|
||||
const deleteAll = useCallback(async () => {
|
||||
if (records.length === 0) return;
|
||||
if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||||
await Promise.all(records.map(r => deleteBacktestResult(r.id)));
|
||||
setRecords([]);
|
||||
setSelected(null);
|
||||
}, [records]);
|
||||
await fetchAll();
|
||||
}, [records, fetchAll]);
|
||||
|
||||
const selectedDetail = selected ? parseDetail(selected) : null;
|
||||
const backtestDetail = selectedBacktest ? parseDetail(selectedBacktest) : null;
|
||||
|
||||
const signalStats = useMemo(() => {
|
||||
const sigs = selectedDetail?.signals ?? [];
|
||||
if (!sigs.length) return null;
|
||||
const buy = sigs.filter(s => s.type === 'BUY' || s.type === 'SHORT_EXIT').length;
|
||||
const sell = sigs.filter(s => s.type === 'SELL' || s.type === 'SHORT_ENTRY' || s.type === 'PARTIAL_SELL').length;
|
||||
return { buy, sell, total: sigs.length };
|
||||
}, [selectedDetail]);
|
||||
const activeSignals = useMemo((): BacktestSignal[] => {
|
||||
if (tab === 'backtest') return backtestDetail?.signals ?? [];
|
||||
if (!selectedLive) return [];
|
||||
return paperTradesToSignals(selectedLive.trades);
|
||||
}, [tab, backtestDetail, selectedLive]);
|
||||
|
||||
const leftContent = records.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="btd-timeline">
|
||||
<span className="btd-timeline-line" />
|
||||
{records.map(r => (
|
||||
<TimelineItem
|
||||
key={r.id}
|
||||
r={r}
|
||||
active={selected?.id === r.id}
|
||||
onClick={() => setSelected(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const equityData = useMemo(() => {
|
||||
if (tab === 'backtest' && backtestDetail?.analysis) {
|
||||
return buildEquityFromSignals(
|
||||
backtestDetail.signals,
|
||||
backtestDetail.analysis.initialCapital,
|
||||
selectedBacktest!.symbol,
|
||||
);
|
||||
}
|
||||
if (tab === 'live' && selectedLive) {
|
||||
return buildEquityFromSignals(paperTradesToSignals(selectedLive.trades), 10_000_000, selectedLive.symbol);
|
||||
}
|
||||
return { curve: [], markers: [], trades: [] };
|
||||
}, [tab, backtestDetail, selectedBacktest, selectedLive]);
|
||||
|
||||
const chartProps = useMemo(() => {
|
||||
if (tab === 'backtest' && selectedBacktest) {
|
||||
const signals = backtestDetail?.signals ?? [];
|
||||
const signalMax = signals.reduce((m, s) => Math.max(m, s.time ?? 0), 0);
|
||||
const toSec = normalizeEpochSec(selectedBacktest.toTime) || signalMax || Math.floor(Date.now() / 1000);
|
||||
const fromSec = normalizeEpochSecOptional(selectedBacktest.fromTime);
|
||||
return {
|
||||
symbol: selectedBacktest.symbol,
|
||||
timeframe: selectedBacktest.timeframe,
|
||||
toTimeSec: toSec,
|
||||
fromTimeSec: fromSec > 0 ? fromSec : undefined,
|
||||
barCount: selectedBacktest.barCount || 300,
|
||||
strategyId: selectedBacktest.strategyId,
|
||||
};
|
||||
}
|
||||
if (tab === 'live' && selectedLive?.trades.length) {
|
||||
const last = selectedLive.trades[selectedLive.trades.length - 1];
|
||||
const ts = last.createdAt ? Math.floor(new Date(last.createdAt).getTime() / 1000) : Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
symbol: selectedLive.symbol,
|
||||
timeframe: '1h',
|
||||
toTimeSec: ts,
|
||||
fromTimeSec: undefined as number | undefined,
|
||||
barCount: 300,
|
||||
strategyId: selectedLive.trades.find(t => t.strategyId)?.strategyId ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [tab, selectedBacktest, selectedLive, backtestDetail]);
|
||||
|
||||
const centerTitle = tab === 'backtest' && selectedBacktest
|
||||
? `${selectedBacktest.strategyName || '전략'} · ${selectedBacktest.symbol.replace(/^KRW-/, '')} · ${selectedBacktest.timeframe}`
|
||||
: tab === 'live' && selectedLive
|
||||
? `${selectedLive.strategyLabel} · ${selectedLive.symbol.replace(/^KRW-/, '')} · ${selectedLive.sourceLabel}`
|
||||
: '';
|
||||
|
||||
const footerTrades = equityData.trades;
|
||||
|
||||
const showRightDetail = tab === 'backtest'
|
||||
? backtestDetail?.analysis != null
|
||||
: selectedLive != null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`btd-page se-page se-page--${theme}`}>
|
||||
<header className="btd-header">
|
||||
<div className="btd-header-brand">
|
||||
<h1 className="btd-header-title">백테스팅</h1>
|
||||
<span className="btd-header-sub">Golden Analysis Command Center</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="btd-loading">분석 데이터 로딩…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BuilderPageShell
|
||||
theme={theme}
|
||||
title="백테스팅"
|
||||
subtitle="Backtest History"
|
||||
loading={loading}
|
||||
loadingText="백테스팅 이력 로딩…"
|
||||
leftStorageKey="btd-left-width"
|
||||
footerStorageKey="btd-footer-height"
|
||||
headerActions={(
|
||||
<>
|
||||
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void fetchList()}>새로고침</button>
|
||||
<button type="button" className="bps-btn bps-btn--danger" disabled={records.length === 0} onClick={() => void deleteAll()}>전체 삭제</button>
|
||||
</>
|
||||
)}
|
||||
leftTitle="실행 이력"
|
||||
left={leftContent}
|
||||
centerHead={selected ? (
|
||||
<span className="bps-center-head-title">{selected.strategyName || '전략 없음'} · {selected.symbol.replace(/^KRW-/, '')}</span>
|
||||
) : undefined}
|
||||
center={selected && selectedDetail ? (
|
||||
<BacktestResultDashboard
|
||||
analysis={selectedDetail.analysis}
|
||||
signals={selectedDetail.signals}
|
||||
strategyName={selected.strategyName}
|
||||
symbol={selected.symbol}
|
||||
timeframe={selected.timeframe}
|
||||
barCount={selected.barCount}
|
||||
createdAt={selected.createdAt}
|
||||
/>
|
||||
) : (
|
||||
<div className="btd-empty">
|
||||
<span>📈</span>
|
||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||
<div className={`btd-page se-page se-page--${theme}`}>
|
||||
<header className="btd-header">
|
||||
<div className="btd-header-brand">
|
||||
<h1 className="btd-header-title">백테스팅</h1>
|
||||
<span className="btd-header-sub">Golden Analysis Command Center</span>
|
||||
</div>
|
||||
)}
|
||||
rightTitle="결과 요약"
|
||||
right={selected ? (
|
||||
<div className="btd-meta">
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">전략</div>
|
||||
<div className="bps-card-body btd-meta-value">{selected.strategyName || '전략 없음'}</div>
|
||||
</div>
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">종목 / 타임프레임</div>
|
||||
<div className="bps-card-body btd-meta-value">{selected.symbol.replace(/^KRW-/, '')} · {selected.timeframe}</div>
|
||||
</div>
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">실행일 / 봉 수</div>
|
||||
<div className="bps-card-body btd-meta-value">{fmtDate(selected.createdAt)} · {selected.barCount.toLocaleString()}봉</div>
|
||||
</div>
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">총 수익률</div>
|
||||
<div className={`bps-card-body btd-meta-value ${(selected.totalReturn ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{pct(selected.totalReturn ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDetail?.analysis && (
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">MDD / Sharpe</div>
|
||||
<div className="bps-card-body btd-meta-value">
|
||||
{pct(selectedDetail.analysis.maxDrawdownPct)} · {selectedDetail.analysis.sharpeRatio.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-header-actions">
|
||||
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}>새로고침</button>
|
||||
{tab === 'backtest' && (
|
||||
<button type="button" className="btd-btn btd-btn--danger" disabled={records.length === 0} onClick={() => void deleteAll()}>전체 삭제</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bps-empty">선택된 결과가 없습니다.</div>
|
||||
)}
|
||||
footerLabel={signalStats ? '시그널 요약' : undefined}
|
||||
footer={signalStats ? (
|
||||
<div className="btd-footer-stats">
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">매수 시그널</span>
|
||||
<span className="btd-footer-stat-value up">{signalStats.buy}</span>
|
||||
</header>
|
||||
|
||||
<div className="btd-body">
|
||||
<aside className="btd-sidebar" style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}>
|
||||
<BacktestExecutionList
|
||||
tab={tab}
|
||||
onTabChange={setTab}
|
||||
backtestRecords={records}
|
||||
liveItems={liveItems}
|
||||
selectedBacktestId={selectedBacktest?.id ?? null}
|
||||
selectedLiveId={selectedLive?.id ?? null}
|
||||
onSelectBacktest={r => { setSelectedBacktest(r); setTab('backtest'); }}
|
||||
onSelectLive={item => { setSelectedLive(item); setTab('live'); }}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className="btd-splitter btd-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="좌측 패널 너비"
|
||||
onPointerDown={onLeftSplit}
|
||||
/>
|
||||
|
||||
<main className="btd-main">
|
||||
{centerTitle && (
|
||||
<div className="btd-main-head">
|
||||
<h2 className="btd-main-title">{centerTitle}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="btd-main-content">
|
||||
{chartProps ? (
|
||||
<BacktestAnalysisChart
|
||||
symbol={chartProps.symbol}
|
||||
timeframe={chartProps.timeframe}
|
||||
toTimeSec={chartProps.toTimeSec}
|
||||
fromTimeSec={chartProps.fromTimeSec}
|
||||
barCount={chartProps.barCount}
|
||||
signals={activeSignals}
|
||||
strategyId={chartProps.strategyId}
|
||||
theme={theme}
|
||||
/>
|
||||
) : (
|
||||
<div className="btd-empty">
|
||||
<span>📈</span>
|
||||
<p>좌측 목록에서 분석할 실행 결과를 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">매도 시그널</span>
|
||||
<span className="btd-footer-stat-value down">{signalStats.sell}</span>
|
||||
|
||||
</main>
|
||||
|
||||
<div
|
||||
className="btd-splitter btd-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="우측 패널 너비"
|
||||
onPointerDown={onRightSplit}
|
||||
/>
|
||||
|
||||
<aside className="btd-right" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
|
||||
<div className="btd-right-stack">
|
||||
<div className={`btd-right-section btd-right-section--kpi${showRightDetail ? '' : ' btd-right-section--solo'}`}>
|
||||
<BacktestKpiPanel
|
||||
analysis={tab === 'backtest' ? (backtestDetail?.analysis ?? null) : null}
|
||||
equityCurve={equityData.curve}
|
||||
totalReturnOverride={tab === 'live' && selectedLive ? selectedLive.totalReturnPct : undefined}
|
||||
winRateOverride={tab === 'live' && selectedLive ? selectedLive.winRatePct : undefined}
|
||||
tradeCountOverride={tab === 'live' && selectedLive ? selectedLive.tradeCount : undefined}
|
||||
mddOverride={tab === 'live' && selectedLive ? selectedLive.mddPct : undefined}
|
||||
sharpeOverride={tab === 'live' && selectedLive ? selectedLive.sharpeRatio : undefined}
|
||||
profitFactorOverride={tab === 'live' && selectedLive ? selectedLive.profitFactor : undefined}
|
||||
emptyText={tab === 'backtest' ? '백테스팅 결과를 선택하세요.' : '실시간 매매 결과를 선택하세요.'}
|
||||
/>
|
||||
</div>
|
||||
{showRightDetail && (
|
||||
<div className="btd-right-section btd-right-section--trades">
|
||||
<BacktestTradeHistoryCard trades={footerTrades} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">총 시그널</span>
|
||||
<span className="btd-footer-stat-value">{signalStats.total}</span>
|
||||
</div>
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">분석 봉 수</span>
|
||||
<span className="btd-footer-stat-value">{selected?.barCount.toLocaleString() ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 운영 현황 대시보드 — 멀티스레드 파이프라인·JVM·트래픽·모의/실거래
|
||||
* GoldenChart Command Center — Master Plan [01] Standard + [04] System/Tech
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import {
|
||||
loadDashboardSummary,
|
||||
@@ -9,15 +9,18 @@ import {
|
||||
type ProcessMonitorDto,
|
||||
type SystemMonitorDto,
|
||||
} from '../utils/backendApi';
|
||||
import DashboardGauge from './dashboard/DashboardGauge';
|
||||
import DashboardSegmentBar from './dashboard/DashboardSegmentBar';
|
||||
import DashboardSparkChart from './dashboard/DashboardSparkChart';
|
||||
import DashboardIndicatorBar from './dashboard/DashboardIndicatorBar';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
import '../styles/dashboardCommand.css';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
onGoChart?: (market: string) => void;
|
||||
}
|
||||
|
||||
const fmtKrw = (n: number) =>
|
||||
n >= 1e8 ? `${(n / 1e8).toFixed(2)}억` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}만` : n.toLocaleString('ko-KR');
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
healthy: '정상',
|
||||
degraded: '주의',
|
||||
@@ -25,21 +28,10 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
disabled: '비활성',
|
||||
};
|
||||
|
||||
function statusClass(s: string): string {
|
||||
if (s === 'healthy') return 'dash-proc--ok';
|
||||
if (s === 'degraded') return 'dash-proc--warn';
|
||||
if (s === 'down') return 'dash-proc--down';
|
||||
return 'dash-proc--off';
|
||||
}
|
||||
const fmtKrw = (n: number) =>
|
||||
n >= 1e8 ? `${(n / 1e8).toFixed(2)}억` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}만` : n.toLocaleString('ko-KR');
|
||||
|
||||
function formatMetricValue(v: unknown): string {
|
||||
if (typeof v === 'boolean') return v ? 'Y' : 'N';
|
||||
if (typeof v === 'number') {
|
||||
if (Number.isInteger(v)) return v.toLocaleString('ko-KR');
|
||||
return v.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
}
|
||||
return String(v ?? '—');
|
||||
}
|
||||
const fmtPct = (v: number) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%`;
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
if (!ms) return '—';
|
||||
@@ -49,38 +41,65 @@ function formatAgo(ms: number): string {
|
||||
return `${Math.floor(sec / 3600)}시간 전`;
|
||||
}
|
||||
|
||||
function statusClass(s: string): string {
|
||||
if (s === 'healthy') return 'gc-proc--ok';
|
||||
if (s === 'degraded') return 'gc-proc--warn';
|
||||
if (s === 'down') return 'gc-proc--down';
|
||||
return 'gc-proc--off';
|
||||
}
|
||||
|
||||
function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' {
|
||||
if (!mon.pipelineEnabled) return 'red';
|
||||
if (mon.jvm.heapPct > 85 || mon.queue.dropped > 0 || mon.staleMarkets.length > 5) return 'yellow';
|
||||
if (mon.processes.some(p => p.status === 'down')) return 'red';
|
||||
if (mon.processes.some(p => p.status === 'degraded')) return 'yellow';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => (
|
||||
<article className={`dash-proc ${statusClass(proc.status)}`}>
|
||||
<header className="dash-proc-head">
|
||||
<span className="dash-proc-layer">{proc.layer}</span>
|
||||
<span className={`dash-proc-badge ${statusClass(proc.status)}`}>
|
||||
{STATUS_LABEL[proc.status] ?? proc.status}
|
||||
</span>
|
||||
<article className={`gc-proc ${statusClass(proc.status)}`}>
|
||||
<header className="gc-proc-head">
|
||||
<span className="gc-proc-layer">{proc.layer}</span>
|
||||
<span className={`gc-proc-badge ${statusClass(proc.status)}`}>{STATUS_LABEL[proc.status] ?? proc.status}</span>
|
||||
</header>
|
||||
<h3 className="dash-proc-name">{proc.name}</h3>
|
||||
<ul className="dash-proc-metrics">
|
||||
{Object.entries(proc.metrics).slice(0, 6).map(([k, v]) => (
|
||||
<li key={k}>
|
||||
<span>{k}</span>
|
||||
<strong>{formatMetricValue(v)}</strong>
|
||||
</li>
|
||||
<h3 className="gc-proc-name">{proc.name}</h3>
|
||||
<ul className="gc-proc-metrics">
|
||||
{Object.entries(proc.metrics).slice(0, 4).map(([k, v]) => (
|
||||
<li key={k}><span>{k}</span><strong>{String(v ?? '—')}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
|
||||
const HISTORY_LEN = 48;
|
||||
|
||||
const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
|
||||
const [data, setData] = useState<DashboardSummaryDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const tickHistory = useRef<number[]>([]);
|
||||
const wsHistory = useRef<number[]>([]);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
const pushHistory = useCallback((ref: React.MutableRefObject<number[]>, v: number) => {
|
||||
ref.current = [...ref.current.slice(-(HISTORY_LEN - 1)), v];
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadDashboardSummary()
|
||||
.then(d => { setData(d); setError(null); })
|
||||
.then(d => {
|
||||
if (d?.systemMonitor) {
|
||||
pushHistory(tickHistory, d.systemMonitor.traffic.ticksProcessedPerSec ?? 0);
|
||||
pushHistory(wsHistory, d.systemMonitor.traffic.wsMessagesPerSec ?? 0);
|
||||
bump(n => n + 1);
|
||||
}
|
||||
setData(d);
|
||||
setError(null);
|
||||
})
|
||||
.catch(e => setError((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [pushHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -88,202 +107,226 @@ const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const mon = data?.systemMonitor;
|
||||
const app = data?.app as Record<string, unknown> | undefined;
|
||||
const mon: SystemMonitorDto | undefined = data?.systemMonitor;
|
||||
const health = mon ? healthLevel(mon) : 'yellow';
|
||||
|
||||
const latencyMs = useMemo(() => {
|
||||
if (!mon) return 0;
|
||||
const tps = mon.traffic.ticksProcessedPerSec ?? 0;
|
||||
return tps > 0 ? Math.max(12, Math.round(1000 / Math.max(tps, 1))) : 45;
|
||||
}, [mon]);
|
||||
|
||||
const stompConnections = data?.monitoredMarkets ?? 0;
|
||||
|
||||
const ta4jPct = mon && mon.memory.maxBarsPerSeries > 0
|
||||
? Math.min(100, (mon.memory.totalBars / (mon.memory.series * mon.memory.maxBarsPerSeries || 1)) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={`dashboard-page theme-${theme}`}>
|
||||
<header className="dashboard-header">
|
||||
<h1>운영 대시보드</h1>
|
||||
<div className="dashboard-header-actions">
|
||||
<div className={`gc-dash-page se-page se-page--${theme}`}>
|
||||
<header className="gc-dash-header">
|
||||
<div className="gc-dash-header-left">
|
||||
<span className="gc-dash-badge">[01] Standard Dashboard</span>
|
||||
<h1 className="gc-dash-title">GoldenChart Command Center</h1>
|
||||
<p className="gc-dash-sub">실시간 투자 분석 · 파이프라인 모니터링 · 시스템 헬스체크</p>
|
||||
</div>
|
||||
<div className="gc-dash-header-right">
|
||||
<span className="gc-dash-stat">Global Latency: <strong>{latencyMs}ms</strong></span>
|
||||
<span className="gc-dash-stat">Active Markets: <strong>{stompConnections}</strong></span>
|
||||
{mon && (
|
||||
<span className="dashboard-live">
|
||||
파이프라인 {mon.pipelineEnabled ? 'ON' : 'OFF'}
|
||||
{mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)} 갱신` : ''}
|
||||
<span className="gc-dash-stat">
|
||||
Pipeline <strong className={mon.pipelineEnabled ? 'up' : 'down'}>{mon.pipelineEnabled ? 'ON' : 'OFF'}</strong>
|
||||
{mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="stg-btn" onClick={refresh} disabled={loading}>
|
||||
{loading ? '갱신 중…' : '새로고침'}
|
||||
<button type="button" className="gc-dash-btn" onClick={refresh} disabled={loading}>
|
||||
{loading ? '갱신…' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <p className="dashboard-error">{error}</p>}
|
||||
{error && <p className="gc-dash-error">{error}</p>}
|
||||
|
||||
{data && mon && (
|
||||
<div className="dashboard-grid">
|
||||
{/* JVM · 트래픽 */}
|
||||
<section className="dashboard-card dashboard-card--wide dash-monitor-top">
|
||||
<h2>JVM · 트래픽</h2>
|
||||
<div className="dash-jvm-row">
|
||||
<div className="dash-jvm-heap">
|
||||
<div className="dash-jvm-label">
|
||||
Heap {mon.jvm.heapUsedMb} / {mon.jvm.heapMaxMb} MB ({mon.jvm.heapPct}%)
|
||||
<div className="gc-dash-body">
|
||||
{/* ── LEFT: Portfolio + Indicators ── */}
|
||||
<aside className="gc-dash-col gc-dash-col--left">
|
||||
<section className="gc-dash-card gc-dash-card--glow">
|
||||
<h2 className="gc-dash-card-title">포트폴리오</h2>
|
||||
<div className="gc-dash-kpi-hero">
|
||||
<div className="gc-dash-kpi-block">
|
||||
<span className="gc-dash-kpi-label">모의 총자산</span>
|
||||
<strong className="gc-dash-kpi-val">{fmtKrw(data.paper.totalAsset)}</strong>
|
||||
<span className={`gc-dash-kpi-sub ${data.paper.totalReturnPct >= 0 ? 'up' : 'down'}`}>
|
||||
{fmtPct(data.paper.totalReturnPct)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dash-jvm-bar">
|
||||
<div
|
||||
className={`dash-jvm-fill${mon.jvm.heapPct > 85 ? ' dash-jvm-fill--warn' : ''}`}
|
||||
style={{ width: `${Math.min(100, mon.jvm.heapPct)}%` }}
|
||||
/>
|
||||
<div className="gc-dash-kpi-block">
|
||||
<span className="gc-dash-kpi-label">실거래 KRW</span>
|
||||
<strong className="gc-dash-kpi-val">{fmtKrw(data.live.krwBalance)}</strong>
|
||||
<span className="gc-dash-kpi-sub">{data.live.configured ? '연동됨' : '미설정'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="dashboard-kv dash-traffic-kv">
|
||||
<li><span>WS 메시지/s</span><strong>{mon.traffic.wsMessagesPerSec ?? 0}</strong></li>
|
||||
<li><span>틱 적재/s</span><strong>{mon.traffic.ticksEnqueuedPerSec ?? 0}</strong></li>
|
||||
<li><span>틱 처리/s</span><strong>{mon.traffic.ticksProcessedPerSec ?? 0}</strong></li>
|
||||
<li><span>주문 실행/s</span><strong>{mon.traffic.ordersExecutedPerSec ?? 0}</strong></li>
|
||||
<li><span>스레드</span><strong>{mon.jvm.threadCount}</strong></li>
|
||||
<li><span>Non-Heap</span><strong>{mon.jvm.nonHeapUsedMb} MB</strong></li>
|
||||
<ul className="gc-dash-kv">
|
||||
<li><span>모의 현금</span><strong>{fmtKrw(data.paper.cashBalance)}</strong></li>
|
||||
<li><span>보유 종목</span><strong>{data.paper.positions?.length ?? 0}</strong></li>
|
||||
<li><span>관심종목</span><strong>{data.watchlistCount}</strong></li>
|
||||
<li><span>전략 감시</span><strong>{data.monitoredMarkets}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ta4j 메모리 */}
|
||||
<section className="dashboard-card">
|
||||
<h2>인메모리 캔들 (Ta4j)</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>종목</span><strong>{mon.memory.markets}</strong></li>
|
||||
<li><span>시리즈</span><strong>{mon.memory.series}</strong></li>
|
||||
<li><span>총 봉 수</span><strong>{mon.memory.totalBars.toLocaleString('ko-KR')}</strong></li>
|
||||
<li><span>시리즈 상한</span><strong>{mon.memory.maxBarsPerSeries}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 이벤트 큐 */}
|
||||
<section className="dashboard-card">
|
||||
<h2>이벤트 큐</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>대기</span><strong>{mon.queue.pending}</strong></li>
|
||||
<li><span>용량</span><strong>{mon.queue.capacity?.toLocaleString('ko-KR') ?? '—'}</strong></li>
|
||||
<li><span>적재율</span><strong>{Math.round((mon.queue.fillRatio ?? 0) * 1000) / 10}%</strong></li>
|
||||
<li><span>드롭</span><strong className={mon.queue.dropped > 0 ? 'warn' : ''}>{mon.queue.dropped}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 멀티스레드 프로세스 */}
|
||||
<section className="dashboard-card dashboard-card--wide">
|
||||
<h2>멀티스레드 프로세스</h2>
|
||||
<div className="dash-proc-grid">
|
||||
{mon.processes.map(p => (
|
||||
<ProcessCard key={p.id} proc={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 스레드 목록 */}
|
||||
<section className="dashboard-card dashboard-card--wide">
|
||||
<h2>관련 스레드</h2>
|
||||
{mon.threads.length === 0 ? (
|
||||
<p className="dashboard-muted">표시할 스레드 없음</p>
|
||||
) : (
|
||||
<table className="dashboard-table dashboard-table--compact">
|
||||
<thead>
|
||||
<tr><th>이름</th><th>ID</th><th>상태</th><th>데몬</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mon.threads.map(t => (
|
||||
<tr key={`${t.id}-${t.name}`}>
|
||||
<td><code>{t.name}</code></td>
|
||||
<td>{t.id}</td>
|
||||
<td>{t.state}</td>
|
||||
<td>{t.daemon ? 'Y' : 'N'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 틱 정체 종목 */}
|
||||
{mon.staleMarkets.length > 0 && (
|
||||
<section className="dashboard-card dashboard-card--wide dash-stale">
|
||||
<h2>틱 정체 종목 ({mon.staleMarkets.length})</h2>
|
||||
<table className="dashboard-table dashboard-table--compact">
|
||||
<thead>
|
||||
<tr><th>종목</th><th>마지막 틱 경과</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mon.staleMarkets.slice(0, 12).map(s => (
|
||||
<tr key={s.market}>
|
||||
<td>
|
||||
<button type="button" className="dashboard-link" onClick={() => onGoChart?.(s.market)}>
|
||||
{s.market.replace('KRW-', '')}
|
||||
</button>
|
||||
</td>
|
||||
<td>{s.lastTickAgeSec}초</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>매매 모드</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>운영 모드</span><strong>{String(app?.tradingMode ?? '—')}</strong></li>
|
||||
<li><span>실거래 자동</span><strong>{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>모의 자동</span><strong>{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>전략 체크</span><strong>{app?.liveStrategyCheck ? 'ON' : 'OFF'}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section className="gc-dash-card">
|
||||
<h2 className="gc-dash-card-title">시스템 상태</h2>
|
||||
<div className={`gc-traffic-light gc-traffic-light--${health}`} aria-label="시스템 상태">
|
||||
<span className="gc-traffic-bulb gc-traffic-bulb--r" />
|
||||
<span className="gc-traffic-bulb gc-traffic-bulb--y" />
|
||||
<span className="gc-traffic-bulb gc-traffic-bulb--g" />
|
||||
</div>
|
||||
<p className="gc-traffic-caption">
|
||||
{health === 'green' ? '정상 운영' : health === 'yellow' ? '주의 필요' : '점검 필요'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>모니터링</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>관심종목</span><strong>{data.watchlistCount}</strong></li>
|
||||
<li><span>전략 감시</span><strong>{data.monitoredMarkets}</strong></li>
|
||||
<li><span>FCM</span><strong>
|
||||
{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '설정됨·서버 미연결') : 'OFF'}
|
||||
</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section className="gc-dash-card">
|
||||
<h2 className="gc-dash-card-title">리소스 인디케이터</h2>
|
||||
<div className="gc-ind-grid">
|
||||
<DashboardIndicatorBar label="JVM Heap" pct={mon.jvm.heapPct} />
|
||||
<DashboardIndicatorBar label="Event Queue" pct={(mon.queue.fillRatio ?? 0) * 100} />
|
||||
<DashboardIndicatorBar label="Ta4j Cache" pct={ta4jPct} />
|
||||
<DashboardIndicatorBar label="WS Load" pct={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 2)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>모의투자</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>현금</span><strong>{fmtKrw(data.paper.cashBalance)} KRW</strong></li>
|
||||
<li><span>총자산</span><strong>{fmtKrw(data.paper.totalAsset)} KRW</strong></li>
|
||||
<li><span>보유 종목</span><strong>{data.paper.positions?.length ?? 0}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section className="gc-dash-card gc-dash-card--compact">
|
||||
<h2 className="gc-dash-card-title">매매 모드</h2>
|
||||
<ul className="gc-dash-kv">
|
||||
<li><span>운영 모드</span><strong>{String(app?.tradingMode ?? '—')}</strong></li>
|
||||
<li><span>실거래 자동</span><strong>{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>모의 자동</span><strong>{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>FCM</span><strong>{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '미연결') : 'OFF'}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>실거래</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>KRW</span><strong>{fmtKrw(data.live.krwBalance)}</strong></li>
|
||||
<li><span>보유</span><strong>{data.live.positions?.length ?? 0} 종목</strong></li>
|
||||
<li><span>연동</span><strong>{data.live.configured ? 'OK' : '미설정'}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
{/* ── CENTER: Infrastructure + Chart ── */}
|
||||
<main className="gc-dash-col gc-dash-col--center">
|
||||
<section className="gc-dash-card gc-dash-card--wide">
|
||||
<h2 className="gc-dash-card-title">[04] 데이터 파이프라인 · Ta4j 엔진</h2>
|
||||
<div className="gc-infra-grid">
|
||||
<DashboardSegmentBar
|
||||
label="Ta4jStorage"
|
||||
valueLabel={`${mon.memory.markets}종목 · ${mon.memory.totalBars.toLocaleString()}봉`}
|
||||
pct={ta4jPct}
|
||||
tone="green"
|
||||
/>
|
||||
<DashboardSegmentBar
|
||||
label="Event Queue"
|
||||
valueLabel={`${mon.queue.pending} / ${mon.queue.capacity?.toLocaleString() ?? '—'}`}
|
||||
pct={(mon.queue.fillRatio ?? 0) * 100}
|
||||
tone={mon.queue.fillRatio > 0.7 ? 'amber' : 'cyan'}
|
||||
/>
|
||||
<DashboardSegmentBar
|
||||
label="Order Worker"
|
||||
valueLabel={`${mon.orders.executed} executed · ${mon.orders.failed} fail`}
|
||||
pct={Math.min(100, mon.orders.ratePerSecond * 10)}
|
||||
tone="cyan"
|
||||
/>
|
||||
<DashboardSegmentBar
|
||||
label="Tick Worker"
|
||||
valueLabel={`${mon.workers.processed.toLocaleString()} processed`}
|
||||
pct={mon.workers.active ? 85 : 20}
|
||||
tone={mon.workers.active ? 'green' : 'red'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-card dashboard-card--wide">
|
||||
<h2>최근 시그널</h2>
|
||||
{data.recentSignals.length === 0 ? (
|
||||
<p className="dashboard-muted">최근 시그널 없음</p>
|
||||
) : (
|
||||
<table className="dashboard-table">
|
||||
<thead>
|
||||
<tr><th>종목</th><th>방향</th><th>가격</th><th>분봉</th><th>시각</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recentSignals.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<button type="button" className="dashboard-link" onClick={() => onGoChart?.(s.market)}>
|
||||
{s.market.replace('KRW-', '')}
|
||||
</button>
|
||||
</td>
|
||||
<td className={s.signalType === 'BUY' ? 'buy' : 'sell'}>{s.signalType}</td>
|
||||
<td>{s.price?.toLocaleString('ko-KR')}</td>
|
||||
<td>{s.candleType}</td>
|
||||
<td>{s.createdAt?.slice(0, 19) ?? '—'}</td>
|
||||
</tr>
|
||||
<section className="gc-dash-card gc-dash-card--chart">
|
||||
<DashboardSparkChart series={tickHistory.current} label="Tick 처리량 (실시간)" height={160} />
|
||||
</section>
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--wide">
|
||||
<h2 className="gc-dash-card-title">멀티스레드 프로세스</h2>
|
||||
<div className="gc-proc-grid">
|
||||
{mon.processes.map(p => <ProcessCard key={p.id} proc={p} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{mon.staleMarkets.length > 0 && (
|
||||
<section className="gc-dash-card gc-dash-card--warn">
|
||||
<h2 className="gc-dash-card-title">틱 정체 종목 ({mon.staleMarkets.length})</h2>
|
||||
<div className="gc-stale-chips">
|
||||
{mon.staleMarkets.slice(0, 8).map(s => (
|
||||
<button key={s.market} type="button" className="gc-stale-chip" onClick={() => onGoChart?.(s.market)}>
|
||||
{s.market.replace('KRW-', '')} · {s.lastTickAgeSec}s
|
||||
</button>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* ── RIGHT: Gauges + Signals ── */}
|
||||
<aside className="gc-dash-col gc-dash-col--right">
|
||||
<section className="gc-dash-card gc-dash-card--glow">
|
||||
<h2 className="gc-dash-card-title">성과 · 리스크</h2>
|
||||
<div>
|
||||
<DashboardGauge
|
||||
label="JVM Heap"
|
||||
value={`${mon.jvm.heapPct}%`}
|
||||
pct={mon.jvm.heapPct}
|
||||
tone={mon.jvm.heapPct > 85 ? 'red' : 'green'}
|
||||
/>
|
||||
<DashboardGauge
|
||||
label="Queue Fill"
|
||||
value={`${Math.round((mon.queue.fillRatio ?? 0) * 100)}%`}
|
||||
pct={(mon.queue.fillRatio ?? 0) * 100}
|
||||
tone={(mon.queue.fillRatio ?? 0) > 0.7 ? 'amber' : 'cyan'}
|
||||
/>
|
||||
<DashboardGauge
|
||||
label="모의 수익률"
|
||||
value={fmtPct(data.paper.totalReturnPct)}
|
||||
pct={Math.min(100, Math.abs(data.paper.totalReturnPct) * 200)}
|
||||
max={100}
|
||||
tone={data.paper.totalReturnPct >= 0 ? 'green' : 'red'}
|
||||
/>
|
||||
<DashboardGauge
|
||||
label="WS msg/s"
|
||||
value={String(mon.traffic.wsMessagesPerSec ?? 0)}
|
||||
pct={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 3)}
|
||||
tone="cyan"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--chart-sm">
|
||||
<DashboardSparkChart series={wsHistory.current} label="WebSocket 수신" height={80} />
|
||||
</section>
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--flex">
|
||||
<h2 className="gc-dash-card-title">최근 시그널 · 체결</h2>
|
||||
{data.recentSignals.length === 0 ? (
|
||||
<p className="gc-dash-muted">최근 시그널 없음</p>
|
||||
) : (
|
||||
<ul className="gc-signal-list">
|
||||
{data.recentSignals.slice(0, 12).map(s => {
|
||||
const buy = s.signalType === 'BUY';
|
||||
return (
|
||||
<li key={s.id} className={`gc-signal-item gc-signal-item--${buy ? 'buy' : 'sell'}`}>
|
||||
<span className="gc-signal-arrow">{buy ? '▲' : '▼'}</span>
|
||||
<button type="button" className="gc-signal-main" onClick={() => onGoChart?.(s.market)}>
|
||||
<strong>{buy ? 'BUY' : 'SELL'}</strong>
|
||||
<span>{s.market.replace('KRW-', '')}</span>
|
||||
<span className="gc-signal-price">@ {s.price?.toLocaleString('ko-KR')}</span>
|
||||
</button>
|
||||
<span className="gc-signal-time">{s.createdAt?.slice(11, 16) ?? ''}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
||||
import {
|
||||
buildVirtualTradingChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import BacktestOscillatorPanes from './BacktestOscillatorPanes';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
toTimeSec: number;
|
||||
fromTimeSec?: number;
|
||||
barCount: number;
|
||||
signals: BacktestSignal[];
|
||||
strategyId?: number | null;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
let _indSeq = 0;
|
||||
function newIndId() {
|
||||
_indSeq += 1;
|
||||
return `btd_${_indSeq}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function defaultOverlayIndicators(
|
||||
getParams: (t: string, d?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
): IndicatorConfig[] {
|
||||
const def = getIndicatorDef('SMA');
|
||||
if (!def) return [];
|
||||
const params: Record<string, number | string | boolean> = { ...def.defaultParams, ...getParams('SMA', def.defaultParams) };
|
||||
for (let i = 1; i <= 11; i++) {
|
||||
params[`length${i}`] = i === 1 ? 14 : 0;
|
||||
params[`visible${i}`] = i === 1;
|
||||
}
|
||||
const cfg = normalizeSmaConfig(enrichIndicatorConfig({
|
||||
id: newIndId(),
|
||||
type: 'SMA',
|
||||
params,
|
||||
plots: def.plots,
|
||||
hlines: def.hlines ?? [],
|
||||
timeframeVisibility: { '1m': true, '3m': true, '5m': true, '10m': true, '15m': true, '30m': true, '1h': true, '4h': true, '1D': true, '1W': true, '1M': true },
|
||||
}));
|
||||
return [cfg];
|
||||
}
|
||||
|
||||
const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
symbol,
|
||||
timeframe: timeframeRaw,
|
||||
toTimeSec,
|
||||
fromTimeSec,
|
||||
barCount,
|
||||
signals,
|
||||
strategyId,
|
||||
theme = 'dark',
|
||||
}) => {
|
||||
const timeframe = normalizeChartTimeframe(timeframeRaw);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [chartType] = useState<ChartType>('candlestick');
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const signalsRef = useRef(signals);
|
||||
signalsRef.current = signals;
|
||||
const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyId) {
|
||||
setStrategy(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadStrategy(strategyId).then(s => {
|
||||
if (!cancelled) setStrategy(s ?? undefined);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [strategyId]);
|
||||
|
||||
const indicators = useMemo(() => {
|
||||
if (strategy) {
|
||||
const inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
|
||||
if (inds.length) return inds.filter(i => i.timeframeVisibility?.[timeframe] !== false);
|
||||
}
|
||||
return defaultOverlayIndicators(getParams);
|
||||
}, [strategy, getParams, getVisualConfig, timeframe]);
|
||||
|
||||
const effectiveBarCount = useMemo(() => {
|
||||
const barSec = timeframeBarSeconds(timeframeRaw);
|
||||
if (fromTimeSec && toTimeSec > fromTimeSec) {
|
||||
return Math.max(barCount, Math.min(500, Math.ceil((toTimeSec - fromTimeSec) / barSec) + 50));
|
||||
}
|
||||
return barCount;
|
||||
}, [barCount, fromTimeSec, toTimeSec, timeframeRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void loadAnalysisCandles(market, timeframe, toTimeSec, effectiveBarCount)
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setBars(data);
|
||||
})
|
||||
.catch(e => {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : '차트 로드 실패');
|
||||
setBars([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe, toTimeSec, effectiveBarCount]);
|
||||
|
||||
const applyMarkers = useCallback((attempt = 0) => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr?.hasMainSeries()) {
|
||||
if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1));
|
||||
return;
|
||||
}
|
||||
mgr.setBacktestMarkers(signalsRef.current, true);
|
||||
}, []);
|
||||
|
||||
const onManagerReady = useCallback((mgr: ChartManager) => {
|
||||
managerRef.current = mgr;
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
|
||||
const onCandlesReady = useCallback(() => {
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
applyMarkers();
|
||||
}, [signals, bars, applyMarkers]);
|
||||
|
||||
useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: isUpbitMarket(market),
|
||||
managerRef,
|
||||
});
|
||||
|
||||
const symLabel = market.replace(/^KRW-/, '');
|
||||
const stratTf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframe;
|
||||
|
||||
return (
|
||||
<div className="btd-analysis-chart">
|
||||
<div className="btd-analysis-toolbar">
|
||||
<div className="btd-analysis-toolbar-left">
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{symLabel}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{timeframe}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">Candle</span>
|
||||
</div>
|
||||
<div className="btd-analysis-toolbar-right">
|
||||
<span className="btd-analysis-tool" title="자석 모드">⊕</span>
|
||||
<span className="btd-analysis-tool" title="드로잉">✎</span>
|
||||
<span className="btd-analysis-tool" title="삭제">⌫</span>
|
||||
<span className="btd-analysis-count">{effectiveBarCount.toLocaleString()}봉</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-main">
|
||||
<div className="btd-analysis-canvas-wrap">
|
||||
{loading && <div className="btd-analysis-loading">차트 로딩…</div>}
|
||||
{error && !loading && <div className="btd-analysis-error">{error}</div>}
|
||||
{!loading && !error && bars.length > 0 && (
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${toTimeSec}-${effectiveBarCount}`}
|
||||
bars={bars}
|
||||
barsMarket={market}
|
||||
market={market}
|
||||
timeframe={stratTf}
|
||||
chartType={chartType}
|
||||
theme={theme}
|
||||
mode="chart"
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[]}
|
||||
logScale={false}
|
||||
onCrosshair={noop}
|
||||
onManagerReady={onManagerReady}
|
||||
onCandlesReady={onCandlesReady}
|
||||
onAddDrawing={noop}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && bars.length === 0 && (
|
||||
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
{!loading && bars.length > 0 && <BacktestOscillatorPanes bars={bars} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestAnalysisChart;
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { BacktestResultRecord } from '../../utils/backendApi';
|
||||
import type { LiveExecutionItem } from '../../utils/liveExecutionGroups';
|
||||
import BacktestSparkline from './BacktestSparkline';
|
||||
import { buildEquityFromSignals } from '../../utils/backtestEquity';
|
||||
import { paperTradesToSignals } from '../../utils/liveExecutionGroups';
|
||||
import { fmtListTimestamp, fmtShortTimestamp, pct, pctAbs } from '../../utils/backtestUiUtils';
|
||||
|
||||
export type ExecutionListTab = 'backtest' | 'live';
|
||||
export type BacktestListSort = 'strategy' | 'symbol' | 'return';
|
||||
|
||||
interface Props {
|
||||
tab: ExecutionListTab;
|
||||
onTabChange: (tab: ExecutionListTab) => void;
|
||||
backtestRecords: BacktestResultRecord[];
|
||||
liveItems: LiveExecutionItem[];
|
||||
selectedBacktestId: number | null;
|
||||
selectedLiveId: string | null;
|
||||
onSelectBacktest: (r: BacktestResultRecord) => void;
|
||||
onSelectLive: (item: LiveExecutionItem) => void;
|
||||
}
|
||||
|
||||
function parseBacktestSpark(r: BacktestResultRecord) {
|
||||
try {
|
||||
const signals = r.signalsJson ? JSON.parse(r.signalsJson) : [];
|
||||
const analysis = r.analysisJson ? JSON.parse(r.analysisJson) : null;
|
||||
const cap = analysis?.initialCapital ?? 10_000_000;
|
||||
return buildEquityFromSignals(signals, cap, r.symbol).curve;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseLiveSpark(item: LiveExecutionItem, initial = 10_000_000) {
|
||||
return buildEquityFromSignals(paperTradesToSignals(item.trades), initial, item.symbol).curve;
|
||||
}
|
||||
|
||||
export default function BacktestExecutionList({
|
||||
tab,
|
||||
onTabChange,
|
||||
backtestRecords,
|
||||
liveItems,
|
||||
selectedBacktestId,
|
||||
selectedLiveId,
|
||||
onSelectBacktest,
|
||||
onSelectLive,
|
||||
}: Props) {
|
||||
const [sort, setSort] = useState<BacktestListSort>('return');
|
||||
|
||||
const sortedBacktests = useMemo(() => {
|
||||
const list = [...backtestRecords];
|
||||
if (sort === 'strategy') list.sort((a, b) => (a.strategyName ?? '').localeCompare(b.strategyName ?? ''));
|
||||
else if (sort === 'symbol') list.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
else list.sort((a, b) => (b.totalReturn ?? 0) - (a.totalReturn ?? 0));
|
||||
return list;
|
||||
}, [backtestRecords, sort]);
|
||||
|
||||
return (
|
||||
<div className="btd-exec-list">
|
||||
<div className="btd-exec-tabs" role="tablist" aria-label="실행 목록">
|
||||
<button type="button" role="tab" aria-selected={tab === 'backtest'} className={`btd-exec-tab${tab === 'backtest' ? ' btd-exec-tab--on' : ''}`} onClick={() => onTabChange('backtest')}>백테스팅</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'live'} className={`btd-exec-tab${tab === 'live' ? ' btd-exec-tab--on' : ''}`} onClick={() => onTabChange('live')}>실시간 매매</button>
|
||||
</div>
|
||||
|
||||
{tab === 'backtest' && (
|
||||
<div className="btd-exec-filters" role="group" aria-label="목록 정렬">
|
||||
<button type="button" className={`btd-exec-filter${sort === 'strategy' ? ' btd-exec-filter--on' : ''}`} onClick={() => setSort('strategy')}>전략별</button>
|
||||
<button type="button" className={`btd-exec-filter${sort === 'symbol' ? ' btd-exec-filter--on' : ''}`} onClick={() => setSort('symbol')}>종목별</button>
|
||||
<button type="button" className={`btd-exec-filter${sort === 'return' ? ' btd-exec-filter--on' : ''}`} onClick={() => setSort('return')}>수익률순</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btd-exec-scroll">
|
||||
{tab === 'backtest' ? (
|
||||
sortedBacktests.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">실시간 차트에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedBacktests.map(r => {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const positive = ret >= 0;
|
||||
const active = selectedBacktestId === r.id;
|
||||
const sym = r.symbol.replace(/^KRW-/, '');
|
||||
return (
|
||||
<button key={r.id} type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectBacktest(r)}>
|
||||
<div className="btd-history-card-row">
|
||||
<div className="btd-history-card-main">
|
||||
<span className="btd-history-title">{r.strategyName || '전략 없음'} | {sym} | {r.timeframe}</span>
|
||||
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
|
||||
</div>
|
||||
<div className="btd-history-card-side">
|
||||
<BacktestSparkline curve={parseBacktestSpark(r)} positive={positive} width={56} height={22} />
|
||||
<span className="btd-history-win">승률 {pctAbs(r.winRate ?? 0)}</span>
|
||||
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>총 수익률 {pct(ret)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : liveItems.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>실시간 매매 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">가상투자 화면에서 자동·수동<br />매매를 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
liveItems.map(item => {
|
||||
const positive = item.totalReturnPct >= 0;
|
||||
const active = selectedLiveId === item.id;
|
||||
const sym = item.symbol.replace(/^KRW-/, '');
|
||||
return (
|
||||
<button key={item.id} type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectLive(item)}>
|
||||
<div className="btd-history-card-row">
|
||||
<div className="btd-history-card-main">
|
||||
<span className="btd-history-title">{item.strategyLabel} | {sym} | {item.sourceLabel}</span>
|
||||
<span className="btd-history-date">{fmtShortTimestamp(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="btd-history-card-side">
|
||||
<BacktestSparkline curve={parseLiveSpark(item)} positive={positive} width={56} height={22} />
|
||||
<span className="btd-history-win">체결 {item.tradeCount}건</span>
|
||||
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>{pct(item.totalReturnPct)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { BacktestAnalysis } from '../../utils/backendApi';
|
||||
import BacktestSparkline from './BacktestSparkline';
|
||||
import type { EquityPoint } from '../../utils/backtestEquity';
|
||||
import { krw, pct, pctAbs } from '../../utils/backtestUiUtils';
|
||||
|
||||
interface Props {
|
||||
analysis: BacktestAnalysis | null;
|
||||
equityCurve: EquityPoint[];
|
||||
totalReturnOverride?: number;
|
||||
winRateOverride?: number;
|
||||
tradeCountOverride?: number;
|
||||
winCountOverride?: number;
|
||||
mddOverride?: number;
|
||||
sharpeOverride?: number;
|
||||
profitFactorOverride?: number;
|
||||
netProfitOverride?: number;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const num = (v: number, dec = 2) => (isFinite(v) ? v.toFixed(dec) : '–');
|
||||
|
||||
const fmtWon = (v: number) =>
|
||||
isFinite(v) ? `₩${Math.round(v).toLocaleString('ko-KR')}` : '–';
|
||||
|
||||
function MetricCell({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
tone?: 'up' | 'down' | 'gold' | 'neutral';
|
||||
}) {
|
||||
return (
|
||||
<div className="btd-analysis-metric">
|
||||
<span className="btd-analysis-metric-label">{label}</span>
|
||||
<span className={`btd-analysis-metric-value${tone ? ` btd-analysis-metric-value--${tone}` : ''}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BacktestKpiPanel({
|
||||
analysis,
|
||||
equityCurve,
|
||||
totalReturnOverride,
|
||||
winRateOverride,
|
||||
tradeCountOverride,
|
||||
winCountOverride,
|
||||
mddOverride,
|
||||
sharpeOverride,
|
||||
profitFactorOverride,
|
||||
netProfitOverride,
|
||||
emptyText = '분석 결과를 선택하세요.',
|
||||
}: Props) {
|
||||
const positive = useMemo(() => {
|
||||
const ret = totalReturnOverride ?? analysis?.totalReturnPct ?? 0;
|
||||
return ret >= 0;
|
||||
}, [analysis, totalReturnOverride]);
|
||||
|
||||
if (!analysis && totalReturnOverride == null) {
|
||||
return <div className="btd-analysis-empty">{emptyText}</div>;
|
||||
}
|
||||
|
||||
const totalReturn = totalReturnOverride ?? analysis?.totalReturnPct ?? 0;
|
||||
const winRate = winRateOverride ?? analysis?.winRate ?? 0;
|
||||
const tradeCount = tradeCountOverride ?? analysis?.numberOfPositions ?? 0;
|
||||
const winCount = winCountOverride ?? analysis?.numberOfWinning ?? Math.round(tradeCount * winRate);
|
||||
const lossCount = analysis?.numberOfLosing ?? Math.max(0, tradeCount - winCount);
|
||||
const breakEven = analysis?.numberOfBreakEven ?? Math.max(0, tradeCount - winCount - lossCount);
|
||||
const mdd = mddOverride ?? analysis?.maxDrawdownPct ?? 0;
|
||||
const sharpe = sharpeOverride ?? analysis?.sharpeRatio ?? 0;
|
||||
const profitFactor = profitFactorOverride ?? (
|
||||
analysis && analysis.grossLoss !== 0
|
||||
? Math.abs(analysis.grossProfit / analysis.grossLoss)
|
||||
: analysis?.profitLossRatio ?? 0
|
||||
);
|
||||
const netProfit = netProfitOverride ?? analysis?.totalProfitLoss ?? 0;
|
||||
const initialCap = analysis?.initialCapital ?? 0;
|
||||
const finalEquity = analysis?.finalEquity ?? initialCap + netProfit;
|
||||
const grossProfit = analysis?.grossProfit ?? 0;
|
||||
const grossLoss = analysis?.grossLoss ?? 0;
|
||||
const avgWin = winCount > 0 ? grossProfit / winCount : 0;
|
||||
const avgLoss = lossCount > 0 ? grossLoss / lossCount : 0;
|
||||
const buyHold = analysis?.buyAndHoldReturnPct ?? 0;
|
||||
const vsHold = analysis?.vsBuyAndHold ?? 0;
|
||||
const maxRunup = analysis?.maxRunupPct ?? 0;
|
||||
const sortino = analysis?.sortinoRatio ?? 0;
|
||||
const calmar = analysis?.calmarRatio ?? 0;
|
||||
const var95 = analysis?.valueAtRisk95 ?? 0;
|
||||
const es = analysis?.expectedShortfall ?? 0;
|
||||
const plRatio = analysis?.profitLossRatio ?? profitFactor;
|
||||
const recovery = mdd !== 0 ? totalReturn / Math.abs(mdd) : 0;
|
||||
|
||||
return (
|
||||
<div className="btd-analysis-stack">
|
||||
<section className="btd-analysis-card btd-analysis-card--profit">
|
||||
<header className="btd-analysis-card-head">
|
||||
<h3 className="btd-analysis-card-title">수익성</h3>
|
||||
<span className="btd-analysis-card-tag">Profitability</span>
|
||||
</header>
|
||||
<div className="btd-analysis-card-body">
|
||||
<div className="btd-analysis-hero">
|
||||
<div className="btd-analysis-hero-main">
|
||||
<span className="btd-analysis-hero-label">총 수익률</span>
|
||||
<strong className={`btd-analysis-hero-value ${totalReturn >= 0 ? 'up' : 'down'}`}>
|
||||
{pct(totalReturn)}
|
||||
</strong>
|
||||
</div>
|
||||
<BacktestSparkline curve={equityCurve} positive={positive} width={80} height={30} />
|
||||
</div>
|
||||
<div className="btd-analysis-grid">
|
||||
<MetricCell label="초기 자본" value={fmtWon(initialCap)} />
|
||||
<MetricCell label="최종 자산" value={fmtWon(finalEquity)} tone={finalEquity >= initialCap ? 'up' : 'down'} />
|
||||
<MetricCell label="순이익" value={krw(netProfit)} tone={netProfit >= 0 ? 'up' : 'down'} />
|
||||
<MetricCell label="평균 수익률" value={pct(analysis?.avgReturnPct ?? 0)} tone={(analysis?.avgReturnPct ?? 0) >= 0 ? 'up' : 'down'} />
|
||||
<MetricCell label="총 이익 (Gross)" value={krw(grossProfit)} tone="up" />
|
||||
<MetricCell label="총 손실 (Gross)" value={krw(-Math.abs(grossLoss))} tone="down" />
|
||||
<MetricCell label="Buy & Hold" value={pct(buyHold)} tone={buyHold >= 0 ? 'up' : 'down'} />
|
||||
<MetricCell label="vs Buy & Hold" value={num(vsHold, 2)} tone={vsHold >= 1 ? 'up' : 'down'} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="btd-analysis-card btd-analysis-card--risk">
|
||||
<header className="btd-analysis-card-head">
|
||||
<h3 className="btd-analysis-card-title">리스크 관리</h3>
|
||||
<span className="btd-analysis-card-tag">Risk</span>
|
||||
</header>
|
||||
<div className="btd-analysis-card-body">
|
||||
<div className="btd-analysis-hero btd-analysis-hero--compact">
|
||||
<div className="btd-analysis-hero-main">
|
||||
<span className="btd-analysis-hero-label">MDD (최대낙폭)</span>
|
||||
<strong className="btd-analysis-hero-value down">{pct(mdd)}</strong>
|
||||
</div>
|
||||
<div className="btd-analysis-hero-side">
|
||||
<span className="btd-analysis-side-label">Max Runup</span>
|
||||
<strong className="btd-analysis-side-value up">{pct(maxRunup)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-grid">
|
||||
<MetricCell label="Sharpe Ratio" value={num(sharpe)} tone="gold" />
|
||||
<MetricCell label="Sortino Ratio" value={num(sortino)} tone="gold" />
|
||||
<MetricCell label="Calmar Ratio" value={num(calmar)} tone="gold" />
|
||||
<MetricCell label="Recovery Factor" value={num(recovery)} tone={recovery >= 1 ? 'up' : 'neutral'} />
|
||||
<MetricCell label="VaR (95%)" value={pct(var95)} tone="down" />
|
||||
<MetricCell label="Expected Shortfall" value={pct(es)} tone="down" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="btd-analysis-card btd-analysis-card--stats">
|
||||
<header className="btd-analysis-card-head">
|
||||
<h3 className="btd-analysis-card-title">거래 통계</h3>
|
||||
<span className="btd-analysis-card-tag">Trades</span>
|
||||
</header>
|
||||
<div className="btd-analysis-card-body">
|
||||
<div className="btd-analysis-hero btd-analysis-hero--compact">
|
||||
<div className="btd-analysis-hero-main">
|
||||
<span className="btd-analysis-hero-label">승률</span>
|
||||
<strong className={`btd-analysis-hero-value ${winRate >= 0.5 ? 'up' : 'down'}`}>
|
||||
{pctAbs(winRate, 1)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="btd-analysis-hero-side">
|
||||
<span className="btd-analysis-side-label">총 거래</span>
|
||||
<strong className="btd-analysis-side-value">{tradeCount}회</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-grid">
|
||||
<MetricCell label="승리" value={`${winCount}회`} tone="up" />
|
||||
<MetricCell label="패배" value={`${lossCount}회`} tone="down" />
|
||||
<MetricCell label="무승부" value={`${breakEven}회`} tone="neutral" />
|
||||
<MetricCell label="체결 비율" value={tradeCount > 0 ? pctAbs(winCount / tradeCount, 0) : '0%'} tone="gold" />
|
||||
<MetricCell label="평균 수익" value={krw(avgWin)} tone="up" />
|
||||
<MetricCell label="평균 손실" value={krw(-Math.abs(avgLoss))} tone="down" />
|
||||
<MetricCell label="Profit Factor" value={num(profitFactor)} tone={profitFactor >= 1 ? 'up' : 'down'} />
|
||||
<MetricCell label="손익비 (P/L)" value={num(plRatio)} tone="gold" />
|
||||
</div>
|
||||
<p className="btd-analysis-footnote">
|
||||
총 {tradeCount}회 · 승 {winCount} / 패 {lossCount}
|
||||
{breakEven > 0 ? ` / 무 ${breakEven}` : ''}
|
||||
· PF {num(profitFactor)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { OHLCVBar } from '../../types';
|
||||
|
||||
function spark(values: number[], w: number, h: number, min?: number, max?: number): string {
|
||||
if (!values.length) return '';
|
||||
const lo = min ?? Math.min(...values);
|
||||
const hi = max ?? Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / Math.max(values.length - 1, 1)) * w;
|
||||
const y = h - ((v - lo) / range) * (h - 6) - 3;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function computeRsi(closes: number[], period = 14): number[] {
|
||||
if (closes.length < period + 1) return [];
|
||||
const out: number[] = [];
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
for (let i = 1; i <= period; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
if (d >= 0) avgGain += d; else avgLoss -= d;
|
||||
}
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
for (let i = period; i < closes.length; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
const gain = d > 0 ? d : 0;
|
||||
const loss = d < 0 ? -d : 0;
|
||||
avgGain = (avgGain * (period - 1) + gain) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||||
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
||||
out.push(100 - 100 / (1 + rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeMacd(closes: number[]): number[] {
|
||||
const ema = (arr: number[], p: number) => {
|
||||
const k = 2 / (p + 1);
|
||||
let v = arr[0];
|
||||
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
|
||||
};
|
||||
const e12 = ema(closes, 12);
|
||||
const e26 = ema(closes, 26);
|
||||
return e12.map((v, i) => v - e26[i]);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
bars: OHLCVBar[];
|
||||
}
|
||||
|
||||
/** 백테스팅 분석 — RSI / MACD 하단 pane (참조 UI) */
|
||||
const BacktestOscillatorPanes: React.FC<Props> = ({ bars }) => {
|
||||
const { rsiPath, macdPath, rsiLast, macdLast } = useMemo(() => {
|
||||
const closes = bars.map(b => b.close);
|
||||
const rsi = computeRsi(closes).slice(-80);
|
||||
const macd = computeMacd(closes).slice(-80);
|
||||
return {
|
||||
rsiPath: spark(rsi, 280, 52, 0, 100),
|
||||
macdPath: spark(macd, 280, 52),
|
||||
rsiLast: rsi[rsi.length - 1],
|
||||
macdLast: macd[macd.length - 1],
|
||||
};
|
||||
}, [bars]);
|
||||
|
||||
if (bars.length < 20) return null;
|
||||
|
||||
return (
|
||||
<div className="btd-osc-stack">
|
||||
<div className="btd-osc-pane">
|
||||
<div className="btd-osc-head">
|
||||
<span className="btd-osc-label">RSI (14)</span>
|
||||
<span className="btd-osc-val">{rsiLast != null ? rsiLast.toFixed(1) : '–'}</span>
|
||||
</div>
|
||||
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
|
||||
<line x1="0" y1="15" x2="280" y2="15" className="btd-osc-ref btd-osc-ref--hi" />
|
||||
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
|
||||
<line x1="0" y1="37" x2="280" y2="37" className="btd-osc-ref btd-osc-ref--lo" />
|
||||
<path d={rsiPath} className="btd-osc-line btd-osc-line--rsi" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="btd-osc-pane">
|
||||
<div className="btd-osc-head">
|
||||
<span className="btd-osc-label">MACD (12, 26, 9)</span>
|
||||
<span className="btd-osc-val">{macdLast != null ? macdLast.toFixed(2) : '–'}</span>
|
||||
</div>
|
||||
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
|
||||
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
|
||||
<path d={macdPath} className="btd-osc-line btd-osc-line--macd" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestOscillatorPanes;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import type { TradeHistoryRow } from '../../utils/backtestEquity';
|
||||
import { pct } from '../../utils/backtestUiUtils';
|
||||
|
||||
const fmtDateTime = (ts: number) => {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
trades: TradeHistoryRow[];
|
||||
}
|
||||
|
||||
export default function BacktestTradeHistoryCard({ trades }: Props) {
|
||||
return (
|
||||
<section className="rsp-trade-card btd-result-card btd-result-card--trades btd-trades-card">
|
||||
<h3 className="rsp-trade-card-title">
|
||||
상세 거래 내역
|
||||
<span className="btd-trades-count">{trades.length}건</span>
|
||||
</h3>
|
||||
<div className="rsp-trade-card-body btd-trades-scroll">
|
||||
{trades.length === 0 ? (
|
||||
<p className="btd-trades-empty">거래 내역이 없습니다.</p>
|
||||
) : (
|
||||
<table className="btd-table btd-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th>
|
||||
<th>유형</th>
|
||||
<th>체결가</th>
|
||||
<th>손익</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
|
||||
<td className={t.side === 'buy' ? 'buy' : 'sell'}>{t.side === 'buy' ? '매수' : '매도'}</td>
|
||||
<td>{Math.round(t.price).toLocaleString()}</td>
|
||||
<td className={t.pnlPct != null ? (t.pnlPct >= 0 ? 'up' : 'down') : ''}>
|
||||
{t.pnlPct != null ? pct(t.pnlPct) : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
pct: number;
|
||||
max?: number;
|
||||
tone?: 'cyan' | 'green' | 'amber' | 'red';
|
||||
}
|
||||
|
||||
const TONE: Record<string, { arc: string; glow: string }> = {
|
||||
cyan: { arc: '#22d3ee', glow: 'rgba(34, 211, 238, 0.45)' },
|
||||
green: { arc: '#34d399', glow: 'rgba(52, 211, 153, 0.45)' },
|
||||
amber: { arc: '#fbbf24', glow: 'rgba(251, 191, 36, 0.45)' },
|
||||
red: { arc: '#f87171', glow: 'rgba(248, 113, 113, 0.45)' },
|
||||
};
|
||||
|
||||
function polar(cx: number, cy: number, r: number, deg: number) {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
/** 반원형 게이지 — Master Plan [01] MDD/Sharpe 스타일 */
|
||||
export default function DashboardGauge({ label, value, pct, max = 100, tone = 'cyan' }: Props) {
|
||||
const clamped = Math.max(0, Math.min(max, pct));
|
||||
const ratio = clamped / max;
|
||||
const start = 180;
|
||||
const sweep = 180 * ratio;
|
||||
const c = TONE[tone] ?? TONE.cyan;
|
||||
const r = 42;
|
||||
const cx = 50;
|
||||
const cy = 52;
|
||||
|
||||
const p0 = polar(cx, cy, r, start);
|
||||
const pEnd = polar(cx, cy, r, start + sweep);
|
||||
const p180 = polar(cx, cy, r, start + 180);
|
||||
const large = sweep > 90 ? 1 : 0;
|
||||
const arcPath = sweep > 0
|
||||
? `M ${p0.x} ${p0.y} A ${r} ${r} 0 ${large} 1 ${pEnd.x} ${pEnd.y}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="gc-gauge">
|
||||
<svg viewBox="0 0 100 62" className="gc-gauge-svg" aria-hidden>
|
||||
<path
|
||||
d={`M ${p0.x} ${p0.y} A ${r} ${r} 0 1 1 ${p180.x} ${p180.y}`}
|
||||
fill="none"
|
||||
stroke="color-mix(in srgb, var(--se-border) 80%, transparent)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{arcPath && (
|
||||
<path
|
||||
d={arcPath}
|
||||
fill="none"
|
||||
stroke={c.arc}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${c.glow})` }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div className="gc-gauge-body">
|
||||
<span className="gc-gauge-value">{value}</span>
|
||||
<span className="gc-gauge-label">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
/** Master Plan [01] — 세로 인디케이터 바 */
|
||||
export default function DashboardIndicatorBar({ label, pct }: Props) {
|
||||
const blocks = 10;
|
||||
const filled = Math.round(Math.max(0, Math.min(100, pct)) / 100 * blocks);
|
||||
return (
|
||||
<div className="gc-ind-bar">
|
||||
<span className="gc-ind-bar-label">{label}</span>
|
||||
<div className="gc-ind-bar-stack" aria-hidden>
|
||||
{Array.from({ length: blocks }, (_, i) => {
|
||||
const idx = blocks - 1 - i;
|
||||
const on = idx < filled;
|
||||
let tone = 'gc-ind-block--mid';
|
||||
if (idx >= 7) tone = 'gc-ind-block--hi';
|
||||
else if (idx <= 2) tone = 'gc-ind-block--lo';
|
||||
return <span key={i} className={`gc-ind-block${on ? ` ${tone} gc-ind-block--on` : ''}`} />;
|
||||
})}
|
||||
</div>
|
||||
<span className="gc-ind-bar-pct">{Math.round(pct)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
valueLabel: string;
|
||||
pct: number;
|
||||
segments?: number;
|
||||
tone?: 'cyan' | 'green' | 'amber' | 'red';
|
||||
}
|
||||
|
||||
const FILL: Record<string, string> = {
|
||||
cyan: '#22d3ee',
|
||||
green: '#34d399',
|
||||
amber: '#fbbf24',
|
||||
red: '#f87171',
|
||||
};
|
||||
|
||||
export default function DashboardSegmentBar({ label, valueLabel, pct, segments = 12, tone = 'cyan' }: Props) {
|
||||
const filled = Math.round(Math.max(0, Math.min(100, pct)) / 100 * segments);
|
||||
const color = FILL[tone] ?? FILL.cyan;
|
||||
return (
|
||||
<div className="gc-seg-bar">
|
||||
<div className="gc-seg-bar-head">
|
||||
<span className="gc-seg-bar-label">{label}</span>
|
||||
<span className="gc-seg-bar-val">{valueLabel}</span>
|
||||
</div>
|
||||
<div className="gc-seg-bar-track" role="meter" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||
{Array.from({ length: segments }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="gc-seg-bar-block"
|
||||
style={{ background: i < filled ? color : undefined, boxShadow: i < filled ? `0 0 6px ${color}55` : undefined }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
series: number[];
|
||||
label?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
function sparkPath(values: number[], w: number, h: number): string {
|
||||
if (values.length < 2) return '';
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = h - 8 - ((v - lo) / range) * (h - 16);
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export default function DashboardSparkChart({ series, label, height = 140 }: Props) {
|
||||
const w = 480;
|
||||
const path = useMemo(() => sparkPath(series, w, height), [series, height]);
|
||||
const area = path ? `${path} L ${w},${height} L 0,${height} Z` : '';
|
||||
const last = series.length ? series[series.length - 1] : 0;
|
||||
|
||||
return (
|
||||
<div className="gc-spark-chart">
|
||||
{label && (
|
||||
<div className="gc-spark-chart-head">
|
||||
<span>{label}</span>
|
||||
<strong>{last.toFixed(1)}/s</strong>
|
||||
</div>
|
||||
)}
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="gc-spark-chart-svg">
|
||||
<defs>
|
||||
<linearGradient id="gcSparkGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor="#6366f1" stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{area && <path d={area} fill="url(#gcSparkGrad)" />}
|
||||
{path && (
|
||||
<path d={path} fill="none" stroke="#22d3ee" strokeWidth="2" style={{ filter: 'drop-shadow(0 0 4px rgba(34,211,238,0.5))' }} />
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,10 +75,10 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
const isChart = displayMode === 'chart';
|
||||
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!running || isChart) return null;
|
||||
if (!running) return null;
|
||||
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
|
||||
}, [running, lastTickAt, snapshot?.updatedAt, isChart]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, running && !isChart);
|
||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/* GoldenChart Command Center — Master Plan [01] + [04] */
|
||||
|
||||
.gc-dash-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: calc(100vh - var(--tmb-h, 44px));
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--se-bg, #131722);
|
||||
color: var(--se-text, var(--text));
|
||||
}
|
||||
|
||||
.gc-dash-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--se-header-border, var(--se-border));
|
||||
background: var(--se-header-bg, var(--se-bg-elevated));
|
||||
}
|
||||
|
||||
.gc-dash-header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gc-dash-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: #22d3ee;
|
||||
border: 1px solid color-mix(in srgb, #22d3ee 35%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
width: fit-content;
|
||||
background: color-mix(in srgb, #22d3ee 8%, transparent);
|
||||
}
|
||||
|
||||
.gc-dash-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
background: var(--se-title-gradient, linear-gradient(90deg, var(--se-gold), #ffe566));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.gc-dash-sub {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.gc-dash-header-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gc-dash-stat {
|
||||
font-size: 0.68rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.gc-dash-stat strong {
|
||||
color: #34d399;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gc-dash-stat strong.down { color: var(--down); }
|
||||
.gc-dash-stat strong.up { color: #34d399; }
|
||||
|
||||
.gc-dash-btn {
|
||||
border: 1px solid color-mix(in srgb, #22d3ee 40%, var(--se-border));
|
||||
background: color-mix(in srgb, #22d3ee 10%, var(--se-bg-elevated));
|
||||
color: var(--se-text);
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gc-dash-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #22d3ee 18%, var(--se-bg-elevated));
|
||||
}
|
||||
|
||||
.gc-dash-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.gc-dash-error {
|
||||
margin: 8px 20px 0;
|
||||
color: var(--se-danger, var(--down));
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.gc-dash-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 24%) minmax(0, 1fr) minmax(240px, 26%);
|
||||
gap: 10px;
|
||||
padding: 10px 12px 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gc-dash-col {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.gc-dash-card {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid color-mix(in srgb, #22d3ee 18%, var(--se-border));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--se-bg-elevated) 92%, #0a1628);
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 color-mix(in srgb, #22d3ee 6%, transparent);
|
||||
}
|
||||
|
||||
.gc-dash-card--glow {
|
||||
border-color: color-mix(in srgb, #22d3ee 32%, var(--se-border));
|
||||
background: linear-gradient(165deg, color-mix(in srgb, #22d3ee 6%, var(--se-bg-elevated)) 0%, var(--se-bg-elevated) 55%);
|
||||
}
|
||||
|
||||
.gc-dash-card--warn {
|
||||
border-color: color-mix(in srgb, #fbbf24 40%, var(--se-border));
|
||||
}
|
||||
|
||||
.gc-dash-card--wide { /* grid child */ }
|
||||
|
||||
.gc-dash-card--chart { padding: 8px 10px 4px; }
|
||||
|
||||
.gc-dash-card--chart-sm { padding: 6px 8px 2px; }
|
||||
|
||||
.gc-dash-card--compact { padding: 10px; }
|
||||
|
||||
.gc-dash-card--flex {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gc-dash-card-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--se-gold, #e6c200);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Portfolio KPI ── */
|
||||
|
||||
.gc-dash-kpi-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.gc-dash-kpi-block {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: color-mix(in srgb, var(--se-bg) 60%, transparent);
|
||||
}
|
||||
|
||||
.gc-dash-kpi-label {
|
||||
display: block;
|
||||
font-size: 0.62rem;
|
||||
color: var(--se-text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gc-dash-kpi-val {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.gc-dash-kpi-sub {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
margin-top: 2px;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.gc-dash-kpi-sub.up { color: #34d399; }
|
||||
.gc-dash-kpi-sub.down { color: var(--down); }
|
||||
|
||||
.gc-dash-kv {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gc-dash-kv li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 5px 0;
|
||||
font-size: 0.72rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 60%, transparent);
|
||||
}
|
||||
|
||||
.gc-dash-kv li:last-child { border-bottom: none; }
|
||||
|
||||
.gc-dash-kv span { color: var(--se-text-muted); }
|
||||
|
||||
.gc-dash-kv strong { color: var(--se-text); font-weight: 600; }
|
||||
|
||||
/* ── Traffic light ── */
|
||||
|
||||
.gc-traffic-light {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
width: 48px;
|
||||
margin: 0 auto 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: color-mix(in srgb, var(--se-bg) 80%, #000);
|
||||
}
|
||||
|
||||
.gc-traffic-bulb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--se-text-dim) 25%, #111);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.gc-traffic-light--green .gc-traffic-bulb--g {
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 12px #34d399;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gc-traffic-light--yellow .gc-traffic-bulb--y {
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 12px #fbbf24;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gc-traffic-light--red .gc-traffic-bulb--r {
|
||||
background: #f87171;
|
||||
box-shadow: 0 0 12px #f87171;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gc-traffic-caption {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
/* ── Indicator bars ── */
|
||||
|
||||
.gc-ind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gc-ind-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gc-ind-bar-label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--se-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gc-ind-bar-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
height: 72px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gc-ind-block {
|
||||
width: 14px;
|
||||
height: 5px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--se-border) 70%, #111);
|
||||
}
|
||||
|
||||
.gc-ind-block--on.gc-ind-block--lo { background: #34d399; box-shadow: 0 0 4px #34d39988; }
|
||||
.gc-ind-block--on.gc-ind-block--mid { background: #22d3ee; box-shadow: 0 0 4px #22d3ee88; }
|
||||
.gc-ind-block--on.gc-ind-block--hi { background: #f87171; box-shadow: 0 0 4px #f8717188; }
|
||||
|
||||
.gc-ind-bar-pct {
|
||||
font-size: 0.58rem;
|
||||
color: var(--se-text-dim);
|
||||
}
|
||||
|
||||
/* ── Segment bars ── */
|
||||
|
||||
.gc-infra-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gc-seg-bar-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.gc-seg-bar-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.gc-seg-bar-val {
|
||||
font-size: 0.62rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.gc-seg-bar-track {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.gc-seg-bar-block {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--se-border) 55%, #0a0f18);
|
||||
}
|
||||
|
||||
/* ── Spark chart ── */
|
||||
|
||||
.gc-spark-chart-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.gc-spark-chart-head strong {
|
||||
color: #22d3ee;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.gc-spark-chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, #0a1628 80%, transparent);
|
||||
}
|
||||
|
||||
/* ── Gauges ── */
|
||||
|
||||
.gc-gauge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gc-gauge {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.gc-gauge-svg {
|
||||
width: 100%;
|
||||
max-width: 120px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gc-gauge-body {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.gc-gauge-value {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.gc-gauge-label {
|
||||
display: block;
|
||||
font-size: 0.58rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
/* ── Process cards ── */
|
||||
|
||||
.gc-proc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gc-proc {
|
||||
border: 1px solid var(--se-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: color-mix(in srgb, var(--se-bg) 70%, transparent);
|
||||
}
|
||||
|
||||
.gc-proc--ok { border-left: 3px solid #34d399; }
|
||||
.gc-proc--warn { border-left: 3px solid #fbbf24; }
|
||||
.gc-proc--down { border-left: 3px solid #f87171; }
|
||||
.gc-proc--off { border-left: 3px solid var(--se-text-dim); opacity: 0.85; }
|
||||
|
||||
.gc-proc-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gc-proc-layer {
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--se-text-dim);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.gc-proc-badge {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gc-proc--ok .gc-proc-badge { background: color-mix(in srgb, #34d399 20%, transparent); color: #34d399; }
|
||||
.gc-proc--warn .gc-proc-badge { background: color-mix(in srgb, #fbbf24 20%, transparent); color: #fbbf24; }
|
||||
.gc-proc--down .gc-proc-badge { background: color-mix(in srgb, #f87171 20%, transparent); color: #f87171; }
|
||||
|
||||
.gc-proc-name {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gc-proc-metrics {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.gc-proc-metrics li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.gc-proc-metrics strong { color: var(--se-text); font-weight: 500; }
|
||||
|
||||
/* ── Signal list ── */
|
||||
|
||||
.gc-signal-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gc-signal-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 7px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 50%, transparent);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.gc-signal-item--buy .gc-signal-arrow { color: #34d399; }
|
||||
.gc-signal-item--sell .gc-signal-arrow { color: #f87171; }
|
||||
|
||||
.gc-signal-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
align-items: baseline;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--se-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gc-signal-main strong {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.gc-signal-price {
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.gc-signal-time {
|
||||
font-size: 0.58rem;
|
||||
color: var(--se-text-dim);
|
||||
}
|
||||
|
||||
.gc-dash-muted {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--se-text-dim);
|
||||
}
|
||||
|
||||
/* ── Stale chips ── */
|
||||
|
||||
.gc-stale-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gc-stale-chip {
|
||||
border: 1px solid color-mix(in srgb, #fbbf24 35%, var(--se-border));
|
||||
background: color-mix(in srgb, #fbbf24 10%, transparent);
|
||||
color: var(--se-text);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.gc-dash-body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gc-dash-col {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
@@ -989,7 +989,8 @@
|
||||
transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out, background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.vtd-card--receiving .vtd-sig-panel {
|
||||
.vtd-card--receiving .vtd-sig-panel,
|
||||
.vtd-card--receiving .vtd-card-chart-panel {
|
||||
box-shadow: inset 0 0 24px color-mix(in srgb, #b9f6ca 8%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
import { API_BASE } from './backendApi';
|
||||
import { timeframeToCandleType } from './chartCandleType';
|
||||
import { fetchUpbitCandlesBeforeCached } from './requestCache';
|
||||
import { normalizeEpochSec } from './backtestUiUtils';
|
||||
|
||||
/** 백엔드·업비트 history API용 ISO (Z 접미사 없음 — 서버에서 추가) */
|
||||
export function toHistoryApiIso(epochSec: number): string {
|
||||
return new Date(epochSec * 1000).toISOString().replace(/\.\d{3}Z$/, '');
|
||||
}
|
||||
|
||||
/** 백테스팅·실매매 분석용 과거 캔들 로드 */
|
||||
export async function loadAnalysisCandles(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
toTimeSec: number,
|
||||
count: number,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const type = timeframeToCandleType(timeframe);
|
||||
const toSec = normalizeEpochSec(toTimeSec);
|
||||
const to = toHistoryApiIso(toSec);
|
||||
const safeCount = Math.max(50, Math.min(count, 500));
|
||||
const params = new URLSearchParams({
|
||||
market,
|
||||
type,
|
||||
to,
|
||||
count: String(safeCount),
|
||||
});
|
||||
|
||||
let data: OHLCVBar[] = [];
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`);
|
||||
if (res.ok) {
|
||||
const raw: Array<Record<string, number>> = await res.json();
|
||||
data = raw.map(d => ({
|
||||
time: d.time,
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume,
|
||||
}));
|
||||
}
|
||||
} catch { /* backend fallback below */ }
|
||||
|
||||
if (data.length === 0) {
|
||||
try {
|
||||
data = await fetchUpbitCandlesBeforeCached(market, timeframe, safeCount, to);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return data.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Timeframe } from '../types';
|
||||
|
||||
export function normalizeChartTimeframe(raw: string | undefined): Timeframe {
|
||||
const t = (raw ?? '1h').trim();
|
||||
const lower = t.toLowerCase();
|
||||
if (lower === '1d') return '1D';
|
||||
if (lower === '1w') return '1W';
|
||||
if (lower === '1m' && t === '1M') return '1M';
|
||||
if (['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h'].includes(lower)) return lower as Timeframe;
|
||||
if (t === '1D' || t === '1W' || t === '1M') return t as Timeframe;
|
||||
return '1h';
|
||||
}
|
||||
|
||||
/** Unix epoch → 초 단위 정규화 (유효하지 않으면 0) */
|
||||
export function normalizeEpochSecOptional(t: number | undefined | null): number {
|
||||
if (t == null || !Number.isFinite(t) || t <= 0) return 0;
|
||||
return t > 1e12 ? Math.floor(t / 1000) : Math.floor(t);
|
||||
}
|
||||
|
||||
/** Unix epoch → 초 단위 정규화 (유효하지 않으면 현재 시각) */
|
||||
export function normalizeEpochSec(t: number | undefined | null): number {
|
||||
const v = normalizeEpochSecOptional(t);
|
||||
return v > 0 ? v : Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
export function fmtListTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function fmtShortTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const yy = String(d.getFullYear()).slice(2);
|
||||
return `${yy}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export const pct = (v: number, dec = 1) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '0.0%';
|
||||
|
||||
export const pctAbs = (v: number, dec = 0) =>
|
||||
isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '0%';
|
||||
|
||||
export const krw = (v: number) =>
|
||||
isFinite(v) ? `${v >= 0 ? '+' : ''}₩${Math.round(v).toLocaleString()}` : '–';
|
||||
|
||||
const TF_SEC: Record<string, number> = {
|
||||
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
||||
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
||||
};
|
||||
|
||||
export function timeframeBarSeconds(tf: string): number {
|
||||
const n = normalizeChartTimeframe(tf);
|
||||
return TF_SEC[n] ?? 3600;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { PaperSummaryDto, PaperTradeDto } from './backendApi';
|
||||
import { computePaperMetrics } from './paperMetrics';
|
||||
|
||||
export interface LiveExecutionItem {
|
||||
id: string;
|
||||
symbol: string;
|
||||
strategyLabel: string;
|
||||
sourceLabel: string;
|
||||
trades: PaperTradeDto[];
|
||||
createdAt: string;
|
||||
totalReturnPct: number;
|
||||
winRatePct: number;
|
||||
tradeCount: number;
|
||||
mddPct: number;
|
||||
sharpeRatio: number;
|
||||
profitFactor: number;
|
||||
}
|
||||
|
||||
function dayKey(iso: string | null | undefined): string {
|
||||
if (!iso) return 'unknown';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'STRATEGY') return '자동';
|
||||
return '수동';
|
||||
}
|
||||
|
||||
/** 가상투자·모의투자 체결 이력을 실행 단위 목록으로 그룹 */
|
||||
export function buildLiveExecutionItems(
|
||||
trades: PaperTradeDto[],
|
||||
summary: PaperSummaryDto | null,
|
||||
strategyNames: Record<number, string> = {},
|
||||
): LiveExecutionItem[] {
|
||||
if (!trades.length) return [];
|
||||
|
||||
const groups = new Map<string, PaperTradeDto[]>();
|
||||
for (const t of trades) {
|
||||
const key = `${t.symbol}|${t.strategyId ?? 0}|${dayKey(t.createdAt)}|${t.source}`;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(t);
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const initial = summary?.initialCapital ?? 10_000_000;
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([key, groupTrades]) => {
|
||||
const sorted = [...groupTrades].sort((a, b) =>
|
||||
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
|
||||
);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const metrics = computePaperMetrics(sorted, summary);
|
||||
const buyVol = sorted.filter(t => t.side === 'BUY').reduce((s, t) => s + t.netAmount, 0);
|
||||
const sellVol = sorted.filter(t => t.side === 'SELL').reduce((s, t) => s + t.netAmount, 0);
|
||||
const pnl = sellVol - buyVol;
|
||||
const totalReturnPct = initial > 0 ? pnl / initial : 0;
|
||||
|
||||
return {
|
||||
id: key,
|
||||
symbol: first.symbol,
|
||||
strategyLabel: first.strategyId != null
|
||||
? (strategyNames[first.strategyId] ?? `전략 #${first.strategyId}`)
|
||||
: '수동 매매',
|
||||
sourceLabel: sourceLabel(first.source),
|
||||
trades: sorted,
|
||||
createdAt: last.createdAt ?? first.createdAt ?? new Date().toISOString(),
|
||||
totalReturnPct,
|
||||
winRatePct: metrics.winRatePct / 100,
|
||||
tradeCount: sorted.length,
|
||||
mddPct: metrics.mddPct / 100,
|
||||
sharpeRatio: metrics.sharpeRatio,
|
||||
profitFactor: metrics.profitFactor,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function paperTradesToSignals(trades: PaperTradeDto[]) {
|
||||
return trades
|
||||
.filter(t => t.createdAt)
|
||||
.map(t => ({
|
||||
time: Math.floor(new Date(t.createdAt!).getTime() / 1000),
|
||||
type: t.side as 'BUY' | 'SELL',
|
||||
price: t.price,
|
||||
barIndex: 0,
|
||||
}));
|
||||
}
|
||||