diff --git a/frontend/src/App.css b/frontend/src/App.css index 989c817..c7f1da6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -12174,6 +12174,25 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } color: var(--accent, #8b5cf6); } +/* TopMenuBar 전체화면 버튼 */ +.tmb-fullscreen-btn { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text2); + cursor: pointer; + flex-shrink: 0; +} +.tmb-fullscreen-btn:hover { + background: var(--bg4); + color: var(--text); +} + /* TopMenuBar 알림 버튼 */ .tmb-notify-group { display: inline-flex; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b9ca182..2246006 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1953,6 +1953,7 @@ function App() { onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })} onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} + onFullscreen={handleFullscreen} /> setShowAlert(v => !v)} magnetMode={magnetMode} onToggleMagnet={() => setMagnetMode(m => m === 'off' ? 'strong' : 'off')} - onFullscreen={handleFullscreen} onUndo={handleUndo} onRedo={handleRedo} canUndo={canUndo} diff --git a/frontend/src/components/BacktestResultModal.tsx b/frontend/src/components/BacktestResultModal.tsx index 3767d3d..ec6eb94 100644 --- a/frontend/src/components/BacktestResultModal.tsx +++ b/frontend/src/components/BacktestResultModal.tsx @@ -8,23 +8,19 @@ import React, { useState } from 'react'; import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { repairUtf8Mojibake } from '../utils/textEncoding'; +import { + pct, + pctAbs, + num, + wonFmt, + colorCls, + SectionTitle, + KpiCard, + MetricRow, + WinRateCircle, + CompareBar, +} from './shared/analysisDashboardUi'; -// ── 포맷 유틸 ──────────────────────────────────────────────────────────────── - -const pct = (v: number, dec = 2) => - isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '–'; -const pctAbs = (v: number, dec = 1) => - isFinite(v) && v !== 0 ? `${(v * 100).toFixed(dec)}%` : '–'; -const num = (v: number, dec = 2) => - isFinite(v) && v !== 0 ? v.toFixed(dec) : '–'; -const wonFmt = (v: number) => { - if (!isFinite(v) || v === 0) return '–'; - const abs = Math.abs(Math.round(v)); - const s = abs >= 100_000_000 ? `${(abs/100_000_000).toFixed(2)}억` - : abs >= 10_000 ? `${(abs/10_000).toFixed(1)}만` - : abs.toLocaleString(); - return (v >= 0 ? '+' : '-') + s + '원'; -}; 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')}`; @@ -34,11 +30,6 @@ const fmtDateStr = (iso: string) => { 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')}`; }; -const colorCls = (v: number) => v > 0 ? 'brd-pos' : v < 0 ? 'brd-neg' : ''; -const colorStyle = (v: number): React.CSSProperties => ({ - color: v > 0 ? 'var(--brd-pos)' : v < 0 ? 'var(--brd-neg)' : undefined, -}); - // ── Props ───────────────────────────────────────────────────────────────── export interface BacktestResultModalProps { @@ -65,106 +56,6 @@ export interface BacktestDashboardProps { reportMode?: boolean; } -// ── 섹션 제목 ───────────────────────────────────────────────────────────── - -function SectionTitle({ icon, title, right }: { icon: string; title: string; right?: React.ReactNode }) { - return ( -
- {icon} - {title} - {right && {right}} -
- ); -} - -// ── KPI 카드 ────────────────────────────────────────────────────────────── - -function KpiCard({ icon, label, value, sub, valueColor, accent }: - { icon: string; label: string; value: string; sub: string; valueColor?: string; accent?: string }) { - return ( -
-
- {icon} - {label} -
-
{value}
-
{sub}
-
- ); -} - -// ── 미니 지표 행 (테이블 스타일) ───────────────────────────────────────── - -function MetricRow({ label, value, cls, note }: { label: string; value: string; cls?: string; note?: string }) { - return ( -
- - {label} - {note && {note}} - - {value} -
- ); -} - -// ── 승률 도넛 (CSS 원) ──────────────────────────────────────────────────── - -function WinRateCircle({ winRate, winning, total }: { winRate: number; winning: number; total: number }) { - const r = 28; - const circ = 2 * Math.PI * r; - const pct2 = Math.max(0, Math.min(1, winRate)); - const dash = pct2 * circ; - return ( -
- - - = 0.5 ? 'var(--brd-pos)' : 'var(--brd-neg)'} - strokeWidth="8" - strokeDasharray={`${dash} ${circ}`} - strokeLinecap="round" - strokeDashoffset={circ * 0.25} - style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }} - /> - {pctAbs(winRate)} - 승률 - -
- ▲ {winning} - ▼ {total - winning} -
-
- ); -} - -// ── 수평 비교 바 ───────────────────────────────────────────────────────── - -function CompareBar({ labelA, valA, labelB, valB, colorA, colorB }: - { labelA: string; valA: number; labelB: string; valB: number; colorA: string; colorB: string }) { - const max = Math.max(Math.abs(valA), Math.abs(valB), 0.01); - const pctA2 = Math.min(100, Math.abs(valA) / max * 100); - const pctB2 = Math.min(100, Math.abs(valB) / max * 100); - return ( -
-
- {labelA} -
-
-
- {pct(valA)} -
-
- {labelB} -
-
-
- {pct(valB)} -
-
- ); -} - // ── 시그널 타입 ─────────────────────────────────────────────────────────── const SIG_LABEL: Record = { diff --git a/frontend/src/components/DashboardPage.tsx b/frontend/src/components/DashboardPage.tsx index 4273905..276d89f 100644 --- a/frontend/src/components/DashboardPage.tsx +++ b/frontend/src/components/DashboardPage.tsx @@ -1,5 +1,5 @@ /** - * GoldenChart Command Center — Master Plan [01] Standard + [04] System/Tech + * GoldenChart 대시보드 — 투자분석 레포트형 카드 UI */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Theme } from '../types'; @@ -8,13 +8,24 @@ import { type DashboardSummaryDto, type ProcessMonitorDto, type SystemMonitorDto, + type TradeSignalDto, } from '../utils/backendApi'; -import DashboardGauge from './dashboard/DashboardGauge'; -import DashboardSegmentBar from './dashboard/DashboardSegmentBar'; import DashboardSparkChart from './dashboard/DashboardSparkChart'; -import DashboardIndicatorBar from './dashboard/DashboardIndicatorBar'; -import '../styles/strategyEditorTheme.css'; -import '../styles/dashboardCommand.css'; +import { + pct, + pctAbs, + num, + wonFmt, + fmtKrwShort, + colorCls, + SectionTitle, + KpiCard, + MetricRow, + WinRateCircle, + CompareBar, + ResourceBar, +} from './shared/analysisDashboardUi'; +import '../styles/dashboardReport.css'; interface Props { theme: Theme; @@ -28,11 +39,6 @@ const STATUS_LABEL: Record = { disabled: '비활성', }; -const fmtKrw = (n: number) => - n >= 1e8 ? `${(n / 1e8).toFixed(2)}억` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}만` : n.toLocaleString('ko-KR'); - -const fmtPct = (v: number) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%`; - function formatAgo(ms: number): string { if (!ms) return '—'; const sec = Math.floor((Date.now() - ms) / 1000); @@ -41,13 +47,6 @@ function formatAgo(ms: number): string { return `${Math.floor(sec / 3600)}시간 전`; } -function statusClass(s: string): string { - if (s === 'healthy') return 'gc-proc--ok'; - if (s === 'degraded') return 'gc-proc--warn'; - if (s === 'down') return 'gc-proc--down'; - return 'gc-proc--off'; -} - function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' { if (!mon.pipelineEnabled) return 'red'; if (mon.jvm.heapPct > 85 || mon.queue.dropped > 0 || mon.staleMarkets.length > 5) return 'yellow'; @@ -56,24 +55,31 @@ function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' { return 'green'; } -const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => ( -
-
- {proc.layer} - {STATUS_LABEL[proc.status] ?? proc.status} -
-

{proc.name}

-
    - {Object.entries(proc.metrics).slice(0, 4).map(([k, v]) => ( -
  • {k}{String(v ?? '—')}
  • - ))} -
+function healthScore(level: 'green' | 'yellow' | 'red'): number { + if (level === 'green') return 1; + if (level === 'yellow') return 0.55; + return 0.2; +} + +function procMiniClass(status: string): string { + if (status === 'healthy') return 'gc-dash-proc-mini--ok'; + if (status === 'degraded') return 'gc-dash-proc-mini--warn'; + if (status === 'down') return 'gc-dash-proc-mini--down'; + return 'gc-dash-proc-mini--off'; +} + +const SIG_LABEL: Record = { BUY: '매수', SELL: '매도' }; + +const ProcessMini: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => ( +
+

{proc.name}

+ {STATUS_LABEL[proc.status] ?? proc.status}
); const HISTORY_LEN = 48; -const DashboardPage: React.FC = ({ theme, onGoChart }) => { +const DashboardPage: React.FC = ({ onGoChart }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -110,6 +116,7 @@ const DashboardPage: React.FC = ({ theme, onGoChart }) => { const mon = data?.systemMonitor; const app = data?.app as Record | undefined; const health = mon ? healthLevel(mon) : 'yellow'; + const healthPct = healthScore(health); const latencyMs = useMemo(() => { if (!mon) return 0; @@ -117,216 +124,371 @@ const DashboardPage: React.FC = ({ theme, onGoChart }) => { return tps > 0 ? Math.max(12, Math.round(1000 / Math.max(tps, 1))) : 45; }, [mon]); - const stompConnections = data?.monitoredMarkets ?? 0; - const ta4jPct = mon && mon.memory.maxBarsPerSeries > 0 ? Math.min(100, (mon.memory.totalBars / (mon.memory.series * mon.memory.maxBarsPerSeries || 1)) * 100) : 0; - return ( -
-
-
- [01] Standard Dashboard -

GoldenChart Command Center

-

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

-
-
- Global Latency: {latencyMs}ms - Active Markets: {stompConnections} - {mon && ( - - Pipeline {mon.pipelineEnabled ? 'ON' : 'OFF'} - {mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)}` : ''} - - )} - -
-
+ const queuePct = (mon?.queue.fillRatio ?? 0) * 100; + const heapPct = mon?.jvm.heapPct ?? 0; - {error &&

{error}

} + const buySignals = useMemo( + () => (data?.recentSignals ?? []).filter(s => s.signalType === 'BUY').length, + [data?.recentSignals], + ); + const sellSignals = (data?.recentSignals?.length ?? 0) - buySignals; + + const printedAt = new Date().toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + + const tradingMode = String(app?.tradingMode ?? '—'); + + return ( +
+ {error &&

{error}

} + {loading && !data &&

대시보드 불러오는 중…

} {data && mon && ( -
- {/* ── LEFT: Portfolio + Indicators ── */} -
-
-

시스템 상태

-
- - - -
-

- {health === 'green' ? '정상 운영' : health === 'yellow' ? '주의 필요' : '점검 필요'} -

-
- -
-

리소스 인디케이터

-
- - - - -
-
- -
-

매매 모드

-
    -
  • 운영 모드{String(app?.tradingMode ?? '—')}
  • -
  • 실거래 자동{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}
  • -
  • 모의 자동{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}
  • -
  • FCM{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '미연결') : 'OFF'}
  • -
-
- - - {/* ── CENTER: Infrastructure + Chart ── */} -
-
-

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

-
- +
+ - 0.7 ? 'amber' : 'cyan'} + - 85 ? 'brd-neg' : heapPct > 70 ? 'brd-warn' : 'brd-pos'} + accent="red" /> - 70 ? 'brd-neg' : ''} + accent="yellow" + /> + +
-
+
-
- -
- -
-

멀티스레드 프로세스

-
- {mon.processes.map(p => )} + {/* 3열 상세 */} +
+
+ +
+ + + + + + + +
-
+ +
+ +
+ + 85 ? 'brd-neg' : ''} note="≥85% 주의" /> + + 0 ? 'brd-neg' : ''} /> + + 0 ? 'brd-warn' : ''} /> + 0 ? 'brd-neg' : ''} /> +
+
+ +
+ +
+ p.status === 'healthy').length} + total={Math.max(mon.processes.length, 1)} + centerLabel="헬스" + /> +
+ + + + + +
+
+
+
+ + {/* 2열: 수익 비교 + 리소스 */} +
+
+ +
+ 0 + ? data.paper.realizedPnl / data.paper.initialCapital + : 0} + colorA={data.paper.totalReturnPct >= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'} + colorB="var(--text3)" + /> +
+
+ 모의 자산 + {fmtKrwShort(data.paper.totalAsset)} +
+
+
+ 실거래 KRW + {fmtKrwShort(data.live.krwBalance)} +
+
+
+ 평가 손익 + + {wonFmt(data.paper.unrealizedPnl)} + +
+
+
+
+ +
+ +
+
+ 85 ? 'brd-neg' : 'brd-pos'} /> + 70 ? 'brd-neg' : 'brd-pos'} /> + 90 ? 'brd-neg' : 'brd-pos'} /> + +
+
+ 주문 처리 + + {mon.orders.executed}건 · 실패 {mon.orders.failed} + +
+ + +
+
+
+ + {/* 실시간 차트 */} +
+
+ +
+
+ +
+
+ +
+
+
+
+ + {/* 프로세스 */} +
+
+ {mon.processes.length}개} + /> +
+ {mon.processes.map(p => )} +
+
+
{mon.staleMarkets.length > 0 && ( -
-

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

-
- {mon.staleMarkets.slice(0, 8).map(s => ( - - ))} +
+
+ +
+ {mon.staleMarkets.slice(0, 12).map(s => ( + + ))} +
-
- )} - - - {/* ── RIGHT: Gauges + Signals ── */} - + {/* 최근 시그널 */} +
+
+ {data.recentSignals.length}건} + /> + {data.recentSignals.length === 0 ? ( +
최근 시그널이 없습니다.
+ ) : ( +
+ + + + + + + + + + + + {data.recentSignals.slice(0, 20).map((s: TradeSignalDto, i: number) => { + const buy = s.signalType === 'BUY'; + return ( + + + + + + + + ); + })} + +
#시간종목구분가격
{i + 1} + {s.createdAt?.slice(0, 16).replace('T', ' ') ?? '—'} + + + + + {SIG_LABEL[s.signalType] ?? s.signalType} + + + {Math.round(s.price).toLocaleString()} +
+
+ )} +
+
+
)}
diff --git a/frontend/src/components/NotifyUiBindings.tsx b/frontend/src/components/NotifyUiBindings.tsx index 95fb0f4..5dee0ad 100644 --- a/frontend/src/components/NotifyUiBindings.tsx +++ b/frontend/src/components/NotifyUiBindings.tsx @@ -25,6 +25,7 @@ interface NotifyTopMenuBarProps { onTradeAlertPopupPosition?: (v: TradeAlertPopupPosition) => void; onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void; onTradeAlertPopupGridCols?: (v: number) => void; + onFullscreen?: () => void; } export const NotifyTopMenuBar: React.FC = props => { diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 7b11fc5..7b810e5 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -97,7 +97,6 @@ export interface ToolbarProps { magnetMode?: 'off' | 'weak' | 'strong'; /** 자석모드 토글 (off↔strong) */ onToggleMagnet?: () => void; - onFullscreen: () => void; onUndo?: () => void; onRedo?: () => void; canUndo?: boolean; @@ -220,12 +219,6 @@ const IcCamera = () => ( ); -const IcFullscreen = () => ( - - - - -); const IcBell = () => ( @@ -814,7 +807,7 @@ const Toolbar: React.FC = ({ onToggleStats, onToggleWatch, onToggleAlert, tradeNotifyUnread = 0, onOpenTradeNotifications, onOpenAppDownload, magnetMode = 'off', onToggleMagnet, - onFullscreen, onUndo, onRedo, canUndo, canRedo, onClearDrawings, + onUndo, onRedo, canUndo, canRedo, onClearDrawings, onToggleGrid, layoutId = '1', syncOptions = DEFAULT_SYNC, onLayoutSelect, onSyncChange, magnifierActive = false, onToggleMagnifier, @@ -1388,9 +1381,6 @@ const Toolbar: React.FC = ({ -
diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 0541b57..5aae103 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -4,7 +4,7 @@ * 레이아웃: * [로고] | [대시보드] [실시간차트] [전략편집기] … | [테마토글] [로그인] */ -import React, { memo } from 'react'; +import React, { memo, useEffect, useState } from 'react'; import type { Theme } from '../types'; import type { AuthSession } from '../utils/auth'; import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect'; @@ -40,6 +40,8 @@ interface TopMenuBarProps { guestMode?: boolean; onLoginClick?: () => void; onLogout?: () => void; + /** 브라우저 전체화면 (모든 화면에서 사용) */ + onFullscreen?: () => void; /** 메뉴별 접근 허용 (없으면 전체 표시) */ menuPermissions?: Record; } @@ -106,6 +108,24 @@ const IcDismissPopups = () => ( ); +const IcFullscreen = () => ( + + + + + + +); + +const IcFullscreenExit = () => ( + + + + + + +); + const IcLogin = () => ( @@ -193,8 +213,16 @@ export const TopMenuBar = memo(function TopMenuBar({ guestMode = false, onLoginClick, onLogout, + onFullscreen, menuPermissions, }: TopMenuBarProps) { + const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement); + useEffect(() => { + const sync = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', sync); + return () => document.removeEventListener('fullscreenchange', sync); + }, []); + const tc = THEME_CONFIG[theme]; const visibleItems = menuPermissions ? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page)) @@ -297,6 +325,21 @@ export const TopMenuBar = memo(function TopMenuBar({ )} + {onFullscreen && ( + <> + +