분석 레포트 화면 수정
This commit is contained in:
@@ -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<string, string> = {
|
||||
BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도',
|
||||
};
|
||||
const SIG_COLOR: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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({
|
||||
</div>
|
||||
|
||||
{/* ── Row 4: 거래 시그널 목록 ── */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-card brd-card--full">
|
||||
<SectionTitle icon="📋" title="거래 시그널" right={
|
||||
<span className="brd-section-count">{signals.length}건</span>
|
||||
}/>
|
||||
{signals.length === 0 ? (
|
||||
<div className="brd-empty-msg">시그널이 없습니다.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="brd-sig-scroll">
|
||||
<table className="brd-sig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width:36}}>#</th>
|
||||
<th>날짜</th>
|
||||
<th>구분</th>
|
||||
<th style={{textAlign:'right'}}>가격</th>
|
||||
<th style={{textAlign:'center',width:50}}>봉#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.slice(0, sigLimit).map((s, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
|
||||
<td style={{textAlign:'center',color:'var(--text3)'}}>{i+1}</td>
|
||||
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
|
||||
<td>
|
||||
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
|
||||
{repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
|
||||
{Math.round(s.price).toLocaleString()}
|
||||
</td>
|
||||
<td style={{textAlign:'center',color:'var(--text3)'}}>{s.barIndex}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!reportMode && signals.length > 8 && (
|
||||
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
|
||||
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!hideSignals && (
|
||||
<div className="brd-row">
|
||||
<BacktestSignalTable signals={signals} expanded={reportMode} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="arp-center-shell arp-report-theme">
|
||||
<div className="arp-report-empty">
|
||||
<span className="arp-report-empty-icon" aria-hidden>📊</span>
|
||||
<p>좌측 목록에서 백테스팅 또는 실시간 매매 실행을 선택하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="arp-center-shell arp-report-theme">
|
||||
<div className="arp-center-inner">
|
||||
<BacktestDashboard
|
||||
analysis={model.analysis}
|
||||
signals={model.signals}
|
||||
strategyName={model.strategyName}
|
||||
symbol={model.symbol}
|
||||
timeframe={model.timeframe}
|
||||
barCount={model.barCount}
|
||||
createdAt={model.createdAt}
|
||||
reportMode
|
||||
hideSignals
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalysisReportCenterPanel;
|
||||
@@ -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 (
|
||||
<div className="arp-context-bar" aria-label="선택 실행 정보">
|
||||
<div className="arp-context-bar__left-gap" aria-hidden />
|
||||
<div className="arp-context-bar__center">
|
||||
<span className="arp-context-symbol">{symbolLabel}</span>
|
||||
<span className="arp-context-meta">{executionMeta(model)}</span>
|
||||
</div>
|
||||
<div className="arp-context-bar__right-gap" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AnalysisReportTypeId>('standard');
|
||||
const [sourceTab, setSourceTab] = useState<ExecutionListTab>('backtest');
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [liveItems, setLiveItems] = useState<LiveExecutionItem[]>([]);
|
||||
@@ -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 (
|
||||
<div className={`arp-page se-page se-page--${theme}`}>
|
||||
<header className="btd-header">
|
||||
<h1 className="btd-header-title">분석레포트</h1>
|
||||
</header>
|
||||
<div className="btd-loading">데이터 로딩…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const headerActions = (
|
||||
<>
|
||||
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}>새로고침</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btd-btn btd-btn--gold"
|
||||
disabled={!reportModel}
|
||||
onClick={() => setReportOpen(true)}
|
||||
>
|
||||
인쇄 / PDF
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`arp-page se-page se-page--${theme}`}>
|
||||
<header className="btd-header">
|
||||
<div className="btd-header-brand">
|
||||
<h1 className="btd-header-title">분석레포트</h1>
|
||||
<span className="btd-header-sub">Golden Analysis Command Center</span>
|
||||
</div>
|
||||
<div className="btd-header-actions">
|
||||
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}>새로고침</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btd-btn btd-btn--gold"
|
||||
disabled={!reportModel}
|
||||
onClick={() => setReportOpen(true)}
|
||||
>
|
||||
인쇄 / PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="arp-body">
|
||||
<aside
|
||||
className="arp-sidebar btd-sidebar"
|
||||
style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}
|
||||
>
|
||||
<>
|
||||
<BuilderPageShell
|
||||
theme={theme}
|
||||
title="분석레포트"
|
||||
subtitle="Golden Analysis Command Center"
|
||||
pageClassName="bps-page--arp"
|
||||
loading={loading}
|
||||
loadingText="분석레포트 로딩…"
|
||||
collapsiblePanels
|
||||
leftStorageKey="arp-left-width"
|
||||
leftDefaultWidth={380}
|
||||
leftCollapsedStorageKey="arp-left-open"
|
||||
rightStorageKey="arp-right-width"
|
||||
rightDefaultWidth={320}
|
||||
rightCollapsedStorageKey="arp-right-open"
|
||||
leftTitle="실행 목록"
|
||||
headerActions={headerActions}
|
||||
banner={<AnalysisReportContextBar model={reportModel} />}
|
||||
left={(
|
||||
<BacktestExecutionList
|
||||
tab={sourceTab}
|
||||
onTabChange={setSourceTab}
|
||||
@@ -195,67 +174,17 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
|
||||
setSourceTab('live');
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className="arp-splitter btd-splitter btd-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="좌측 패널 너비"
|
||||
onPointerDown={onLeftSplit}
|
||||
/>
|
||||
|
||||
<main className="arp-main">
|
||||
<div className="arp-type-tabs" role="tablist" aria-label="레포트 유형">
|
||||
{ANALYSIS_REPORT_TYPES.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={reportTypeId === t.id}
|
||||
className={`arp-type-tab${reportTypeId === t.id ? ' arp-type-tab--on' : ''}`}
|
||||
title={t.subtitle}
|
||||
onClick={() => setReportTypeId(t.id)}
|
||||
>
|
||||
<span className="arp-type-tab-num">{t.tabLabel.split(' ')[0]}</span>
|
||||
<span className="arp-type-tab-name">{t.tabLabel.replace(/^\d+\s*/, '')}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="arp-type-tabs-end">
|
||||
<button
|
||||
type="button"
|
||||
className="arp-tab-report-btn"
|
||||
disabled={!reportModel}
|
||||
title="상세분석 레포트"
|
||||
onClick={() => setReportOpen(true)}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9"/>
|
||||
<line x1="9" y1="21" x2="9" y2="9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="arp-viewport">
|
||||
<AnalysisReportViews
|
||||
typeId={reportTypeId}
|
||||
ctx={ctx}
|
||||
theme={theme}
|
||||
allBacktests={records}
|
||||
compareBacktest={compareBacktest}
|
||||
compareLive={compareLive}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
center={<AnalysisReportCenterPanel model={reportModel} />}
|
||||
right={<AnalysisReportSignalsPanel model={reportModel} />}
|
||||
/>
|
||||
|
||||
<BacktestAnalysisReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
model={reportModel}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="arp-signals-empty">
|
||||
<p>선택된 실행의 거래 시그널이 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="arp-report-theme arp-signals-panel">
|
||||
<BacktestSignalTable signals={model.signals} expanded className="brd-card--sig-fill" />
|
||||
<footer className="arp-report-print-footer arp-report-print-footer--signals">
|
||||
<p>본 레포트는 GoldenChart 백테스팅·투자분석 결과이며 투자 권유가 아닙니다.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalysisReportSignalsPanel;
|
||||
@@ -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<Props> = 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 (
|
||||
<div className="qpd-chart-empty">자산 곡선을 표시할 데이터가 부족합니다.</div>
|
||||
);
|
||||
}
|
||||
|
||||
const init = initialCapital ?? curve[0]?.equity ?? 0;
|
||||
const retPct = init > 0 ? ((chart.finalEquity - init) / init) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="qpd-equity-dd-wrap">
|
||||
<svg className="qpd-equity-dd-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="qpd-eq-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(34,197,94,0.35)" />
|
||||
<stop offset="100%" stopColor="rgba(34,197,94,0.02)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="qpd-dd-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(239,68,68,0.02)" />
|
||||
<stop offset="100%" stopColor="rgba(239,68,68,0.35)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* 구분선 */}
|
||||
<line x1={pad.l} y1={splitY} x2={W - pad.r} y2={splitY}
|
||||
stroke="rgba(255,255,255,0.12)" strokeDasharray="4 3" />
|
||||
|
||||
{/* Equity 영역 */}
|
||||
<text x={pad.l} y={pad.t - 2} className="qpd-chart-axis-title" fill="#94a3b8">자산 (Equity)</text>
|
||||
{chart.eqTicks.map(v => (
|
||||
<g key={`eq-${v}`}>
|
||||
<line x1={pad.l} y1={chart.toEqY(v)} x2={W - pad.r} y2={chart.toEqY(v)}
|
||||
stroke="rgba(255,255,255,0.06)" />
|
||||
<text x={pad.l - 6} y={chart.toEqY(v) + 3} textAnchor="end"
|
||||
className="qpd-chart-tick" fill="#64748b">{fmtWon(v)}</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={chart.eqArea} fill="url(#qpd-eq-fill)" />
|
||||
<path d={chart.eqPath} fill="none" stroke="#22c55e" strokeWidth="2.2" strokeLinejoin="round" />
|
||||
|
||||
{/* Drawdown 영역 */}
|
||||
<text x={pad.l} y={splitY + 14} className="qpd-chart-axis-title" fill="#94a3b8">낙폭 (Drawdown %)</text>
|
||||
<line x1={pad.l} y1={splitY + 8} x2={W - pad.r} y2={splitY + 8}
|
||||
stroke="rgba(255,255,255,0.15)" />
|
||||
{chart.ddTicks.map(v => (
|
||||
<g key={`dd-${v}`}>
|
||||
<text x={pad.l - 6} y={chart.toDdY(v) + 3} textAnchor="end"
|
||||
className="qpd-chart-tick" fill="#64748b">{v.toFixed(1)}%</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={chart.ddArea} fill="url(#qpd-dd-fill)" />
|
||||
<path d={chart.ddPath} fill="none" stroke="#ef4444" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
|
||||
{/* MDD 마커 */}
|
||||
<circle cx={chart.mddX} cy={chart.mddY} r="4" fill="#ef4444" stroke="#fff" strokeWidth="1" />
|
||||
<text x={chart.mddX} y={chart.mddY - 8} textAnchor="middle"
|
||||
className="qpd-chart-annotation" fill="#ef4444">
|
||||
MDD {(chart.mddPct * 100).toFixed(1)}%
|
||||
</text>
|
||||
|
||||
{/* X축 */}
|
||||
{chart.xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={H - 6} textAnchor="middle" className="qpd-chart-tick" fill="#64748b">
|
||||
{l.label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
<div className="qpd-equity-dd-legend">
|
||||
<span className="qpd-leg qpd-leg--eq">자산 곡선</span>
|
||||
<span className="qpd-leg qpd-leg--dd">Underwater 낙폭</span>
|
||||
<span className={`qpd-leg qpd-leg--ret ${retPct >= 0 ? 'up' : 'down'}`}>
|
||||
기간 수익 {retPct >= 0 ? '+' : ''}{retPct.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EquityDrawdownChart.displayName = 'EquityDrawdownChart';
|
||||
export default EquityDrawdownChart;
|
||||
@@ -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 (
|
||||
<article className={`qpd-kpi qpd-kpi--${status}`}>
|
||||
<div className="qpd-kpi-head">
|
||||
<span className="qpd-kpi-label">{label}</span>
|
||||
<span className={`qpd-kpi-badge qpd-kpi-badge--${status}`}>
|
||||
{status === 'good' ? '양호' : status === 'warn' ? '보통' : status === 'bad' ? '주의' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="qpd-kpi-value">{value}</div>
|
||||
<div className="qpd-kpi-bench">기준: {benchmark}</div>
|
||||
<p className="qpd-kpi-hint">{hint}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeLogTable({ ctx }: { ctx: AnalysisReportContext }) {
|
||||
const rows = useMemo(() => {
|
||||
const buys = new Map<string, { time: number; price: number; qty: number }>();
|
||||
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 (
|
||||
<div className="qpd-log-wrap">
|
||||
<table className="qpd-log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th>
|
||||
<th>구분</th>
|
||||
<th>진입가</th>
|
||||
<th>청산가</th>
|
||||
<th>수량</th>
|
||||
<th>수익률</th>
|
||||
<th>보유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={7} className="qpd-log-empty">거래 내역 없음</td></tr>
|
||||
) : rows.map(r => (
|
||||
<tr key={r.id} className={r.pnlPct >= 0 ? 'qpd-row--up' : 'qpd-row--down'}>
|
||||
<td>{r.time}</td>
|
||||
<td>{r.side}</td>
|
||||
<td>{formatUpbitKrwPrice(r.entry)}</td>
|
||||
<td>{formatUpbitKrwPrice(r.exit)}</td>
|
||||
<td>{r.qty.toFixed(4)}</td>
|
||||
<td className={r.pnlPct >= 0 ? 'up' : 'down'}>
|
||||
{r.pnlPct >= 0 ? '+' : ''}{r.pnlPct.toFixed(2)}%
|
||||
</td>
|
||||
<td>{r.duration}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuantPerformanceDetail({ ctx, theme }: Props) {
|
||||
const [tab, setTab] = useState<DetailTab>('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 (
|
||||
<div className="qpd-empty">
|
||||
<span className="qpd-empty-icon">📊</span>
|
||||
<p>좌측 목록에서 백테스팅 또는 실시간 매매 실행을 선택하세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="qpd-detail">
|
||||
<header className="qpd-head">
|
||||
<div className="qpd-head-main">
|
||||
<h2 className="qpd-title">{repairUtf8Mojibake(ctx.strategyName)}</h2>
|
||||
<div className="qpd-meta">
|
||||
<span className="qpd-badge">{ctx.symbol}</span>
|
||||
<span className="qpd-badge">{ctx.timeframe}</span>
|
||||
<span className="qpd-badge qpd-badge--muted">
|
||||
{ctx.sourceTab === 'backtest' ? '백테스팅' : '모의·실시간'}
|
||||
</span>
|
||||
<span className="qpd-badge">거래 {tradeCount}회</span>
|
||||
<span className={`qpd-badge qpd-badge--ret ${totalRet >= 0 ? 'up' : 'down'}`}>
|
||||
수익 {totalRet >= 0 ? '+' : ''}{(totalRet * 100).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="qpd-kpi-row">
|
||||
<KpiCard
|
||||
label="승률 (Win Rate)"
|
||||
value={pctAbs(winRate, 1)}
|
||||
benchmark="50% 이상 (추세추종 40%+)"
|
||||
status={winStatus}
|
||||
hint="이익 청산 비율 — 전략 유형에 따라 기준 상이"
|
||||
/>
|
||||
<KpiCard
|
||||
label="손익비 (Profit Factor)"
|
||||
value={pf >= 99 ? '99+' : pf.toFixed(2)}
|
||||
benchmark="1.5+ 양호 · 2.0+ 우수"
|
||||
status={pfStatus}
|
||||
hint="총 이익 ÷ 총 손실 — 1 미만이면 구조적 손실"
|
||||
/>
|
||||
<KpiCard
|
||||
label="MDD (최대 낙폭)"
|
||||
value={`${(mdd * 100).toFixed(2)}%`}
|
||||
benchmark="-15% 이하 권장"
|
||||
status={mddStatus}
|
||||
hint="전고점 대비 최대 하락 — 멘탈·자금 관리 한계 판단"
|
||||
/>
|
||||
<KpiCard
|
||||
label="샤프 지수 (Sharpe)"
|
||||
value={sharpe.toFixed(2)}
|
||||
benchmark="1.0+ 양호 · 2.0+ 우수"
|
||||
status={sharpeStatus}
|
||||
hint="변동성 대비 위험조정 수익"
|
||||
/>
|
||||
<KpiCard
|
||||
label="평균 보유 기간"
|
||||
value={fmtDuration(avgHoldSec)}
|
||||
benchmark="전략 성격(단타/스윙)과 일치"
|
||||
status={holdStatus}
|
||||
hint={holdingPts.length > 0
|
||||
? `${holdingPts.length}건 평균 · ${ctx.timeframe} 전략과 비교`
|
||||
: '보유 구간 데이터 없음'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="qpd-subtabs" role="tablist" aria-label="상세 분석">
|
||||
{([
|
||||
['overview', '① 종합 (자산·분포)'],
|
||||
['trades', '② 매매 타점 차트'],
|
||||
['log', '③ 거래 내역'],
|
||||
] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
className={`qpd-subtab${tab === id ? ' qpd-subtab--on' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="qpd-panel" role="tabpanel">
|
||||
{tab === 'overview' && (
|
||||
<div className="qpd-overview">
|
||||
<section className="qpd-section">
|
||||
<header className="qpd-section-head">
|
||||
<h3>자산 성장 곡선 & 낙폭 (Equity · Drawdown)</h3>
|
||||
<p>
|
||||
상단은 계좌 자산 변화, 하단은 전고점 대비 Underwater 낙폭입니다.
|
||||
MDD가 깊거나 회복 기간이 길면 실전 적용 시 버티기 어렵습니다.
|
||||
</p>
|
||||
</header>
|
||||
<EquityDrawdownChart
|
||||
curve={ctx.equityCurve}
|
||||
initialCapital={a?.initialCapital}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="qpd-section">
|
||||
<header className="qpd-section-head">
|
||||
<h3>수익률 분포 (Returns Distribution)</h3>
|
||||
<p>
|
||||
매매건별 수익률 히스토그램 — 우측(양수) 꼬리가 길수록 유리합니다.
|
||||
좌측으로 길게 늘어지면 큰 손실 위험이 큽니다.
|
||||
</p>
|
||||
</header>
|
||||
<ReturnsHistogramChart trades={ctx.trades} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'trades' && chartRange && (
|
||||
<section className="qpd-section qpd-section--chart">
|
||||
<header className="qpd-section-head">
|
||||
<h3>매매 타점 마킹 (Trade Plot)</h3>
|
||||
<p>
|
||||
캔들 차트 위 매수(▲)·매도(▼) 시점 — 의도한 조건에서 진입·청산했는지,
|
||||
슬리피지·오류 체결 여부를 검증합니다.
|
||||
</p>
|
||||
</header>
|
||||
<div className="qpd-trade-chart">
|
||||
<BacktestAnalysisChart
|
||||
symbol={chartRange.symbol}
|
||||
timeframe={chartRange.timeframe}
|
||||
toTimeSec={chartRange.toTimeSec}
|
||||
fromTimeSec={chartRange.fromTimeSec}
|
||||
barCount={chartRange.barCount}
|
||||
signals={chartRange.signals}
|
||||
strategyId={chartRange.strategyId}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'trades' && !chartRange && (
|
||||
<div className="qpd-chart-empty">매매 타점을 표시할 시그널이 없습니다.</div>
|
||||
)}
|
||||
|
||||
{tab === 'log' && (
|
||||
<section className="qpd-section">
|
||||
<header className="qpd-section-head">
|
||||
<h3>거래 실행 로그</h3>
|
||||
<p>진입·청산 가격, 수익률, 보유 시간을 확인합니다.</p>
|
||||
</header>
|
||||
<TradeLogTable ctx={ctx} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Props> = 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 <div className="qpd-chart-empty">수익률 분포를 계산할 거래가 없습니다.</div>;
|
||||
}
|
||||
|
||||
const skew = stats.rightTail > stats.leftTail ? 'positive' : stats.leftTail > stats.rightTail ? 'negative' : 'neutral';
|
||||
|
||||
return (
|
||||
<div className="qpd-hist-wrap">
|
||||
<svg className="qpd-hist-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
|
||||
{[0.25, 0.5, 0.75, 1].map(r => {
|
||||
const y = pad.t + innerH * (1 - r);
|
||||
const cnt = Math.round(maxCount * r);
|
||||
return (
|
||||
<g key={r}>
|
||||
<line x1={pad.l} y1={y} x2={W - pad.r} y2={y} stroke="rgba(255,255,255,0.06)" />
|
||||
<text x={pad.l - 6} y={y + 3} textAnchor="end" className="qpd-chart-tick" fill="#64748b">{cnt}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{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 (
|
||||
<g key={b.bucket}>
|
||||
<rect
|
||||
x={x}
|
||||
y={pad.t + innerH - barH}
|
||||
width={Math.max(barW, 4)}
|
||||
height={barH}
|
||||
fill={fill}
|
||||
opacity={0.85}
|
||||
rx={2}
|
||||
/>
|
||||
{b.count > 0 && (
|
||||
<text x={x + barW / 2} y={pad.t + innerH - barH - 4}
|
||||
textAnchor="middle" fontSize="9" fill={fill} fontWeight="600">
|
||||
{b.count}
|
||||
</text>
|
||||
)}
|
||||
<text x={x + barW / 2} y={H - 8} textAnchor="middle"
|
||||
className="qpd-chart-tick" fill="#64748b" fontSize="8">
|
||||
{b.bucket.replace(' ', '')}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<text x={(W + pad.l) / 2} y={H - 2} textAnchor="middle" className="qpd-chart-tick" fill="#94a3b8">
|
||||
매매건별 수익률 (%)
|
||||
</text>
|
||||
</svg>
|
||||
<div className="qpd-hist-stats">
|
||||
<div className="qpd-hist-stat">
|
||||
<span>평균 수익률</span>
|
||||
<strong className={stats.avg >= 0 ? 'up' : 'down'}>
|
||||
{stats.avg >= 0 ? '+' : ''}{stats.avg.toFixed(2)}%
|
||||
</strong>
|
||||
</div>
|
||||
<div className="qpd-hist-stat">
|
||||
<span>수익 / 손실</span>
|
||||
<strong>{stats.wins} / {stats.losses}</strong>
|
||||
</div>
|
||||
<div className="qpd-hist-stat">
|
||||
<span>꼬리 형태</span>
|
||||
<strong className={skew === 'positive' ? 'up' : skew === 'negative' ? 'down' : ''}>
|
||||
{skew === 'positive' ? '우측(양의) 꼬리 우세' : skew === 'negative' ? '좌측(손실) 꼬리 우세 — 주의' : '균형'}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ReturnsHistogramChart.displayName = 'ReturnsHistogramChart';
|
||||
export default ReturnsHistogramChart;
|
||||
@@ -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<string, string> = {
|
||||
BUY: '매수',
|
||||
SELL: '매도',
|
||||
SHORT_ENTRY: '공매도',
|
||||
SHORT_EXIT: '공매도청산',
|
||||
PARTIAL_SELL: '분할매도',
|
||||
};
|
||||
const SIG_COLOR: Record<string, string> = {
|
||||
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 (
|
||||
<div className={`brd-card brd-card--full brd-card--sig-panel${className ? ` ${className}` : ''}`}>
|
||||
<SectionTitle
|
||||
icon="📋"
|
||||
title="거래 시그널"
|
||||
right={<span className="brd-section-count">{signals.length}건</span>}
|
||||
/>
|
||||
{signals.length === 0 ? (
|
||||
<div className="brd-empty-msg">시그널이 없습니다.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="brd-sig-scroll">
|
||||
<table className="brd-sig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}>#</th>
|
||||
<th>날짜</th>
|
||||
<th>구분</th>
|
||||
<th style={{ textAlign: 'right' }}>가격</th>
|
||||
<th style={{ textAlign: 'center', width: 50 }}>봉#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.slice(0, sigLimit).map((s, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{i + 1}</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums' }}>{fmtDate(s.time)}</td>
|
||||
<td>
|
||||
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
|
||||
{repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{Math.round(s.price).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{s.barIndex}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!expanded && signals.length > 8 && (
|
||||
<button type="button" className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
|
||||
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user