diff --git a/.tmp_master_plan/page_01.png b/.tmp_master_plan/page_01.png new file mode 100644 index 0000000..74b8bb7 Binary files /dev/null and b/.tmp_master_plan/page_01.png differ diff --git a/.tmp_master_plan/page_02.png b/.tmp_master_plan/page_02.png new file mode 100644 index 0000000..e34f0de Binary files /dev/null and b/.tmp_master_plan/page_02.png differ diff --git a/.tmp_master_plan/page_03.png b/.tmp_master_plan/page_03.png new file mode 100644 index 0000000..e766829 Binary files /dev/null and b/.tmp_master_plan/page_03.png differ diff --git a/.tmp_master_plan/page_04.png b/.tmp_master_plan/page_04.png new file mode 100644 index 0000000..920ce87 Binary files /dev/null and b/.tmp_master_plan/page_04.png differ diff --git a/.tmp_master_plan/page_05.png b/.tmp_master_plan/page_05.png new file mode 100644 index 0000000..f492ace Binary files /dev/null and b/.tmp_master_plan/page_05.png differ diff --git a/.tmp_master_plan/page_06.png b/.tmp_master_plan/page_06.png new file mode 100644 index 0000000..88b7ca5 Binary files /dev/null and b/.tmp_master_plan/page_06.png differ diff --git a/.tmp_master_plan/page_07.png b/.tmp_master_plan/page_07.png new file mode 100644 index 0000000..6faee03 Binary files /dev/null and b/.tmp_master_plan/page_07.png differ diff --git a/.tmp_master_plan/page_08.png b/.tmp_master_plan/page_08.png new file mode 100644 index 0000000..ffd99d1 Binary files /dev/null and b/.tmp_master_plan/page_08.png differ diff --git a/.tmp_master_plan/page_09.png b/.tmp_master_plan/page_09.png new file mode 100644 index 0000000..6751853 Binary files /dev/null and b/.tmp_master_plan/page_09.png differ diff --git a/.tmp_master_plan/page_10.png b/.tmp_master_plan/page_10.png new file mode 100644 index 0000000..94062cc Binary files /dev/null and b/.tmp_master_plan/page_10.png differ diff --git a/.tmp_master_plan/page_11.png b/.tmp_master_plan/page_11.png new file mode 100644 index 0000000..a5078f5 Binary files /dev/null and b/.tmp_master_plan/page_11.png differ diff --git a/.tmp_master_plan/page_12.png b/.tmp_master_plan/page_12.png new file mode 100644 index 0000000..4e2ea41 Binary files /dev/null and b/.tmp_master_plan/page_12.png differ diff --git a/.tmp_master_plan/page_13.png b/.tmp_master_plan/page_13.png new file mode 100644 index 0000000..f9464e1 Binary files /dev/null and b/.tmp_master_plan/page_13.png differ diff --git a/.tmp_master_plan/page_14.png b/.tmp_master_plan/page_14.png new file mode 100644 index 0000000..b7af0f9 Binary files /dev/null and b/.tmp_master_plan/page_14.png differ diff --git a/.tmp_master_plan/page_15.png b/.tmp_master_plan/page_15.png new file mode 100644 index 0000000..5889812 Binary files /dev/null and b/.tmp_master_plan/page_15.png differ diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index 3f00414..9690445 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -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 ( - - ); -} - interface Props { theme?: Theme; } export function BacktestHistoryPage({ theme = 'dark' }: Props) { + const [tab, setTab] = useState('backtest'); const [records, setRecords] = useState([]); - const [selected, setSelected] = useState(null); + const [liveItems, setLiveItems] = useState([]); + const [selectedBacktest, setSelectedBacktest] = useState(null); + const [selectedLive, setSelectedLive] = useState(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) => { + 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 ? ( -
-

백테스팅 이력이 없습니다.

-

차트 화면에서 전략을 선택하고
백테스팅을 실행하세요.

-
- ) : ( -
- - {records.map(r => ( - setSelected(r)} - /> - ))} -
- ); + 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 ( +
+
+
+

백테스팅

+ Golden Analysis Command Center +
+
+
분석 데이터 로딩…
+
+ ); + } return ( - - - - - )} - leftTitle="실행 이력" - left={leftContent} - centerHead={selected ? ( - {selected.strategyName || '전략 없음'} · {selected.symbol.replace(/^KRW-/, '')} - ) : undefined} - center={selected && selectedDetail ? ( - - ) : ( -
- 📈 -

좌측 목록에서 백테스팅 결과를 선택하세요.

+
+
+
+

백테스팅

+ Golden Analysis Command Center
- )} - rightTitle="결과 요약" - right={selected ? ( -
-
-
전략
-
{selected.strategyName || '전략 없음'}
-
-
-
종목 / 타임프레임
-
{selected.symbol.replace(/^KRW-/, '')} · {selected.timeframe}
-
-
-
실행일 / 봉 수
-
{fmtDate(selected.createdAt)} · {selected.barCount.toLocaleString()}봉
-
-
-
총 수익률
-
= 0 ? 'up' : 'down'}`}> - {pct(selected.totalReturn ?? 0)} -
-
- {selectedDetail?.analysis && ( -
-
MDD / Sharpe
-
- {pct(selectedDetail.analysis.maxDrawdownPct)} · {selectedDetail.analysis.sharpeRatio.toFixed(2)} -
-
+
+ + {tab === 'backtest' && ( + )}
- ) : ( -
선택된 결과가 없습니다.
- )} - footerLabel={signalStats ? '시그널 요약' : undefined} - footer={signalStats ? ( -
-
- 매수 시그널 - {signalStats.buy} +
+ +
+ + +
+ +
+ {centerTitle && ( +
+

{centerTitle}

+
+ )} +
+ {chartProps ? ( + + ) : ( +
+ 📈 +

좌측 목록에서 분석할 실행 결과를 선택하세요.

+
+ )}
-
- 매도 시그널 - {signalStats.sell} + +
+ +
+ +
- ) : undefined} - /> + +
+
); } diff --git a/frontend/src/components/DashboardPage.tsx b/frontend/src/components/DashboardPage.tsx index f6aea2c..4273905 100644 --- a/frontend/src/components/DashboardPage.tsx +++ b/frontend/src/components/DashboardPage.tsx @@ -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 = { healthy: '정상', degraded: '주의', @@ -25,21 +28,10 @@ const STATUS_LABEL: Record = { 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 }) => ( -
-
- {proc.layer} - - {STATUS_LABEL[proc.status] ?? proc.status} - +
+
+ {proc.layer} + {STATUS_LABEL[proc.status] ?? proc.status}
-

{proc.name}

-
    - {Object.entries(proc.metrics).slice(0, 6).map(([k, v]) => ( -
  • - {k} - {formatMetricValue(v)} -
  • +

    {proc.name}

    +
      + {Object.entries(proc.metrics).slice(0, 4).map(([k, v]) => ( +
    • {k}{String(v ?? '—')}
    • ))}
); +const HISTORY_LEN = 48; + const DashboardPage: React.FC = ({ theme, onGoChart }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const tickHistory = useRef([]); + const wsHistory = useRef([]); + const [, bump] = useState(0); + + const pushHistory = useCallback((ref: React.MutableRefObject, 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 = ({ theme, onGoChart }) => { return () => window.clearInterval(id); }, [refresh]); + const mon = data?.systemMonitor; const app = data?.app as Record | 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 ( -
-
-

운영 대시보드

-
+
+
+
+ [01] Standard Dashboard +

GoldenChart Command Center

+

실시간 투자 분석 · 파이프라인 모니터링 · 시스템 헬스체크

+
+
+ Global Latency: {latencyMs}ms + Active Markets: {stompConnections} {mon && ( - - 파이프라인 {mon.pipelineEnabled ? 'ON' : 'OFF'} - {mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)} 갱신` : ''} + + Pipeline {mon.pipelineEnabled ? 'ON' : 'OFF'} + {mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)}` : ''} )} -
- {error &&

{error}

} + {error &&

{error}

} {data && mon && ( -
- {/* JVM · 트래픽 */} -
-

JVM · 트래픽

-
-
-
- Heap {mon.jvm.heapUsedMb} / {mon.jvm.heapMaxMb} MB ({mon.jvm.heapPct}%) +
+ {/* ── LEFT: Portfolio + Indicators ── */} + -
-

실거래

-
    -
  • KRW{fmtKrw(data.live.krwBalance)}
  • -
  • 보유{data.live.positions?.length ?? 0} 종목
  • -
  • 연동{data.live.configured ? 'OK' : '미설정'}
  • -
-
+ {/* ── CENTER: Infrastructure + Chart ── */} +
+
+

[04] 데이터 파이프라인 · Ta4j 엔진

+
+ + 0.7 ? 'amber' : 'cyan'} + /> + + +
+
-
-

최근 시그널

- {data.recentSignals.length === 0 ? ( -

최근 시그널 없음

- ) : ( - - - - - - {data.recentSignals.map(s => ( - - - - - - - +
+ +
+ +
+

멀티스레드 프로세스

+
+ {mon.processes.map(p => )} +
+
+ + {mon.staleMarkets.length > 0 && ( +
+

틱 정체 종목 ({mon.staleMarkets.length})

+
+ {mon.staleMarkets.slice(0, 8).map(s => ( + ))} -
-
종목방향가격분봉시각
- - {s.signalType}{s.price?.toLocaleString('ko-KR')}{s.candleType}{s.createdAt?.slice(0, 19) ?? '—'}
+
+
)} - + + + {/* ── RIGHT: Gauges + Signals ── */} +
)}
diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx new file mode 100644 index 0000000..db69b60 --- /dev/null +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -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) => Record, +): IndicatorConfig[] { + const def = getIndicatorDef('SMA'); + if (!def) return []; + const params: Record = { ...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 = ({ + symbol, + timeframe: timeframeRaw, + toTimeSec, + fromTimeSec, + barCount, + signals, + strategyId, + theme = 'dark', +}) => { + const timeframe = normalizeChartTimeframe(timeframeRaw); + const { getParams, getVisualConfig } = useIndicatorSettings(); + const [bars, setBars] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [chartType] = useState('candlestick'); + const [strategy, setStrategy] = useState(); + const managerRef = useRef(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 ( +
+
+
+ {symLabel} + {timeframe} + Candle +
+
+ + + + {effectiveBarCount.toLocaleString()}봉 +
+
+
+
+ {loading &&
차트 로딩…
} + {error && !loading &&
{error}
} + {!loading && !error && bars.length > 0 && ( + + )} + {!loading && !error && bars.length === 0 && ( +
표시할 캔들 데이터가 없습니다.
+ )} +
+ {!loading && bars.length > 0 && } +
+
+ ); +}; + +export default BacktestAnalysisChart; diff --git a/frontend/src/components/backtest/BacktestExecutionList.tsx b/frontend/src/components/backtest/BacktestExecutionList.tsx new file mode 100644 index 0000000..02c1bb7 --- /dev/null +++ b/frontend/src/components/backtest/BacktestExecutionList.tsx @@ -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('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 ( +
+
+ + +
+ + {tab === 'backtest' && ( +
+ + + +
+ )} + +
+ {tab === 'backtest' ? ( + sortedBacktests.length === 0 ? ( +
+

백테스팅 이력이 없습니다.

+

실시간 차트에서 전략을 선택하고
백테스팅을 실행하세요.

+
+ ) : ( + sortedBacktests.map(r => { + const ret = r.totalReturn ?? 0; + const positive = ret >= 0; + const active = selectedBacktestId === r.id; + const sym = r.symbol.replace(/^KRW-/, ''); + return ( + + ); + }) + ) + ) : liveItems.length === 0 ? ( +
+

실시간 매매 이력이 없습니다.

+

가상투자 화면에서 자동·수동
매매를 실행하세요.

+
+ ) : ( + liveItems.map(item => { + const positive = item.totalReturnPct >= 0; + const active = selectedLiveId === item.id; + const sym = item.symbol.replace(/^KRW-/, ''); + return ( + + ); + }) + )} +
+
+ ); +} diff --git a/frontend/src/components/backtest/BacktestKpiPanel.tsx b/frontend/src/components/backtest/BacktestKpiPanel.tsx new file mode 100644 index 0000000..fe23787 --- /dev/null +++ b/frontend/src/components/backtest/BacktestKpiPanel.tsx @@ -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 ( +
+ {label} + + {value} + +
+ ); +} + +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
{emptyText}
; + } + + 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 ( +
+
+
+

수익성

+ Profitability +
+
+
+
+ 총 수익률 + = 0 ? 'up' : 'down'}`}> + {pct(totalReturn)} + +
+ +
+
+ + = initialCap ? 'up' : 'down'} /> + = 0 ? 'up' : 'down'} /> + = 0 ? 'up' : 'down'} /> + + + = 0 ? 'up' : 'down'} /> + = 1 ? 'up' : 'down'} /> +
+
+
+ +
+
+

리스크 관리

+ Risk +
+
+
+
+ MDD (최대낙폭) + {pct(mdd)} +
+
+ Max Runup + {pct(maxRunup)} +
+
+
+ + + + = 1 ? 'up' : 'neutral'} /> + + +
+
+
+ +
+
+

거래 통계

+ Trades +
+
+
+
+ 승률 + = 0.5 ? 'up' : 'down'}`}> + {pctAbs(winRate, 1)} + +
+
+ 총 거래 + {tradeCount}회 +
+
+
+ + + + 0 ? pctAbs(winCount / tradeCount, 0) : '0%'} tone="gold" /> + + + = 1 ? 'up' : 'down'} /> + +
+

+ 총 {tradeCount}회 · 승 {winCount} / 패 {lossCount} + {breakEven > 0 ? ` / 무 ${breakEven}` : ''} + · PF {num(profitFactor)} +

+
+
+
+ ); +} diff --git a/frontend/src/components/backtest/BacktestOscillatorPanes.tsx b/frontend/src/components/backtest/BacktestOscillatorPanes.tsx new file mode 100644 index 0000000..490de33 --- /dev/null +++ b/frontend/src/components/backtest/BacktestOscillatorPanes.tsx @@ -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 = ({ 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 ( +
+
+
+ RSI (14) + {rsiLast != null ? rsiLast.toFixed(1) : '–'} +
+ + + + + + +
+
+
+ MACD (12, 26, 9) + {macdLast != null ? macdLast.toFixed(2) : '–'} +
+ + + + +
+
+ ); +}; + +export default BacktestOscillatorPanes; diff --git a/frontend/src/components/backtest/BacktestTradeHistoryCard.tsx b/frontend/src/components/backtest/BacktestTradeHistoryCard.tsx new file mode 100644 index 0000000..cfb4f96 --- /dev/null +++ b/frontend/src/components/backtest/BacktestTradeHistoryCard.tsx @@ -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 ( +
+

+ 상세 거래 내역 + {trades.length}건 +

+
+ {trades.length === 0 ? ( +

거래 내역이 없습니다.

+ ) : ( + + + + + + + + + + + {trades.map(t => ( + + + + + + + ))} + +
일시유형체결가손익
{fmtDateTime(t.time)}{t.side === 'buy' ? '매수' : '매도'}{Math.round(t.price).toLocaleString()}= 0 ? 'up' : 'down') : ''}> + {t.pnlPct != null ? pct(t.pnlPct) : '–'} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardGauge.tsx b/frontend/src/components/dashboard/DashboardGauge.tsx new file mode 100644 index 0000000..d089cb5 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardGauge.tsx @@ -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 = { + 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 ( +
+ + + {arcPath && ( + + )} + +
+ {value} + {label} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardIndicatorBar.tsx b/frontend/src/components/dashboard/DashboardIndicatorBar.tsx new file mode 100644 index 0000000..3648d86 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardIndicatorBar.tsx @@ -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 ( +
+ {label} +
+ {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 ; + })} +
+ {Math.round(pct)}% +
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardSegmentBar.tsx b/frontend/src/components/dashboard/DashboardSegmentBar.tsx new file mode 100644 index 0000000..ea6c50d --- /dev/null +++ b/frontend/src/components/dashboard/DashboardSegmentBar.tsx @@ -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 = { + 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 ( +
+
+ {label} + {valueLabel} +
+
+ {Array.from({ length: segments }, (_, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardSparkChart.tsx b/frontend/src/components/dashboard/DashboardSparkChart.tsx new file mode 100644 index 0000000..622e0cf --- /dev/null +++ b/frontend/src/components/dashboard/DashboardSparkChart.tsx @@ -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 ( +
+ {label && ( +
+ {label} + {last.toFixed(1)}/s +
+ )} + + + + + + + + {area && } + {path && ( + + )} + +
+ ); +} diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx index 0598432..e0e6deb 100644 --- a/frontend/src/components/virtual/VirtualTargetCard.tsx +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -75,10 +75,10 @@ const VirtualTargetCard: React.FC = ({ 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 (
.chart-wrap, +.btd-analysis-canvas-wrap > div { + height: 100%; + min-height: 360px; +} + +.btd-analysis-loading, +.btd-analysis-error { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.82rem; + color: var(--se-text-muted); +} + +.btd-analysis-error { color: var(--btd-bear); } + +.btd-kpi-panel { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} + +.btd-kpi-panel--empty { + padding: 24px 12px; + text-align: center; + color: var(--se-text-dim); + font-size: 0.78rem; +} + +.btd-kpi-block { + border: 1px solid var(--btd-divider); + border-radius: 8px; + background: var(--btd-surface); + padding: 12px; +} + +.btd-kpi-block-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 2px; +} + +.btd-kpi-block-title { + margin: 0; + font-size: 0.72rem; + font-weight: 800; + color: var(--btd-gold); + letter-spacing: 0.02em; +} + +.btd-kpi-block-sub { + font-size: 0.62rem; + color: var(--se-text-dim); + margin-bottom: 8px; +} + +.btd-kpi-hero { + font-size: 1.5rem; + font-weight: 800; + line-height: 1.1; + margin-bottom: 8px; +} + +.btd-kpi-hero.up { color: var(--btd-bull); } +.btd-kpi-hero.down { color: var(--btd-bear); } + +.btd-kpi-dl { + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.btd-kpi-dl--grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.btd-kpi-dl div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.btd-kpi-dl dt { + font-size: 0.62rem; + color: var(--se-text-muted); +} + +.btd-kpi-dl dd { + margin: 0; + font-size: 0.82rem; + font-weight: 700; + color: var(--se-text); +} + +.btd-kpi-dl dd.up { color: var(--btd-bull); } +.btd-kpi-dl dd.down { color: var(--btd-bear); } +.btd-kpi-dl dd.gold { color: var(--btd-gold); } + +.btd-table-card--footer { + border: none; + background: transparent; + box-shadow: none; + padding: 0; + height: 100%; +} + +.btd-table-card--footer .btd-table-scroll { + max-height: 100%; +} + +/* ══ Golden Analysis — 전용 페이지 레이아웃 ══ */ + +.btd-page { + display: flex; + flex-direction: column; + flex: 1; + height: calc(100vh - var(--tmb-h, 44px)); + min-height: 0; + background: var(--se-bg, var(--bg)); + color: var(--se-text, var(--text)); + overflow: hidden; +} + +.btd-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 18px; + border-bottom: 1px solid var(--se-header-border, var(--se-border)); + background: var(--se-header-bg, var(--se-bg-elevated)); + flex-shrink: 0; +} + +.btd-header-brand { + display: flex; + align-items: baseline; + gap: 12px; +} + +.btd-header-title { + margin: 0; + font-size: 1.05rem; + 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; + color: transparent; +} + +.btd-header-sub { + font-size: 0.72rem; + color: var(--se-text-muted); +} + +.btd-header-actions { + display: flex; + gap: 8px; +} + +.btd-btn { + border: 1px solid var(--se-border); + background: var(--se-bg-elevated, var(--bg2)); + color: var(--se-text); + border-radius: 8px; + padding: 7px 14px; + font-size: 0.78rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.btd-btn:hover:not(:disabled) { + background: var(--se-item-hover-bg); + border-color: color-mix(in srgb, var(--se-accent) 35%, var(--se-border)); +} + +.btd-btn--ghost { background: transparent; } +.btd-btn--danger { border-color: color-mix(in srgb, var(--se-sell) 45%, transparent); color: var(--se-sell); } +.btd-btn:disabled { opacity: 0.35; cursor: not-allowed; } + +.btd-loading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--se-text-muted); +} + +.btd-body { + flex: 1; + min-height: 0; + display: flex; + align-items: stretch; +} + +.btd-sidebar, +.btd-right { + flex-shrink: 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--se-panel-shell-bg, var(--se-bg)); + border-color: var(--se-border); +} + +.btd-sidebar { + border-right: 1px solid var(--btd-divider); + padding: 10px 8px; +} + +.btd-right { + border-left: 1px solid var(--btd-divider); + padding: 10px 8px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} + +.btd-right-stack { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.btd-right-section { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.btd-right-section--solo { + flex: 1 1 auto; +} + +.btd-right-section--kpi { + overflow-y: auto; + padding: 4px 2px; + gap: 0; +} + +.btd-right-section--trades { + padding: 0; +} + +.btd-right-section--trades .btd-trades-card { + flex: 1; + min-height: 0; + height: 100%; +} + +.btd-trades-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--btd-divider); + border-radius: 8px; + background: var(--btd-surface); + overflow: hidden; +} + +.btd-trades-card-head { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--btd-divider); + background: color-mix(in srgb, var(--btd-gold-bg) 60%, var(--btd-surface)); +} + +.btd-trades-card-head .btd-kpi-card-title { + margin: 0; + font-size: 0.78rem; +} + +.btd-trades-count { + font-size: 0.62rem; + color: var(--se-text-muted); + font-variant-numeric: tabular-nums; +} + +.btd-trades-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +.btd-trades-empty { + margin: 0; + padding: 20px 12px; + text-align: center; + color: var(--se-text-dim); + font-size: 0.72rem; +} + +.btd-table--compact th, +.btd-table--compact td { + padding: 6px 8px; + font-size: 0.65rem; +} + +.btd-table--compact th { + font-size: 0.58rem; +} + +.btd-main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--se-center-bg, var(--se-bg)); +} + +.btd-main-head { + flex-shrink: 0; + padding: 8px 12px 0; +} + +.btd-main-title { + margin: 0; + font-size: 0.88rem; + font-weight: 700; + color: var(--btd-gold); +} + +.btd-main-content { + flex: 1; + min-height: 0; + padding: 8px 10px; + display: flex; + flex-direction: column; +} + +.btd-main .btd-empty { + flex: 1; + min-height: 280px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--se-text-dim); + font-size: 0.85rem; + border: 1px dashed var(--btd-divider); + border-radius: 8px; + background: color-mix(in srgb, var(--btd-surface) 50%, transparent); +} + +.btd-main .btd-empty span { font-size: 48px; opacity: 0.35; } + +.btd-sidebar .btd-sidebar-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 12px; + text-align: center; + color: var(--se-text-dim); + font-size: 0.78rem; + gap: 8px; +} + +.btd-splitter { + flex-shrink: 0; + background: var(--btd-divider); + transition: background 0.12s; + touch-action: none; + z-index: 6; +} + +.btd-splitter:hover, +.btd-splitter.btd-splitter--active { + background: color-mix(in srgb, var(--btd-gold) 35%, var(--btd-divider)); +} + +.btd-splitter--v { width: 4px; cursor: col-resize; } +.btd-splitter--h { height: 4px; cursor: row-resize; margin: 0 8px; } + +.btd-footer { + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 88px; + border-top: 1px solid var(--btd-divider); + background: var(--btd-surface); + margin: 0 8px 8px; + border-radius: 8px; + overflow: hidden; +} + +.btd-footer .btd-table-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +.btd-footer-label { + flex-shrink: 0; + padding: 6px 12px; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.12em; + color: var(--btd-gold); + border-bottom: 1px solid var(--btd-divider); +} + +/* ── 목록 카드 (참조 UI) ── */ + +.btd-exec-filters { + display: flex; + gap: 4px; + padding: 4px 0 8px; + flex-shrink: 0; +} + +.btd-exec-filter { + flex: 1; + padding: 5px 4px; + border: 1px solid var(--btd-divider); + border-radius: 6px; + background: transparent; + color: var(--se-text-muted); + font-size: 0.62rem; + font-weight: 600; + cursor: pointer; +} + +.btd-exec-filter--on { + color: var(--btd-gold); + border-color: color-mix(in srgb, var(--btd-gold) 45%, var(--btd-divider)); + background: var(--btd-gold-bg); +} + +.btd-history-card { + width: 100%; + text-align: left; + border: 1px solid var(--btd-divider); + border-radius: 8px; + background: var(--btd-surface); + padding: 10px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.btd-history-card:hover { + border-color: color-mix(in srgb, var(--btd-gold) 35%, var(--btd-divider)); +} + +.btd-history-card--active { + border-color: var(--se-item-selected-border, var(--btd-gold-border-strong)); + background: var(--se-item-selected-bg, var(--btd-gold-bg)); + box-shadow: var(--se-strat-item-shadow, 0 1px 3px rgba(0, 0, 0, 0.22)); +} + +.btd-history-card-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.btd-history-card-main { + flex: 1; + min-width: 0; +} + +.btd-history-title { + display: block; + font-size: 0.72rem; + font-weight: 700; + color: var(--se-text); + line-height: 1.35; + margin-bottom: 4px; +} + +.btd-history-date { + display: block; + font-size: 0.62rem; + color: var(--se-text-dim); +} + +.btd-history-card-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + flex-shrink: 0; +} + +.btd-history-win { + font-size: 0.62rem; + color: var(--se-text-muted); +} + +.btd-history-roi { + font-size: 0.78rem; + font-weight: 800; +} + +.btd-history-roi.up { color: var(--btd-bull); } +.btd-history-roi.down { color: var(--btd-bear); } + +/* ── 차트 영역 ── */ + +.btd-analysis-chart { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--btd-divider); + border-radius: 8px; + background: var(--btd-surface); + overflow: hidden; +} + +.btd-analysis-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--btd-divider); + flex-shrink: 0; + background: color-mix(in srgb, var(--btd-bg) 50%, var(--btd-surface)); +} + +.btd-analysis-toolbar-left, +.btd-analysis-toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.btd-analysis-select--static { + font-size: 0.72rem; + font-weight: 600; + color: var(--se-text); + padding: 4px 10px; + border-radius: 6px; + border: 1px solid var(--btd-divider); + background: color-mix(in srgb, var(--btd-bg) 60%, transparent); +} + +.btd-analysis-tool { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + border: 1px solid var(--btd-divider); + color: var(--se-text-muted); + font-size: 0.82rem; + cursor: default; +} + +.btd-analysis-main { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.btd-analysis-canvas-wrap { + flex: 1; + min-height: 320px; + position: relative; + overflow: hidden; +} + +.btd-analysis-canvas-wrap .tv-chart-wrap { + height: 100% !important; + min-height: 320px; +} + +.btd-analysis-canvas-wrap .chart-container { + min-height: 320px; +} + +/* ── RSI / MACD pane ── */ + +.btd-osc-stack { + flex-shrink: 0; + display: flex; + flex-direction: column; + border-top: 1px solid var(--btd-divider); +} + +.btd-osc-pane { + padding: 6px 10px 4px; + border-bottom: 1px solid color-mix(in srgb, var(--btd-divider) 70%, transparent); +} + +.btd-osc-pane:last-child { border-bottom: none; } + +.btd-osc-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2px; +} + +.btd-osc-label { + font-size: 0.62rem; + font-weight: 700; + color: var(--se-text-muted); +} + +.btd-osc-val { + font-size: 0.62rem; + color: var(--btd-gold); + font-variant-numeric: tabular-nums; +} + +.btd-osc-svg { + width: 100%; + height: 52px; + display: block; +} + +.btd-osc-ref { + stroke: color-mix(in srgb, var(--se-text-muted) 35%, transparent); + stroke-width: 0.5; + stroke-dasharray: 3 3; +} + +.btd-osc-ref--hi { stroke: color-mix(in srgb, var(--btd-bear) 40%, transparent); } +.btd-osc-ref--lo { stroke: color-mix(in srgb, var(--btd-bull) 40%, transparent); } + +.btd-osc-line--rsi { stroke: #7e57c2; stroke-width: 1.5; } +.btd-osc-line--macd { stroke: #2962ff; stroke-width: 1.5; } + +/* ── KPI 카드 (참조 UI) ── */ + +.btd-kpi-card { + border: 1px solid var(--btd-divider); + border-radius: 8px; + background: var(--btd-surface); + padding: 12px; + margin-bottom: 10px; +} + +.btd-right-kpi .btd-kpi-card:last-child { + margin-bottom: 0; +} + +.btd-kpi-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.btd-kpi-card-title { + margin: 0; + font-size: 0.78rem; + font-weight: 800; + color: var(--btd-gold); +} + +.btd-kpi-card-sub { + margin: 2px 0 0; + font-size: 0.58rem; + color: var(--se-text-dim); +} + +.btd-kpi-mega { + font-size: 1.75rem; + font-weight: 800; + line-height: 1.1; +} + +.btd-kpi-mega.up { color: var(--btd-bull); } +.btd-kpi-mega.down { color: var(--btd-bear); } + +.btd-kpi-mega-label { + margin: 0 0 10px; + font-size: 0.62rem; + color: var(--se-text-muted); +} + +.btd-kpi-metrics { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.btd-kpi-metrics div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.btd-kpi-metrics dt { + font-size: 0.68rem; + color: var(--se-text-muted); +} + +.btd-kpi-metrics dd { + margin: 0; + font-size: 0.78rem; + font-weight: 700; + color: var(--se-text); + text-align: right; +} + +.btd-kpi-metrics dd.up { color: var(--btd-bull); } +.btd-kpi-metrics dd.down { color: var(--btd-bear); } +.btd-kpi-metrics dd.gold { color: var(--btd-gold); } + +.btd-kpi-metric--mdd dd { + font-size: 1rem; + font-weight: 800; +} + +/* ── 분석 결과 카드 (실시간 차트 rsp-trade-card 연동) ── */ + +.btd-kpi-stack.rsp-trade-stack { + padding: 0; + gap: 8px; + height: auto; + min-height: min-content; +} + +.btd-kpi-stack .btd-result-card { + flex: 0 0 auto; + min-height: 0; +} + +.btd-kpi-stack .rsp-trade-card-body .top-panel { + padding: 8px 10px 8px; + gap: 8px; +} + +.btd-kpi-stack .btd-metric-field { + margin-bottom: 0; +} + +.btd-kpi-stack .top-label { + font-size: 10px; + color: var(--se-text-muted, var(--text2)); + margin-bottom: 4px; +} + +.btd-kpi-stack .btd-metric-value { + font-weight: 700; + font-size: 12px; + text-align: right; + color: var(--se-text, var(--text)); +} + +.btd-kpi-stack .btd-metric-value--hero { + font-size: 1.35rem; + font-weight: 800; + text-align: center; + padding: 10px 8px; +} + +.btd-kpi-stack .btd-metric-value--up { color: var(--up); } +.btd-kpi-stack .btd-metric-value--down { color: var(--down); } +.btd-kpi-stack .btd-metric-value--gold { color: var(--se-gold); } + +.btd-result-hero-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; +} + +.btd-metric-field--hero { + flex: 1; + min-width: 0; +} + +.btd-metric-grid { + display: grid; + gap: 8px; +} + +.btd-metric-grid--2 { + grid-template-columns: 1fr 1fr; +} + +.btd-result-card--stats { + border-color: var(--border); + background: var(--bg3); +} + +.btd-result-card--stats .rsp-trade-card-title { + color: var(--se-text, var(--text)); +} + +.btd-result-card--trades { + border-color: color-mix(in srgb, var(--se-gold) 32%, var(--border)); + background: linear-gradient(180deg, color-mix(in srgb, var(--se-gold) 8%, transparent) 0%, var(--bg3) 48px); + height: 100%; + display: flex; + flex-direction: column; +} + +.btd-result-card--trades .rsp-trade-card-title { + color: var(--se-gold); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.btd-result-footnote { + margin: 4px 0 0; + font-size: 10px; + color: var(--se-text-dim, var(--text3)); + text-align: center; +} + +.btd-trades-count { + font-size: 10px; + font-weight: 600; + color: var(--se-text-muted); +} + +.btd-trades-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +.btd-kpi-panel--empty { + padding: 24px 12px; + text-align: center; + color: var(--se-text-dim); + font-size: 0.78rem; + border: 1px dashed var(--se-border); + border-radius: 10px; + background: var(--se-bg-muted, var(--bg3)); +} + +.btd-exec-tab--on { + color: var(--se-gold); + background: var(--btd-gold-bg); + box-shadow: inset 0 -2px 0 var(--se-gold); +} + +.btd-exec-filter--on { + color: var(--se-gold); + border-color: var(--se-item-selected-border); + background: var(--se-item-selected-bg); +} + +.btd-analysis-chart { + border-color: var(--se-border); + background: var(--se-bg-elevated, var(--bg2)); +} + +.btd-splitter { + background: var(--se-border); +} + +.btd-splitter:hover, +.btd-splitter.btd-splitter--active { + background: color-mix(in srgb, var(--se-gold) 35%, var(--se-border)); +} + +/* ── 투자 분석 KPI (우측 상단) ── */ + +.btd-analysis-empty { + padding: 24px 12px; + text-align: center; + color: var(--se-text-dim); + font-size: 0.78rem; + border: 1px dashed var(--se-border); + border-radius: 10px; + background: var(--se-bg-muted, var(--bg3)); +} + +.btd-analysis-stack { + display: flex; + flex-direction: column; + gap: 8px; + flex: 0 0 auto; + min-height: min-content; + width: 100%; +} + +.btd-analysis-card { + flex: 0 0 auto; + border-radius: 10px; + border: 1px solid var(--se-border, var(--border)); + background: var(--se-bg-elevated, var(--bg3)); + overflow: visible; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); +} + +.btd-analysis-card--profit { + border-color: rgba(255, 107, 107, 0.38); + background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--se-bg-elevated, var(--bg3)) 52px); +} + +.btd-analysis-card--risk { + border-color: rgba(77, 171, 247, 0.38); + background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--se-bg-elevated, var(--bg3)) 52px); +} + +.btd-analysis-card--stats { + border-color: color-mix(in srgb, var(--se-text-muted, var(--text2)) 28%, var(--se-border, var(--border))); + background: linear-gradient(180deg, color-mix(in srgb, var(--se-text-muted) 6%, transparent) 0%, var(--se-bg-elevated, var(--bg3)) 52px); +} + +.btd-analysis-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px 6px; + border-bottom: 1px solid var(--se-border, var(--border)); +} + +.btd-analysis-card--profit .btd-analysis-card-title { color: var(--se-up, var(--up)); } +.btd-analysis-card--risk .btd-analysis-card-title { color: var(--se-info, #4dabf7); } +.btd-analysis-card--stats .btd-analysis-card-title { color: var(--se-text, var(--text)); } + +.btd-analysis-card-title { + margin: 0; + font-size: 13px; + font-weight: 700; +} + +.btd-analysis-card-tag { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--se-text-muted, var(--text2)); + opacity: 0.85; +} + +.btd-analysis-card-body { + padding: 8px 10px 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.btd-analysis-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--se-bg, var(--bg)) 55%, transparent); +} + +.btd-analysis-hero--compact { + align-items: center; +} + +.btd-analysis-hero-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.btd-analysis-hero-label { + font-size: 10px; + color: var(--se-text-muted, var(--text2)); +} + +.btd-analysis-hero-value { + font-size: 1.35rem; + font-weight: 800; + line-height: 1.1; + color: var(--se-text, var(--text)); +} + +.btd-analysis-hero-value.up { color: var(--se-up, var(--up)); } +.btd-analysis-hero-value.down { color: var(--se-down, var(--down)); } + +.btd-analysis-hero-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; +} + +.btd-analysis-side-label { + font-size: 9px; + color: var(--se-text-muted, var(--text2)); +} + +.btd-analysis-side-value { + font-size: 0.95rem; + font-weight: 700; + color: var(--se-text, var(--text)); +} + +.btd-analysis-side-value.up { color: var(--se-up, var(--up)); } + +.btd-analysis-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.btd-analysis-metric { + display: flex; + flex-direction: column; + gap: 3px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--se-bg, var(--bg)) 40%, transparent); + min-width: 0; +} + +.btd-analysis-metric-label { + font-size: 9px; + color: var(--se-text-muted, var(--text2)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.btd-analysis-metric-value { + font-size: 11px; + font-weight: 700; + color: var(--se-text, var(--text)); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.btd-analysis-metric-value--up { color: var(--se-up, var(--up)); } +.btd-analysis-metric-value--down { color: var(--se-down, var(--down)); } +.btd-analysis-metric-value--gold { color: var(--se-gold, var(--btd-gold)); } +.btd-analysis-metric-value--neutral { color: var(--se-text-muted, var(--text2)); } + +.btd-analysis-footnote { + margin: 0; + padding-top: 2px; + font-size: 9px; + color: var(--se-text-dim, var(--text3)); + text-align: center; + line-height: 1.4; +} + +.btd-right-section--kpi .btd-analysis-stack { + padding-bottom: 4px; +} diff --git a/frontend/src/styles/dashboardCommand.css b/frontend/src/styles/dashboardCommand.css new file mode 100644 index 0000000..828320a --- /dev/null +++ b/frontend/src/styles/dashboardCommand.css @@ -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; + } +} diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index 78e567b..c2113ba 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -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); } diff --git a/frontend/src/utils/analysisChartData.ts b/frontend/src/utils/analysisChartData.ts new file mode 100644 index 0000000..05897c7 --- /dev/null +++ b/frontend/src/utils/analysisChartData.ts @@ -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 { + 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> = 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); +} diff --git a/frontend/src/utils/backtestUiUtils.ts b/frontend/src/utils/backtestUiUtils.ts new file mode 100644 index 0000000..91869b0 --- /dev/null +++ b/frontend/src/utils/backtestUiUtils.ts @@ -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 = { + '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; +} diff --git a/frontend/src/utils/liveExecutionGroups.ts b/frontend/src/utils/liveExecutionGroups.ts new file mode 100644 index 0000000..6bfb782 --- /dev/null +++ b/frontend/src/utils/liveExecutionGroups.ts @@ -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 = {}, +): LiveExecutionItem[] { + if (!trades.length) return []; + + const groups = new Map(); + 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, + })); +}