= ({
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,
+ }));
+}