diff --git a/frontend/src/components/BacktestResultModal.tsx b/frontend/src/components/BacktestResultModal.tsx index f331497..2f4594a 100644 --- a/frontend/src/components/BacktestResultModal.tsx +++ b/frontend/src/components/BacktestResultModal.tsx @@ -4,7 +4,7 @@ * BacktestDashboard — 카드 기반 대시보드 (재사용 가능) * BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼 */ -import React, { useState } from 'react'; +import React from 'react'; import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { repairUtf8Mojibake } from '../utils/textEncoding'; @@ -21,6 +21,7 @@ import { CompareBar, } from './shared/analysisDashboardUi'; import { analysisMethodLabel } from '../utils/analysisMethodLabels'; +import BacktestSignalTable from './backtest/BacktestSignalTable'; const fmtDate = (ts: number) => { const d = new Date(ts * 1000); @@ -55,17 +56,12 @@ export interface BacktestDashboardProps { createdAt?: string; /** 레포트·PDF — 시그널 전체 표시·인쇄용 레이아웃 */ reportMode?: boolean; + /** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */ + hideSignals?: boolean; } // ── 시그널 타입 ─────────────────────────────────────────────────────────── -const SIG_LABEL: Record = { - BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도', -}; -const SIG_COLOR: Record = { - BUY:'brd-sig-buy', SELL:'brd-sig-sell', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn', -}; - // ══════════════════════════════════════════════════════════════════════════ // BacktestDashboard — 메인 대시보드 컴포넌트 (재사용 가능) // ══════════════════════════════════════════════════════════════════════════ @@ -73,9 +69,8 @@ const SIG_COLOR: Record = { export function BacktestDashboard({ analysis, signals, strategyName, symbol, timeframe, barCount, createdAt, reportMode = false, + hideSignals = false, }: BacktestDashboardProps) { - const [sigExpanded, setSigExpanded] = useState(reportMode); - const a = analysis; if (!a) { return ( @@ -86,7 +81,6 @@ export function BacktestDashboard({ ); } - const sigLimit = reportMode || sigExpanded ? signals.length : 8; const periodStr = signals.length >= 2 ? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}` : `${barCount}봉`; @@ -276,54 +270,11 @@ export function BacktestDashboard({ {/* ── Row 4: 거래 시그널 목록 ── */} -
-
- {signals.length}건 - }/> - {signals.length === 0 ? ( -
시그널이 없습니다.
- ) : ( - <> -
- - - - - - - - - - - - {signals.slice(0, sigLimit).map((s, i) => ( - - - - - - - - ))} - -
#날짜구분가격봉#
{i+1}{fmtDate(s.time)} - - {repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)} - - - {Math.round(s.price).toLocaleString()} - {s.barIndex}
-
- {!reportMode && signals.length > 8 && ( - - )} - - )} + {!hideSignals && ( +
+
-
+ )}
); diff --git a/frontend/src/components/analysisReport/AnalysisReportCenterPanel.tsx b/frontend/src/components/analysisReport/AnalysisReportCenterPanel.tsx new file mode 100644 index 0000000..a6055ed --- /dev/null +++ b/frontend/src/components/analysisReport/AnalysisReportCenterPanel.tsx @@ -0,0 +1,43 @@ +/** + * 분석레포트 — 중앙 패널 (종목·실행 분석 상세) + */ +import React from 'react'; +import { BacktestDashboard } from '../BacktestResultModal'; +import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal'; + +interface Props { + model: BacktestAnalysisReportModel | null; +} + +export function AnalysisReportCenterPanel({ model }: Props) { + if (!model) { + return ( +
+
+ 📊 +

좌측 목록에서 백테스팅 또는 실시간 매매 실행을 선택하세요.

+
+
+ ); + } + + return ( +
+
+ +
+
+ ); +} + +export default AnalysisReportCenterPanel; diff --git a/frontend/src/components/analysisReport/AnalysisReportContextBar.tsx b/frontend/src/components/analysisReport/AnalysisReportContextBar.tsx new file mode 100644 index 0000000..c602d49 --- /dev/null +++ b/frontend/src/components/analysisReport/AnalysisReportContextBar.tsx @@ -0,0 +1,31 @@ +/** + * 분석레포트 — 상단 컨텍스트 바 (중앙 패널 좌측 정렬 · 종목명 + 실행 정보) + */ +import React from 'react'; +import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal'; + +interface Props { + model: BacktestAnalysisReportModel | null; +} + +function executionMeta(model: BacktestAnalysisReportModel): string { + const kind = model.reportKind === 'live' ? '실시간 매매' : '백테스팅'; + return model.createdAt ? `${kind} · 분석 실행 ${model.createdAt}` : kind; +} + +export default function AnalysisReportContextBar({ model }: Props) { + if (!model) return null; + + const symbolLabel = model.symbolKo?.trim() || model.symbol; + + return ( +
+
+
+ {symbolLabel} + {executionMeta(model)} +
+
+
+ ); +} diff --git a/frontend/src/components/analysisReport/AnalysisReportPage.tsx b/frontend/src/components/analysisReport/AnalysisReportPage.tsx index f671abd..7c1feb2 100644 --- a/frontend/src/components/analysisReport/AnalysisReportPage.tsx +++ b/frontend/src/components/analysisReport/AnalysisReportPage.tsx @@ -1,7 +1,7 @@ /** - * 분석레포트 — 좌: 백테스팅/실시간 목록(백테스팅 화면과 동일) · 우: PDF 10 Variations 탭 + * 분석레포트 — 가상매매와 동일 3패널 (좌: 목록 · 중: 분석 · 우: 시그널) */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { Theme } from '../../types'; import { loadBacktestResults, @@ -11,33 +11,25 @@ import { type BacktestResultRecord, type StrategyDto, } from '../../utils/backendApi'; -import type { AnalysisReportTypeId } from '../../utils/analysisReportTypes'; -import { ANALYSIS_REPORT_TYPES } from '../../utils/analysisReportTypes'; import { buildContextFromBacktest, buildContextFromLive, } from '../../utils/analysisReportContext'; import { buildLiveExecutionItems, type LiveExecutionItem } from '../../utils/liveExecutionGroups'; import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents'; -import { - readStoredSize, - storeSize, - usePanelResize, -} from '../strategyEditor/usePanelResize'; import BacktestExecutionList, { type ExecutionListTab } from '../backtest/BacktestExecutionList'; -import AnalysisReportViews from './AnalysisReportViews'; +import BuilderPageShell from '../layout/BuilderPageShell'; +import AnalysisReportCenterPanel from './AnalysisReportCenterPanel'; +import AnalysisReportSignalsPanel from './AnalysisReportSignalsPanel'; +import AnalysisReportContextBar from './AnalysisReportContextBar'; import BacktestAnalysisReportModal from '../backtest/BacktestAnalysisReportModal'; import { buildBacktestReportModel, buildLiveReportModel, } from '../../utils/backtestReportModel'; -import '../../styles/strategyEditorTheme.css'; import '../../styles/backtestDashboard.css'; import '../../styles/analysisReportPage.css'; -const LEFT_KEY = 'arp-left-width'; -const LEFT_DEFAULT = 380; - interface Props { theme?: Theme; } @@ -52,7 +44,6 @@ function pickCompareBacktest( } export function AnalysisReportPage({ theme = 'dark' }: Props) { - const [reportTypeId, setReportTypeId] = useState('standard'); const [sourceTab, setSourceTab] = useState('backtest'); const [records, setRecords] = useState([]); const [liveItems, setLiveItems] = useState([]); @@ -62,11 +53,6 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) { const [loading, setLoading] = useState(true); const [reportOpen, setReportOpen] = useState(false); - const [leftWidth, setLeftWidth] = useState(() => Math.max(readStoredSize(LEFT_KEY, LEFT_DEFAULT), 300)); - const leftRef = useRef(leftWidth); - leftRef.current = leftWidth; - const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v)); - const refreshLive = useCallback(async () => { const [trades, summary, stratList] = await Promise.all([ loadPaperTrades(), @@ -108,11 +94,6 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) { return pickCompareBacktest(selectedBacktest, records); }, [records, selectedBacktest]); - const compareLive = useMemo(() => { - if (!selectedLive) return null; - return liveItems.find(x => x.id !== selectedLive.id) ?? null; - }, [liveItems, selectedLive]); - const ctx = useMemo(() => { if (sourceTab === 'backtest' && selectedBacktest) { return buildContextFromBacktest(selectedBacktest, compareBacktest); @@ -143,42 +124,40 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) { return null; }, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]); - if (loading) { - return ( -
-
-

분석레포트

-
-
데이터 로딩…
-
- ); - } + const headerActions = ( + <> + + + + ); return ( -
-
-
-

분석레포트

- Golden Analysis Command Center -
-
- - -
-
- -
- - -
- -
-
- {ANALYSIS_REPORT_TYPES.map(t => ( - - ))} -
- -
-
-
- -
-
-
+ )} + center={} + right={} + /> setReportOpen(false)} model={reportModel} /> -
+ ); } diff --git a/frontend/src/components/analysisReport/AnalysisReportSignalsPanel.tsx b/frontend/src/components/analysisReport/AnalysisReportSignalsPanel.tsx new file mode 100644 index 0000000..ad6e24d --- /dev/null +++ b/frontend/src/components/analysisReport/AnalysisReportSignalsPanel.tsx @@ -0,0 +1,31 @@ +/** + * 분석레포트 — 우측 패널 (거래 시그널 목록) + */ +import React from 'react'; +import BacktestSignalTable from '../backtest/BacktestSignalTable'; +import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal'; + +interface Props { + model: BacktestAnalysisReportModel | null; +} + +export function AnalysisReportSignalsPanel({ model }: Props) { + if (!model) { + return ( +
+

선택된 실행의 거래 시그널이 여기에 표시됩니다.

+
+ ); + } + + return ( +
+ +
+

본 레포트는 GoldenChart 백테스팅·투자분석 결과이며 투자 권유가 아닙니다.

+
+
+ ); +} + +export default AnalysisReportSignalsPanel; diff --git a/frontend/src/components/analysisReport/EquityDrawdownChart.tsx b/frontend/src/components/analysisReport/EquityDrawdownChart.tsx new file mode 100644 index 0000000..72351ef --- /dev/null +++ b/frontend/src/components/analysisReport/EquityDrawdownChart.tsx @@ -0,0 +1,173 @@ +import React, { memo, useMemo } from 'react'; +import type { EquityPoint } from '../../utils/backtestEquity'; +import { drawdownSeries } from '../../utils/analysisReportContext'; + +interface Props { + curve: EquityPoint[]; + initialCapital?: number; +} + +function fmtDate(ts: number): string { + const d = new Date(ts * 1000); + return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; +} + +function fmtWon(v: number): string { + if (v >= 1e8) return `${(v / 1e8).toFixed(1)}억`; + if (v >= 1e4) return `${Math.round(v / 1e4)}만`; + return Math.round(v).toLocaleString(); +} + +/** 자산 곡선(상) + 낙폭 Underwater(하) — 동일 X축 */ +const EquityDrawdownChart: React.FC = memo(({ curve, initialCapital }) => { + const W = 900; + const H = 320; + const pad = { l: 52, r: 16, t: 14, b: 24 }; + const splitY = pad.t + (H - pad.t - pad.b) * 0.58; + const eqH = splitY - pad.t - 8; + const ddH = H - pad.b - splitY - 8; + + const chart = useMemo(() => { + if (curve.length < 2) return null; + + const times = curve.map(p => p.time); + const equities = curve.map(p => p.equity); + const dd = drawdownSeries(curve); + const ddPcts = dd.map(d => d.ddPct * 100); + + const tMin = Math.min(...times); + const tMax = Math.max(...times); + const tRange = tMax - tMin || 1; + + const eMin = Math.min(...equities) * 0.99; + const eMax = Math.max(...equities) * 1.01; + const eRange = eMax - eMin || 1; + + const ddMin = Math.min(...ddPcts, -0.5); + const ddMax = 0.5; + const ddRange = ddMax - ddMin || 1; + + const toX = (t: number) => pad.l + ((t - tMin) / tRange) * (W - pad.l - pad.r); + const toEqY = (e: number) => pad.t + eqH - ((e - eMin) / eRange) * eqH; + const toDdY = (pct: number) => splitY + 8 + ddH - ((pct - ddMin) / ddRange) * ddH; + + const eqPath = curve.map((p, i) => + `${i === 0 ? 'M' : 'L'}${toX(p.time).toFixed(1)},${toEqY(p.equity).toFixed(1)}`, + ).join(' '); + const eqArea = `${eqPath} L${toX(curve[curve.length - 1].time).toFixed(1)},${splitY} L${toX(curve[0].time).toFixed(1)},${splitY} Z`; + + const ddPath = dd.map((d, i) => + `${i === 0 ? 'M' : 'L'}${toX(d.time).toFixed(1)},${toDdY(d.ddPct * 100).toFixed(1)}`, + ).join(' '); + const ddArea = `${ddPath} L${toX(dd[dd.length - 1].time).toFixed(1)},${splitY + 8 + ddH} L${toX(dd[0].time).toFixed(1)},${splitY + 8 + ddH} Z`; + + let mddPct = 0; + let mddTime = curve[0].time; + for (const d of dd) { + if (d.ddPct < mddPct) { + mddPct = d.ddPct; + mddTime = d.time; + } + } + + const mddIdx = dd.findIndex(d => d.time === mddTime); + const mddPoint = mddIdx >= 0 ? dd[mddIdx] : dd[0]; + + const xStep = Math.max(1, Math.floor(curve.length / 6)); + const xLabels: { x: number; label: string }[] = []; + for (let i = 0; i < curve.length; i += xStep) { + xLabels.push({ x: toX(curve[i].time), label: fmtDate(curve[i].time) }); + } + + const eqTicks = [0, 0.25, 0.5, 0.75, 1].map(r => eMin + eRange * r); + const ddTicks = [0, ddMin * 0.5, ddMin].filter((v, i, a) => a.indexOf(v) === i); + + return { + eqPath, eqArea, ddPath, ddArea, + toX, toEqY, toDdY, + xLabels, eqTicks, ddTicks, + mddPct, mddX: toX(mddPoint.time), mddY: toDdY(mddPoint.ddPct * 100), + finalEquity: curve[curve.length - 1].equity, + }; + }, [curve, eqH, ddH, splitY]); + + if (!chart || curve.length < 2) { + return ( +
자산 곡선을 표시할 데이터가 부족합니다.
+ ); + } + + const init = initialCapital ?? curve[0]?.equity ?? 0; + const retPct = init > 0 ? ((chart.finalEquity - init) / init) * 100 : 0; + + return ( +
+ + + + + + + + + + + + + {/* 구분선 */} + + + {/* Equity 영역 */} + 자산 (Equity) + {chart.eqTicks.map(v => ( + + + {fmtWon(v)} + + ))} + + + + {/* Drawdown 영역 */} + 낙폭 (Drawdown %) + + {chart.ddTicks.map(v => ( + + {v.toFixed(1)}% + + ))} + + + + {/* MDD 마커 */} + + + MDD {(chart.mddPct * 100).toFixed(1)}% + + + {/* X축 */} + {chart.xLabels.map((l, i) => ( + + {l.label} + + ))} + +
+ 자산 곡선 + Underwater 낙폭 + = 0 ? 'up' : 'down'}`}> + 기간 수익 {retPct >= 0 ? '+' : ''}{retPct.toFixed(2)}% + +
+
+ ); +}); + +EquityDrawdownChart.displayName = 'EquityDrawdownChart'; +export default EquityDrawdownChart; diff --git a/frontend/src/components/analysisReport/QuantPerformanceDetail.tsx b/frontend/src/components/analysisReport/QuantPerformanceDetail.tsx new file mode 100644 index 0000000..4162d92 --- /dev/null +++ b/frontend/src/components/analysisReport/QuantPerformanceDetail.tsx @@ -0,0 +1,352 @@ +/** + * 분석레포트 — 우측 상세 (5대 KPI + 3종 필수 그래프) + */ +import React, { useMemo, useState } from 'react'; +import type { Theme } from '../../types'; +import type { AnalysisReportContext } from '../../utils/analysisReportContext'; +import { buildHoldingPoints } from '../../utils/analysisReportMetrics'; +import { repairUtf8Mojibake } from '../../utils/textEncoding'; +import { formatUpbitKrwPrice } from '../../utils/safeFormat'; +import { pctAbs } from '../../utils/backtestUiUtils'; +import EquityDrawdownChart from './EquityDrawdownChart'; +import ReturnsHistogramChart from './ReturnsHistogramChart'; +import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart'; + +type DetailTab = 'overview' | 'trades' | 'log'; + +interface Props { + ctx: AnalysisReportContext | null; + theme: Theme; +} + +type BenchStatus = 'good' | 'warn' | 'bad' | 'neutral'; + +function benchStatus( + value: number, + good: (v: number) => boolean, + warn: (v: number) => boolean, +): BenchStatus { + if (good(value)) return 'good'; + if (warn(value)) return 'warn'; + return 'bad'; +} + +function fmtDuration(sec: number): string { + if (sec <= 0) return '—'; + if (sec < 3600) return `${Math.round(sec / 60)}분`; + if (sec < 86400) { + const h = Math.floor(sec / 3600); + const m = Math.round((sec % 3600) / 60); + return m > 0 ? `${h}시간 ${m}분` : `${h}시간`; + } + const d = Math.floor(sec / 86400); + const h = Math.round((sec % 86400) / 3600); + return h > 0 ? `${d}일 ${h}시간` : `${d}일`; +} + +function 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')}`; +} + +function KpiCard({ + label, + value, + benchmark, + status, + hint, +}: { + label: string; + value: string; + benchmark: string; + status: BenchStatus; + hint: string; +}) { + return ( +
+
+ {label} + + {status === 'good' ? '양호' : status === 'warn' ? '보통' : status === 'bad' ? '주의' : '—'} + +
+
{value}
+
기준: {benchmark}
+

{hint}

+
+ ); +} + +function TradeLogTable({ ctx }: { ctx: AnalysisReportContext }) { + const rows = useMemo(() => { + const buys = new Map(); + const out: { + id: number; + time: string; + side: string; + entry: number; + exit: number; + qty: number; + pnlPct: number; + duration: string; + }[] = []; + const sorted = [...ctx.trades].sort((a, b) => a.time - b.time); + for (const t of sorted) { + if (t.side === 'buy') { + buys.set(t.symbol, { time: t.time, price: t.price, qty: t.quantity }); + } else { + const buy = buys.get(t.symbol); + const dur = buy ? t.time - buy.time : 0; + out.push({ + id: t.id, + time: fmtDateTime(t.time), + side: '매수→매도', + entry: buy?.price ?? t.price, + exit: t.price, + qty: t.quantity, + pnlPct: (t.pnlPct ?? 0) * 100, + duration: fmtDuration(dur), + }); + buys.delete(t.symbol); + } + } + return out; + }, [ctx.trades]); + + return ( +
+ + + + + + + + + + + + + + {rows.length === 0 ? ( + + ) : rows.map(r => ( + = 0 ? 'qpd-row--up' : 'qpd-row--down'}> + + + + + + + + + ))} + +
일시구분진입가청산가수량수익률보유
거래 내역 없음
{r.time}{r.side}{formatUpbitKrwPrice(r.entry)}{formatUpbitKrwPrice(r.exit)}{r.qty.toFixed(4)}= 0 ? 'up' : 'down'}> + {r.pnlPct >= 0 ? '+' : ''}{r.pnlPct.toFixed(2)}% + {r.duration}
+
+ ); +} + +export default function QuantPerformanceDetail({ ctx, theme }: Props) { + const [tab, setTab] = useState('overview'); + + const a = ctx?.analysis; + const holdingPts = useMemo( + () => (ctx ? buildHoldingPoints(ctx.signals, ctx.symbol) : []), + [ctx], + ); + const avgHoldSec = useMemo(() => { + if (holdingPts.length === 0) return 0; + return (holdingPts.reduce((s, p) => s + p.durationMin, 0) / holdingPts.length) * 60; + }, [holdingPts]); + + const chartRange = useMemo(() => { + if (!ctx || ctx.signals.length === 0) return null; + const times = ctx.signals.map(s => s.time); + const tMax = Math.max(...times); + const barSec = 3600; + const barCount = ctx.barCount || 300; + return { + symbol: ctx.symbol, + timeframe: ctx.timeframe, + toTimeSec: tMax + barSec, + fromTimeSec: tMax - barSec * barCount, + barCount, + signals: ctx.signals, + strategyId: ctx.backtestRecord?.strategyId ?? ctx.liveItem?.trades.find(t => t.strategyId)?.strategyId ?? null, + }; + }, [ctx]); + + if (!ctx) { + return ( +
+ 📊 +

좌측 목록에서 백테스팅 또는 실시간 매매 실행을 선택하세요.

+
+ ); + } + + const winRate = a?.winRate ?? 0; + const pf = a?.profitLossRatio ?? 0; + const mdd = a?.maxDrawdownPct ?? 0; + const sharpe = a?.sharpeRatio ?? 0; + const totalRet = a?.totalReturnPct ?? 0; + const tradeCount = a?.numberOfPositions ?? ctx.trades.filter(t => t.side === 'sell').length; + + const winStatus = benchStatus(winRate, v => v >= 0.5, v => v >= 0.4); + const pfStatus = benchStatus(pf, v => v >= 2, v => v >= 1.5); + const mddStatus = benchStatus(Math.abs(mdd), v => v <= 0.1, v => v <= 0.15); + const sharpeStatus = benchStatus(sharpe, v => v >= 2, v => v >= 1); + const holdStatus: BenchStatus = holdingPts.length > 0 ? 'neutral' : 'warn'; + + return ( +
+
+
+

{repairUtf8Mojibake(ctx.strategyName)}

+
+ {ctx.symbol} + {ctx.timeframe} + + {ctx.sourceTab === 'backtest' ? '백테스팅' : '모의·실시간'} + + 거래 {tradeCount}회 + = 0 ? 'up' : 'down'}`}> + 수익 {totalRet >= 0 ? '+' : ''}{(totalRet * 100).toFixed(2)}% + +
+
+
+ +
+ + = 99 ? '99+' : pf.toFixed(2)} + benchmark="1.5+ 양호 · 2.0+ 우수" + status={pfStatus} + hint="총 이익 ÷ 총 손실 — 1 미만이면 구조적 손실" + /> + + + 0 + ? `${holdingPts.length}건 평균 · ${ctx.timeframe} 전략과 비교` + : '보유 구간 데이터 없음'} + /> +
+ + + +
+ {tab === 'overview' && ( +
+
+
+

자산 성장 곡선 & 낙폭 (Equity · Drawdown)

+

+ 상단은 계좌 자산 변화, 하단은 전고점 대비 Underwater 낙폭입니다. + MDD가 깊거나 회복 기간이 길면 실전 적용 시 버티기 어렵습니다. +

+
+ +
+ +
+
+

수익률 분포 (Returns Distribution)

+

+ 매매건별 수익률 히스토그램 — 우측(양수) 꼬리가 길수록 유리합니다. + 좌측으로 길게 늘어지면 큰 손실 위험이 큽니다. +

+
+ +
+
+ )} + + {tab === 'trades' && chartRange && ( +
+
+

매매 타점 마킹 (Trade Plot)

+

+ 캔들 차트 위 매수(▲)·매도(▼) 시점 — 의도한 조건에서 진입·청산했는지, + 슬리피지·오류 체결 여부를 검증합니다. +

+
+
+ +
+
+ )} + + {tab === 'trades' && !chartRange && ( +
매매 타점을 표시할 시그널이 없습니다.
+ )} + + {tab === 'log' && ( +
+
+

거래 실행 로그

+

진입·청산 가격, 수익률, 보유 시간을 확인합니다.

+
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/analysisReport/ReturnsHistogramChart.tsx b/frontend/src/components/analysisReport/ReturnsHistogramChart.tsx new file mode 100644 index 0000000..21058b7 --- /dev/null +++ b/frontend/src/components/analysisReport/ReturnsHistogramChart.tsx @@ -0,0 +1,109 @@ +import React, { memo, useMemo } from 'react'; +import { buildReturnDistribution } from '../../utils/analysisReportMetrics'; +import type { TradeHistoryRow } from '../../utils/backtestEquity'; + +interface Props { + trades: TradeHistoryRow[]; +} + +const ReturnsHistogramChart: React.FC = memo(({ trades }) => { + const buckets = useMemo(() => buildReturnDistribution(trades), [trades]); + const withPnl = useMemo( + () => trades.filter(t => t.pnlPct != null).map(t => (t.pnlPct ?? 0) * 100), + [trades], + ); + + const stats = useMemo(() => { + if (withPnl.length === 0) return null; + const avg = withPnl.reduce((a, b) => a + b, 0) / withPnl.length; + const wins = withPnl.filter(v => v > 0).length; + const losses = withPnl.filter(v => v < 0).length; + const leftTail = withPnl.filter(v => v < -5).length; + const rightTail = withPnl.filter(v => v > 10).length; + return { avg, wins, losses, leftTail, rightTail, total: withPnl.length }; + }, [withPnl]); + + const W = 900; + const H = 220; + const pad = { l: 44, r: 20, t: 16, b: 36 }; + const innerW = W - pad.l - pad.r; + const innerH = H - pad.t - pad.b; + const maxCount = Math.max(...buckets.map(b => b.count), 1); + const barW = buckets.length > 0 ? innerW / buckets.length - 4 : 0; + + if (buckets.length === 0 || !stats) { + return
수익률 분포를 계산할 거래가 없습니다.
; + } + + const skew = stats.rightTail > stats.leftTail ? 'positive' : stats.leftTail > stats.rightTail ? 'negative' : 'neutral'; + + return ( +
+ + {[0.25, 0.5, 0.75, 1].map(r => { + const y = pad.t + innerH * (1 - r); + const cnt = Math.round(maxCount * r); + return ( + + + {cnt} + + ); + })} + {buckets.map((b, i) => { + const barH = (b.count / maxCount) * innerH; + const x = pad.l + i * (innerW / buckets.length) + 2; + const positive = b.from >= 0; + const fill = positive ? '#22c55e' : '#ef4444'; + return ( + + + {b.count > 0 && ( + + {b.count} + + )} + + {b.bucket.replace(' ', '')} + + + ); + })} + + 매매건별 수익률 (%) + + +
+
+ 평균 수익률 + = 0 ? 'up' : 'down'}> + {stats.avg >= 0 ? '+' : ''}{stats.avg.toFixed(2)}% + +
+
+ 수익 / 손실 + {stats.wins} / {stats.losses} +
+
+ 꼬리 형태 + + {skew === 'positive' ? '우측(양의) 꼬리 우세' : skew === 'negative' ? '좌측(손실) 꼬리 우세 — 주의' : '균형'} + +
+
+
+ ); +}); + +ReturnsHistogramChart.displayName = 'ReturnsHistogramChart'; +export default ReturnsHistogramChart; diff --git a/frontend/src/components/backtest/BacktestSignalTable.tsx b/frontend/src/components/backtest/BacktestSignalTable.tsx new file mode 100644 index 0000000..8ed6304 --- /dev/null +++ b/frontend/src/components/backtest/BacktestSignalTable.tsx @@ -0,0 +1,91 @@ +/** + * 백테스팅 · 투자분석 — 거래 시그널 테이블 + */ +import React, { useState } from 'react'; +import type { BacktestSignal } from '../../utils/backendApi'; +import { repairUtf8Mojibake } from '../../utils/textEncoding'; +import { SectionTitle } from '../shared/analysisDashboardUi'; + +const SIG_LABEL: Record = { + BUY: '매수', + SELL: '매도', + SHORT_ENTRY: '공매도', + SHORT_EXIT: '공매도청산', + PARTIAL_SELL: '분할매도', +}; +const SIG_COLOR: Record = { + BUY: 'brd-sig-buy', + SELL: 'brd-sig-sell', + SHORT_ENTRY: 'brd-neg', + SHORT_EXIT: 'brd-pos', + PARTIAL_SELL: 'brd-warn', +}; + +const fmtDate = (ts: number) => { + const d = new Date(ts * 1000); + return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`; +}; + +interface Props { + signals: BacktestSignal[]; + /** true — 전체 표시, 접기 버튼 없음 */ + expanded?: boolean; + className?: string; +} + +export default function BacktestSignalTable({ signals, expanded = false, className }: Props) { + const [sigExpanded, setSigExpanded] = useState(expanded); + const showAll = expanded || sigExpanded; + const sigLimit = showAll ? signals.length : 8; + + return ( +
+ {signals.length}건} + /> + {signals.length === 0 ? ( +
시그널이 없습니다.
+ ) : ( + <> +
+ + + + + + + + + + + + {signals.slice(0, sigLimit).map((s, i) => ( + + + + + + + + ))} + +
#날짜구분가격봉#
{i + 1}{fmtDate(s.time)} + + {repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)} + + + {Math.round(s.price).toLocaleString()} + {s.barIndex}
+
+ {!expanded && signals.length > 8 && ( + + )} + + )} +
+ ); +} diff --git a/frontend/src/components/layout/BuilderPageShell.tsx b/frontend/src/components/layout/BuilderPageShell.tsx index 34490c6..60d03af 100644 --- a/frontend/src/components/layout/BuilderPageShell.tsx +++ b/frontend/src/components/layout/BuilderPageShell.tsx @@ -219,6 +219,9 @@ export default function BuilderPageShell({ '--bps-left-width': leftOpen ? `${leftWidth}px` : '0px', '--bps-right-width': rightOpen ? `${rightWidth}px` : '0px', '--bps-left-open': leftOpen ? '1' : '0', + '--bps-right-open': rightOpen ? '1' : '0', + '--bps-left-splitter': leftOpen ? '5px' : '0px', + '--bps-right-splitter': rightOpen ? '5px' : '0px', '--bps-left-handle': '16px', '--bps-splitter-w': '5px', }) as React.CSSProperties, [leftOpen, leftWidth, rightOpen, rightWidth]); diff --git a/frontend/src/styles/analysisReportPage.css b/frontend/src/styles/analysisReportPage.css index 70f8fac..2b28db3 100644 --- a/frontend/src/styles/analysisReportPage.css +++ b/frontend/src/styles/analysisReportPage.css @@ -1,9 +1,256 @@ /** * The Quant Command Center — Analysis Report Styles - * TRADING_REPORT_UI_DESIGN.pdf 기반 */ -/* ── 페이지 레이아웃 ── */ +/* ── BuilderPageShell 연동 (가상매매와 동일 3패널) ── */ +.bps-page--arp .arp-context-bar { + display: flex; + flex-shrink: 0; + align-items: stretch; + min-height: 38px; + border-bottom: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--se-header-bg, var(--bg2)) 88%, var(--se-bg, var(--bg1))); +} + +.bps-page--arp .arp-context-bar__left-gap { + flex: 0 0 calc( + var(--bps-left-width, 380px) + + var(--bps-left-handle, 16px) + + var(--bps-left-splitter, 5px) + ); + min-width: var(--bps-left-handle, 16px); +} + +.bps-page--arp .arp-context-bar__right-gap { + flex: 0 0 calc( + var(--bps-right-width, 320px) + + var(--bps-left-handle, 16px) + + var(--bps-right-splitter, 5px) + ); + min-width: var(--bps-left-handle, 16px); +} + +.bps-page--arp .arp-context-bar__center { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px 14px; + padding: 8px 12px; +} + +.bps-page--arp .arp-context-symbol { + font-size: 1.05rem; + font-weight: 800; + color: var(--se-gold, #e6c200); + letter-spacing: -0.02em; + line-height: 1.2; +} + +.bps-page--arp .arp-context-meta { + font-size: 0.72rem; + font-weight: 500; + color: var(--se-text-dim, var(--text3)); + line-height: 1.35; +} + +.bps-page--arp .bps-center-content { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + padding: 10px 12px 12px; + background: var(--se-center-bg, var(--se-bg)); +} + +/* 좌·중·우 패널 동일 높이 — flex 체인 */ +.bps-page--arp .bps-body { + align-items: stretch; + min-height: 0; +} + +.bps-page--arp .bps-side-wrap { + align-self: stretch; + min-height: 0; +} + +.bps-page--arp .bps-side-wrap--left .bps-left--collapsible { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: auto; + align-self: stretch; +} + +.bps-page--arp .bps-main { + flex: 1; + min-height: 0; + align-self: stretch; + display: flex; + flex-direction: column; +} + +.bps-page--arp .bps-main-row { + flex: 1; + min-height: 0; + align-items: stretch; +} + +.bps-page--arp .bps-center, +.bps-page--arp .bps-center-work { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.bps-page--arp .bps-side-wrap--right .bps-right--collapsible { + align-self: stretch; + min-height: 0; + height: auto; +} + +.bps-page--arp .bps-side-wrap--left { + align-self: stretch; + min-height: 0; +} + +.bps-page--arp .bps-side-wrap--right { + align-self: stretch; + min-height: 0; +} + +.bps-page--arp .bps-side-wrap--right .bps-right.bps-right--collapsible { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + align-self: stretch; +} + +/* 좌측 실행 목록 카드 — 우측 시그널 카드와 동일 높이 */ +.bps-page--arp .bps-side-wrap--left .bps-left--collapsible .bps-panel { + flex: 1; + min-height: 0; + height: auto; + margin: 10px; + padding: 14px 12px 12px; + border-radius: 14px; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-panel-card-border); + box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); +} + +.bps-page--arp .bps-panel-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.bps-page--arp .bps-panel-body .btd-exec-list { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.bps-page--arp .bps-panel-body .btd-exec-scroll, +.bps-page--arp .bps-panel-body .btd-exec-scroll.vl-scroll { + flex: 1; + min-height: 0; +} + +.bps-page--arp .bps-right-body { + padding: 0; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.bps-page--arp .arp-center-inner { + width: 100%; + max-width: 960px; + margin: 0 auto; +} + +/* 중앙 분석 카드 — 우측 시그널 카드와 동일 높이 */ +.bps-page--arp .arp-center-shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-radius: 14px; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-panel-card-border); + box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); + overflow: auto; + padding: 12px 14px 14px; +} + +.bps-page--arp .arp-center-shell .arp-report-empty { + flex: 1; + min-height: 200px; +} + +.bps-page--arp .arp-signals-panel { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + margin: 10px; + border-radius: 14px; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-panel-card-border); + box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); +} + +.bps-page--arp .arp-signals-panel .brd-card--sig-fill { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + border: none; + border-radius: 0; + background: transparent; +} + +.bps-page--arp .arp-signals-panel .brd-sig-scroll { + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; +} + +.bps-page--arp .arp-signals-empty { + flex: 1; + min-height: 0; + margin: 10px; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + border-radius: 14px; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-panel-card-border); + box-shadow: var(--se-panel-card-shadow); + text-align: center; + color: var(--se-text-dim, var(--text3)); + font-size: 12px; + line-height: 1.5; +} + +/* ── 페이지 레이아웃 (legacy) ── */ .arp-page { display: flex; flex-direction: column; @@ -18,9 +265,122 @@ flex: 1; min-height: 0; overflow: hidden; + align-items: stretch; + gap: 0; + padding: 10px; + box-sizing: border-box; } -/* ── 사이드바 ── */ +/* ── 공통 카드 박스 (좌·우 동일 높이) ── */ +.arp-card { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--se-bg-elevated, var(--bg2)); + border: 1px solid var(--se-border, var(--border)); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18); +} + +.arp-card--list { + flex-shrink: 0; + min-width: 180px; + max-width: 360px; + padding: 8px 6px 6px; +} + +.arp-card--empty { + flex: 1; + min-width: 0; +} + +.arp-main { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* ── 우측 2열: 분석 레포트 + 거래 시그널 ── */ +.arp-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); + gap: 10px; + flex: 1; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; +} + +.arp-card--report, +.arp-card--signals { + min-height: 0; + height: 100%; +} + +.arp-card-scroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 14px 14px; +} + +.arp-signals-inner { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; + padding: 0; +} + +.arp-signals-inner .brd-card--sig-fill { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + border: none; + border-radius: 0; + background: transparent; +} + +.arp-signals-inner .brd-sig-scroll { + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; +} + +/* brd 테마 변수 — 분석·시그널 카드 공통 */ +.arp-report-theme { + color: var(--se-text, var(--text)); + font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif; + letter-spacing: normal; + word-break: keep-all; + line-height: 1.45; + --text: var(--se-text, #e8eef5); + --text2: var(--se-text-muted, #94a3b8); + --text3: var(--se-text-dim, #64748b); + --bg1: var(--se-bg, #0a1628); + --bg2: color-mix(in srgb, var(--se-bg-elevated, #0d1e35) 92%, #000); + --bg3: var(--se-bg-muted, #122038); + --bg4: color-mix(in srgb, var(--se-border, #1e3050) 55%, transparent); + --border: var(--se-border, #1e3050); + --brd-pos: var(--se-success, #22c55e); + --brd-neg: var(--se-danger, #ef4444); + --brd-warn: #eab308; + --brd-blue: #60a5fa; + --brd-purple: #a78bfa; + --brd-teal: #2dd4bf; + --brd-yellow: var(--se-gold, #e6c200); + --brd-green: #4ade80; +} + +/* ── 사이드바 (legacy — 카드로 대체됨) ── */ .arp-sidebar { display: flex; flex-direction: column; @@ -31,23 +391,23 @@ overflow: hidden; } .arp-splitter { - width: 4px; + width: 6px; cursor: col-resize; - background: var(--se-border, #1e3050); + background: transparent; flex-shrink: 0; z-index: 10; + margin: 0 -3px; + position: relative; +} +.arp-splitter::after { + content: ''; + position: absolute; + inset: 8px 2px; + border-radius: 3px; + background: var(--se-border, #1e3050); transition: background 0.2s; } -.arp-splitter:hover { background: var(--se-gold, #ffd700); } - -/* ── 메인 영역 ── */ -.arp-main { - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - overflow: hidden; -} +.arp-splitter:hover::after { background: var(--se-gold, #ffd700); } /* ── 탭 바 ── */ .arp-type-tabs { @@ -773,3 +1133,545 @@ .qcc-hero-row { gap: 4px; } .qcc-hero { flex: 0 0 100%; } } + +/* ════════════════════════════════════════ + Quant Performance Detail (qpd-*) + ════════════════════════════════════════ */ +.qpd-detail { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + background: var(--se-bg, #0a1628); +} + +.qpd-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--se-text-muted, #6a7f96); + padding: 32px; +} +.qpd-empty-icon { font-size: 48px; opacity: 0.4; } + +.qpd-head { + flex-shrink: 0; + padding: 12px 16px 8px; + border-bottom: 1px solid var(--se-border, #1e3050); +} +.qpd-title { + margin: 0 0 6px; + font-size: 16px; + font-weight: 700; + color: var(--se-text-primary, #e8eef5); +} +.qpd-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.qpd-badge { + font-size: 10.5px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + color: var(--se-text-secondary, #94a3b8); + border: 1px solid var(--se-border, #1e3050); +} +.qpd-badge--muted { opacity: 0.85; } +.qpd-badge--ret.up { color: #22c55e; border-color: rgba(34, 197, 94, 0.35); } +.qpd-badge--ret.down { color: #ef4444; border-color: rgba(239, 68, 68, 0.35); } + +.qpd-kpi-row { + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--se-border, #1e3050); + background: var(--se-surface, #0d1e35); +} +@media (max-width: 1200px) { + .qpd-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (max-width: 720px) { + .qpd-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +.qpd-kpi { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--se-border, #1e3050); + background: rgba(0, 0, 0, 0.15); + min-height: 108px; + display: flex; + flex-direction: column; + gap: 4px; +} +.qpd-kpi--good { border-color: rgba(34, 197, 94, 0.35); } +.qpd-kpi--warn { border-color: rgba(234, 179, 8, 0.35); } +.qpd-kpi--bad { border-color: rgba(239, 68, 68, 0.35); } + +.qpd-kpi-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} +.qpd-kpi-label { + font-size: 10.5px; + font-weight: 600; + color: var(--se-text-muted, #6a7f96); + line-height: 1.3; +} +.qpd-kpi-badge { + font-size: 9px; + font-weight: 700; + padding: 2px 6px; + border-radius: 3px; + flex-shrink: 0; +} +.qpd-kpi-badge--good { background: rgba(34, 197, 94, 0.2); color: #22c55e; } +.qpd-kpi-badge--warn { background: rgba(234, 179, 8, 0.2); color: #eab308; } +.qpd-kpi-badge--bad { background: rgba(239, 68, 68, 0.2); color: #ef4444; } +.qpd-kpi-badge--neutral { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } + +.qpd-kpi-value { + font-size: 22px; + font-weight: 800; + color: var(--se-gold, #ffd700); + line-height: 1.1; +} +.qpd-kpi--good .qpd-kpi-value { color: #22c55e; } +.qpd-kpi--bad .qpd-kpi-value { color: #ef4444; } + +.qpd-kpi-bench { + font-size: 9.5px; + color: var(--se-text-muted, #64748b); +} +.qpd-kpi-hint { + margin: 0; + font-size: 9px; + color: var(--se-text-muted, #556677); + line-height: 1.35; + flex: 1; +} + +.qpd-subtabs { + flex-shrink: 0; + display: flex; + gap: 4px; + padding: 8px 12px 0; + border-bottom: 1px solid var(--se-border, #1e3050); + background: var(--se-surface, #0d1e35); +} +.qpd-subtab { + padding: 8px 14px; + font-size: 12px; + font-weight: 600; + color: var(--se-text-muted, #6a7f96); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} +.qpd-subtab:hover { color: var(--se-gold, #ffd700); } +.qpd-subtab--on { + color: #ffd700; + border-bottom-color: #ffd700; +} + +.qpd-panel { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px; +} + +.qpd-overview { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 960px; + margin: 0 auto; + width: 100%; +} + +.qpd-section { + border: 1px solid var(--se-border, #1e3050); + border-radius: 10px; + background: var(--se-surface, #0d1e35); + overflow: hidden; +} +.qpd-section--chart { + display: flex; + flex-direction: column; + min-height: 420px; +} +.qpd-section-head { + padding: 12px 14px 10px; + border-bottom: 1px solid var(--se-border, #1e3050); +} +.qpd-section-head h3 { + margin: 0 0 4px; + font-size: 13px; + font-weight: 700; + color: var(--se-text-primary, #e8eef5); +} +.qpd-section-head p { + margin: 0; + font-size: 11px; + color: var(--se-text-muted, #6a7f96); + line-height: 1.45; +} + +.qpd-equity-dd-wrap, +.qpd-hist-wrap { + padding: 8px 10px 12px; +} +.qpd-equity-dd-svg, +.qpd-hist-svg { + display: block; + width: 100%; + height: auto; + min-height: 200px; +} +.qpd-chart-axis-title { font-size: 9px; font-weight: 600; } +.qpd-chart-tick { font-size: 8.5px; } +.qpd-chart-annotation { font-size: 9px; font-weight: 700; } + +.qpd-equity-dd-legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 6px 4px 0; + font-size: 10.5px; + color: var(--se-text-muted, #6a7f96); +} +.qpd-leg::before { + content: ''; + display: inline-block; + width: 10px; + height: 3px; + margin-right: 5px; + vertical-align: middle; + border-radius: 1px; +} +.qpd-leg--eq::before { background: #22c55e; } +.qpd-leg--dd::before { background: #ef4444; } +.qpd-leg--ret.up { color: #22c55e; } +.qpd-leg--ret.down { color: #ef4444; } + +.qpd-hist-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 8px 4px 0; + border-top: 1px solid var(--se-border, #1e3050); + margin-top: 8px; +} +.qpd-hist-stat { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 10px; + color: var(--se-text-muted, #6a7f96); +} +.qpd-hist-stat strong { + font-size: 12px; + color: var(--se-text-primary, #e8eef5); +} +.qpd-hist-stat strong.up { color: #22c55e; } +.qpd-hist-stat strong.down { color: #ef4444; } + +.qpd-chart-empty { + padding: 40px 20px; + text-align: center; + color: var(--se-text-muted, #6a7f96); + font-size: 13px; +} + +.qpd-trade-chart { + flex: 1; + min-height: 380px; + display: flex; + flex-direction: column; +} +.qpd-trade-chart .btd-analysis-chart-wrap { + flex: 1; + min-height: 360px; +} + +.qpd-log-wrap { + overflow: auto; + max-height: min(520px, 60vh); +} +.qpd-log-table { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; +} +.qpd-log-table th { + position: sticky; + top: 0; + background: var(--se-surface-elevated, #122038); + padding: 8px 10px; + text-align: left; + font-size: 10px; + font-weight: 700; + color: var(--se-gold, #ffd700); + border-bottom: 1px solid var(--se-border, #1e3050); + white-space: nowrap; +} +.qpd-log-table td { + padding: 7px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + color: var(--se-text-secondary, #94a3b8); +} +.qpd-log-table tr:hover td { background: rgba(255, 215, 0, 0.04); } +.qpd-log-empty { + text-align: center; + padding: 24px; + color: var(--se-text-muted, #6a7f96); +} +.qpd-row--up td:nth-child(6) { color: #22c55e; } +.qpd-row--down td:nth-child(6) { color: #ef4444; } +.qpd-log-table .up { color: #22c55e; font-weight: 700; } +.qpd-log-table .down { color: #ef4444; font-weight: 700; } + +/* ════════════════════════════════════════ + 분석레포트 우측 상세 (PDF 팝업과 동일 · 테마 색상) + ════════════════════════════════════════ */ +.arp-report-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--se-text-dim, var(--text3)); + padding: 32px; +} +.arp-report-empty-icon { font-size: 48px; opacity: 0.45; } + +.arp-report-print-header { + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 2px solid var(--se-gold, #e6c200); +} + +.arp-report-print-title { + margin: 0 0 6px; + font-size: 1.05rem; + font-weight: 800; + color: var(--se-text, var(--text)); + letter-spacing: -0.02em; +} + +.arp-report-print-meta { + margin: 0; + font-size: 0.72rem; + color: var(--se-text-dim, var(--text3)); +} + +.arp-report-print-symbol { + margin: 8px 0 0; + font-size: 0.95rem; + font-weight: 700; + color: var(--se-gold, #e6c200); +} + +.arp-report-print-footer { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid var(--se-border, var(--border)); + font-size: 0.62rem; + color: var(--se-text-dim, var(--text3)); + text-align: center; +} +.arp-report-print-footer p { margin: 0; } +.arp-report-print-footer--signals { + flex-shrink: 0; + margin-top: auto; + padding: 8px 10px 10px; +} + +/* BacktestDashboard — 레포트 레이아웃 (색상은 테마 유지) */ +.bps-page--arp .arp-center-inner .brd-dashboard, +.bps-page--arp .arp-center-inner .brd-dashboard--report { + padding: 0; + background: transparent; + min-height: auto; + height: auto; + gap: 10px; + overflow: visible; +} + +.bps-page--arp .arp-center-inner .brd-dash-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px 20px; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--se-gold) 8%, transparent) 0%, + color-mix(in srgb, var(--brd-blue) 10%, transparent) 100% + ); + border: 1px solid color-mix(in srgb, var(--se-gold) 22%, var(--se-border)); +} + +.arp-report-theme .brd-dash-header-left { min-width: 0; } + +.arp-report-theme .brd-dash-strategy { + font-size: 0.95rem; + word-break: keep-all; + overflow-wrap: anywhere; + line-height: 1.45; + white-space: normal; +} + +.arp-report-theme .brd-dash-header-kpi { + display: flex; + flex-shrink: 0; + gap: 14px; + align-items: flex-start; +} + +.arp-report-theme .brd-dash-header-kpi--report { + align-items: center; +} + +.arp-report-theme .brd-dash-big-kpi { + text-align: right; + min-width: 4.8rem; + flex-shrink: 0; +} +.arp-report-theme .brd-dash-big-kpi:last-child { min-width: 7.5rem; } + +.arp-report-theme .brd-dash-big-kpi--report { + display: inline-flex; + flex-direction: row; + align-items: baseline; + gap: 5px; + white-space: nowrap; +} + +.arp-report-theme .brd-dash-big-kpi--report .brd-dash-big-label { + order: -1; + margin-top: 0; + font-size: 0.62rem; + color: var(--se-text-dim, var(--text3)); +} + +.arp-report-theme .brd-dash-big-val { + font-size: 1.05rem; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.15; + white-space: nowrap; +} + +.arp-report-theme .brd-dash-big-label { + font-size: 0.62rem; + margin-top: 2px; + white-space: nowrap; + color: var(--se-text-dim, var(--text3)); +} + +.arp-report-theme .brd-dash-badge { + background: color-mix(in srgb, var(--brd-blue) 14%, transparent); + color: var(--brd-blue); + border-color: color-mix(in srgb, var(--brd-blue) 35%, transparent); +} + +.arp-report-theme .brd-dash-badge--time, +.arp-report-theme .brd-dash-badge--method { + background: var(--bg3); + color: var(--text3); + border-color: var(--border); +} + +.arp-report-theme .brd-kpi-card, +.arp-report-theme .brd-card { + background: var(--bg2); + border: 1px solid var(--border); + box-shadow: none; + overflow: visible; +} + +.arp-report-theme .brd-kpi-card:hover { + transform: none; + box-shadow: none; +} + +.arp-report-theme .brd-kpi-sub, +.arp-report-theme .brd-kpi-label, +.arp-report-theme .brd-metric-label, +.arp-report-theme .brd-metric-note, +.arp-report-theme .brd-section-text, +.arp-report-theme .brd-compare-label, +.arp-report-theme .brd-pnl-label, +.arp-report-theme .brd-bench-label { + overflow: visible; + text-overflow: unset; + white-space: normal; + word-break: keep-all; + line-height: 1.4; +} + +.arp-report-theme .brd-kpi-sub { min-height: 1.2em; } + +.arp-report-theme .brd-section-title { + background: var(--bg3); + border-bottom-color: var(--border); + color: var(--text2); +} + +.arp-report-theme .brd-metric-label, +.arp-report-theme .brd-kpi-label { + color: var(--text3); +} + +.arp-report-theme .brd-compare-track, +.arp-report-theme .brd-pnl-track, +.arp-report-theme .brd-bench-summary { + background: var(--bg3); +} + +.arp-report-theme .brd-sig-table th { + background: var(--bg3); + color: var(--text2); +} + +.arp-report-theme .brd-sig-table td { + border-color: color-mix(in srgb, var(--border) 70%, transparent); +} + +.arp-report-theme .brd-sig-even { + background: color-mix(in srgb, var(--bg3) 50%, transparent); +} + +@media (max-width: 960px) { + .arp-detail-grid { + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) minmax(200px, 34vh); + } +} + +@media (max-width: 720px) { + .arp-body { padding: 6px; gap: 0; } + .arp-card--list { padding: 6px 4px 4px; } + .bps-page--arp .arp-center-inner .brd-dash-header { + grid-template-columns: 1fr; + } + .bps-page--arp .arp-center-inner .brd-dash-header-kpi { + justify-content: flex-start; + } +}