분석레포트 화면 추가
@@ -7,7 +7,7 @@ public final class MenuIds {
|
||||
private MenuIds() {}
|
||||
|
||||
public static final List<String> ALL = List.of(
|
||||
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "notifications", "settings",
|
||||
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "analysis-report", "notifications", "settings",
|
||||
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
|
||||
"settings_strategy", "settings_paper", "settings_virtual", "settings_live",
|
||||
"settings_trend-search",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 분석레포트 메뉴 권한
|
||||
INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES
|
||||
('ADMIN', 'analysis-report', 1),
|
||||
('USER', 'analysis-report', 1),
|
||||
('GUEST', 'analysis-report', 0)
|
||||
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed);
|
||||
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -228,6 +228,7 @@ html.theme-blue {
|
||||
min-width: 0;
|
||||
}
|
||||
.tmb-nav-btn {
|
||||
position: relative; /* 알림 뱃지 absolute 기준점 */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -12193,37 +12194,21 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* TopMenuBar 알림 버튼 */
|
||||
.tmb-notify-group {
|
||||
/* TopMenuBar 알림 ── 아이콘 버튼 제거, 메뉴 뱃지 + 팝업 닫기 버튼만 유지 */
|
||||
.tmb-notify-dismiss-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.tmb-notify-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tmb-notify-btn:hover { background: var(--bg4); color: var(--text); }
|
||||
.tmb-notify-dismiss-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tmb-notify-dismiss-btn:hover:not(:disabled) {
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
@@ -12234,10 +12219,11 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* 공통 뱃지 — 알림 팝업 닫기 버튼 내 또는 메뉴 버튼 위에 표시 */
|
||||
.tmb-notify-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
@@ -12246,8 +12232,15 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 메뉴 버튼 내 뱃지 (알림목록 메뉴) */
|
||||
.tmb-notify-badge--nav {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tmb-notify-layout {
|
||||
position: relative;
|
||||
|
||||
@@ -120,6 +120,7 @@ const StrategyPage = lazy(() => import('./components/StrategyPage'));
|
||||
const StrategyEditorPage = lazy(() => import('./components/StrategyEditorPage'));
|
||||
const TradeNotificationListPage = lazy(() => import('./components/TradeNotificationListPage'));
|
||||
const BacktestHistoryPage = lazy(() => import('./components/BacktestHistoryPage').then(m => ({ default: m.BacktestHistoryPage })));
|
||||
const AnalysisReportPage = lazy(() => import('./components/analysisReport/AnalysisReportPage').then(m => ({ default: m.AnalysisReportPage })));
|
||||
const SettingsPage = lazy(() => import('./components/SettingsPage'));
|
||||
const PaperTradingPage = lazy(() => import('./components/PaperTradingPage'));
|
||||
const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage'));
|
||||
@@ -2004,6 +2005,16 @@ function App() {
|
||||
: <FormalLoginGate onLogin={requireFormalLogin} />
|
||||
)}
|
||||
|
||||
{menuPage === 'analysis-report' && (
|
||||
formalLogin
|
||||
? (
|
||||
<Suspense fallback={<PageLoadFallback />}>
|
||||
<AnalysisReportPage theme={theme} />
|
||||
</Suspense>
|
||||
)
|
||||
: <FormalLoginGate onLogin={requireFormalLogin} />
|
||||
)}
|
||||
|
||||
{menuPage === 'paper' && (
|
||||
formalLogin ? (
|
||||
<Suspense fallback={<PageLoadFallback />}>
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import type { AuthSession } from '../utils/auth';
|
||||
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { canAccessMenu } from '../utils/permissions';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'analysis-report' | 'notifications' | 'settings';
|
||||
|
||||
interface TopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
theme: Theme;
|
||||
onPage: (page: MenuPage) => void;
|
||||
onTheme: () => void;
|
||||
/** 미확인 매매 시그널 알림 수 */
|
||||
/** 미확인 매매 시그널 알림 수 — 알림목록 메뉴 뱃지로 표시 */
|
||||
tradeNotifyUnread?: number;
|
||||
/** @deprecated 알림 아이콘 버튼 제거됨, 하위 호환 유지 */
|
||||
onOpenNotifications?: () => void;
|
||||
/** 모바일 앱 다운로드 모달 */
|
||||
onOpenAppDownload?: () => void;
|
||||
@@ -66,6 +66,16 @@ const IcChart = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcAnalysisReport = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 2h8a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/>
|
||||
<path d="M3 5H2a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h8"/>
|
||||
<line x1="7" y1="6" x2="11" y2="6"/>
|
||||
<line x1="7" y1="9" x2="11" y2="9"/>
|
||||
<line x1="7" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcBacktest = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="9" width="2.5" height="5" rx="0.5"/>
|
||||
@@ -98,15 +108,6 @@ const IcNotify = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 알림 팝업 전체 닫기 */
|
||||
const IcDismissPopups = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="9" height="10" rx="1"/>
|
||||
<path d="M11 5.5 14 2.5M14 5.5 11 2.5"/>
|
||||
<line x1="4.5" y1="6.5" x2="8.5" y2="10.5"/>
|
||||
<line x1="8.5" y1="6.5" x2="4.5" y2="10.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcFullscreen = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -191,6 +192,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
|
||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||
{ page: 'analysis-report', label: '분석레포트', icon: <IcAnalysisReport /> },
|
||||
{ page: 'notifications', label: '알림목록', icon: <IcNotify /> },
|
||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
||||
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
|
||||
@@ -199,7 +201,6 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
export const TopMenuBar = memo(function TopMenuBar({
|
||||
activePage, theme, onPage, onTheme,
|
||||
tradeNotifyUnread = 0,
|
||||
onOpenNotifications,
|
||||
onOpenAppDownload,
|
||||
tradeNotifyToastCount = 0,
|
||||
onDismissAllNotifyPopups,
|
||||
@@ -227,8 +228,6 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
const visibleItems = menuPermissions
|
||||
? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page))
|
||||
: MENU_ITEMS;
|
||||
const showNotifications = menuPermissions == null || menuPermissions.notifications === true;
|
||||
|
||||
return (
|
||||
<header className="top-menubar">
|
||||
{/* 로고 */}
|
||||
@@ -254,6 +253,11 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
>
|
||||
<span className="tmb-nav-icon">{icon}</span>
|
||||
<span className="tmb-nav-label">{label}</span>
|
||||
{page === 'notifications' && tradeNotifyUnread > 0 && (
|
||||
<span className="tmb-notify-badge tmb-notify-badge--nav">
|
||||
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -274,54 +278,6 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
{onOpenNotifications && showNotifications && (
|
||||
<span className="tmb-divider" aria-hidden="true" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{onOpenNotifications && showNotifications && (
|
||||
<>
|
||||
<div className="tmb-notify-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`tmb-notify-btn ${activePage === 'notifications' ? 'tmb-nav-btn--active' : ''}`}
|
||||
onClick={onOpenNotifications}
|
||||
title="매매 시그널 알림"
|
||||
aria-label="매매 시그널 알림"
|
||||
>
|
||||
<IcNotify />
|
||||
{tradeNotifyUnread > 0 && (
|
||||
<span className="tmb-notify-badge">
|
||||
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<TradeAlertPopupMenubarSelect
|
||||
position={tradeAlertPopupPosition}
|
||||
layout={tradeAlertPopupLayout}
|
||||
gridCols={tradeAlertPopupGridCols}
|
||||
onPositionChange={onTradeAlertPopupPosition}
|
||||
onLayoutChange={onTradeAlertPopupLayout}
|
||||
onGridColsChange={onTradeAlertPopupGridCols}
|
||||
/>
|
||||
{onDismissAllNotifyPopups && (
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-notify-dismiss-btn"
|
||||
onClick={onDismissAllNotifyPopups}
|
||||
disabled={tradeNotifyToastCount <= 0}
|
||||
title={
|
||||
tradeNotifyToastCount > 0
|
||||
? `알림 팝업 ${tradeNotifyToastCount}건 전체 닫기`
|
||||
: '닫을 알림 팝업 없음'
|
||||
}
|
||||
aria-label="알림 팝업 전체 닫기"
|
||||
>
|
||||
<IcDismissPopups />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="tmb-divider" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 분석레포트 — 좌: 백테스팅/실시간 목록(백테스팅 화면과 동일) · 우: PDF 10 Variations 탭
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import {
|
||||
loadBacktestResults,
|
||||
loadPaperTrades,
|
||||
loadPaperSummary,
|
||||
loadStrategies,
|
||||
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 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;
|
||||
}
|
||||
|
||||
function pickCompareBacktest(
|
||||
selected: BacktestResultRecord,
|
||||
records: BacktestResultRecord[],
|
||||
): BacktestResultRecord | null {
|
||||
const others = records.filter(r => r.id !== selected.id);
|
||||
const sameSymbol = others.filter(r => r.symbol === selected.symbol);
|
||||
return sameSymbol[0] ?? others[0] ?? null;
|
||||
}
|
||||
|
||||
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[]>([]);
|
||||
const [selectedBacktest, setSelectedBacktest] = useState<BacktestResultRecord | null>(null);
|
||||
const [selectedLive, setSelectedLive] = useState<LiveExecutionItem | null>(null);
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
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(),
|
||||
loadPaperSummary(),
|
||||
loadStrategies(),
|
||||
]);
|
||||
setStrategies(stratList);
|
||||
const strategyNames = Object.fromEntries(stratList.map(s => [s.id, s.name]));
|
||||
const live = buildLiveExecutionItems(trades, summary, strategyNames);
|
||||
setLiveItems(live);
|
||||
setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null));
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [recs, stratList] = await Promise.all([
|
||||
loadBacktestResults(),
|
||||
loadStrategies(),
|
||||
]);
|
||||
setRecords(recs);
|
||||
setStrategies(stratList);
|
||||
setSelectedBacktest(prev => (prev && recs.some(r => r.id === prev.id) ? prev : recs[0] ?? null));
|
||||
await refreshLive();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshLive]);
|
||||
|
||||
useEffect(() => { void fetchAll(); }, [fetchAll]);
|
||||
useEffect(() => {
|
||||
const onPaper = () => { void refreshLive(); };
|
||||
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
|
||||
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
|
||||
}, [refreshLive]);
|
||||
|
||||
const compareBacktest = useMemo(() => {
|
||||
if (!selectedBacktest) return null;
|
||||
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);
|
||||
}
|
||||
if (sourceTab === 'live' && selectedLive) {
|
||||
return buildContextFromLive(selectedLive);
|
||||
}
|
||||
return null;
|
||||
}, [sourceTab, selectedBacktest, selectedLive, compareBacktest]);
|
||||
|
||||
const strategyNamesById = useMemo(
|
||||
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
||||
[strategies],
|
||||
);
|
||||
|
||||
const reportModel = useMemo(() => {
|
||||
if (sourceTab === 'backtest' && selectedBacktest && ctx?.analysis) {
|
||||
return buildBacktestReportModel(
|
||||
selectedBacktest,
|
||||
ctx.analysis,
|
||||
ctx.signals,
|
||||
strategyNamesById,
|
||||
);
|
||||
}
|
||||
if (sourceTab === 'live' && selectedLive) {
|
||||
return buildLiveReportModel(selectedLive, ctx?.signals ?? [], '1h');
|
||||
}
|
||||
return null;
|
||||
}, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`arp-page se-page se-page--${theme}`}>
|
||||
<header className="arp-header">
|
||||
<h1 className="arp-header-title">분석레포트</h1>
|
||||
</header>
|
||||
<div className="arp-loading">데이터 로딩…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`arp-page se-page se-page--${theme}`}>
|
||||
<header className="arp-header">
|
||||
<div className="arp-header-brand">
|
||||
<h1 className="arp-header-title">분석레포트</h1>
|
||||
<span className="arp-header-sub">TradingReport UI Design · 10 Variations</span>
|
||||
</div>
|
||||
<div className="arp-header-actions">
|
||||
<button type="button" className="arp-btn" onClick={() => void fetchAll()}>새로고침</button>
|
||||
<button
|
||||
type="button"
|
||||
className="arp-btn arp-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` }}
|
||||
>
|
||||
<BacktestExecutionList
|
||||
tab={sourceTab}
|
||||
onTabChange={setSourceTab}
|
||||
backtestRecords={records}
|
||||
liveItems={liveItems}
|
||||
selectedBacktestId={selectedBacktest?.id ?? null}
|
||||
selectedLiveId={selectedLive?.id ?? null}
|
||||
onSelectBacktest={r => {
|
||||
setSelectedBacktest(r);
|
||||
setSourceTab('backtest');
|
||||
}}
|
||||
onSelectLive={item => {
|
||||
setSelectedLive(item);
|
||||
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>
|
||||
<div className="arp-viewport">
|
||||
<AnalysisReportViews
|
||||
typeId={reportTypeId}
|
||||
ctx={ctx}
|
||||
theme={theme}
|
||||
allBacktests={records}
|
||||
compareBacktest={compareBacktest}
|
||||
compareLive={compareLive}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<BacktestAnalysisReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
model={reportModel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalysisReportPage;
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* PDF TradingReport 공통 UI (글래스 패널·차트·히트맵)
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { pct, pctAbs } from '../shared/analysisDashboardUi';
|
||||
|
||||
export type PanelTone = 'gold' | 'cyan' | 'neutral';
|
||||
|
||||
export function GlassPanel({
|
||||
tone = 'gold',
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
tone?: PanelTone;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`arp-panel arp-panel--${tone} ${className}`.trim()}>
|
||||
{(title || subtitle) && (
|
||||
<header className="arp-panel-head">
|
||||
{title && <h3 className="arp-panel-title">{title}</h3>}
|
||||
{subtitle && <p className="arp-panel-sub">{subtitle}</p>}
|
||||
</header>
|
||||
)}
|
||||
<div className="arp-panel-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** PDF Assets Growth — 영역 채우기 라인 */
|
||||
export function EquityGrowthChart({
|
||||
values,
|
||||
height = 140,
|
||||
positive = true,
|
||||
}: {
|
||||
values: number[];
|
||||
height?: number;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
const w = 400;
|
||||
const path = useMemo(() => {
|
||||
if (values.length < 2) return { line: '', area: '' };
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = height - 12 - ((v - lo) / range) * (height - 24);
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const line = pts.join(' ');
|
||||
const area = `${line} L ${w},${height} L 0,${height} Z`;
|
||||
return { line, area };
|
||||
}, [values, height]);
|
||||
|
||||
const stroke = positive ? '#00e5ff' : '#ff1744';
|
||||
const fillId = positive ? 'arpEqCyan' : 'arpEqRed';
|
||||
|
||||
return (
|
||||
<svg className="arp-equity-chart" viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={path.area} fill={`url(#${fillId})`} />
|
||||
<path d={path.line} fill="none" stroke={stroke} strokeWidth="2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonthlyHeatmap({
|
||||
cells,
|
||||
showValues = false,
|
||||
}: {
|
||||
cells: { year: number; month: number; returnPct: number }[];
|
||||
showValues?: boolean;
|
||||
}) {
|
||||
const months = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
||||
const years = [...new Set(cells.map(c => c.year))].sort();
|
||||
const map = new Map(cells.map(c => [`${c.year}-${c.month}`, c.returnPct]));
|
||||
|
||||
return (
|
||||
<div className="arp-heatmap arp-heatmap--rich">
|
||||
<div className="arp-heatmap-head">
|
||||
<span />
|
||||
{months.map(m => <span key={m}>{m}</span>)}
|
||||
</div>
|
||||
{years.map(y => (
|
||||
<div key={y} className="arp-heatmap-row">
|
||||
<span className="arp-heatmap-y">{y}</span>
|
||||
{months.map((_, mi) => {
|
||||
const v = map.get(`${y}-${mi}`) ?? 0;
|
||||
const intensity = Math.min(1, Math.abs(v) * 6);
|
||||
const bg = v > 0
|
||||
? `rgba(0, 230, 118, ${0.2 + intensity * 0.7})`
|
||||
: v < 0
|
||||
? `rgba(255, 23, 68, ${0.2 + intensity * 0.7})`
|
||||
: 'rgba(60,70,90,0.35)';
|
||||
return (
|
||||
<span key={mi} className="arp-heatmap-cell" style={{ background: bg }} title={pct(v)}>
|
||||
{showValues && v !== 0 && (
|
||||
<span className="arp-heatmap-val">{pctAbs(v, 0)}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrafficLight({ level }: { level: 'green' | 'yellow' | 'red' }) {
|
||||
return (
|
||||
<div className="arp-traffic">
|
||||
<span className={`arp-traffic-bulb${level === 'red' ? ' arp-traffic-bulb--on' : ''}`} data-c="r" />
|
||||
<span className={`arp-traffic-bulb${level === 'yellow' ? ' arp-traffic-bulb--on' : ''}`} data-c="y" />
|
||||
<span className={`arp-traffic-bulb${level === 'green' ? ' arp-traffic-bulb--on' : ''}`} data-c="g" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DonutAllocation({
|
||||
slices,
|
||||
}: {
|
||||
slices: { label: string; pct: number; color: string }[];
|
||||
}) {
|
||||
const total = slices.reduce((s, x) => s + x.pct, 0) || 1;
|
||||
let acc = 0;
|
||||
const r = 40;
|
||||
const c = 2 * Math.PI * r;
|
||||
return (
|
||||
<div className="arp-donut-wrap">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||
{slices.map((sl, i) => {
|
||||
const dash = (sl.pct / total) * c;
|
||||
const offset = c * 0.25 - acc;
|
||||
acc += dash;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={sl.color}
|
||||
strokeWidth="14"
|
||||
strokeDasharray={`${dash} ${c - dash}`}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<ul className="arp-donut-legend">
|
||||
{slices.map(sl => (
|
||||
<li key={sl.label}>
|
||||
<span className="arp-donut-dot" style={{ background: sl.color }} />
|
||||
{sl.label} {sl.pct.toFixed(1)}%
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiTile({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
variant = 'gold',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
variant?: 'gold' | 'green' | 'red';
|
||||
}) {
|
||||
return (
|
||||
<div className={`arp-kpi-tile arp-kpi-tile--${variant}`}>
|
||||
<span className="arp-kpi-tile-label">{label}</span>
|
||||
<strong className="arp-kpi-tile-value">{value}</strong>
|
||||
{sub && <span className="arp-kpi-tile-sub">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,8 @@ interface Props {
|
||||
/** 분석 레포트 팝업 (백테스팅 상단 툴바) */
|
||||
onOpenReport?: () => void;
|
||||
reportDisabled?: boolean;
|
||||
/** 거래 로그 클릭 시 해당 시점으로 차트 이동 */
|
||||
focusTimeSec?: number | null;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -105,6 +107,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
onToggleOverlay,
|
||||
onOpenReport,
|
||||
reportDisabled = false,
|
||||
focusTimeSec = null,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const { defaults: appDefaults } = useAppSettings();
|
||||
@@ -300,6 +303,15 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
applyMarkers();
|
||||
}, [signals, bars, applyMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusTimeSec == null) return;
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
const barSec = timeframeBarSeconds(timeframeRaw);
|
||||
const pad = Math.max(barSec * 48, 3600);
|
||||
mgr.applyVisibleTimeRange(focusTimeSec - pad, focusTimeSec + pad);
|
||||
}, [focusTimeSec, timeframeRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) applyPaneRatio(mgr);
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
/**
|
||||
* The Quant Command Center — Analysis Report Styles
|
||||
* TRADING_REPORT_UI_DESIGN.pdf 기반
|
||||
*/
|
||||
|
||||
/* ── 페이지 레이아웃 ── */
|
||||
.arp-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--se-text-primary, #e0e8f0);
|
||||
background: var(--se-bg, #0a1628);
|
||||
}
|
||||
.arp-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 사이드바 ── */
|
||||
.arp-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
max-width: 360px;
|
||||
background: var(--se-surface, #0d1e35);
|
||||
border-right: 1px solid var(--se-border, #1e3050);
|
||||
overflow: hidden;
|
||||
}
|
||||
.arp-splitter {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: var(--se-border, #1e3050);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.arp-splitter:hover { background: var(--se-accent-cyan, #00e5ff); }
|
||||
|
||||
/* ── 메인 영역 ── */
|
||||
.arp-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 탭 바 ── */
|
||||
.arp-type-tabs {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
background: var(--se-surface, #0d1e35);
|
||||
border-bottom: 1px solid var(--se-border, #1e3050);
|
||||
padding: 0 8px;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.arp-type-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.arp-type-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
.arp-type-tab:hover { color: #00e5ff; }
|
||||
.arp-type-tab[aria-selected="true"] {
|
||||
color: #ffd700;
|
||||
border-bottom-color: #ffd700;
|
||||
}
|
||||
|
||||
/* ── 뷰포트 ── */
|
||||
.arp-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
공통 QCC 뷰 기반
|
||||
════════════════════════════════════════ */
|
||||
.qcc-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 6px 8px 4px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 메타 배지 */
|
||||
.qcc-view-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qcc-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
color: #00e5ff;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qcc-badge--muted {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--se-text-muted, #6a7f96);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.qcc-badge--strategy {
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.qcc-empty-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
.qcc-empty-page span { font-size: 36px; }
|
||||
.qcc-empty-page p { font-size: 13px; }
|
||||
.qcc-empty { padding: 20px; text-align: center; color: var(--se-text-muted); font-size: 12px; }
|
||||
|
||||
/* 패널 */
|
||||
.qcc-panel {
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-panel--gold { border-color: rgba(255, 215, 0, 0.25); background: rgba(255, 215, 0, 0.05); }
|
||||
.qcc-panel--cyan { border-color: rgba(0, 229, 255, 0.25); background: rgba(0, 229, 255, 0.04); }
|
||||
.qcc-panel--neutral { border-color: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.03); }
|
||||
.qcc-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--se-text-secondary, #a0b4c8);
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qcc-panel-sub {
|
||||
font-size: 10px;
|
||||
color: var(--se-text-muted);
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 섹션 타이틀 ── */
|
||||
.qcc-section-title {
|
||||
font-size: clamp(14px, 1.8vw, 22px);
|
||||
font-weight: 800;
|
||||
color: #e0e8f0;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.qcc-section-sub {
|
||||
font-size: 11px;
|
||||
color: var(--se-text-muted);
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 컬러 유틸 */
|
||||
.up { color: #00e676; }
|
||||
.down { color: #ff1744; }
|
||||
.qcc-hero-gold { color: #ffd700; }
|
||||
|
||||
/* 스파크 차트 */
|
||||
.qcc-spark {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
01 Standard
|
||||
════════════════════════════════════════ */
|
||||
.qcc-standard {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-standard-left {
|
||||
flex: 0 0 220px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.qcc-standard-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qcc-summary-rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.qcc-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.qcc-summary-row span { color: var(--se-text-muted); }
|
||||
.qcc-summary-row strong { font-weight: 700; font-size: 13px; }
|
||||
.qcc-summary-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 4px 0; }
|
||||
|
||||
/* Hero row */
|
||||
.qcc-hero-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qcc-hero {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: rgba(255,255,255,0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-hero--up { border-color: rgba(0,230,118,0.35); background: rgba(0,230,118,0.05); }
|
||||
.qcc-hero--down { border-color: rgba(255,23,68,0.35); background: rgba(255,23,68,0.05); }
|
||||
.qcc-hero--mdd { border-color: rgba(255,23,68,0.25); }
|
||||
.qcc-hero--sharpe { border-color: rgba(255,215,0,0.25); background: rgba(255,215,0,0.04); }
|
||||
.qcc-hero-label { font-size: 10px; color: var(--se-text-muted); font-weight: 600; flex-shrink: 0; }
|
||||
.qcc-hero-value {
|
||||
font-size: clamp(18px, 2.5vw, 28px);
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qcc-hero--up .qcc-hero-value { color: #00e676; }
|
||||
.qcc-hero--down .qcc-hero-value { color: #ff1744; }
|
||||
.qcc-hero--mdd .qcc-hero-value { color: #ff1744; }
|
||||
.qcc-hero-sub { font-size: 10px; color: var(--se-text-muted); flex-shrink: 0; }
|
||||
.qcc-hero-sub-line { height: 2px; background: rgba(0,229,255,0.5); margin: 6px 0; flex-shrink: 0; }
|
||||
|
||||
/* Growth panel */
|
||||
.qcc-growth-panel { flex: 1; min-height: 0; }
|
||||
.qcc-growth-chart-wrap { flex: 1; min-height: 0; display: flex; align-items: stretch; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
02 Risk
|
||||
════════════════════════════════════════ */
|
||||
.qcc-risk {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-risk-left {
|
||||
flex: 0 0 240px;
|
||||
min-width: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.qcc-risk-bullets {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.qcc-risk-bullets li { font-size: 11.5px; line-height: 1.55; color: var(--se-text-secondary); }
|
||||
.qcc-risk-bullets strong { color: #e0e8f0; }
|
||||
.qcc-risk-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-risk-bottom {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-risk-bottom > .qcc-panel { flex: 1; min-width: 0; }
|
||||
.qcc-chart-wrap { flex: 1; min-height: 60px; display: flex; align-items: stretch; overflow: hidden; }
|
||||
.qcc-vol-label { text-align: center; font-size: 10px; color: var(--se-text-muted); flex-shrink: 0; margin-top: 4px; }
|
||||
.qcc-var-svg { width: 100%; max-height: 120px; display: block; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
03 Efficiency
|
||||
════════════════════════════════════════ */
|
||||
.qcc-efficiency {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-efficiency-left {
|
||||
flex: 0 0 240px;
|
||||
min-width: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.qcc-eff-desc { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcc-eff-desc p { font-size: 11.5px; line-height: 1.55; color: var(--se-text-secondary); margin: 0; }
|
||||
.qcc-eff-desc strong { color: #e0e8f0; }
|
||||
.qcc-efficiency-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.qcc-eff-center {
|
||||
flex: 0 0 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.qcc-gauge-svg { width: 160px; height: 160px; }
|
||||
.qcc-winrate-bar-wrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: rgba(0,230,118,0.12);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0,230,118,0.3);
|
||||
}
|
||||
.qcc-winrate-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00e676, #69f0ae);
|
||||
border-radius: 12px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.qcc-winrate-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #0a1628;
|
||||
}
|
||||
.qcc-eff-bars {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-end;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 200px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.qcc-eff-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 60px;
|
||||
}
|
||||
.qcc-eff-bar-label { font-size: 12px; font-weight: 700; text-align: center; }
|
||||
.qcc-eff-bar-label span { font-size: 10px; color: var(--se-text-muted); display: block; }
|
||||
.qcc-eff-bar-track {
|
||||
width: 40px;
|
||||
height: 160px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-eff-bar-fill {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
transition: height 0.5s ease;
|
||||
}
|
||||
.qcc-eff-bar-fill.up { background: linear-gradient(180deg, #00e676, #00b44e); }
|
||||
.qcc-eff-bar-fill.down { background: linear-gradient(180deg, #ff1744, #aa0033); }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
04 Temporal
|
||||
════════════════════════════════════════ */
|
||||
.qcc-temporal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
}
|
||||
.qcc-temporal-header { flex-shrink: 0; }
|
||||
.qcc-temporal-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,229,255,0.15);
|
||||
}
|
||||
.qcc-temporal-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.qcc-temporal-table th {
|
||||
background: rgba(0,229,255,0.08);
|
||||
color: var(--se-text-muted);
|
||||
font-weight: 600;
|
||||
padding: 7px 8px;
|
||||
text-align: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid rgba(0,229,255,0.15);
|
||||
}
|
||||
.qcc-temporal-table td {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.qcc-year-cell {
|
||||
font-weight: 700;
|
||||
color: var(--se-text-secondary);
|
||||
background: rgba(0,0,0,0.2);
|
||||
text-align: right !important;
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
.qcc-month-cell { transition: background 0.2s; }
|
||||
.qcc-ytd-col {
|
||||
font-weight: 800;
|
||||
border-left: 2px solid rgba(255,215,0,0.3);
|
||||
background: rgba(255,215,0,0.06) !important;
|
||||
}
|
||||
.qcc-zero { color: var(--se-text-muted); font-size: 10px; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
05 Logs
|
||||
════════════════════════════════════════ */
|
||||
.qcc-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 6px;
|
||||
}
|
||||
.qcc-logs-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,229,255,0.15);
|
||||
}
|
||||
.qcc-logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
.qcc-logs-table th {
|
||||
background: rgba(0,229,255,0.08);
|
||||
color: var(--se-text-muted);
|
||||
font-weight: 600;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid rgba(0,229,255,0.15);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qcc-logs-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qcc-row--up { background: rgba(0,230,118,0.04); }
|
||||
.qcc-row--down { background: rgba(255,23,68,0.04); }
|
||||
.qcc-row--up:hover { background: rgba(0,230,118,0.09); }
|
||||
.qcc-row--down:hover { background: rgba(255,23,68,0.09); }
|
||||
.qcc-sym { font-weight: 700; color: #00e5ff; }
|
||||
.qcc-side { color: var(--se-text-muted); font-size: 11px; }
|
||||
.qcc-num { text-align: right; color: #e0e8f0; }
|
||||
.qcc-dur { color: var(--se-text-muted); }
|
||||
.qcc-empty-cell { padding: 40px; text-align: center; color: var(--se-text-muted); }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
06 Allocation
|
||||
════════════════════════════════════════ */
|
||||
.qcc-allocation {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.qcc-alloc-left {
|
||||
flex: 0 0 300px;
|
||||
min-width: 220px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.qcc-alloc-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qcc-alloc-legend { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||
.qcc-alloc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.qcc-alloc-dot {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qcc-alloc-sym { flex: 1; font-weight: 700; color: #00e5ff; }
|
||||
.qcc-alloc-pct { min-width: 52px; text-align: right; color: var(--se-text-secondary); }
|
||||
.qcc-alloc-krw { min-width: 110px; text-align: right; color: var(--se-text-muted); font-size: 11px; }
|
||||
.qcc-donut-svg { width: min(100%, 300px); height: auto; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
07 Stats
|
||||
════════════════════════════════════════ */
|
||||
.qcc-stats {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-stats-left {
|
||||
flex: 0 0 230px;
|
||||
min-width: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.qcc-stats-summary { margin-top: 12px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.qcc-stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.qcc-stats-row span { color: var(--se-text-muted); }
|
||||
.qcc-stats-row strong { font-weight: 700; }
|
||||
.qcc-stats-chart {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-hist-svg { width: 100%; height: 100%; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
08 AI Feedback
|
||||
════════════════════════════════════════ */
|
||||
.qcc-ai {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.qcc-ai-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.qcc-ai-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qcc-ai-icon { flex-shrink: 0; opacity: 0.85; }
|
||||
.qcc-ai-terminal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.qcc-ai-line { display: flex; gap: 6px; }
|
||||
.qcc-ai-prompt { color: #ffd700; flex-shrink: 0; }
|
||||
.qcc-ai-label { color: #ffd700; font-weight: 700; }
|
||||
.qcc-ai-text { color: var(--se-text-secondary); }
|
||||
.qcc-ai-hl { color: #ffd700; text-decoration: underline; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
09 Comparative
|
||||
════════════════════════════════════════ */
|
||||
.qcc-comparative {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-comp-header { flex-shrink: 0; }
|
||||
.qcc-comp-chart-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
}
|
||||
.qcc-comp-svg { width: 100%; flex: 1; min-height: 0; display: block; }
|
||||
.qcc-comp-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qcc-comp-leg { display: flex; align-items: center; gap: 4px; }
|
||||
.qcc-comp-leg--strat { color: #00e676; }
|
||||
.qcc-comp-leg--bench { color: #888; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
10 Timing
|
||||
════════════════════════════════════════ */
|
||||
.qcc-timing {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-timing-left {
|
||||
flex: 0 0 260px;
|
||||
min-width: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.qcc-timing-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qcc-timing-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.qcc-timing-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
.qcc-timing-stat span { color: var(--se-text-muted); }
|
||||
.qcc-timing-stat strong { font-weight: 700; }
|
||||
.qcc-scatter { width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
반응형
|
||||
════════════════════════════════════════ */
|
||||
@media (max-width: 1100px) {
|
||||
.qcc-standard-left { flex: 0 0 180px; }
|
||||
.qcc-risk-left { flex: 0 0 190px; }
|
||||
.qcc-efficiency-left { flex: 0 0 190px; }
|
||||
.qcc-stats-left { flex: 0 0 180px; }
|
||||
.qcc-alloc-left { flex: 0 0 240px; }
|
||||
.qcc-timing-left { flex: 0 0 210px; }
|
||||
.qcc-hero-value { font-size: clamp(16px, 2vw, 24px); }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.qcc-standard, .qcc-risk, .qcc-efficiency,
|
||||
.qcc-stats, .qcc-timing, .qcc-allocation {
|
||||
flex-direction: column;
|
||||
}
|
||||
.qcc-standard-left, .qcc-risk-left, .qcc-efficiency-left,
|
||||
.qcc-stats-left, .qcc-timing-left, .qcc-alloc-left {
|
||||
flex: none;
|
||||
max-height: 180px;
|
||||
}
|
||||
.qcc-hero-row { flex-wrap: wrap; }
|
||||
.qcc-hero { flex: 0 0 calc(50% - 4px); }
|
||||
.qcc-alloc-right { justify-content: flex-start; }
|
||||
.qcc-eff-bars { display: none; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.arp-type-tab { font-size: 10px; padding: 6px 8px; }
|
||||
.qcc-hero-row { gap: 4px; }
|
||||
.qcc-hero { flex: 0 0 100%; }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
BacktestAnalysis,
|
||||
BacktestResultRecord,
|
||||
BacktestSignal,
|
||||
PaperTradeDto,
|
||||
} from './backendApi';
|
||||
import type { LiveExecutionItem } from './liveExecutionGroups';
|
||||
import type { EquityPoint, TradeHistoryRow } from './backtestEquity';
|
||||
import { buildEquityFromSignals } from './backtestEquity';
|
||||
import { paperTradesToSignals } from './liveExecutionGroups';
|
||||
import { repairUtf8Mojibake } from './textEncoding';
|
||||
|
||||
export type AnalysisSourceTab = 'backtest' | 'live';
|
||||
|
||||
export interface AnalysisReportContext {
|
||||
sourceTab: AnalysisSourceTab;
|
||||
strategyName: string;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
barCount: number;
|
||||
createdAt?: string;
|
||||
analysis: BacktestAnalysis | null;
|
||||
signals: BacktestSignal[];
|
||||
trades: TradeHistoryRow[];
|
||||
equityCurve: EquityPoint[];
|
||||
paperTrades: PaperTradeDto[];
|
||||
backtestRecord?: BacktestResultRecord;
|
||||
liveItem?: LiveExecutionItem;
|
||||
compareBacktest?: BacktestResultRecord | null;
|
||||
}
|
||||
|
||||
function parseBacktestRecord(r: BacktestResultRecord) {
|
||||
let analysis: BacktestAnalysis | null = null;
|
||||
let signals: BacktestSignal[] = [];
|
||||
try {
|
||||
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
|
||||
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
|
||||
} catch { /* ignore */ }
|
||||
const cap = analysis?.initialCapital ?? 10_000_000;
|
||||
const eq = buildEquityFromSignals(signals, cap, r.symbol);
|
||||
return {
|
||||
analysis,
|
||||
signals,
|
||||
trades: eq.trades,
|
||||
equityCurve: eq.curve,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildContextFromBacktest(
|
||||
r: BacktestResultRecord,
|
||||
compare?: BacktestResultRecord | null,
|
||||
): AnalysisReportContext {
|
||||
const parsed = parseBacktestRecord(r);
|
||||
return {
|
||||
sourceTab: 'backtest',
|
||||
strategyName: repairUtf8Mojibake(r.strategyName || '전략 없음'),
|
||||
symbol: r.symbol,
|
||||
timeframe: r.timeframe,
|
||||
barCount: r.barCount || 300,
|
||||
createdAt: r.createdAt,
|
||||
analysis: parsed.analysis,
|
||||
signals: parsed.signals,
|
||||
trades: parsed.trades,
|
||||
equityCurve: parsed.equityCurve,
|
||||
paperTrades: [],
|
||||
backtestRecord: r,
|
||||
compareBacktest: compare ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildContextFromLive(item: LiveExecutionItem): AnalysisReportContext {
|
||||
const signals = paperTradesToSignals(item.trades);
|
||||
const cap = 10_000_000;
|
||||
const eq = buildEquityFromSignals(signals, cap, item.symbol);
|
||||
const finalEquity = cap * (1 + item.totalReturnPct);
|
||||
const analysis: BacktestAnalysis = {
|
||||
initialCapital: cap,
|
||||
finalEquity,
|
||||
totalReturnPct: item.totalReturnPct,
|
||||
totalProfitLoss: finalEquity - cap,
|
||||
grossProfit: 0,
|
||||
grossLoss: 0,
|
||||
avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0,
|
||||
profitLossRatio: item.profitFactor,
|
||||
numberOfPositions: item.tradeCount,
|
||||
numberOfWinning: Math.round(item.tradeCount * item.winRatePct),
|
||||
numberOfLosing: Math.max(0, item.tradeCount - Math.round(item.tradeCount * item.winRatePct)),
|
||||
numberOfBreakEven: 0,
|
||||
winRate: item.winRatePct,
|
||||
maxDrawdownPct: item.mddPct,
|
||||
maxRunupPct: 0,
|
||||
sharpeRatio: item.sharpeRatio,
|
||||
sortinoRatio: 0,
|
||||
calmarRatio: 0,
|
||||
valueAtRisk95: 0,
|
||||
expectedShortfall: 0,
|
||||
buyAndHoldReturnPct: 0,
|
||||
vsBuyAndHold: 0,
|
||||
};
|
||||
return {
|
||||
sourceTab: 'live',
|
||||
strategyName: repairUtf8Mojibake(item.strategyLabel),
|
||||
symbol: item.symbol,
|
||||
timeframe: '1h',
|
||||
barCount: 300,
|
||||
createdAt: item.createdAt,
|
||||
analysis,
|
||||
signals,
|
||||
trades: eq.trades,
|
||||
equityCurve: eq.curve,
|
||||
paperTrades: item.trades,
|
||||
liveItem: item,
|
||||
compareBacktest: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 월별 수익률 히트맵 셀 (0~1 비율) */
|
||||
export function buildMonthlyHeatmap(
|
||||
trades: TradeHistoryRow[],
|
||||
initialCapital: number,
|
||||
): { year: number; month: number; returnPct: number }[] {
|
||||
const byKey = new Map<string, number>();
|
||||
for (const t of trades) {
|
||||
if (t.pnlPct == null) continue;
|
||||
const d = new Date(t.time * 1000);
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
byKey.set(key, (byKey.get(key) ?? 0) + t.pnlPct);
|
||||
}
|
||||
const out: { year: number; month: number; returnPct: number }[] = [];
|
||||
for (const [key, v] of byKey) {
|
||||
const [y, m] = key.split('-').map(Number);
|
||||
out.push({ year: y, month: m, returnPct: v });
|
||||
}
|
||||
return out.sort((a, b) => a.year - b.year || a.month - b.month);
|
||||
}
|
||||
|
||||
export function drawdownSeries(curve: EquityPoint[]): { time: number; ddPct: number }[] {
|
||||
let peak = curve[0]?.equity ?? 0;
|
||||
return curve.map(p => {
|
||||
if (p.equity > peak) peak = p.equity;
|
||||
const dd = peak > 0 ? (p.equity - peak) / peak : 0;
|
||||
return { time: p.time, ddPct: dd };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { BacktestSignal } from './backendApi';
|
||||
import type { TradeHistoryRow, EquityPoint } from './backtestEquity';
|
||||
|
||||
/** CAGR 추정 */
|
||||
export function estimateCagr(totalReturnPct: number, signals: BacktestSignal[]): number {
|
||||
if (signals.length < 2 || !isFinite(totalReturnPct)) return totalReturnPct;
|
||||
const t0 = signals[0].time;
|
||||
const t1 = signals[signals.length - 1].time;
|
||||
const years = (t1 - t0) / (365.25 * 86400);
|
||||
if (years <= 0.01) return totalReturnPct;
|
||||
const mult = 1 + totalReturnPct;
|
||||
if (mult <= 0) return totalReturnPct;
|
||||
return Math.pow(mult, 1 / years) - 1;
|
||||
}
|
||||
|
||||
/** 월별×연도 수익률 행렬 (히트맵 + Temporal 탭) */
|
||||
export function buildMonthlyMatrix(
|
||||
trades: TradeHistoryRow[],
|
||||
): { year: number; month: number; returnPct: number }[] {
|
||||
const byKey = new Map<string, number>();
|
||||
for (const t of trades) {
|
||||
if (t.pnlPct == null) continue;
|
||||
const d = new Date(t.time * 1000);
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
byKey.set(key, (byKey.get(key) ?? 0) + t.pnlPct);
|
||||
}
|
||||
const out: { year: number; month: number; returnPct: number }[] = [];
|
||||
for (const [key, v] of byKey) {
|
||||
const [y, m] = key.split('-').map(Number);
|
||||
out.push({ year: y, month: m, returnPct: v });
|
||||
}
|
||||
return out.sort((a, b) => a.year - b.year || a.month - b.month);
|
||||
}
|
||||
|
||||
/** YTD(연도별 합산) */
|
||||
export function buildYtdByYear(monthly: { year: number; month: number; returnPct: number }[]): Map<number, number> {
|
||||
const m = new Map<number, number>();
|
||||
for (const r of monthly) {
|
||||
m.set(r.year, (m.get(r.year) ?? 0) + r.returnPct);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 수익률 분포 버킷 (Stats 탭 히스토그램) */
|
||||
export function buildReturnDistribution(
|
||||
trades: TradeHistoryRow[],
|
||||
): { bucket: string; from: number; to: number; count: number }[] {
|
||||
const withPnl = trades.filter(t => t.pnlPct != null).map(t => (t.pnlPct ?? 0) * 100);
|
||||
if (withPnl.length === 0) return [];
|
||||
|
||||
const buckets: { label: string; min: number; max: number }[] = [
|
||||
{ label: '< -10%', min: -Infinity, max: -10 },
|
||||
{ label: '-10~-5%', min: -10, max: -5 },
|
||||
{ label: '-5~0%', min: -5, max: 0 },
|
||||
{ label: '0~2%', min: 0, max: 2 },
|
||||
{ label: '2~4%', min: 2, max: 4 },
|
||||
{ label: '4~6%', min: 4, max: 6 },
|
||||
{ label: '6~10%', min: 6, max: 10 },
|
||||
{ label: '10~50%', min: 10, max: 50 },
|
||||
{ label: '50~100%', min: 50, max: 100 },
|
||||
{ label: '100~500%', min: 100, max: 500 },
|
||||
{ label: '500%+', min: 500, max: Infinity },
|
||||
];
|
||||
|
||||
return buckets.map(b => ({
|
||||
bucket: b.label,
|
||||
from: b.min,
|
||||
to: b.max,
|
||||
count: withPnl.filter(v => v >= b.min && v < b.max).length,
|
||||
})).filter(b => b.count > 0 || (b.from >= -10 && b.to <= 10));
|
||||
}
|
||||
|
||||
/** 보유시간(분/시간) 계산 — BUY 신호 후 SELL 신호까지 */
|
||||
export interface HoldingPoint {
|
||||
durationMin: number;
|
||||
pnlPct: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export function buildHoldingPoints(
|
||||
signals: BacktestSignal[],
|
||||
symbol: string,
|
||||
): HoldingPoint[] {
|
||||
const sorted = [...signals].sort((a, b) => a.time - b.time);
|
||||
const pts: HoldingPoint[] = [];
|
||||
const buys: BacktestSignal[] = [];
|
||||
|
||||
for (const s of sorted) {
|
||||
if (s.type === 'BUY') {
|
||||
buys.push(s);
|
||||
} else if ((s.type === 'SELL' || s.type === 'SHORT_EXIT' || s.type === 'PARTIAL_SELL') && buys.length > 0) {
|
||||
const buy = buys.pop()!;
|
||||
const durSec = s.time - buy.time;
|
||||
const pnl = buy.price > 0 ? (s.price - buy.price) / buy.price : 0;
|
||||
pts.push({ durationMin: durSec / 60, pnlPct: pnl * 100, symbol });
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** 자본 배분 슬라이스 (Allocation 탭) */
|
||||
export interface AllocationSlice {
|
||||
symbol: string;
|
||||
pct: number;
|
||||
capitalKrw: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const SLICE_COLORS = ['#00e5ff', '#00e676', '#ffd700', '#ab47bc', '#ff7043', '#42a5f5'];
|
||||
|
||||
export function buildAllocationSlices(
|
||||
trades: TradeHistoryRow[],
|
||||
initialCapital: number,
|
||||
): AllocationSlice[] {
|
||||
const bySymbol = new Map<string, number>();
|
||||
for (const t of trades) {
|
||||
bySymbol.set(t.symbol, (bySymbol.get(t.symbol) ?? 0) + 1);
|
||||
}
|
||||
const total = [...bySymbol.values()].reduce((a, b) => a + b, 0) || 1;
|
||||
return [...bySymbol.entries()]
|
||||
.map(([sym, cnt], i) => ({
|
||||
symbol: sym,
|
||||
pct: cnt / total,
|
||||
capitalKrw: initialCapital * (cnt / total),
|
||||
color: SLICE_COLORS[i % SLICE_COLORS.length],
|
||||
}))
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
}
|
||||
|
||||
/** 변동성 시리즈 (롤링 20봉 표준편차) */
|
||||
export function buildVolatilitySeries(curve: EquityPoint[], window = 20): { time: number; vol: number }[] {
|
||||
if (curve.length < 2) return [];
|
||||
const returns: number[] = [];
|
||||
for (let i = 1; i < curve.length; i++) {
|
||||
const r = curve[i - 1].equity > 0 ? (curve[i].equity - curve[i - 1].equity) / curve[i - 1].equity : 0;
|
||||
returns.push(r);
|
||||
}
|
||||
const out: { time: number; vol: number }[] = [];
|
||||
for (let i = window - 1; i < returns.length; i++) {
|
||||
const slice = returns.slice(i - window + 1, i + 1);
|
||||
const mean = slice.reduce((a, b) => a + b, 0) / window;
|
||||
const variance = slice.reduce((a, b) => a + (b - mean) ** 2, 0) / window;
|
||||
out.push({ time: curve[i + 1].time, vol: Math.sqrt(variance) * 100 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 드로다운 시리즈 */
|
||||
export function drawdownSeries(curve: EquityPoint[]): { time: number; ddPct: number }[] {
|
||||
let peak = curve[0]?.equity ?? 0;
|
||||
return curve.map(p => {
|
||||
if (p.equity > peak) peak = p.equity;
|
||||
const dd = peak > 0 ? (p.equity - peak) / peak : 0;
|
||||
return { time: p.time, ddPct: dd };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* TRADING_REPORT_UI_DESIGN.pdf — The Quant Command Center 10 Dimensions
|
||||
*/
|
||||
|
||||
export type AnalysisReportTypeId =
|
||||
| 'standard'
|
||||
| 'risk'
|
||||
| 'efficiency'
|
||||
| 'temporal'
|
||||
| 'logs'
|
||||
| 'allocation'
|
||||
| 'stats'
|
||||
| 'ai-feedback'
|
||||
| 'comparative'
|
||||
| 'timing';
|
||||
|
||||
export interface AnalysisReportTypeDef {
|
||||
id: AnalysisReportTypeId;
|
||||
tabLabel: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export const ANALYSIS_REPORT_TYPES: AnalysisReportTypeDef[] = [
|
||||
{ id: 'standard', tabLabel: '01 Standard', title: 'Standard', subtitle: 'Assets & P&L Summary' },
|
||||
{ id: 'risk', tabLabel: '02 Risk', title: 'Risk', subtitle: 'Stress-Testing Capital Preservation' },
|
||||
{ id: 'efficiency', tabLabel: '03 Efficiency', title: 'Efficiency', subtitle: 'Execution Quality & Profit Extraction' },
|
||||
{ id: 'temporal', tabLabel: '04 Temporal', title: 'Temporal', subtitle: 'Seasonality & Time-Based Consistency' },
|
||||
{ id: 'logs', tabLabel: '05 Logs', title: 'Logs', subtitle: 'Granular Transparency for Every Execution' },
|
||||
{ id: 'allocation', tabLabel: '06 Allocation', title: 'Allocation', subtitle: 'Capital Distribution & Asset Weights' },
|
||||
{ id: 'stats', tabLabel: '07 Stats', title: 'Stats', subtitle: 'Shape of Returns: Statistical Distribution' },
|
||||
{ id: 'ai-feedback', tabLabel: '08 AI Feedback', title: 'AI Feedback', subtitle: 'Qualitative Insights via Local LLM' },
|
||||
{ id: 'comparative', tabLabel: '09 Comparative', title: 'Comparative', subtitle: 'Strategy vs. Benchmark: Alpha Generation' },
|
||||
{ id: 'timing', tabLabel: '10 Timing', title: 'Timing', subtitle: 'Holding Periods & Entry Precision' },
|
||||
];
|
||||
|
||||
export function getReportType(id: AnalysisReportTypeId): AnalysisReportTypeDef {
|
||||
return ANALYSIS_REPORT_TYPES.find(t => t.id === id) ?? ANALYSIS_REPORT_TYPES[0];
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
||||
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
|
||||
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
|
||||
P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'),
|
||||
P('메뉴-분석레포트', 'analysis-report', '레포트', '분석', '투자분석'),
|
||||
P('메뉴-설정', 'settings', '환경설정'),
|
||||
|
||||
// ── 실시간 차트 · 툴바 ──
|
||||
|
||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -21,7 +21,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings',
|
||||
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||
] as const;
|
||||
|
||||
@@ -37,6 +37,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
'analysis-report': '분석레포트',
|
||||
notifications: '알림목록',
|
||||
settings: '설정',
|
||||
settings_general: '설정 · 일반',
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |