import React, { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react'; import DrawingToolbar from './components/DrawingToolbar'; import BottomBar from './components/BottomBar'; import TradingChart from './components/TradingChart'; import StatsPanel from './components/StatsPanel'; import WatchList from './components/WatchList'; import AlertManager from './components/AlertManager'; import ObjectTree from './components/ObjectTree'; import IndicatorContextToolbar from './components/IndicatorContextToolbar'; import IndicatorSettingsModal from './components/IndicatorSettingsModal'; import IndicatorBulkSettingsModal from './components/IndicatorBulkSettingsModal'; import MainChartSettingsModal from './components/MainChartSettingsModal'; import DrawingContextToolbar from './components/DrawingContextToolbar'; import DrawingSettingsModal from './components/DrawingSettingsModal'; import FibTZSettingsModal from './components/FibTZSettingsModal'; import ChartSlot, { type ChartSlotHandle } from './components/ChartSlot'; import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from './utils/layoutTypes'; import { generateBars, formatPrice, SYMBOLS } from './utils/dataGenerator'; import { normalizeChartTimeFormat, setChartTimeFormat, useChartTimeFormat, } from './utils/chartTimeFormat'; import { setTradeAlertTimeFormat, useTradeAlertTimeFormat, } from './utils/tradeAlertTimeFormat'; import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from './utils/timezone'; import { calcStats, type PriceStats } from './utils/calculations'; import { getKoreanName } from './utils/marketNameCache'; import { saveState, loadState, DEFAULT_MAIN_CHART_STYLE } from './utils/storage'; import { useWorkspacePersist } from './hooks/useWorkspacePersist'; import type { WatchlistItem, HoldingsItem } from './utils/backendApi'; import { addWatchlistItem, removeWatchlistItem, upsertHoldingsItem, removeHoldingsItem, loadWatchlist, } from './utils/backendApi'; import { enrichIndicatorConfig, getIndicatorDef } from './utils/indicatorRegistry'; import { initializeIndicatorConfigForEditor } from './utils/indicatorSettingsEditor'; import { buildMainIndicatorConfig, chartIndicatorsNeedDefaultsRefresh, indicatorSettingsTemplateId, indicatorTypeFromSettingsId, isIndicatorSettingsTemplateId, mergeGlobalDefaultsOntoChartIndicators, } from './utils/indicatorMainConfig'; import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig'; import { normalizeIchimokuConfig } from './utils/ichimokuConfig'; import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi'; import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage'; import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData'; import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from './types'; import type { ChartManager } from './utils/ChartManager'; import { useHistoryLoader, LOAD_MORE_TRIGGER } from './hooks/useHistoryLoader'; import { useMarketTicker } from './hooks/useMarketTicker'; import { useUpbitOrderbook } from './hooks/useUpbitOrderbook'; import { useIndicatorSettings } from './hooks/useIndicatorSettings'; import { consumeSkipNextChartDefaultsSync, markChartIndicatorSaveCommitted, } from './utils/indicatorChartDefaultsSync'; import { duplicateIndicatorInList, mergeIndicators, reorderIndicatorGroup, removeIndicatorFromList, removeIndicatorTypeFromList, splitIndicatorPane, replaceIndicatorConfigsFromTypes, } from './utils/indicatorPaneMerge'; import { getOrderedSettingsIndicatorTypes, sortIndicatorsByTypeOrder, } from './utils/indicatorListOrder'; import { useAppSettings } from './hooks/useAppSettings'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings'; import MarketPanel from './components/MarketPanel'; import TopMenuBar, { type MenuPage } from './components/TopMenuBar'; import StrategyPage from './components/StrategyPage'; import StrategyEditorPage from './components/StrategyEditorPage'; import BacktestPanel from './components/BacktestPanel'; import { useBacktest } from './hooks/useBacktest'; import LiveStrategyPanel from './components/LiveStrategyPanel'; import { TradeNotificationProvider } from './contexts/TradeNotificationContext'; import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext'; import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier'; import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings'; import { AppNotificationLayer } from './components/AppNotificationLayer'; import TradeNotificationListPage from './components/TradeNotificationListPage'; import BacktestSettingsModal from './components/BacktestSettingsModal'; import { BacktestResultModal } from './components/BacktestResultModal'; import { BacktestHistoryPage } from './components/BacktestHistoryPage'; import SettingsPage from './components/SettingsPage'; import PaperTradingPage from './components/PaperTradingPage'; import VirtualTradingPage from './components/VirtualTradingPage'; import TrendSearchPage from './components/TrendSearchPage'; import VerificationBoardPage from './components/VerificationBoardPage'; import DashboardPage from './components/DashboardPage'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import ChartLegendBar from './components/ChartLegendBar'; import { resolveChartLiveQuote } from './utils/chartLiveQuote'; import RightSidePanel from './components/RightSidePanel'; import { type BacktestSettingsDto, type BacktestAnalysis, DEFAULT_BACKTEST_SETTINGS, loadBacktestSettings, loadStrategies as loadStrategiesForLive, loadLiveStrategySettings, type LiveStrategySettingsDto, type BacktestSignal, } from './utils/backendApi'; import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy'; import type { ChartRealtimeSource } from './hooks/useChartRealtimeData'; import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; import { useIsMobile } from './hooks/useMediaQuery'; import MobileChartDock from './components/MobileChartDock'; import LoginModal from './components/LoginModal'; import AppDownloadModal from './components/AppDownloadModal'; import SplashScreen from './components/SplashScreen'; import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth'; import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry'; import { syncDocumentTheme } from './utils/documentTheme'; import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions'; import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions'; import { clearAdminUnlock } from './utils/adminUnlock'; import type { LoginResponse } from './utils/backendApi'; import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; import './App.css'; import './styles/appPopup.css'; import './styles/paperDashboard.css'; import './styles/tradeRightPanel.css'; import './styles/virtualTradingDashboard.css'; import './styles/backtestDashboard.css'; let _indCounter = 0; function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; } /** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */ let _wsFlashTimer: ReturnType | null = null; function flashWsDot() { document.querySelectorAll('.ws-dot').forEach(el => el.classList.add('receiving')); if (_wsFlashTimer) clearTimeout(_wsFlashTimer); _wsFlashTimer = setTimeout(() => { document.querySelectorAll('.ws-dot').forEach(el => el.classList.remove('receiving')); _wsFlashTimer = null; }, 600); } // ─── App ─────────────────────────────────────────────────────────────────── function App() { const initial = loadState(); const [sessionKey, setSessionKey] = useState(0); const [appEntered, setAppEnteredState] = useState(() => isAppEntered()); const [authUser, setAuthUser] = useState(() => isAppEntered() ? getAuthSession() : null, ); const [guestMode, setGuestMode] = useState(() => isAppEntered() && !getAuthSession(), ); const [loginOpen, setLoginOpen] = useState(false); const [appDownloadOpen, setAppDownloadOpen] = useState(false); // 전역 지표 파라미터 + 시각 설정 DB 동기화 const { getParams: getIndicatorParams, saveParams: saveIndicatorParams, getVisualConfig: getIndicatorVisual, saveVisual: saveIndicatorVisual, saveAllIndicatorDefaults, settingsRevision: indicatorSettingsRevision, } = useIndicatorSettings(sessionKey); const getParams = getIndicatorParams; const saveParams = saveIndicatorParams; const getVisualConfig = getIndicatorVisual; const saveVisual = saveIndicatorVisual; // 앱 전역 기본 설정 DB 동기화 (하드코딩 대체) const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey); const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey); const displayTimezone = useDisplayTimezone(); const chartTimeFormat = useChartTimeFormat(); const tradeAlertTimeFormat = useTradeAlertTimeFormat(); // DB 로드 전에는 localStorage 기본값, 로드 후 DB 값으로 오버라이드됨 const [symbol, setSymbol] = useState(initial.symbol); const [timeframe, setTimeframe] = useState(initial.timeframe); const [chartType, setChartType] = useState(initial.chartType); const [theme, setTheme] = useState(initial.theme); const [mode, setMode] = useState('chart'); const [logScale, setLogScale] = useState(false); const [indicators, setIndicators] = useState(initial.indicators ?? []); const [drawingTool, setDrawingTool] = useState('cursor'); const [drawings, setDrawings] = useState([]); const [drawingsLocked, setDrawingsLocked] = useState(false); const [drawingsVisible, setDrawingsVisible] = useState(true); const [indicatorsVisible, setIndicatorsVisible] = useState(true); const [magnetMode, setMagnetMode] = useState<'off'|'weak'|'strong'>('off'); const [snapToIndicator, setSnapToIndicator] = useState(false); const [keepLockedOnDelete,setKeepLockedOnDelete]= useState(false); const [legend, setLegend] = useState(null); const [showStats, setShowStats] = useState(false); const [showWatch, setShowWatch] = useState(false); const [showAlert, setShowAlert] = useState(false); const [showObjectTree, setShowObjectTree] = useState(false); const [percentScale, setPercentScale] = useState(false); const [showGrid, setShowGrid] = useState(true); const [magnifierActive, setMagnifierActive] = useState(false); const [showMarketPanel, setShowMarketPanel] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false); const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade'); const [menuPage, setMenuPage] = useState('chart'); const [settingsInitialCategory, setSettingsInitialCategory] = useState(); const [verificationFocusIssueId, setVerificationFocusIssueId] = useState(null); useEffect(() => { if (menuPage !== 'settings') setSettingsInitialCategory(undefined); }, [menuPage]); const chartVisible = menuPage === 'chart'; const isMobile = useIsMobile(); // ── 마켓 패널 시세 데이터 ───────────────────────────────────────────── const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(); // ── 레이아웃 상태 ───────────────────────────────────────────────────── const [layoutDef, setLayoutDef] = useState(() => LAYOUTS[0]); const [syncOptions, setSyncOptions] = useState(DEFAULT_SYNC); const [activeSlot, setActiveSlot] = useState(0); // 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용) const [activeSlotIndicators, setActiveSlotIndicators] = useState([]); /** * 단일→다중 전환 시 슬롯 0 의 마운트 초기값으로 전달할 설정. * React 18 배치 업데이트 덕분에 setLayoutDef 와 동일 렌더에 반영되어 * ChartSlot 이 처음 마운트될 때 이 값이 useState 초기값으로 사용된다. */ const [slot0InitProps, setSlot0InitProps] = useState<{ symbol: string; timeframe: Timeframe; indicators: IndicatorConfig[]; } | null>(null); /** 멀티슬롯용: 슬롯 1+ 초기 props (슬롯 인덱스 → 초기값 맵) */ const [slotInitProps, setSlotInitProps] = useState>({}); const [syncTime, setSyncTime] = useState(0); const [syncVisibleRange, setSyncVisibleRange] = useState<{ from: number; to: number } | null>(null); const [syncLogicalRange, setSyncLogicalRange] = useState<{ from: number; to: number } | null>(null); // 멀티 레이아웃에서 활성 슬롯의 타임프레임을 BottomBar/툴바에 반영 const [activeSlotTf, setActiveSlotTf] = useState(timeframe); const [activeSlotSymbol, setActiveSlotSymbol] = useState(initial.symbol); const handleTimezoneChange = useCallback((tz: string) => { const next = normalizeTimezone(tz); setDisplayTimezone(next); saveAppDef({ displayTimezone: next }); const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; managerRef.current?.setDisplayTimezone(next, tf, chartTimeFormat); }, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, chartTimeFormat]); const applyChartTimeFormatToManagers = useCallback((next: string) => { const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; managerRef.current?.setChartTimeFormat(next); slotRefs.current.forEach(slot => slot?.setChartTimeFormat?.(next, tf)); }, [layoutDef.count, activeSlotTf, timeframe]); const handleChartTimeFormatChange = useCallback((format: string) => { const next = normalizeChartTimeFormat(format); setChartTimeFormat(next); saveAppDef({ chartTimeFormat: next }); applyChartTimeFormatToManagers(next); }, [saveAppDef, applyChartTimeFormatToManagers]); const handleTradeAlertTimeFormatChange = useCallback((format: string) => { const next = normalizeChartTimeFormat(format); setTradeAlertTimeFormat(next); saveAppDef({ tradeAlertTimeFormat: next }); }, [saveAppDef]); const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol; const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol); const chartTicker = marketTickers.get(chartSymbol); const orderbookPrevClose = chartTicker ? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0) : 0; const [tradeFill, setTradeFill] = useState(null); const applyTradeFill = useCallback(( req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex?: number, ) => { setTradeFill(prev => ({ market: req.market, price: req.price, side: req.side, seq: (prev?.seq ?? 0) + 1, })); if (layoutDef.count > 1) { const idx = slotIndex ?? activeSlot; setActiveSlot(idx); slotRefs.current[idx]?.setSymbol(req.market); setActiveSlotSymbol(req.market); } else if (req.market !== symbol) { setSymbol(req.market); setLegend(null); } }, [layoutDef.count, activeSlot, symbol]); const handleOrderbookPick = useCallback((price: number, rowType: 'ask' | 'bid') => { applyTradeFill({ market: chartSymbol, price, // 매수 호가(bid) 클릭 → 매수 탭, 매도 호가(ask) 클릭 → 매도 탭 side: rowType === 'bid' ? 'buy' : 'sell', }); }, [applyTradeFill, chartSymbol]); const handleTradeMarketSelect = useCallback((s: string) => { if (layoutDef.count > 1) { slotRefs.current[activeSlot]?.setSymbol(s); setActiveSlotSymbol(s); } else { setSymbol(s); setLegend(null); } }, [layoutDef.count, activeSlot]); // 활성 슬롯 변경 시 해당 슬롯의 타임프레임/심볼/지표를 읽어 표시 업데이트 const handleSetActiveSlot = useCallback((idx: number) => { setActiveSlot(idx); // symbolRef는 initialSymbol로 초기화되어 있으므로 즉시 읽어도 정확한 값 반환 const slotImmediate = slotRefs.current[idx]; if (slotImmediate) { const sym = slotImmediate.getSymbol(); if (sym) setActiveSlotSymbol(sym); const tf = slotImmediate.getTimeframe(); if (tf) setActiveSlotTf(tf); setActiveSlotIndicators(slotImmediate.getIndicators()); } else { // 슬롯이 아직 마운트되지 않은 경우 rAF로 재시도 requestAnimationFrame(() => { const slot = slotRefs.current[idx]; if (!slot) return; const tf = slot.getTimeframe(); if (tf) setActiveSlotTf(tf); const sym = slot.getSymbol(); if (sym) setActiveSlotSymbol(sym); setActiveSlotIndicators(slot.getIndicators()); }); } }, []); // 멀티 모드에서 활성 슬롯 지표가 바뀌면 상태 갱신 (IndicatorModal 반영용) const handleActiveSlotIndicatorsChange = useCallback((inds: IndicatorConfig[]) => { setActiveSlotIndicators(inds); }, []); // 멀티 모드에서 활성 슬롯 심볼이 바뀌면 상태 갱신 (호가창 심볼 동기화) const handleActiveSlotSymbolChange = useCallback((sym: string) => { setActiveSlotSymbol(sym); }, []); // Toolbar 에 전달할 활성 지표 목록: // 멀티 레이아웃이면 활성 슬롯 지표, 싱글이면 전역 indicators const toolbarActiveIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators; /** * 레이아웃 전환 핸들러 * ─ 단일 → 다중: 현재 싱글 차트의 심볼/타임프레임/지표를 슬롯 0 의 초기값으로 기록 * ─ 다중 → 단일: 활성 슬롯의 심볼/타임프레임/지표를 싱글 차트 상태에 즉시 복원 * (ChartSlot 이 unmount 되기 전에 ref 에서 읽어야 함) */ const handleLayoutSelect = useCallback((def: LayoutDef) => { if (def.count > 1 && layoutDef.count === 1) { // 단일 → 다중: 싱글 차트 설정을 슬롯 0 초기값으로 저장 // setLayoutDef 와 같은 렌더에 배치되어 ChartSlot 마운트 시 반영됨 setSlot0InitProps({ symbol, timeframe, indicators: indicators.map(i => ({ ...i })), }); setActiveSlot(0); setActiveSlotIndicators(indicators.map(i => ({ ...i }))); } else if (def.count === 1 && layoutDef.count > 1) { // 다중 → 단일: 활성 슬롯 설정 읽기 (slot ref 는 이 시점에서만 유효) const slot = slotRefs.current[activeSlot]; if (slot) { const slotSymbol = slot.getSymbol(); const slotTf = slot.getTimeframe(); const slotInds = slot.getIndicators().map(i => ({ ...i })); setSymbol(slotSymbol); setTimeframe(slotTf); setIndicators(slotInds); setLegend(null); } // 다음번 다중 전환 시 초기값이 남아있지 않도록 초기화 setSlot0InitProps(null); } setLayoutDef(def); // 레이아웃 변경 시 DB 에도 저장 saveAppDef({ defaultLayoutId: def.id }); }, [layoutDef.count, activeSlot, symbol, timeframe, indicators, saveAppDef]); const appLayoutHydratedRef = useRef(false); useEffect(() => { if (!appSettingsLoaded || appLayoutHydratedRef.current) return; appLayoutHydratedRef.current = true; if (appDefaults.layoutId) { const def = LAYOUTS.find(l => l.id === appDefaults.layoutId); if (def) setLayoutDef(def); } if (appDefaults.syncOptions) { setSyncOptions(appDefaults.syncOptions); } }, [appSettingsLoaded, appDefaults.layoutId, appDefaults.syncOptions]); // 다중 → 싱글 전환 시 activeSlotIndicators 초기화 useEffect(() => { if (layoutDef.count <= 1) setActiveSlotIndicators([]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutDef.count]); // ── 시리즈 클릭/호버 컨텍스트 툴바 ──────────────────────────────────── const [ctxToolbar, setCtxToolbar] = useState<{ entryId: string; screenX: number; screenY: number; } | null>(null); // 호버 hide 지연 타이머 (레이블 → 툴바 이동 시 깜빡임 방지) const hoverHideTimer = useRef | null>(null); const clearHoverTimer = useCallback(() => { if (hoverHideTimer.current) { clearTimeout(hoverHideTimer.current); hoverHideTimer.current = null; } }, []); const handleHoverPaneLegend = useCallback((id: string, sx: number, sy: number) => { clearHoverTimer(); setCtxToolbar({ entryId: id, screenX: sx, screenY: sy }); }, [clearHoverTimer]); const handleLeavePaneLegend = useCallback(() => { clearHoverTimer(); hoverHideTimer.current = setTimeout(() => { setCtxToolbar(null); hoverHideTimer.current = null; }, 180); }, [clearHoverTimer]); // ── 드로잉 컨텍스트 툴바 & 설정 모달 ──────────────────────────────────── const [selectedDrawingId, setSelectedDrawingId] = useState(null); const [drawingCtxToolbar, setDrawingCtxToolbar] = useState<{ id: string; screenX: number; screenY: number; } | null>(null); const [drawingSettingsId, setDrawingSettingsId] = useState(null); const handleDrawingClick = useCallback((id: string | null, sx: number, sy: number) => { if (!id) { // 빈 공간 클릭 → 선택 해제 setSelectedDrawingId(null); setDrawingCtxToolbar(null); return; } setSelectedDrawingId(id); setDrawingCtxToolbar({ id, screenX: sx, screenY: sy }); setCtxToolbar(null); // 인디케이터 툴바 닫기 }, []); const handleDrawingDoubleClick = useCallback((id: string, sx: number, sy: number) => { setSelectedDrawingId(id); setDrawingCtxToolbar(null); setDrawingSettingsId(id); void sx; void sy; }, []); // ── 인디케이터 설정 모달 ─────────────────────────────────────────────── const [settingsModalId, setSettingsModalId] = useState(null); const [showBulkIndSettings, setShowBulkIndSettings] = useState(false); // ── 메인 차트 설정 ───────────────────────────────────────────────────── const [mainChartStyle, setMainChartStyle] = useState( initial.mainChartStyle ?? DEFAULT_MAIN_CHART_STYLE ); const [showMainChartModal, setShowMainChartModal] = useState(false); const handleMainChartSave = useCallback((style: MainChartStyle) => { setMainChartStyle(style); saveState({ mainChartStyle: style }); setShowMainChartModal(false); // DB 에도 저장 → 다음 로드 시 하드코딩 기본값 대신 사용 saveAppDef({ mainChartStyle: style as unknown as Record }); }, [saveAppDef]); // ── 드로잉 Undo/Redo 스택 ──────────────────────────────────────────────── const undoStack = useRef([]); const redoStack = useRef([]); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const pushDrawing = useCallback((newDrawings: Drawing[]) => { undoStack.current.push(drawings); redoStack.current = []; setDrawings(newDrawings); setCanUndo(true); setCanRedo(false); }, [drawings]); const handleUndo = useCallback(() => { if (undoStack.current.length === 0) return; redoStack.current.push(drawings); const prev = undoStack.current.pop()!; setDrawings(prev); setCanUndo(undoStack.current.length > 0); setCanRedo(true); }, [drawings]); const handleRedo = useCallback(() => { if (redoStack.current.length === 0) return; undoStack.current.push(drawings); const next = redoStack.current.pop()!; setDrawings(next); setCanUndo(true); setCanRedo(redoStack.current.length > 0); }, [drawings]); const [stats, setStats] = useState(null); const [watchlist, setWatchlist] = useState(() => { const base = initial.watchlist?.length ? initial.watchlist : [...UPBIT_MARKETS.slice(0, 5), ...SYMBOLS.slice(0, 4)]; return [...new Set([...base, ...getFavorites()])]; }); /** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */ const [favoritesList, setFavoritesList] = useState(getFavorites); // DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작) const [dbLoaded, setDbLoaded] = useState(false); const enterMainApp = useCallback(() => { setAppEntered(); setAppEnteredState(true); }, []); const handleLoginSuccess = useCallback((res: LoginResponse) => { const session: AuthSession = { userId: res.userId, username: res.username, displayName: res.displayName, role: normalizeRole(res.role), }; setAuthSession(session); setAuthUser(session); setGuestMode(false); setDbLoaded(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); invalidateMenuPermissionsCache(); setSessionKey(k => k + 1); enterMainApp(); }, [enterMainApp]); const handleGuestEnter = useCallback(() => { clearAuthSession(); setAuthUser(null); setGuestMode(true); setDbLoaded(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); invalidateMenuPermissionsCache(); setSessionKey(k => k + 1); enterMainApp(); }, [enterMainApp]); const handleLogout = useCallback(() => { clearAuthSession(); setAuthUser(null); setGuestMode(false); setDbLoaded(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); invalidateMenuPermissionsCache(); setSessionKey(k => k + 1); clearAppEntered(); setAppEnteredState(false); setLoginOpen(false); }, []); const guardedSetMenuPage = useCallback((page: MenuPage) => { if (page === 'notifications' && !canMenu('notifications')) return; if (page !== 'notifications' && !canMenu(page)) { setMenuPage(firstAllowedTopMenu(menuPermissions)); return; } setMenuPage(page); }, [canMenu, menuPermissions]); useEffect(() => { if (!menuPermsLoaded) return; if (menuPage === 'notifications') { if (!canMenu('notifications')) setMenuPage(firstAllowedTopMenu(menuPermissions)); return; } if (!canMenu(menuPage)) { setMenuPage(firstAllowedTopMenu(menuPermissions)); } }, [menuPage, menuPermsLoaded, menuPermissions, canMenu]); // 앱 설정 DB 로드 완료 시: 워크스페이스 로드 전 localStorage 기본값 일부만 교체 // layoutId 는 워크스페이스가 우선 — 로그인 직후 멀티 레이아웃으로 잠깐 전환되며 캔들이 비는 현상 방지 useEffect(() => { if (!appSettingsLoaded) return; if (dbLoaded) return; if (appDefaults.mainChartStyle) setMainChartStyle(appDefaults.mainChartStyle); if (appDefaults.syncOptions) setSyncOptions(appDefaults.syncOptions); // eslint-disable-next-line react-hooks/exhaustive-deps }, [appSettingsLoaded, dbLoaded]); const chartCandleAreaPriceLabels = appDefaults.chartCandleAreaPriceLabels ?? true; const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true; const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true; const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true; const chartLegendOptions = appDefaults.chartLegendOptions; const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true; const chartPaneSeparator = appDefaults.chartPaneSeparator; const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; const [paperCashBalance, setPaperCashBalance] = useState(0); const [paperCoinQty, setPaperCoinQty] = useState(0); const [paperRefreshKey, setPaperRefreshKey] = useState(0); const handlePaperOrderFilled = useCallback(() => { setPaperRefreshKey(k => k + 1); notifyPaperTradesChanged(); }, []); const refreshPaperAccount = useCallback(async (forMarket?: string) => { const summary = await loadPaperSummary(); if (!summary) return; setPaperCashBalance(summary.cashBalance); const m = forMarket ?? symbol; const pos = summary.positions?.find(p => p.symbol === m); setPaperCoinQty(pos?.quantity ?? 0); }, [symbol]); useEffect(() => { if (appSettingsLoaded) refreshPaperAccount(); }, [appSettingsLoaded, refreshPaperAccount]); useEffect(() => { if (!appSettingsLoaded || !appDefaults.fcmPushEnabled) return; import('./utils/fcmPush').then(({ initFcmPush }) => { void initFcmPush((title, body) => { console.info('[FCM]', title, body); }); }); }, [appSettingsLoaded, appDefaults.fcmPushEnabled]); useEffect(() => { if (appSettingsLoaded && paperTradingEnabled) refreshPaperAccount(symbol); }, [symbol, appSettingsLoaded, paperTradingEnabled, refreshPaperAccount]); const [alerts, setAlerts] = useState>( (initial.alertPrices ?? []).map(p => ({ price: p, label: '' })) ); const managerRef = useRef(null); /** 차트 매니저 준비 전 도착한 STOMP 틱 (준비 후 1회 적용) */ const pendingRealtimeBarRef = useRef(null); /** reloadAll setData 완료 전 — 이전 종목 rawBars에 실시간 틱 혼입 방지 */ const chartLiveReadyRef = useRef(false); const useUpbit = isUpbitMarket(symbol); // ── 백테스팅 ─────────────────────────────────────────────────────────────── const { signals: btSignals, stats: btStats, analysis: btAnalysis, resultSymbol: btSymbol, resultTimeframe: btTimeframe, runSeq: btRunSeq, running: btRunning, run: btRun, clear: btClear, invalidateRuns: btInvalidateRuns, } = useBacktest(); const [btSettings, setBtSettings] = useState(DEFAULT_BACKTEST_SETTINGS); const [showBtSettings, setShowBtSettings] = useState(false); const [showBtResult, setShowBtResult] = useState(false); const [btStrategyName, setBtStrategyName] = useState('전략'); /** 현재 차트와 일치하는 백테스팅 결과만 UI·마커에 반영 */ const btMatchesChart = useMemo( () => btSignals.length > 0 && btSymbol != null && btTimeframe != null && btSymbol === symbol && btTimeframe === timeframe, [btSignals.length, btSymbol, btTimeframe, symbol, timeframe], ); const btMarkerPayloadRef = useRef<{ symbol: string; timeframe: string; signals: BacktestSignal[]; showPrice: boolean; } | null>(null); /** applyBacktestMarkersWhenReady rAF 재시도 시 최신 차트 종목·타임프레임 검증용 */ const chartContextRef = useRef({ symbol, timeframe }); chartContextRef.current = { symbol, timeframe }; const barsMarketRef = useRef(null); const syncBacktestMarkersRef = useRef<() => void>(() => {}); /** ChartManager 메인 시리즈 준비 후 백테스트 마커 적용 (로딩·remount 직후 대응) */ const applyBacktestMarkersWhenReady = useCallback(( payload: NonNullable, attempt = 0, ) => { btMarkerPayloadRef.current = payload; const { symbol: curSym, timeframe: curTf } = chartContextRef.current; if (payload.symbol !== curSym || payload.timeframe !== curTf) return; const mgr = managerRef.current; if (!mgr || !mgr.hasMainSeries()) { if (attempt < 120) { requestAnimationFrame(() => applyBacktestMarkersWhenReady(payload, attempt + 1)); } return; } if (payload.symbol !== chartContextRef.current.symbol || payload.timeframe !== chartContextRef.current.timeframe) return; mgr.setBacktestMarkers(payload.signals, payload.showPrice); }, []); /** reloadAll 직후 등 — payload 가 있을 때만 재적용 (없을 때 clear 하지 않음, 경쟁 방지) */ const syncBacktestMarkers = useCallback(() => { const payload = btMarkerPayloadRef.current; if ( payload && payload.symbol === symbol && payload.timeframe === timeframe && payload.signals.length > 0 ) { applyBacktestMarkersWhenReady(payload); } }, [symbol, timeframe, applyBacktestMarkersWhenReady]); syncBacktestMarkersRef.current = syncBacktestMarkers; // ── 실시간 전략 체크 (가상투자 세션 우선, 없으면 전역+관심종목) ───────────── const [showLivePanel, setShowLivePanel] = useState(false); const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]); const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState(); const [activeLiveSettings, setActiveLiveSettings] = useState([]); const { session: virtualSession, isVirtualActive, monitoredMarkets: virtualMonitoredMarkets } = useVirtualLiveStrategy(); const refreshActiveLiveSettings = useCallback(() => { loadActiveLiveStrategySettings() .then(setActiveLiveSettings) .catch(() => setActiveLiveSettings([])); }, []); useEffect(() => { refreshActiveLiveSettings(); }, [refreshActiveLiveSettings, isVirtualActive, virtualMonitoredMarkets.join(','), virtualSession.executionType, virtualSession.positionMode]); useEffect(() => { const onVirtualChanged = () => refreshActiveLiveSettings(); window.addEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged); return () => window.removeEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged); }, [refreshActiveLiveSettings]); useEffect(() => { if (!appSettingsLoaded) return; if (isVirtualActive) { const active = activeLiveSettings.find(s => s.market === symbol); setLiveStrategyCandleTypes(active?.strategyCandleTypes); return; } loadLiveStrategySettings(symbol) .then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes)) .catch(() => { /* optional */ }); }, [symbol, appSettingsLoaded, appDefaults.liveStrategyId, isVirtualActive, activeLiveSettings]); const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => { if (isVirtualActive) { const active = activeLiveSettings.find(s => s.market === symbol); return { market: symbol, strategyId: active?.strategyId ?? virtualSession.globalStrategyId, isLiveCheck: true, executionType: active?.executionType ?? virtualSession.executionType, positionMode: active?.positionMode ?? virtualSession.positionMode, strategyCandleTypes: active?.strategyCandleTypes ?? liveStrategyCandleTypes, }; } return { market: symbol, strategyId: appDefaults.liveStrategyId ?? null, isLiveCheck: appDefaults.liveStrategyCheck ?? false, executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', strategyCandleTypes: liveStrategyCandleTypes, }; }, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes]); /** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */ const monitoredMarkets = useMemo(() => { if (isVirtualActive) return virtualMonitoredMarkets; if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; const set = new Set(watchlist); if (symbol) set.add(symbol); return [...set]; }, [isVirtualActive, virtualMonitoredMarkets, watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]); const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]); const liveStompSubscriptions = useMemo(() => { if (monitoredMarkets.length === 0) return []; const filtered = activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)); if (filtered.length > 0) return expandLiveStrategySubscriptions(filtered); if (marketSubscriptions.length > 0) return marketSubscriptions; const types = liveStrategyCandleTypes?.length ? liveStrategyCandleTypes : []; if (types.length > 0) { return monitoredMarkets.flatMap(m => types.map(candleType => ({ market: m, candleType }))); } return []; }, [monitoredMarkets, activeLiveSettings, marketSubscriptions, liveStrategyCandleTypes]); useEffect(() => { if (monitoredMarkets.length === 0) { setMarketSubscriptions([]); return; } if (isVirtualActive) { setMarketSubscriptions(expandLiveStrategySubscriptions( activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)), )); return; } if (!appDefaults.liveStrategyCheck) { setMarketSubscriptions([]); return; } loadActiveLiveStrategySettings() .then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list))) .catch(() => { /* liveStompSubscriptions 폴백 */ }); }, [isVirtualActive, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(','), activeLiveSettings]); const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => { if (isVirtualActive) return; saveAppDef({ liveStrategyCheck: saved.isLiveCheck, liveStrategyId: saved.strategyId ?? undefined, liveExecutionType: saved.executionType, livePositionMode: saved.positionMode, }); if (saved.strategyCandleTypes) { setLiveStrategyCandleTypes(saved.strategyCandleTypes); } if (appDefaults.liveStrategyCheck && saved.isLiveCheck) { refreshActiveLiveSettings(); } }, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings]); const prevFavoritesRef = useRef(getFavorites()); /** 별표 토글 → localStorage·워치리스트 동기화 */ useEffect(() => { const onFavoritesChanged = (e: Event) => { const markets = (e as CustomEvent).detail ?? getFavorites(); prevFavoritesRef.current = markets; setFavoritesList(markets); setWatchlist(markets); }; window.addEventListener(FAVORITES_CHANGED_EVENT, onFavoritesChanged); return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onFavoritesChanged); }, []); /** 기존 localStorage 관심종목 → DB 1회 이전 */ useEffect(() => { if (!dbLoaded) return; let cancelled = false; (async () => { try { const items = await loadWatchlist(); if (cancelled) return; const dbSyms = items.map(i => i.symbol); if (dbSyms.length > 0) { setWatchlist(dbSyms); setFavoritesList(dbSyms); saveFavorites(dbSyms, false); return; } const local = getFavorites(); if (local.length === 0) return; for (const s of local) { try { await addWatchlistItem({ symbol: s }); } catch { /* ignore */ } } const refreshed = await loadWatchlist(); if (cancelled) return; const syms = refreshed.map(i => i.symbol); setWatchlist(syms); setFavoritesList(syms); saveFavorites(syms, false); } catch { /* ignore */ } })(); return () => { cancelled = true; }; }, [dbLoaded]); const liveStrategyName = useMemo(() => { const sid = liveStrategySettings.strategyId; return sid != null ? liveStrategies.find(s => s.id === sid)?.name ?? null : null; }, [liveStrategies, liveStrategySettings.strategyId]); const liveStrategyNameByMarket = useMemo(() => { const map: Record = {}; for (const s of activeLiveSettings) { if (s.strategyId != null) { const name = liveStrategies.find(st => st.id === s.strategyId)?.name; if (name) map[s.market] = name; } } return map; }, [activeLiveSettings, liveStrategies]); const goToMarketChart = useCallback((market: string) => { if (layoutDef.count > 1) { slotRefs.current[activeSlot]?.setSymbol(market); if (syncOptions.symbol) { slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setSymbol(market)); } } else { setSymbol(market); setLegend(null); } }, [layoutDef.count, activeSlot, syncOptions.symbol]); // 전략 목록 로드 (실시간 패널용) // menuPage가 chart로 복귀하거나 실시간 패널이 열릴 때마다 재로드하여 // 전략 설정 화면에서 추가/수정된 전략이 즉시 반영되도록 한다. const refreshLiveStrategies = useCallback(() => { loadStrategiesForLive() .then(list => setLiveStrategies( list .filter(s => s.id != null) .map(s => ({ id: s.id as number, name: s.name })) )) .catch(() => {}); }, []); useEffect(() => { refreshLiveStrategies(); }, [menuPage, showLivePanel, refreshLiveStrategies]); const liveNotifierRef = useRef(null); const clearLiveMarkers = useCallback(() => { liveNotifierRef.current?.clearMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }, []); useEffect(() => { loadBacktestSettings().then(s => { if (s) setBtSettings(s); }); }, []); // 단일차트 모드: 과거 데이터 추가 로드 const { isLoadingMore: isSingleLoadingMore, loadMoreRef: singleLoadMoreRef } = useHistoryLoader({ symbol, timeframe, isUpbit: useUpbit, managerRef, }); // 개발 환경: 브라우저 콘솔에서 window.__chartMgr__.logIndicatorLastValues() 로 // 각 지표의 실제 계산값을 확인 가능 (갱신 여부 진단용) useEffect(() => { if (import.meta.env.DEV) { (window as unknown as Record)['__chartMgr__'] = managerRef; } }, []); // 멀티 레이아웃: 각 슬롯 ref 배열 (최대 8개) const slotRefs = useRef<(ChartSlotHandle | null)[]>(Array(8).fill(null)); useEffect(() => { if (!appSettingsLoaded) return; const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; managerRef.current?.setChartTimeFormat(chartTimeFormat); slotRefs.current.forEach(slot => slot?.setChartTimeFormat?.(chartTimeFormat, tf)); }, [appSettingsLoaded, chartTimeFormat, timeframe, activeSlotTf, layoutDef.count]); useEffect(() => { if (!appSettingsLoaded) return; managerRef.current?.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels); managerRef.current?.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels); slotRefs.current.forEach(slot => { slot?.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels); slot?.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels); }); }, [appSettingsLoaded, chartCandleAreaPriceLabels, chartSeriesPriceLabels]); useEffect(() => { if (!appSettingsLoaded) return; const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; const h = wrapper?.clientHeight; managerRef.current?.setVolumeVisible(chartVolumeVisible, h); slotRefs.current.forEach(slot => slot?.setVolumeVisible(chartVolumeVisible)); }, [appSettingsLoaded, chartVolumeVisible]); useEffect(() => { if (!appSettingsLoaded) return; managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator); slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator)); }, [appSettingsLoaded, chartPaneSeparator]); // mainChartStyle 변경 → ChartManager 즉시 적용 useEffect(() => { managerRef.current?.updateMainSeriesStyle(mainChartStyle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mainChartStyle]); // 종목·타임프레임 변경 시 이전 백테스팅 결과·마커 제거 + 진행 중 run 무효화 useEffect(() => { btInvalidateRuns(); btMarkerPayloadRef.current = null; btClear(); managerRef.current?.clearBacktestMarkers(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [symbol, timeframe]); // 백테스팅 시그널 → ref 갱신 후 ChartManager 마커 동기화 useEffect(() => { if (btMatchesChart && btSignals.length > 0) { const payload = { symbol, timeframe, signals: btSignals, showPrice: appDefaults.btShowPrice, }; btMarkerPayloadRef.current = payload; applyBacktestMarkersWhenReady(payload); } else { btMarkerPayloadRef.current = null; managerRef.current?.clearBacktestMarkers(); } let innerRaf = 0; const outerRaf = requestAnimationFrame(() => { innerRaf = requestAnimationFrame(syncBacktestMarkers); }); return () => { cancelAnimationFrame(outerRaf); if (innerRaf) cancelAnimationFrame(innerRaf); }; }, [btMatchesChart, btSignals, btRunSeq, symbol, timeframe, appDefaults.btShowPrice, applyBacktestMarkersWhenReady, syncBacktestMarkers]); // ── 시뮬레이션 데이터 (Upbit 아닐 때) ────────────────────────────────── const [simBars, setSimBars] = useState(() => !isUpbitMarket(initial.symbol) ? generateBars(initial.symbol, initial.timeframe) : [] ); useEffect(() => { if (useUpbit) return; const b = generateBars(symbol, timeframe); setSimBars(b); setStats(calcStats(b)); setLegend(null); }, [symbol, timeframe, useUpbit]); // ── Upbit 실시간 데이터 ───────────────────────────────────────────────── // 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신 const handleTickUpdate = useCallback((bar: OHLCVBar) => { if (barsMarketRef.current !== chartContextRef.current.symbol) return; if (!chartLiveReadyRef.current) { pendingRealtimeBarRef.current = bar; return; } if (!managerRef.current) { pendingRealtimeBarRef.current = bar; flashWsDot(); return; } managerRef.current.updateBar(bar); flashWsDot(); // 수신 중 형광 glow (DOM 직접 조작, re-render 없음) }, []); // 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신 const handleNewCandle = useCallback((newBar: OHLCVBar) => { if (barsMarketRef.current !== chartContextRef.current.symbol) return; if (!chartLiveReadyRef.current) { pendingRealtimeBarRef.current = newBar; return; } if (!managerRef.current) { pendingRealtimeBarRef.current = newBar; flashWsDot(); return; } managerRef.current.appendBar(newBar); flashWsDot(); }, []); const handleCandlesReady = useCallback(() => { chartLiveReadyRef.current = true; const pending = pendingRealtimeBarRef.current; const mgr = managerRef.current; if (!pending || !mgr || barsMarketRef.current !== chartContextRef.current.symbol) return; pendingRealtimeBarRef.current = null; mgr.updateBar(pending); }, []); const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP'; const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData( symbol, timeframe, useMemo(() => ({ onTickUpdate: handleTickUpdate, onNewCandle: handleNewCandle, enabled: useUpbit, source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT', }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), ); // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) const bars = useUpbit ? upbitBars : simBars; const barsMarket = useUpbit ? upbitBarsMarket : symbol; barsMarketRef.current = barsMarket; /** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (paint 전에 실행) */ useLayoutEffect(() => { chartLiveReadyRef.current = false; pendingRealtimeBarRef.current = null; }, [symbol, timeframe]); // Upbit 초기 로딩 완료 시 stats 업데이트 useEffect(() => { if (useUpbit && upbitBars.length > 0) { setStats(calcStats(upbitBars)); setLegend(null); } }, [useUpbit, upbitBars]); // ── Watchlist 가격 맵 ────────────────────────────────────────────────── const priceMap = useMemo(() => { const map: Record = {}; for (const sym of watchlist) { const b = isUpbitMarket(sym) ? [] : generateBars(sym, '1D'); if (b.length >= 2) { map[sym] = { price: b[b.length-1].close, changePct: (b[b.length-1].close - b[b.length-2].close) / b[b.length-2].close * 100 }; } } return map; }, [watchlist]); // body 포털(알림 팝업 등) 테마 CSS 변수 동기화 useEffect(() => { syncDocumentTheme(theme); }, [theme]); // ── DB 워크스페이스 동기화 ───────────────────────────────────────────── const workspaceState = useMemo(() => ({ layoutId: layoutDef.id, syncOptions, slots: [{ slotIndex: 0, symbol, timeframe, chartType, theme, mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle, }], }), [layoutDef.id, syncOptions, symbol, timeframe, chartType, theme, mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle]); const { isLoaded: _wsLoaded } = useWorkspacePersist({ sessionKey, state: workspaceState, onLoad: (data) => { try { if (data.layoutId) { const def = LAYOUTS.find(l => l.id === data.layoutId); if (def) setLayoutDef(def); } if (data.syncOptions) setSyncOptions(data.syncOptions as typeof syncOptions); const slot0 = Array.isArray(data.slots) && data.slots.length > 0 ? (data.slots[0] as Record) : null; if (slot0) { if (slot0.symbol) setSymbol(slot0.symbol as string); if (slot0.timeframe) setTimeframe(slot0.timeframe as Timeframe); if (slot0.chartType) setChartType(slot0.chartType as ChartType); if (slot0.theme) setTheme(slot0.theme as Theme); if (slot0.mode) setMode(slot0.mode as ChartMode); if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale)); if (Array.isArray(slot0.indicators)) { const loaded = (slot0.indicators as IndicatorConfig[]).map(ind => { if (ind.type === 'SMA') return normalizeSmaConfig(ind); if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); return ind; }); setIndicators(sortIndicatorsByTypeOrder(loaded)); } if (Array.isArray(slot0.drawings)) setDrawings(slot0.drawings as Drawing[]); if (slot0.drawingsLocked != null) setDrawingsLocked(Boolean(slot0.drawingsLocked)); if (slot0.drawingsVisible != null) setDrawingsVisible(Boolean(slot0.drawingsVisible)); if (slot0.mainChartStyle) setMainChartStyle(slot0.mainChartStyle as MainChartStyle); } // 슬롯 1+ 초기 props 수집 (멀티슬롯 전환 시 각 ChartSlot 에 전달) if (Array.isArray(data.slots) && data.slots.length > 1) { const extra: typeof slotInitProps = {}; for (let i = 1; i < data.slots.length; i++) { const s = data.slots[i] as Record; extra[i] = { symbol: s.symbol as string | undefined, timeframe: s.timeframe as Timeframe | undefined, indicators: Array.isArray(s.indicators) ? sortIndicatorsByTypeOrder( (s.indicators as IndicatorConfig[]).map(ind => { if (ind.type === 'SMA') return normalizeSmaConfig(ind); if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); return ind; }), ) : undefined, mainChartStyle: s.mainChartStyle as MainChartStyle | undefined, }; } setSlotInitProps(extra); } } catch (e) { console.warn('[DB load] parse error', e); } setDbLoaded(true); }, onWatchlistLoad: (items: WatchlistItem[]) => { const syms = items.map(i => i.symbol); initFavoritesFromDb(syms); if (syms.length > 0) { setWatchlist(syms); setFavoritesList(syms); } }, noAutoSave: !dbLoaded, }); /** 로그인·로그아웃 시에만 차트 재마운트 (종목·타임프레임은 TradingChart 내부 reloadAll로 처리) */ const chartMountKey = useMemo(() => sessionKey, [sessionKey]); // DB에 워크스페이스 없음(204) → appDefaults 레이아웃 적용 후 저장 허용 useEffect(() => { if (!_wsLoaded || dbLoaded) return; setDbLoaded(true); const def = LAYOUTS.find(l => l.id === appDefaults.layoutId); if (def) setLayoutDef(def); }, [_wsLoaded, dbLoaded, appDefaults.layoutId]); // ── 키보드 단축키 ───────────────────────────────────────────────────── useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.key === 'Escape') { setDrawingTool('cursor'); setSelectedDrawingId(null); setDrawingCtxToolbar(null); } if (e.key === 'f' || e.key === 'F') managerRef.current?.fitContent(); if (e.key === 'l' || e.key === 'L') setLogScale(v => !v); if (e.altKey && e.key === 's') handleScreenshot(); if (mode === 'trading') { if (e.key === 'h' || e.key === 'H') setDrawingTool('hline'); if (e.key === 'v' || e.key === 'V') setDrawingTool('vline'); if (e.key === 't' || e.key === 'T') setDrawingTool('trendline'); if (e.key === 'm' || e.key === 'M') setDrawingTool('measure'); if (e.key === 'r' || e.key === 'R') setDrawingTool('rect'); if (e.key === 'z' && !e.ctrlKey) setDrawingTool('fibtz'); if (e.ctrlKey && e.key === 'z') { e.preventDefault(); handleUndo(); } if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); handleRedo(); } } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); useEffect(() => { if (mode === 'chart' && drawingTool !== 'zoom' && drawingTool !== 'magnifier') { setDrawingTool('cursor'); } }, [mode]); useEffect(() => { if (menuPage !== 'chart') { setShowMarketPanel(false); setMobileRightOpen(false); } }, [menuPage]); useEffect(() => { if (!isMobile) setMobileRightOpen(false); }, [isMobile]); // percentScale / showGrid 변경 시 ChartManager 에 즉시 반영 useEffect(() => { managerRef.current?.setPercentScale(percentScale); }, [percentScale]); useEffect(() => { managerRef.current?.setGrid(showGrid); }, [showGrid]); // ── 핸들러 ──────────────────────────────────────────────────────────── /** Time sync: 한 슬롯의 가시 시간 범위가 바뀌면 나머지에 전파 */ const handleVisibleRangeChange = useCallback((r: { from: number; to: number }) => { if (syncOptions.time) setSyncVisibleRange(r); }, [syncOptions.time]); /** Date range sync: 한 슬롯의 가시 논리 범위가 바뀌면 나머지에 전파 */ const handleLogicalRangeChange = useCallback((r: { from: number; to: number }) => { if (syncOptions.dateRange) setSyncLogicalRange(r); }, [syncOptions.dateRange]); /** * 멀티차트 슬롯 전체 보기: * 해당 슬롯의 심볼/타임프레임/지표를 싱글 차트에 적용하고 싱글 레이아웃으로 전환한다. */ const handleSlotFullView = useCallback((slotSymbol: string, slotIdx: number) => { const slot = slotRefs.current[slotIdx]; if (slot) { setSymbol(slot.getSymbol()); setTimeframe(slot.getTimeframe()); setIndicators(slot.getIndicators().map(i => ({ ...i }))); } else { setSymbol(slotSymbol); } setLegend(null); setSlot0InitProps(null); setLayoutDef(LAYOUTS[0]); // 싱글 레이아웃(id: '1') }, []); // ── 보조지표 단독 전체화면 모드 ──────────────────────────────────────────── const [focusedIndicatorId, setFocusedIndicatorId] = useState(null); useEffect(() => { setFocusedIndicatorId(null); }, [sessionKey]); /** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) */ const effectiveIndicators = useMemo(() => { if (!focusedIndicatorId || layoutDef.count > 1) return indicators; return indicators.filter(ind => { const def = getIndicatorDef(ind.type); return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId; }); }, [focusedIndicatorId, indicators, layoutDef.count]); const handleExpandIndicator = useCallback((id: string) => { setFocusedIndicatorId(id); }, []); const handleRestoreIndicators = useCallback(() => { setFocusedIndicatorId(null); }, []); /** * 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동. * overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨. */ const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => { setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev)); }, []); const handleMergeIndicators = useCallback((fromId: string, intoId: string) => { setIndicators(prev => mergeIndicators(fromId, intoId, prev)); }, []); const handleSplitIndicatorPane = useCallback((hostId: string) => { setIndicators(prev => splitIndicatorPane(hostId, prev)); }, []); /** * 지표 추가: * - 싱글 레이아웃: 메인 차트에 추가 * - 멀티 레이아웃: 활성 슬롯(ChartSlot ref)에 추가 */ const applyChartIndicators = useCallback((chartIndicators: IndicatorConfig[]) => { if (layoutDef.count > 1) { slotRefs.current[activeSlot]?.setIndicators(chartIndicators); setActiveSlotIndicators(chartIndicators.map(i => ({ ...i }))); } else { setIndicators(chartIndicators); } }, [layoutDef.count, activeSlot]); /** 설정 목록 순서 변경 → 차트 pane 순서 동기화 */ const handleIndicatorListOrderChange = useCallback((orderedTypes: string[]) => { const resort = (inds: IndicatorConfig[]) => sortIndicatorsByTypeOrder(inds, orderedTypes); if (layoutDef.count > 1) { const slotRef = slotRefs.current[activeSlot]; if (!slotRef) return; const next = resort(slotRef.getIndicators()); slotRef.setIndicators(next); setActiveSlotIndicators(next.map(i => ({ ...i }))); } else { setIndicators(prev => resort(prev)); } }, [layoutDef.count, activeSlot]); const handleAddIndicator = useCallback((type: string, fromConfig?: IndicatorConfig) => { if (layoutDef.count > 1) { const slotRef = slotRefs.current[activeSlot]; if (slotRef) { slotRef.addIndicator(type, fromConfig); return; } } setIndicators(prev => { if (prev.some(i => i.type === type)) return prev; const def = getIndicatorDef(type); if (!def) return prev; let cfg: IndicatorConfig; if (fromConfig) { cfg = { ...fromConfig, id: newIndId(), type: def.type }; } else { const params = getParams(type, def.defaultParams); const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(type, def.plots, def.hlines); cfg = { id: newIndId(), type: def.type, params, plots, hlines, ...(cloudColors ? { cloudColors } : {}), ...(bandBackground ? { bandBackground } : {}), }; } if (type === 'SMA') { cfg = normalizeSmaConfig({ ...cfg, plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(), }); } else if (type === 'IchimokuCloud') { cfg = normalizeIchimokuConfig(cfg); } return sortIndicatorsByTypeOrder([...prev, cfg]); }); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); /** 지표 추가 팝업 — 탭 전체 추가 (탭 목록 순서 유지) */ const handleAddIndicators = useCallback((types: string[]) => { if (!types.length) return; const orderedTypes = types.filter(t => getIndicatorDef(t)); if (orderedTypes.length === 0) return; if (layoutDef.count > 1) { const slotRef = slotRefs.current[activeSlot]; if (slotRef) { slotRef.addIndicators(orderedTypes); return; } } setIndicators( sortIndicatorsByTypeOrder( replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId), orderedTypes, ), ); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); /** * 지표 제거 (type 기준): * - 싱글: 메인 차트에서 제거 * - 멀티: 활성 슬롯에서 제거 */ const handleRemoveByType = useCallback((type: string) => { if (layoutDef.count > 1) { const slotRef = slotRefs.current[activeSlot]; if (slotRef) { slotRef.removeIndicatorByType(type); return; } } setIndicators(prev => removeIndicatorTypeFromList(prev, type)); }, [layoutDef.count, activeSlot]); /** * 타임프레임 변경: * - 싱글: 메인 차트 변경 * - 멀티: 활성 슬롯 변경 (Interval Sync 시 전체 슬롯) */ // magnetMode 변경 → 싱글차트 + 멀티슬롯 전체 크로스헤어 모드 즉시 반영 useEffect(() => { const enabled = magnetMode !== 'off'; // 싱글 레이아웃 메인 차트 managerRef.current?.setCrosshairMagnet(enabled); // 멀티 레이아웃 슬롯 전체 slotRefs.current.forEach(slot => slot?.setCrosshairMagnet(enabled)); }, [magnetMode]); // 테마 순환: dark → blue → light → dark, DB 에 저장 const handleThemeToggle = useCallback(() => { setTheme(t => { const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark'; saveAppDef({ defaultTheme: next }); return next; }); }, [saveAppDef]); const handleTimeframe = useCallback((tf: Timeframe) => { if (layoutDef.count > 1) { slotRefs.current[activeSlot]?.setTimeframe(tf); setActiveSlotTf(tf); if (syncOptions.interval) { slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setTimeframe(tf)); } } else { setTimeframe(tf); } // 타임프레임 변경 → DB 기본값 갱신 saveAppDef({ defaultTimeframe: tf }); }, [layoutDef.count, activeSlot, syncOptions.interval, saveAppDef]); const chartIndicatorsForSettings = layoutDef.count > 1 ? activeSlotIndicators : indicators; /** 지표 추가 팝업 — 항목별 설정 모달 */ const handleOpenIndicatorSettings = useCallback((type: string) => { const onChart = chartIndicatorsForSettings.find(i => i.type === type); setSettingsModalId(onChart ? onChart.id : indicatorSettingsTemplateId(type)); setCtxToolbar(null); }, [chartIndicatorsForSettings]); /** 지표 설정 저장 (params/plots/plotVisibility 변경) → 자동으로 재렌더링 */ const handleIndicatorSave = useCallback((updated: IndicatorConfig) => { const fromTemplate = isIndicatorSettingsTemplateId(settingsModalId ?? '') || updated.id.startsWith('template_'); markChartIndicatorSaveCommitted(); saveParams(updated.type, updated.params); saveVisual( updated.type, updated.plots, updated.hlines, updated.cloudColors, updated.bandBackground, ); const applyUpdate = (prev: IndicatorConfig[]): IndicatorConfig[] => { if (fromTemplate) { const merged = enrichIndicatorConfig(updated); return prev.map(ind => ind.type === updated.type ? { ...merged, id: ind.id, hidden: ind.hidden } : ind, ); } return prev.map(ind => (ind.id === updated.id ? updated : ind)); }; if (layoutDef.count > 1) { applyChartIndicators(applyUpdate(activeSlotIndicators)); } else { setIndicators(applyUpdate); } setSettingsModalId(null); setCtxToolbar(null); }, [ saveParams, saveVisual, settingsModalId, layoutDef.count, activeSlotIndicators, applyChartIndicators, ]); const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators; /** 설정 화면에서 DB 기본값 저장 후 차트 복귀 시 — 전역 기본값을 차트 인스턴스에 일괄 반영 */ const lastSyncedSettingsRevisionRef = useRef(indicatorSettingsRevision); const syncChartsFromSavedDefaults = useCallback(() => { const merge = (inds: IndicatorConfig[]) => mergeGlobalDefaultsOntoChartIndicators(inds, getParams, getVisualConfig); if (layoutDef.count > 1) { slotRefs.current.slice(0, layoutDef.count).forEach(slot => { if (!slot) return; const current = slot.getIndicators(); const next = merge(current); if (chartIndicatorsNeedDefaultsRefresh(current, next)) { slot.setIndicators(next); } }); const activeInds = slotRefs.current[activeSlot]?.getIndicators() ?? activeSlotIndicators; const mergedActive = merge(activeInds); if (chartIndicatorsNeedDefaultsRefresh(activeInds, mergedActive)) { setActiveSlotIndicators(mergedActive.map(i => ({ ...i }))); } } else { setIndicators(prev => { const next = merge(prev); return chartIndicatorsNeedDefaultsRefresh(prev, next) ? next : prev; }); } }, [layoutDef.count, activeSlot, activeSlotIndicators, getParams, getVisualConfig]); useLayoutEffect(() => { if (!chartVisible) return; if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return; if (consumeSkipNextChartDefaultsSync()) { lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision; return; } syncChartsFromSavedDefaults(); lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision; }, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]); /** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */ const handleBulkIndicatorApply = useCallback((result: { chartIndicators: IndicatorConfig[]; allConfigs: IndicatorConfig[]; }) => { const { chartIndicators, allConfigs } = result; applyChartIndicators(sortIndicatorsByTypeOrder(chartIndicators)); allConfigs.forEach(ind => { saveParams(ind.type, ind.params); saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors, ind.bandBackground); }); setShowBulkIndSettings(false); }, [applyChartIndicators, saveParams, saveVisual]); /** 지표 숨김/표시 토글 */ const handleToggleIndicatorHidden = useCallback((id: string) => { setIndicators(prev => prev.map(ind => ind.id === id ? { ...ind, hidden: !ind.hidden } : ind )); }, []); /** 지표 ID로 삭제 */ const handleDuplicateIndicator = useCallback((sourceId: string) => { if (layoutDef.count > 1) { slotRefs.current[activeSlot]?.duplicateIndicator(sourceId); return; } setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev); }, [layoutDef.count, activeSlot]); const handleRemoveIndicatorById = useCallback((id: string) => { setIndicators(prev => removeIndicatorFromList(prev, id)); setFocusedIndicatorId(prev => prev === id ? null : prev); setCtxToolbar(null); setSettingsModalId(null); }, []); const handleScreenshot = () => { const url = managerRef.current?.screenshot(); if (!url) return; const a = document.createElement('a'); a.href = url; a.download = `chart_${symbol}_${timeframe}_${Date.now()}.png`; a.click(); }; const handleFullscreen = () => { if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {}); else document.exitFullscreen().catch(() => {}); }; // 실시간 모드: latestBar 우선, 없으면 마지막 bars 사용 const lastKnownBar = useUpbit ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) : (bars.length > 0 ? bars[bars.length - 1] : null); /** 크로스헤어 OHLC·지표 범례 */ const hoverBar = legend ?? lastKnownBar; /** 종목명 옆·우측 상단 시세 — 실시간 티커/최신 봉 (크로스헤어 무관) */ const quoteBar = lastKnownBar; const liveQuote = useMemo( () => resolveChartLiveQuote(chartTicker, quoteBar, bars), [chartTicker, quoteBar, bars], ); const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]); if (!appEntered) { return ( ); } return (
0} popupEnabled={appDefaults.tradeAlertPopup ?? true} chartMarkersActive={menuPage === 'chart'} onMarkersChange={markers => { managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice); }} /> {/* ── 최상단 글로벌 메뉴바 ─────────────────────────────────────── */} setAppDownloadOpen(true)} authUser={authUser} guestMode={guestMode} menuPermissions={menuPermissions} onLoginClick={() => setLoginOpen(true)} onLogout={handleLogout} tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })} onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} /> setLoginOpen(false)} onSuccess={handleLoginSuccess} /> {appDownloadOpen && ( setAppDownloadOpen(false)} /> )} {/* ── 투자전략 화면 ──────────────────────────────────────────────── */} {menuPage === 'dashboard' && ( { goToMarketChart(m); setMenuPage('chart'); }} /> )} {menuPage === 'strategy' && ( )} {menuPage === 'strategy-editor' && ( )} {/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */} {menuPage === 'backtest' && ( )} {menuPage === 'paper' && ( { setSettingsInitialCategory('paper'); setMenuPage('settings'); }} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} /> )} {menuPage === 'virtual' && ( saveAppDef({ paperAutoTradeEnabled: v })} onPaperOrderFilled={handlePaperOrderFilled} /> )} {menuPage === 'trend-search' && ( )} {menuPage === 'verification-board' && ( setVerificationFocusIssueId(null)} /> )} {/* ── 설정 화면 ──────────────────────────────────────────────────── */} {menuPage === 'notifications' && canMenu('notifications') && ( { goToMarketChart(market); setMenuPage('chart'); }} /> )} {menuPage === 'settings' && canMenu('settings') && ( setMagnetMode(m)} btAutoPopup={appDefaults.btAutoPopup} onBtAutoPopup={v => saveAppDef({ btAutoPopup: v })} btShowPrice={appDefaults.btShowPrice} onBtShowPrice={v => saveAppDef({ btShowPrice: v })} liveMarket={symbol} liveStrategies={liveStrategies} onClearLiveMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }} tradeAlertPopup={appDefaults.tradeAlertPopup} onTradeAlertPopup={v => saveAppDef({ tradeAlertPopup: v })} tradeAlertSoundEnabled={appDefaults.tradeAlertSoundEnabled} onTradeAlertSoundEnabled={v => saveAppDef({ tradeAlertSoundEnabled: v })} tradeAlertSound={appDefaults.tradeAlertSound} onTradeAlertSound={v => saveAppDef({ tradeAlertSound: v })} tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })} tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} liveSettings={liveStrategySettings} onLiveSettingsChange={handleLiveSettingsChange} watchlistCount={watchlist.length} monitoredMarkets={monitoredMarkets} virtualDriven={isVirtualActive} getIndicatorParams={getIndicatorParams} saveIndicatorParams={saveIndicatorParams} getIndicatorVisual={getIndicatorVisual} saveIndicatorVisual={saveIndicatorVisual} saveAllIndicatorDefaults={saveAllIndicatorDefaults} indicatorSettingsRevision={indicatorSettingsRevision} chartIndicators={bulkSettingsIndicators} onAddIndicatorToChart={handleAddIndicator} onRemoveIndicatorFromChart={handleRemoveByType} onIndicatorListOrderChange={handleIndicatorListOrderChange} chartCandleAreaPriceLabels={chartCandleAreaPriceLabels} onChartCandleAreaPriceLabels={v => { saveAppDef({ chartCandleAreaPriceLabels: v }); managerRef.current?.setCandleAreaPriceLabelsEnabled(v); slotRefs.current.forEach(slot => slot?.setCandleAreaPriceLabelsEnabled(v)); }} chartSeriesPriceLabels={chartSeriesPriceLabels} onChartSeriesPriceLabels={v => { saveAppDef({ chartSeriesPriceLabels: v }); managerRef.current?.setIndicatorAreaPriceLabelsEnabled(v); slotRefs.current.forEach(slot => slot?.setIndicatorAreaPriceLabelsEnabled(v)); }} chartVolumeVisible={chartVolumeVisible} onChartVolumeVisible={v => { saveAppDef({ chartVolumeVisible: v }); const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; managerRef.current?.setVolumeVisible(v, wrapper?.clientHeight); slotRefs.current.forEach(slot => slot?.setVolumeVisible(v)); }} chartLiveReceiveHighlight={chartLiveReceiveHighlight} onChartLiveReceiveHighlight={v => saveAppDef({ chartLiveReceiveHighlight: v })} chartLegendOptions={chartLegendOptions} onChartLegendOptionsChange={patch => { saveAppDef({ chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record, }); }} chartCrosshairInfoVisible={chartCrosshairInfoVisible} onChartCrosshairInfoVisible={v => saveAppDef({ chartCrosshairInfoVisible: v })} chartPaneSeparator={chartPaneSeparator} onChartPaneSeparatorChange={opts => { saveAppDef({ chartPaneSeparator: opts }); managerRef.current?.setPaneSeparatorOptions(opts); slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts)); }} paperTradingEnabled={paperTradingEnabled} onPaperTradingEnabled={v => saveAppDef({ paperTradingEnabled: v })} paperInitialCapital={appDefaults.paperInitialCapital} onPaperInitialCapital={v => saveAppDef({ paperInitialCapital: v })} paperFeeRatePct={appDefaults.paperFeeRatePct} onPaperFeeRatePct={v => saveAppDef({ paperFeeRatePct: v })} paperSlippagePct={appDefaults.paperSlippagePct} onPaperSlippagePct={v => saveAppDef({ paperSlippagePct: v })} paperMinOrderKrw={appDefaults.paperMinOrderKrw} onPaperMinOrderKrw={v => saveAppDef({ paperMinOrderKrw: v })} paperAutoTradeEnabled={appDefaults.paperAutoTradeEnabled} onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })} paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })} virtualTargetMaxCount={appDefaults.virtualTargetMaxCount} onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })} trendSearchSettings={appDefaults.trendSearchSettings} onTrendSearchSettingsChange={s => saveAppDef({ trendSearchSettings: resolveTrendSearchAppSettings(s), })} onPaperAccountReset={async () => { await resetPaperAccount(); await refreshPaperAccount(); }} tradingMode={appDefaults.tradingMode} onTradingMode={v => saveAppDef({ tradingMode: v })} liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled} onLiveAutoTradeEnabled={v => saveAppDef({ liveAutoTradeEnabled: v })} hasUpbitKeys={appDefaults.hasUpbitKeys} upbitAccessKeyMasked={appDefaults.upbitAccessKeyMasked} onUpbitKeys={(access, secret) => { const patch: Parameters[0] = {}; const a = access.trim(); const s = secret.trim(); const isMasked = (v: string) => v.startsWith('····') || v.startsWith('****'); if (a && !isMasked(a)) patch.upbitAccessKey = a; if (s && !isMasked(s)) patch.upbitSecretKey = s; if (Object.keys(patch).length) saveAppDef(patch); }} chartRealtimeSource={appDefaults.chartRealtimeSource} onChartRealtimeSource={v => saveAppDef({ chartRealtimeSource: v })} liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct} onLiveAutoTradeBudgetPct={v => saveAppDef({ liveAutoTradeBudgetPct: v })} fcmPushEnabled={appDefaults.fcmPushEnabled} onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })} displayTimezone={displayTimezone} onDisplayTimezoneChange={handleTimezoneChange} chartTimeFormat={chartTimeFormat} onChartTimeFormatChange={handleChartTimeFormatChange} tradeAlertTimeFormat={tradeAlertTimeFormat} onTradeAlertTimeFormatChange={handleTradeAlertTimeFormatChange} verificationIssueNotify={appDefaults.verificationIssueNotify} onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })} onFcmTest={async () => { const { sendFcmTest } = await import('./utils/backendApi'); const r = await sendFcmTest(); window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.'); }} initialCategory={settingsInitialCategory} /> )} {/* ── 실시간 차트 화면 ────────────────────────────────────────────── */}
setAppDownloadOpen(true)} symbol={symbol} timeframe={layoutDef.count > 1 ? activeSlotTf : timeframe} chartType={chartType} theme={theme} mode={mode} logScale={logScale} showGrid={showGrid} activeIndicators={toolbarActiveIndicators} onSymbol={s => { if (layoutDef.count > 1) { // 멀티 레이아웃: 활성 슬롯의 심볼 변경 slotRefs.current[activeSlot]?.setSymbol(s); // symbol sync 활성화된 경우 모든 슬롯 일괄 변경 if (syncOptions.symbol) { slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setSymbol(s)); } } else { setSymbol(s); setLegend(null); } }} onTimeframe={handleTimeframe} onChartType={setChartType} onTheme={handleThemeToggle} onMode={() => setMode(m => m === 'chart' ? 'trading' : 'chart')} onLogScale={() => setLogScale(v => !v)} onToggleGrid={() => setShowGrid(v => !v)} onFitContent={() => managerRef.current?.fitContent()} onScrollToNow={() => managerRef.current?.scrollToRealTime()} onAddIndicator={handleAddIndicator} onAddIndicators={handleAddIndicators} onIndicatorOrderChange={handleIndicatorListOrderChange} onRemoveByType={handleRemoveByType} onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)} onOpenIndicatorSettings={handleOpenIndicatorSettings} onAutoFib={() => managerRef.current?.drawAutoFib(80)} onClearFib={() => managerRef.current?.clearFib()} onScreenshot={handleScreenshot} onToggleStats={() => setShowStats(v => !v)} onToggleWatch={() => setShowWatch(v => !v)} onToggleAlert={() => setShowAlert(v => !v)} magnetMode={magnetMode} onToggleMagnet={() => setMagnetMode(m => m === 'off' ? 'strong' : 'off')} onFullscreen={handleFullscreen} onUndo={handleUndo} onRedo={handleRedo} canUndo={canUndo} canRedo={canRedo} onClearDrawings={() => { // 모든 드로잉 삭제 + 피보나치 삭제 + undo/redo 스택 초기화 undoStack.current = []; redoStack.current = []; setCanUndo(false); setCanRedo(false); setDrawings([]); managerRef.current?.clearFib(); managerRef.current?.clearDrawings(); }} layoutId={layoutDef.id} syncOptions={syncOptions} onLayoutSelect={handleLayoutSelect} onSyncChange={(key, val) => { setSyncOptions(prev => { const next = { ...prev, [key]: val }; saveAppDef({ syncOptions: next as unknown as Record }); return next; }); }} magnifierActive={magnifierActive} onToggleMagnifier={() => setMagnifierActive(v => !v)} marketPanelActive={showMarketPanel} onToggleMarketPanel={() => { setShowMarketPanel(v => { const next = !v; if (isMobile && next) setMobileRightOpen(false); return next; }); }} backtestActive={btMatchesChart} backtestRunning={btRunning} backtestRunningId={undefined} onRunBacktest={menuPage === 'chart' && layoutDef.count === 1 ? async (opts) => { if (opts.strategyName) setBtStrategyName(opts.strategyName); if (useUpbit && (isLoading || bars.length === 0)) return; const runSymbol = symbol; const runTf = timeframe; const r = await btRun({ ...opts, bars, timeframe: runTf, settings: btSettings, symbol: runSymbol, }); if ( r && r.symbol === runSymbol && r.timeframe === runTf && r.signals.length > 0 ) { const showPrice = appDefaults.btShowPrice; const payload = { symbol: runSymbol, timeframe: runTf, signals: r.signals, showPrice, }; btMarkerPayloadRef.current = payload; applyBacktestMarkersWhenReady(payload); } if (appDefaults.btAutoPopup) setShowBtResult(true); } : undefined} onOpenBtSettings={() => setShowBtSettings(true)} onOpenBtResults={() => setShowBtResult(true)} hasBtResult={btMatchesChart} onClearBtMarkers={() => { btMarkerPayloadRef.current = null; btClear(); managerRef.current?.clearBacktestMarkers(); }} onToggleLiveStrategy={() => setShowLivePanel(v => !v)} liveStrategyActive={showLivePanel || monitoredMarkets.length > 0} showChartQuote={useUpbit} chartLiveQuote={liveQuote} chartQuoteLoading={isLoading} chartQuoteError={error} /> {isMobile && (showMarketPanel || mobileRightOpen) && ( { setDrawings([]); managerRef.current?.clearDrawings(); }} onUndo={() => setDrawings(prev => prev.slice(0, -1))} locked={drawingsLocked} visible={drawingsVisible} onToggleLock={() => setDrawingsLocked(v => !v)} onToggleEye={() => setDrawingsVisible(v => !v)} indicatorsVisible={indicatorsVisible} magnetMode={magnetMode} snapToIndicator={snapToIndicator} drawingCount={drawings.length} indicatorCount={indicators.length} onMagnetMode={setMagnetMode} onSnapToIndicator={setSnapToIndicator} onHideDrawings={() => setDrawingsVisible(v => !v)} onHideIndicators={() => setIndicatorsVisible(v => !v)} onHideAll={() => { setDrawingsVisible(false); setIndicatorsVisible(false); }} onClearIndicators={() => setIndicators([])} keepLockedOnDelete={keepLockedOnDelete} onToggleKeepLocked={() => setKeepLockedOnDelete(v => !v)} /> {/* ── 멀티 레이아웃: 슬롯 2개 이상일 때 별도 그리드 렌더 ─────────── */} {layoutDef.count > 1 && (
{/* 슬롯 0 = 메인 차트 영역 (a) */}
{ slotRefs.current[0] = el; }} slotIndex={0} theme={theme} active={activeSlot === 0} onActivate={() => handleSetActiveSlot(0)} initialSymbol={slot0InitProps?.symbol} initialTimeframe={slot0InitProps?.timeframe} initialIndicators={slot0InitProps?.indicators} forcedSymbol={syncOptions.symbol ? symbol : undefined} forcedTf={syncOptions.interval ? timeframe : undefined} syncTime={syncOptions.crosshair ? syncTime : undefined} onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }} syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined} onVisibleRangeChange={handleVisibleRangeChange} syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined} onLogicalRangeChange={handleLogicalRangeChange} onFullView={sym => handleSlotFullView(sym, 0)} magnifierEnabled={magnifierActive && activeSlot === 0} onMagnifierClose={() => setMagnifierActive(false)} onIndicatorsChange={activeSlot === 0 ? handleActiveSlotIndicatorsChange : undefined} onSymbolChange={activeSlot === 0 ? handleActiveSlotSymbolChange : undefined} magnetMode={magnetMode} onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} chartCandleAreaPriceLabels={chartCandleAreaPriceLabels} chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} chartPaneSeparator={chartPaneSeparator} chartCrosshairInfoVisible={chartCrosshairInfoVisible} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} marketTickers={marketTickers} compactMode chartVisible={chartVisible} />
{/* 슬롯 1~7 */} {(['b','c','d','e','f','g','h'] as const) .slice(0, layoutDef.count - 1) .map((area, i) => (
{ slotRefs.current[i + 1] = el; }} slotIndex={i + 1} theme={theme} active={activeSlot === i + 1} onActivate={() => handleSetActiveSlot(i + 1)} initialSymbol={slotInitProps[i + 1]?.symbol} initialTimeframe={slotInitProps[i + 1]?.timeframe} initialIndicators={slotInitProps[i + 1]?.indicators} forcedSymbol={syncOptions.symbol ? symbol : undefined} forcedTf={syncOptions.interval ? timeframe : undefined} syncTime={syncOptions.crosshair ? syncTime : undefined} onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }} syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined} onVisibleRangeChange={handleVisibleRangeChange} syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined} onLogicalRangeChange={handleLogicalRangeChange} onFullView={sym => handleSlotFullView(sym, i + 1)} magnifierEnabled={magnifierActive && activeSlot === i + 1} onMagnifierClose={() => setMagnifierActive(false)} onIndicatorsChange={activeSlot === i + 1 ? handleActiveSlotIndicatorsChange : undefined} onSymbolChange={activeSlot === i + 1 ? handleActiveSlotSymbolChange : undefined} magnetMode={magnetMode} onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} chartCandleAreaPriceLabels={chartCandleAreaPriceLabels} chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} chartPaneSeparator={chartPaneSeparator} chartCrosshairInfoVisible={chartCrosshairInfoVisible} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} marketTickers={marketTickers} compactMode chartVisible={chartVisible} />
))}
)} {/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */}
1 ? 'none' : undefined }}> syncBacktestMarkersRef.current()} onCandlesReady={handleCandlesReady} onManagerReady={mgr => { managerRef.current = mgr; const pending = pendingRealtimeBarRef.current; if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbol && bars.length > 0) { pendingRealtimeBarRef.current = null; const last = bars[bars.length - 1]; if (last && pending.time === last.time) mgr.updateBar(pending); else if (!last || pending.time > last.time) void mgr.appendBar(pending); else mgr.updateBar({ ...pending, time: last.time }); } else { pendingRealtimeBarRef.current = null; } mgr.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels); mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels); const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight); mgr.setPaneSeparatorOptions(chartPaneSeparator); mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); syncBacktestMarkersRef.current(); // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 mgr.subscribeVisibleLogicalRange(r => { if (r && r.from < LOAD_MORE_TRIGGER) { singleLoadMoreRef.current(); } }); }} onAddDrawing={d => pushDrawing([...drawings, d])} onSeriesClick={(entryId, point) => { if (!entryId) { setCtxToolbar(null); return; } setCtxToolbar({ entryId, screenX: point.x, screenY: point.y }); setDrawingCtxToolbar(null); // 드로잉 툴바 닫기 }} onSeriesDoubleClick={(entryId) => { setCtxToolbar(null); if (!entryId) return; if (entryId === '__main__') { setShowMainChartModal(true); } else { setSettingsModalId(entryId); } }} onHoverPaneLegend={handleHoverPaneLegend} onLeavePaneLegend={handleLeavePaneLegend} selectedDrawingId={selectedDrawingId} onDrawingClick={handleDrawingClick} onDrawingDoubleClick={handleDrawingDoubleClick} magnifierEnabled={magnifierActive} onMagnifierClose={() => setMagnifierActive(false)} onReorderIndicators={handleReorderIndicators} onMergeIndicators={handleMergeIndicators} onSplitIndicatorPane={handleSplitIndicatorPane} onExpandIndicator={handleExpandIndicator} onRemoveIndicator={handleRemoveIndicatorById} onDuplicateIndicator={handleDuplicateIndicator} focusedIndicatorId={focusedIndicatorId} onRestoreIndicators={handleRestoreIndicators} onToggleIndicatorHidden={handleToggleIndicatorHidden} onOpenIndicatorSettings={id => setSettingsModalId(id)} volumeVisible={chartVolumeVisible} candleAreaPriceLabelsEnabled={chartCandleAreaPriceLabels} indicatorAreaPriceLabelsEnabled={chartSeriesPriceLabels} paneSeparatorOptions={chartPaneSeparator} /> {isSingleLoadingMore && (
과거 데이터 로딩...
)} {/* 백테스팅 결과 통계 배지 */} {btMatchesChart && layoutDef.count === 1 && ( { btMarkerPayloadRef.current = null; btClear(); managerRef.current?.clearBacktestMarkers(); }} /> )} {/* 실시간 전략 체크 패널 */} {showLivePanel && menuPage === 'chart' && layoutDef.count === 1 && ( { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }} onSettingsChange={handleLiveSettingsChange} onClose={() => setShowLivePanel(false)} /> )} {/* 백테스팅 설정 모달 */} {showBtSettings && ( setShowBtSettings(false)} onSettingsChange={s => { setBtSettings(s); setShowBtSettings(false); }} /> )} {/* 백테스팅 결과 모달 */} {showBtResult && ( setShowBtResult(false)} /> )} {/* 시리즈 클릭/호버 컨텍스트 툴바 */} {ctxToolbar && (() => { const cfg = ctxToolbar.entryId !== '__main__' ? indicators.find(i => i.id === ctxToolbar.entryId) ?? null : null; return ( { const id = ctxToolbar.entryId; setCtxToolbar(null); if (id === '__main__') { setShowMainChartModal(true); } else { setSettingsModalId(id); } }} onToggleHidden={() => { handleToggleIndicatorHidden(ctxToolbar.entryId); }} onRemove={() => handleRemoveIndicatorById(ctxToolbar.entryId)} onClose={() => setCtxToolbar(null)} /> ); })()} {/* 드로잉 컨텍스트 툴바 */} {drawingCtxToolbar && (() => { const d = drawings.find(dr => dr.id === drawingCtxToolbar.id); if (!d) return null; return ( { setDrawingCtxToolbar(null); setDrawingSettingsId(d.id); }} onToggleVisible={() => { setDrawings(prev => prev.map(dr => dr.id === d.id ? { ...dr, visible: dr.visible !== false ? false : true } : dr ) ); setDrawingCtxToolbar(null); }} onRemove={() => { pushDrawing(drawings.filter(dr => dr.id !== d.id)); setDrawingCtxToolbar(null); setSelectedDrawingId(null); }} onClose={() => { setDrawingCtxToolbar(null); setSelectedDrawingId(null); }} /> ); })()} {/* 드로잉 설정 모달 */} {drawingSettingsId && (() => { const d = drawings.find(dr => dr.id === drawingSettingsId); if (!d) return null; const handleSaveDrawing = (updated: typeof d) => { setDrawings(prev => prev.map(dr => dr.id === updated!.id ? updated! : dr)); setDrawingSettingsId(null); setSelectedDrawingId(updated!.id); }; const handleCancelDrawing = () => { setDrawingSettingsId(null); }; if (d.type === 'fibtz') { return ( ); } return ( { setDrawings(prev => prev.map(dr => dr.id === updated.id ? updated : dr)); setDrawingSettingsId(null); setSelectedDrawingId(updated.id); }} onCancel={() => { setDrawingSettingsId(null); }} /> ); })()} {/* 메인 차트 설정 모달 */} {showMainChartModal && ( setShowMainChartModal(false)} /> )} {/* 보조지표 일괄 설정 */} {showBulkIndSettings && ( setShowBulkIndSettings(false)} /> )} {/* 인디케이터 설정 모달 */} {settingsModalId && settingsModalId !== '__main__' && (() => { let cfg: IndicatorConfig | null = null; if (isIndicatorSettingsTemplateId(settingsModalId)) { const type = indicatorTypeFromSettingsId(settingsModalId); if (!type) return null; cfg = buildMainIndicatorConfig( type, chartIndicatorsForSettings, getParams, getVisualConfig, ); } else { const onChart = chartIndicatorsForSettings.find(i => i.id === settingsModalId); if (onChart) { cfg = initializeIndicatorConfigForEditor(onChart, getParams, getVisualConfig); } } if (!cfg) return null; return ( setSettingsModalId(null)} /> ); })()}
{/* ── 우측 슬라이드 패널 (매수/매도 + 호가) ───────────────────── */} { refreshPaperAccount(symbol); handlePaperOrderFilled(); }} />
{/* ── 플로팅 패널: position:fixed로 항상 표시 (싱글/멀티 모드 공통) ─── */} {showObjectTree && ( setDrawings(prev => prev.map(d => d.id === id ? { ...d, visible: d.visible !== false ? false : true } : d) )} onRemove={id => setDrawings(prev => prev.filter(d => d.id !== id))} onClose={() => setShowObjectTree(false)} /> )} {showStats && setShowStats(false)} />} {showWatch && ( { setSymbol(s); setShowWatch(false); }} onAdd={s => { setWatchlist(prev => [...new Set([...prev, s])]); addWatchlistItem({ symbol: s }).catch(() => {}); }} onRemove={s => { setWatchlist(prev => prev.filter(x => x !== s)); removeWatchlistItem(s).catch(() => {}); }} onClose={() => setShowWatch(false)} /> )} {showAlert && ( { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }} onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }} onClose={() => setShowAlert(false)} /> )} {isMobile && ( { setShowMarketPanel(v => { const next = !v; if (next) setMobileRightOpen(false); return next; }); }} onOpenTrade={() => { setShowMarketPanel(false); setMobileRightTab('trade'); setMobileRightOpen(true); }} onOpenOrderbook={() => { setShowMarketPanel(false); setMobileRightTab('orderbook'); setMobileRightOpen(true); }} onClosePanels={() => { setShowMarketPanel(false); setMobileRightOpen(false); }} onTool={setDrawingTool} /> )} 1 ? activeSlotTf : timeframe} logScale={logScale} percentScale={percentScale} showGrid={showGrid} lastTime={bars[bars.length - 1]?.time ?? 0} displayTimezone={displayTimezone} onTimezoneChange={handleTimezoneChange} onTimeframe={handleTimeframe} onLogScale={() => setLogScale(v => !v)} onPercentScale={() => setPercentScale(v => !v)} onAutoScale={() => managerRef.current?.autoScale()} onFitContent={() => managerRef.current?.fitContent()} onToggleGrid={() => setShowGrid(v => !v)} onAddIndicators={handleAddIndicators} onAddIndicator={handleAddIndicator} />
{/* chart-screen */} { setVerificationFocusIssueId(issueId); guardedSetMenuPage('verification-board'); }} tradingMode={appDefaults.tradingMode} hasUpbitKeys={appDefaults.hasUpbitKeys} paperTradingEnabled={paperTradingEnabled} liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct} paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} onOrderDone={() => { refreshPaperAccount(symbol); handlePaperOrderFilled(); }} />
); } export default App;