diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8873cc2..597de4f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,156 +1,22 @@ -import React, { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo, lazy, Suspense } 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 { - type ChartOverlayToggleKey, - type ChartOverlayVisibility, - chartOverlayKeyForType, - DEFAULT_CHART_OVERLAY_VISIBILITY, - listChartOverlayToggleItems, -} from './utils/chartOverlayVisibility'; -import { - type ChartCustomOverlaySelection, - type ChartCustomOverlaySlotId, - createDefaultChartCustomOverlaySelection, - isCustomOverlaySlotActive, - resolveActiveCustomOverlaySlot, - syncChartManagerCustomOverlaysByActiveSlot, -} from './utils/chartCustomOverlay'; -import ChartCustomOverlaySettingsModal from './components/ChartCustomOverlaySettingsModal'; -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 React, { useState, useEffect, useCallback, lazy, Suspense } from 'react'; 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 { getAppSettingsCache, 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 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 type { LiveMarker } from './hooks/useLiveStrategyMarkers'; import { FormalLoginGate } from './components/FormalLoginGate'; import { isFormalLogin, isTradingMenuPage, - isTradingSettingsCategory, FORMAL_LOGIN_REQUIRED_MSG, } from './utils/tradingAccess'; -import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings'; +import { NotifyTopMenuBar } from './components/NotifyUiBindings'; import { AppNotificationLayer } from './components/AppNotificationLayer'; -import BacktestSettingsModal from './components/BacktestSettingsModal'; -import BacktestAnalysisReportModal from './components/backtest/BacktestAnalysisReportModal'; -import { buildChartBacktestReportModel } from './utils/backtestReportModel'; -import { fmtListTimestamp } from './utils/backtestUiUtils'; - -const DashboardPage = lazy(() => import('./components/DashboardPage')); -const StrategyPage = lazy(() => import('./components/StrategyPage')); -const StrategyEditorPage = lazy(() => import('./components/StrategyEditorPage')); -const TradeNotificationListPage = lazy(() => import('./components/TradeNotificationListPage')); -const BacktestHistoryPage = lazy(() => import('./components/BacktestHistoryPage').then(m => ({ default: m.BacktestHistoryPage }))); -const AnalysisReportPage = lazy(() => import('./components/analysisReport/AnalysisReportPage').then(m => ({ default: m.AnalysisReportPage }))); -const SettingsPage = lazy(() => import('./components/SettingsPage')); -const PaperTradingPage = lazy(() => import('./components/PaperTradingPage')); -const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage')); -const TrendSearchPage = lazy(() => import('./components/TrendSearchPage')); -const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage')); - -function PageLoadFallback() { - return
화면 로딩 중…
; -} -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 { Theme } from './types'; import type { ChartRealtimeSource } from './hooks/useChartRealtimeData'; -import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; -import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; -import { useIsMobile, useIsCompactDevice } from './hooks/useMediaQuery'; -import { isTickerFeedPage } from './utils/tickerFeedPages'; -import MobileChartDock from './components/MobileChartDock'; +import { useIsMobile } from './hooks/useMediaQuery'; import LoginModal from './components/LoginModal'; import AppDownloadModal from './components/AppDownloadModal'; import SplashScreen from './components/SplashScreen'; @@ -168,6 +34,8 @@ import { clearAdminUnlock } from './utils/adminUnlock'; import type { LoginResponse } from './utils/backendApi'; import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; +import { ChartWorkspaceRoot } from './chart/ChartWorkspaceRoot'; +import { useChartWorkspaceContext } from './chart/ChartWorkspaceContext'; import './App.css'; import './styles/appPopup.css'; import './styles/paperDashboard.css'; @@ -175,24 +43,397 @@ import './styles/tradeRightPanel.css'; import './styles/virtualTradingDashboard.css'; import './styles/backtestDashboard.css'; -let _indCounter = 0; -function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; } +const DashboardPage = lazy(() => import('./components/DashboardPage')); +const StrategyPage = lazy(() => import('./components/StrategyPage')); +const StrategyEditorPage = lazy(() => import('./components/StrategyEditorPage')); +const TradeNotificationListPage = lazy(() => import('./components/TradeNotificationListPage')); +const BacktestHistoryPage = lazy(() => import('./components/BacktestHistoryPage').then(m => ({ default: m.BacktestHistoryPage }))); +const AnalysisReportPage = lazy(() => import('./components/analysisReport/AnalysisReportPage').then(m => ({ default: m.AnalysisReportPage }))); +const SettingsPage = lazy(() => import('./components/SettingsPage')); +const PaperTradingPage = lazy(() => import('./components/PaperTradingPage')); +const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage')); +const TrendSearchPage = lazy(() => import('./components/TrendSearchPage')); +const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage')); -/** 모든 .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); +function PageLoadFallback() { + return
화면 로딩 중…
; } -// ─── App ─────────────────────────────────────────────────────────────────── -function App() { - const initial = loadState(); +function AppMainContent({ + theme, + onThemeToggle, + sessionKey, + formalLogin, + requireFormalLogin, + authUser, + guestMode, + loginOpen, + setLoginOpen, + appDownloadOpen, + setAppDownloadOpen, + menuPage, + setMenuPage, + guardedSetMenuPage, + settingsInitialCategory, + setSettingsInitialCategory, + verificationFocusIssueId, + setVerificationFocusIssueId, + menuPermissions, + canMenu, + appDefaults, + appSettingsLoaded, + saveAppDef, + getIndicatorParams, + saveIndicatorParams, + getIndicatorVisual, + saveIndicatorVisual, + saveAllIndicatorDefaults, + indicatorSettingsRevision, + handleLoginSuccess, + handleLogout, +}: { + theme: Theme; + onThemeToggle: () => void; + sessionKey: number; + formalLogin: boolean; + requireFormalLogin: () => void; + authUser: AuthSession | null; + guestMode: boolean; + loginOpen: boolean; + setLoginOpen: (v: boolean) => void; + appDownloadOpen: boolean; + setAppDownloadOpen: (v: boolean) => void; + menuPage: MenuPage; + setMenuPage: (p: MenuPage) => void; + guardedSetMenuPage: (p: MenuPage) => void; + settingsInitialCategory: SettingsCategoryId | undefined; + setSettingsInitialCategory: (c: SettingsCategoryId | undefined) => void; + verificationFocusIssueId: number | null; + setVerificationFocusIssueId: (id: number | null) => void; + menuPermissions: ReturnType['permissions']; + canMenu: ReturnType['can']; + appDefaults: ReturnType['defaults']; + appSettingsLoaded: boolean; + saveAppDef: ReturnType['save']; + getIndicatorParams: ReturnType['getParams']; + saveIndicatorParams: ReturnType['saveParams']; + getIndicatorVisual: ReturnType['getVisualConfig']; + saveIndicatorVisual: ReturnType['saveVisual']; + saveAllIndicatorDefaults: ReturnType['saveAllIndicatorDefaults']; + indicatorSettingsRevision: number; + handleLoginSuccess: (res: LoginResponse) => void; + handleLogout: () => void; +}) { + const isMobile = useIsMobile(); + const { + symbol, + indicators, + goToMarketChart, + refreshPaperAccount, + handlePaperOrderFilled, + paperRefreshKey, + mode, + marketTickers, + settingsPageProps, + chartScreen, + } = useChartWorkspaceContext(); + const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; + const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; + + const handleFullscreen = () => { + if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {}); + else document.exitFullscreen().catch(() => {}); + }; + + return ( + +
+ 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 })} + onFullscreen={handleFullscreen} + /> + + setLoginOpen(false)} + onSuccess={handleLoginSuccess} + /> + + {appDownloadOpen && ( + setAppDownloadOpen(false)} /> + )} + + {menuPage === 'dashboard' && ( + }> + { goToMarketChart(m); setMenuPage('chart'); }} /> + + )} + + {menuPage === 'strategy' && ( + formalLogin + ? ( + }> + + + ) + : + )} + + {menuPage === 'strategy-editor' && ( + formalLogin + ? ( + }> + + + ) + : + )} + + {menuPage === 'backtest' && ( + formalLogin + ? ( + }> + + + ) + : + )} + + {menuPage === 'analysis-report' && ( + formalLogin + ? ( + }> + + + ) + : + )} + + {menuPage === 'paper' && ( + formalLogin ? ( + }> + { + if (!formalLogin) { requireFormalLogin(); return; } + setSettingsInitialCategory('trading'); + setMenuPage('settings'); + }} + onGoChart={m => { + goToMarketChart(m); + setMenuPage('chart'); + }} + /> + + ) : ( + + ) + )} + + {menuPage === 'virtual' && ( + formalLogin ? ( + }> + saveAppDef({ paperAutoTradeEnabled: v })} + onPaperOrderFilled={handlePaperOrderFilled} + /> + + ) : ( + + ) + )} + + {menuPage === 'trend-search' && ( + }> + + + )} + + {menuPage === 'verification-board' && ( + }> + setVerificationFocusIssueId(null)} + /> + + )} + + {menuPage === 'notifications' && canMenu('notifications') && ( + formalLogin ? ( + }> + { + goToMarketChart(market); + setMenuPage('chart'); + }} + /> + + ) : ( + + ) + )} + + {menuPage === 'settings' && canMenu('settings') && ( + }> + saveAppDef({ btAutoPopup: v })} + btShowPrice={appDefaults.btShowPrice} + onBtShowPrice={v => saveAppDef({ btShowPrice: v })} + liveMarket={symbol} + 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 })} + getIndicatorParams={getIndicatorParams} + saveIndicatorParams={saveIndicatorParams} + getIndicatorVisual={getIndicatorVisual} + saveIndicatorVisual={saveIndicatorVisual} + saveAllIndicatorDefaults={saveAllIndicatorDefaults} + indicatorSettingsRevision={indicatorSettingsRevision} + 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 })} + paperOrderFillMode={appDefaults.paperOrderFillMode} + onPaperOrderFillMode={v => saveAppDef({ paperOrderFillMode: v })} + paperOrderAutoCancelPct={appDefaults.paperOrderAutoCancelPct} + onPaperOrderAutoCancelPct={v => saveAppDef({ paperOrderAutoCancelPct: v })} + virtualTargetMaxCount={appDefaults.virtualTargetMaxCount} + onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })} + trendSearchSettings={appDefaults.trendSearchSettings} + onTrendSearchSettingsChange={s => saveAppDef({ + trendSearchSettings: resolveTrendSearchAppSettings(s), + })} + 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 })} + 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} + {...settingsPageProps} + /> + + )} + + {chartScreen} + + { + 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(); + }} + /> +
+
+ ); +} + +function App() { const [sessionKey, setSessionKey] = useState(0); const [appEntered, setAppEnteredState] = useState(() => isAppEntered()); const [authUser, setAuthUser] = useState(() => @@ -210,7 +451,6 @@ function App() { }, []); const [appDownloadOpen, setAppDownloadOpen] = useState(false); - // 전역 지표 파라미터 + 시각 설정 DB 동기화 const { getParams: getIndicatorParams, saveParams: saveIndicatorParams, @@ -224,383 +464,17 @@ function App() { 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(loadLocalTheme); - const [mode, setMode] = useState('chart'); - const [logScale, setLogScale] = useState(false); - const [indicators, setIndicators] = useState(initial.indicators ?? []); - const indicatorsRef = useRef(indicators); - useEffect(() => { indicatorsRef.current = indicators; }, [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 [theme, setTheme] = useState(loadLocalTheme); 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 isCompactDevice = useIsCompactDevice(); - const tickerFeedActive = isTickerFeedPage(menuPage); - - const priorityMarketsRef = useRef([symbol]); - const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []); - /** enabled=차트·매매 화면만. lite=태블릿. loadFull=마켓 패널 열림 */ - const marketTickerOpts = useMemo(() => ({ - enabled: tickerFeedActive, - lite: isCompactDevice, - loadFull: showMarketPanel && tickerFeedActive, - getPriorityMarkets, - }), [tickerFeedActive, isCompactDevice, showMarketPanel, getPriorityMarkets]); - const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts); - - // ── 레이아웃 상태 ───────────────────────────────────────────────────── - 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 chartScreenActive = menuPage === 'chart'; - const orderbookEnabled = chartScreenActive && isUpbitMarket(chartSymbol); - const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol, orderbookEnabled); - 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()])]; - }); - priorityMarketsRef.current = useMemo( - () => [symbol, ...watchlist], - [symbol, watchlist], - ); - /** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */ - const [favoritesList, setFavoritesList] = useState(getFavorites); - // DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작) - const [dbLoaded, setDbLoaded] = useState(false); const enterMainApp = useCallback(() => { setAppEntered(); @@ -617,7 +491,6 @@ function App() { setAuthSession(session); setAuthUser(session); setGuestMode(false); - setDbLoaded(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); @@ -630,7 +503,6 @@ function App() { clearAuthSession(); setAuthUser(null); setGuestMode(true); - setDbLoaded(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); @@ -643,7 +515,6 @@ function App() { clearAuthSession(); setAuthUser(null); setGuestMode(false); - setDbLoaded(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); @@ -678,1042 +549,6 @@ function App() { } }, [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 chartHoverToolbarVisible = appDefaults.chartHoverToolbarVisible ?? 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 || !formalLogin) return; - refreshPaperAccount(); - }, [appSettingsLoaded, formalLogin, 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 || !formalLogin || !paperTradingEnabled) return; - refreshPaperAccount(symbol); - }, [symbol, appSettingsLoaded, formalLogin, 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('전략'); - const [btReportCreatedAt, setBtReportCreatedAt] = 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(() => { - if (!formalLogin) { - setActiveLiveSettings([]); - return; - } - refreshActiveLiveSettings(); - }, [formalLogin, 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 || !formalLogin) 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, formalLogin, 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 (!formalLogin) return []; - if (isVirtualActive) return virtualMonitoredMarkets; - if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; - const set = new Set(watchlist); - if (symbol) set.add(symbol); - return [...set]; - }, [formalLogin, 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 (!formalLogin) { - setMarketSubscriptions([]); - return; - } - 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 폴백 */ }); - }, [formalLogin, 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 => { - const rows = list - .filter(s => s.id != null) - .map(s => ({ id: s.id as number, name: s.name })); - setLiveStrategies(rows); - const sid = appDefaults.liveStrategyId; - if (sid != null && !rows.some(r => r.id === sid)) { - saveAppDef({ liveStrategyId: undefined, liveStrategyCheck: false }); - } - }) - .catch(() => {}); - }, [appDefaults.liveStrategyId, saveAppDef]); - - useEffect(() => { - if (!formalLogin) { - setLiveStrategies([]); - return; - } - refreshLiveStrategies(); - }, [menuPage, showLivePanel, formalLogin, refreshLiveStrategies]); - - const liveNotifierRef = useRef(null); - const liveMarkersRef = useRef([]); - const clearLiveMarkers = useCallback(() => { - liveMarkersRef.current = []; - 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; - const apply = () => { - managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator); - managerRef.current?.refreshPaneSeparatorOverlay(); - slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator)); - }; - apply(); - if (menuPage !== 'chart') return; - const raf = requestAnimationFrame(() => requestAnimationFrame(apply)); - return () => cancelAnimationFrame(raf); - }, [appSettingsLoaded, menuPage, 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]); - - // 금액 표시 토글 변경 시 실시간 시그널 마커 텍스트 재적용 - useEffect(() => { - if (menuPage !== 'chart' || liveMarkersRef.current.length === 0) return; - managerRef.current?.setLiveStrategyMarkers(liveMarkersRef.current, appDefaults.btShowPrice); - }, [menuPage, appDefaults.btShowPrice]); - - // ── 시뮬레이션 데이터 (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: chartScreenActive && useUpbit, - source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT', - }), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartRealtimeSource]), - ); - - // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) - const bars = useUpbit ? upbitBars : simBars; - const barsMarket = useUpbit ? upbitBarsMarket : symbol; - barsMarketRef.current = barsMarket; - - const chartBtReportModel = useMemo(() => { - if (!btMatchesChart) return null; - const reportSymbol = btSymbol ?? symbol; - const reportTf = btTimeframe ?? timeframe; - return buildChartBacktestReportModel({ - analysis: btAnalysis, - stats: btStats, - signals: btSignals, - strategyName: btStrategyName, - symbol: reportSymbol, - timeframe: reportTf, - barCount: bars.length, - createdAt: btReportCreatedAt, - }); - }, [ - btMatchesChart, - btAnalysis, - btStats, - btSignals, - btStrategyName, - btSymbol, - btTimeframe, - symbol, - timeframe, - bars.length, - btReportCreatedAt, - ]); - - /** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (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, mode, logScale, - indicators, drawings, drawingsLocked, drawingsVisible, - mainChartStyle, - }], - }), [layoutDef.id, syncOptions, symbol, timeframe, chartType, - 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.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); - }, []); - - const [overlayVisibility, setOverlayVisibility] = useState( - () => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }), - ); - const overlayVisibilityRef = useRef(overlayVisibility); - useEffect(() => { - overlayVisibilityRef.current = overlayVisibility; - }, [overlayVisibility]); - - const candleOverlayToggles = useMemo( - () => listChartOverlayToggleItems(overlayVisibility), - [overlayVisibility], - ); - - const [customOverlaySelection, setCustomOverlaySelection] = useState( - () => createDefaultChartCustomOverlaySelection(), - ); - const [custom1OverlaySelection, setCustom1OverlaySelection] = useState( - () => createDefaultChartCustomOverlaySelection(), - ); - const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState(null); - const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState(null); - const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom'); - const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1'); - - const syncCustomOverlaysToManager = useCallback((mgr: import('./utils/ChartManager').ChartManager) => { - syncChartManagerCustomOverlaysByActiveSlot( - mgr, - activeCustomOverlaySlot, - customOverlaySelection, - custom1OverlaySelection, - ); - }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); - - const handleCustomOverlayActiveChange = useCallback((active: boolean) => { - const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot); - setActiveCustomOverlaySlot(nextSlot); - const mgr = managerRef.current; - if (mgr) { - syncChartManagerCustomOverlaysByActiveSlot( - mgr, - nextSlot, - customOverlaySelection, - custom1OverlaySelection, - ); - } - }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); - - const handleCustom1OverlayActiveChange = useCallback((active: boolean) => { - const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot); - setActiveCustomOverlaySlot(nextSlot); - const mgr = managerRef.current; - if (mgr) { - syncChartManagerCustomOverlaysByActiveSlot( - mgr, - nextSlot, - customOverlaySelection, - custom1OverlaySelection, - ); - } - }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); - - const handleSaveCustomOverlaySelection = useCallback(( - slot: ChartCustomOverlaySlotId, - selection: ChartCustomOverlaySelection, - ) => { - const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection; - const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection; - if (slot === 'custom') setCustomOverlaySelection(selection); - else setCustom1OverlaySelection(selection); - setCustomOverlaySettingsSlot(null); - const mgr = managerRef.current; - if (!mgr) return; - syncChartManagerCustomOverlaysByActiveSlot( - mgr, - activeCustomOverlaySlot, - nextCustomSel, - nextCustom1Sel, - ); - }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); - - const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { - setOverlayVisibility(prev => { - const next = { ...prev, [key]: !prev[key] }; - const mgr = managerRef.current; - mgr?.setChartOverlayGroupVisible(key, next[key]); - if (mgr) { - for (const info of mgr.getIndicatorPaneInfo()) { - if (chartOverlayKeyForType(info.type) !== key) continue; - const reactInd = indicatorsRef.current.find(i => i.id === info.id); - mgr.applyIndicatorStyle(reactInd - ? { ...info.config, ...reactInd, id: info.id, type: info.type } - : info.config); - } - } - return next; - }); - }, []); - - /** - * 보조지표 순서 변경 (드래그 핸들): 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 (기기별 localStorage) const handleThemeToggle = useCallback(() => { setTheme(t => { const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark'; @@ -1722,185 +557,20 @@ function App() { }); }, []); - // 최초 이 기기: DB defaultTheme → localStorage 1회만 (이후 기기별 독립) + useEffect(() => { + syncDocumentTheme(theme); + }, [theme]); + useEffect(() => { if (!appSettingsLoaded) return; setTheme(migrateDbThemeToLocalIfNeeded(getAppSettingsCache().defaultTheme)); }, [appSettingsLoaded]); - 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], ); - const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]); - if (!appEntered) { return ( - setAppDownloadOpen(true)} > -
- 0 && appSettingsLoaded} - popupEnabled={appDefaults.tradeAlertPopup ?? true} - chartMarkersActive={menuPage === 'chart'} - onMarkersChange={markers => { - liveMarkersRef.current = markers; - managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice); - }} - /> - - {/* ── 최상단 글로벌 메뉴바 ─────────────────────────────────────── */} - setAppDownloadOpen(true)} + onThemeToggle={handleThemeToggle} + sessionKey={sessionKey} + formalLogin={formalLogin} + requireFormalLogin={requireFormalLogin} 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 })} - onFullscreen={handleFullscreen} - /> - - setLoginOpen(false)} - onSuccess={handleLoginSuccess} - /> - - {appDownloadOpen && ( - setAppDownloadOpen(false)} /> - )} - - {/* ── 투자전략 화면 ──────────────────────────────────────────────── */} - {menuPage === 'dashboard' && ( - }> - { goToMarketChart(m); setMenuPage('chart'); }} /> - - )} - - {menuPage === 'strategy' && ( - formalLogin - ? ( - }> - - - ) - : - )} - - {menuPage === 'strategy-editor' && ( - formalLogin - ? ( - }> - - - ) - : - )} - - {/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */} - {menuPage === 'backtest' && ( - formalLogin - ? ( - }> - - - ) - : - )} - - {menuPage === 'analysis-report' && ( - formalLogin - ? ( - }> - - - ) - : - )} - - {menuPage === 'paper' && ( - formalLogin ? ( - }> - { - if (!formalLogin) { requireFormalLogin(); return; } - setSettingsInitialCategory('trading'); - setMenuPage('settings'); - }} - onGoChart={m => { - goToMarketChart(m); - setMenuPage('chart'); - }} - /> - - ) : ( - - ) - )} - - {menuPage === 'virtual' && ( - formalLogin ? ( - }> - saveAppDef({ paperAutoTradeEnabled: v })} - onPaperOrderFilled={handlePaperOrderFilled} - /> - - ) : ( - - ) - )} - - {menuPage === 'trend-search' && ( - }> - - - )} - - {menuPage === 'verification-board' && ( - }> - setVerificationFocusIssueId(null)} - /> - - )} - - {/* ── 설정 화면 ──────────────────────────────────────────────────── */} - {menuPage === 'notifications' && canMenu('notifications') && ( - formalLogin ? ( - }> - { - 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 })} - chartHoverToolbarVisible={chartHoverToolbarVisible} - onChartHoverToolbarVisible={v => saveAppDef({ chartHoverToolbarVisible: v })} - chartPaneSeparator={chartPaneSeparator} - onChartPaneSeparatorChange={opts => { - saveAppDef({ chartPaneSeparator: opts }); - managerRef.current?.setPaneSeparatorOptions(opts); - managerRef.current?.refreshPaneSeparatorOverlay(); - 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 })} - paperOrderFillMode={appDefaults.paperOrderFillMode} - onPaperOrderFillMode={v => saveAppDef({ paperOrderFillMode: v })} - paperOrderAutoCancelPct={appDefaults.paperOrderAutoCancelPct} - onPaperOrderAutoCancelPct={v => saveAppDef({ paperOrderAutoCancelPct: 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')} - 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 && formalLogin - ? 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); - setBtReportCreatedAt(fmtListTimestamp(new Date().toISOString())); - } - if (appDefaults.btAutoPopup) setShowBtResult(true); - } - : undefined} - onOpenBtSettings={() => { - if (!formalLogin) { requireFormalLogin(); return; } - setShowBtSettings(true); - }} - onOpenBtResults={() => { - if (!formalLogin) { requireFormalLogin(); return; } - setShowBtResult(true); - }} - hasBtResult={btMatchesChart} - onClearBtMarkers={() => { - btMarkerPayloadRef.current = null; - btClear(); - managerRef.current?.clearBacktestMarkers(); - }} - onToggleLiveStrategy={() => { - if (!formalLogin) { requireFormalLogin(); return; } - setShowLivePanel(v => !v); - }} - liveStrategyActive={formalLogin && (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} - chartHoverToolbarVisible={chartHoverToolbarVisible} - 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} - chartHoverToolbarVisible={chartHoverToolbarVisible} - chartLiveReceiveHighlight={chartLiveReceiveHighlight} - chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} - displayTimezone={displayTimezone} - marketTickers={marketTickers} - compactMode - chartVisible={chartVisible} - /> -
- ))} -
- )} - - {/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */} -
1 ? 'none' : undefined }}> - - - { - const mgr = managerRef.current; - mgr?.setChartOverlayVisibility(overlayVisibilityRef.current); - if (mgr) syncCustomOverlaysToManager(mgr); - 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); - mgr.setChartOverlayVisibility(overlayVisibilityRef.current); - syncCustomOverlaysToManager(mgr); - 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} - candleOverlayToggles={candleOverlayToggles} - onToggleCandleOverlay={handleToggleCandleOverlay} - customOverlayActive={customOverlayActive} - onCustomOverlayActiveChange={handleCustomOverlayActiveChange} - onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')} - custom1OverlayActive={custom1OverlayActive} - onCustom1OverlayActiveChange={handleCustom1OverlayActiveChange} - onOpenCustom1OverlaySettings={() => setCustomOverlaySettingsSlot('custom1')} - showHoverToolbar={chartHoverToolbarVisible} - /> - {customOverlaySettingsSlot === 'custom' && ( - handleSaveCustomOverlaySelection('custom', sel)} - onCancel={() => setCustomOverlaySettingsSlot(null)} - /> - )} - {customOverlaySettingsSlot === 'custom1' && ( - handleSaveCustomOverlaySelection('custom1', sel)} - onCancel={() => setCustomOverlaySettingsSlot(null)} - /> - )} - {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); }} - /> - )} - - {/* 백테스팅 결과 — 투자분석 상세 레포트 (인쇄·PDF) */} - setShowBtResult(false)} - model={chartBtReportModel} - /> - - {/* 시리즈 클릭/호버 컨텍스트 툴바 */} - {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(); - }} + setMenuPage={setMenuPage} + guardedSetMenuPage={guardedSetMenuPage} + settingsInitialCategory={settingsInitialCategory} + setSettingsInitialCategory={setSettingsInitialCategory} + verificationFocusIssueId={verificationFocusIssueId} + setVerificationFocusIssueId={setVerificationFocusIssueId} + menuPermissions={menuPermissions} + canMenu={canMenu} + appDefaults={appDefaults} + appSettingsLoaded={appSettingsLoaded} + saveAppDef={saveAppDef} + getIndicatorParams={getIndicatorParams} + saveIndicatorParams={saveIndicatorParams} + getIndicatorVisual={getIndicatorVisual} + saveIndicatorVisual={saveIndicatorVisual} + saveAllIndicatorDefaults={saveAllIndicatorDefaults} + indicatorSettingsRevision={indicatorSettingsRevision} + handleLoginSuccess={handleLoginSuccess} + handleLogout={handleLogout} /> - -
-
+ ); } diff --git a/frontend/src/chart/ChartWorkspaceContext.tsx b/frontend/src/chart/ChartWorkspaceContext.tsx new file mode 100644 index 0000000..9924b09 --- /dev/null +++ b/frontend/src/chart/ChartWorkspaceContext.tsx @@ -0,0 +1,51 @@ +import React, { createContext, useContext } from 'react'; +import type { ChartMode, IndicatorConfig } from '../types'; +import type { TickerData } from '../hooks/useMarketTicker'; +import type { SettingsPageChartBindings } from './useChartWorkspace'; + +export interface ChartWorkspaceSharedState { + symbol: string; + indicators: IndicatorConfig[]; + goToMarketChart: (market: string) => void; + refreshPaperAccount: (forMarket?: string) => Promise; + paperCashBalance: number; + paperCoinQty: number; + handlePaperOrderFilled: () => void; + paperRefreshKey: number; + mode: ChartMode; + priorityMarkets: string[]; + marketPanelOpen: boolean; + marketTickers: Map; + marketLoading: boolean; + usdRate: number; + settingsPageProps: SettingsPageChartBindings; +} + +export interface ChartWorkspaceContextValue extends ChartWorkspaceSharedState { + /** 실시간 차트 화면 — `.app` flex 레이아웃 안에서 렌더해야 좌·우 패널·메뉴가 정상 표시됨 */ + chartScreen: React.ReactNode; +} + +const ChartWorkspaceContext = createContext(null); + +export function ChartWorkspaceProvider({ + value, + children, +}: { + value: ChartWorkspaceContextValue; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useChartWorkspaceContext(): ChartWorkspaceContextValue { + const ctx = useContext(ChartWorkspaceContext); + if (!ctx) { + throw new Error('useChartWorkspaceContext must be used within ChartWorkspaceProvider'); + } + return ctx; +} diff --git a/frontend/src/chart/ChartWorkspaceRoot.tsx b/frontend/src/chart/ChartWorkspaceRoot.tsx new file mode 100644 index 0000000..a7194ce --- /dev/null +++ b/frontend/src/chart/ChartWorkspaceRoot.tsx @@ -0,0 +1,42 @@ +import React, { forwardRef, useImperativeHandle, useMemo } from 'react'; +import { LiveSignalNotifier } from '../components/LiveSignalNotifier'; +import { ChartWorkspaceProvider } from './ChartWorkspaceContext'; +import { ChartWorkspaceView } from './ChartWorkspaceView'; +import { useChartWorkspace, type UseChartWorkspaceParams } from './useChartWorkspace'; +import type { ChartWorkspaceBridge } from './chartWorkspaceBridge'; + +export interface ChartWorkspaceRootProps extends UseChartWorkspaceParams { + visible: boolean; + children: React.ReactNode; +} + +export const ChartWorkspaceRoot = forwardRef( + function ChartWorkspaceRoot(props, ref) { + const { visible, children, ...workspaceParams } = props; + const { + bridge, + contextValue, + viewProps, + liveNotifierProps, + liveNotifierRef, + } = useChartWorkspace(workspaceParams); + + useImperativeHandle(ref, () => bridge, [bridge]); + + const providerValue = useMemo(() => ({ + ...contextValue, + chartScreen: ( +
+ +
+ ), + }), [contextValue, visible, viewProps]); + + return ( + + + {children} + + ); + }, +); diff --git a/frontend/src/chart/ChartWorkspaceView.tsx b/frontend/src/chart/ChartWorkspaceView.tsx new file mode 100644 index 0000000..0bce5ce --- /dev/null +++ b/frontend/src/chart/ChartWorkspaceView.tsx @@ -0,0 +1,1136 @@ +import React 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 { type LayoutDef, type SyncOptions } from '../utils/layoutTypes'; +import type { + OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, + ChartMode, MainChartStyle, TradeOrderFillRequest, +} from '../types'; +import type { ChartManager } from '../utils/ChartManager'; +import { LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader'; +import type { WsStatus } from '../hooks/useChartRealtimeData'; +import type { TickerData } from '../hooks/useMarketTicker'; +import type { OrderbookState } from '../hooks/useUpbitOrderbook'; +import type { BacktestStats } from '../utils/backendApi'; +import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal'; +import BacktestAnalysisReportModal from '../components/backtest/BacktestAnalysisReportModal'; +import { type MenuPage } from '../components/TopMenuBar'; +import BacktestPanel from '../components/BacktestPanel'; +import LiveStrategyPanel from '../components/LiveStrategyPanel'; +import BacktestSettingsModal from '../components/BacktestSettingsModal'; +import MarketPanel from '../components/MarketPanel'; +import ChartLegendBar from '../components/ChartLegendBar'; +import RightSidePanel from '../components/RightSidePanel'; +import ChartCustomOverlaySettingsModal from '../components/ChartCustomOverlaySettingsModal'; +import MobileChartDock from '../components/MobileChartDock'; +import { ChartToolbarNotify } from '../components/NotifyUiBindings'; +import { + buildMainIndicatorConfig, + indicatorSettingsTemplateId, + indicatorTypeFromSettingsId, + isIndicatorSettingsTemplateId, +} from '../utils/indicatorMainConfig'; +import { initializeIndicatorConfigForEditor } from '../utils/indicatorSettingsEditor'; +import type { PriceStats } from '../utils/calculations'; +import type { ChartOverlayToggleKey, ChartOverlayVisibility } from '../utils/chartOverlayVisibility'; +import type { ChartCustomOverlaySelection, ChartCustomOverlaySlotId } from '../utils/chartCustomOverlay'; +import type { ChartLegendVisibility } from '../types/chartLegend'; +import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; +import type { LiveStrategySettingsDto } from '../utils/backendApi'; +import type { BacktestSettingsDto } from '../utils/backendApi'; +import { addWatchlistItem, removeWatchlistItem } from '../utils/backendApi'; +import { fmtListTimestamp } from '../utils/backtestUiUtils'; + +export interface ChartWorkspaceViewProps { + onOpenNotificationsPage: () => void; + onOpenAppDownload: () => void; + symbol: string; + timeframe: Timeframe; + chartType: ChartType; + theme: Theme; + mode: ChartMode; + logScale: boolean; + showGrid: boolean; + toolbarActiveIndicators: IndicatorConfig[]; + layoutDef: LayoutDef; + syncOptions: SyncOptions; + activeSlot: number; + activeSlotSymbol: string; + activeSlotTf: Timeframe; + slot0InitProps: { symbol: string; timeframe: Timeframe; indicators: IndicatorConfig[] } | null; + slotInitProps: Record; + syncTime: number; + syncVisibleRange: { from: number; to: number } | null; + syncLogicalRange: { from: number; to: number } | null; + slotRefs: React.MutableRefObject<(ChartSlotHandle | null)[]>; + managerRef: React.MutableRefObject; + undoStack: React.MutableRefObject; + redoStack: React.MutableRefObject; + canUndo: boolean; + canRedo: boolean; + drawingTool: string; + drawings: Drawing[]; + drawingsLocked: boolean; + drawingsVisible: boolean; + indicatorsVisible: boolean; + magnetMode: 'off' | 'weak' | 'strong'; + snapToIndicator: boolean; + keepLockedOnDelete: boolean; + legend: LegendData | null; + showStats: boolean; + showWatch: boolean; + showAlert: boolean; + showObjectTree: boolean; + percentScale: boolean; + magnifierActive: boolean; + showMarketPanel: boolean; + mobileRightOpen: boolean; + mobileRightTab: 'trade' | 'orderbook'; + marketTickers: Map; + marketLoading: boolean; + usdRate: number; + formalLogin: boolean; + requireFormalLogin: () => void; + chartVisible: boolean; + menuPage: MenuPage; + isMobile: boolean; + indicators: IndicatorConfig[]; + effectiveIndicators: IndicatorConfig[]; + mainChartStyle: MainChartStyle; + showMainChartModal: boolean; + showBulkIndSettings: boolean; + settingsModalId: string | null; + ctxToolbar: { entryId: string; screenX: number; screenY: number } | null; + drawingCtxToolbar: { id: string; screenX: number; screenY: number } | null; + drawingSettingsId: string | null; + selectedDrawingId: string | null; + focusedIndicatorId: string | null; + overlayVisibility: ChartOverlayVisibility; + overlayVisibilityRef: React.MutableRefObject; + candleOverlayToggles: ReturnType; + customOverlaySelection: ChartCustomOverlaySelection; + custom1OverlaySelection: ChartCustomOverlaySelection; + activeCustomOverlaySlot: ChartCustomOverlaySlotId | null; + customOverlaySettingsSlot: ChartCustomOverlaySlotId | null; + customOverlayActive: boolean; + custom1OverlayActive: boolean; + chartCandleAreaPriceLabels: boolean; + chartSeriesPriceLabels: boolean; + chartVolumeVisible: boolean; + chartPaneSeparator: ChartPaneSeparatorOptions; + chartCrosshairInfoVisible: boolean; + chartHoverToolbarVisible: boolean; + chartLiveReceiveHighlight: boolean; + chartRealtimeSource: 'BACKEND_STOMP' | 'UPBIT_DIRECT'; + displayTimezone: string; + chartTimeFormat: string; + chartLegendOptions: ChartLegendVisibility; + useUpbit: boolean; + wsStatus: WsStatus; + bars: OHLCVBar[]; + barsMarket: string; + barsMarketRef: React.MutableRefObject; + chartMountKey: number; + chartLiveReadyRef: React.MutableRefObject; + pendingRealtimeBarRef: React.MutableRefObject; + isSingleLoadingMore: boolean; + singleLoadMoreRef: React.MutableRefObject<() => void>; + btMatchesChart: boolean; + btRunning: boolean; + btSettings: BacktestSettingsDto; + btStats: BacktestStats | null; + btSignals: import('../utils/backendApi').BacktestSignal[]; + btClear: () => void; + btMarkerPayloadRef: React.MutableRefObject<{ + symbol: string; + timeframe: string; + signals: import('../utils/backendApi').BacktestSignal[]; + showPrice: boolean; + } | null>; + syncBacktestMarkersRef: React.MutableRefObject<() => void>; + showBtSettings: boolean; + showBtResult: boolean; + showLivePanel: boolean; + liveStrategies: { id: number; name: string }[]; + liveStrategySettings: LiveStrategySettingsDto; + monitoredMarkets: string[]; + isVirtualActive: boolean; + watchlist: string[]; + chartBtReportModel: BacktestAnalysisReportModel | null; + chartSymbol: string; + chartScreenActive: boolean; + orderbook: OrderbookState; + obWsStatus: WsStatus; + spread: { bestAsk: number; bestBid: number; spread: number; pct: number }; + orderbookPrevClose: number; + chartTicker: TickerData | undefined; + tradeFill: TradeOrderFillRequest | null; + paperCashBalance: number; + paperCoinQty: number; + paperTradingEnabled: boolean; + paperAutoTradeEnabled: boolean; + appDefaults: import('../utils/backendApi').AppSettingsDto; + stats: PriceStats | null; + priceMap: Record; + alerts: Array<{ price: number; label: string }>; + hoverBar: LegendData | OHLCVBar | null; + quoteBar: OHLCVBar | null; + liveQuote: ReturnType; + isLoading: boolean; + error: string | null; + bulkSettingsIndicators: IndicatorConfig[]; + chartIndicatorsForSettings: IndicatorConfig[]; + getParams: ReturnType['getParams']; + getVisualConfig: ReturnType['getVisualConfig']; + activeSlotIndicators: IndicatorConfig[]; + setSymbol: React.Dispatch>; + setLegend: React.Dispatch>; + setChartType: React.Dispatch>; + setMode: React.Dispatch>; + setLogScale: React.Dispatch>; + setShowGrid: React.Dispatch>; + setDrawingTool: React.Dispatch>; + setDrawings: React.Dispatch>; + setDrawingsLocked: React.Dispatch>; + setDrawingsVisible: React.Dispatch>; + setIndicatorsVisible: React.Dispatch>; + setMagnetMode: React.Dispatch>; + setSnapToIndicator: React.Dispatch>; + setKeepLockedOnDelete: React.Dispatch>; + setShowStats: React.Dispatch>; + setShowWatch: React.Dispatch>; + setShowAlert: React.Dispatch>; + setShowObjectTree: React.Dispatch>; + setMagnifierActive: React.Dispatch>; + setShowMarketPanel: React.Dispatch>; + setMobileRightOpen: React.Dispatch>; + setMobileRightTab: React.Dispatch>; + setSyncTime: React.Dispatch>; + setSyncVisibleRange: React.Dispatch>; + setSyncLogicalRange: React.Dispatch>; + setActiveSlotSymbol: React.Dispatch>; + setCanUndo: React.Dispatch>; + setCanRedo: React.Dispatch>; + setCtxToolbar: React.Dispatch>; + setDrawingCtxToolbar: React.Dispatch>; + setDrawingSettingsId: React.Dispatch>; + setSelectedDrawingId: React.Dispatch>; + setShowMainChartModal: React.Dispatch>; + setShowBulkIndSettings: React.Dispatch>; + setSettingsModalId: React.Dispatch>; + setCustomOverlaySettingsSlot: React.Dispatch>; + setShowBtSettings: React.Dispatch>; + setShowBtResult: React.Dispatch>; + setShowLivePanel: React.Dispatch>; + setWatchlist: React.Dispatch>; + setAlerts: React.Dispatch>>; + setIndicators: React.Dispatch>; + setSyncOptions: React.Dispatch>; + setPercentScale: React.Dispatch>; + setBtStrategyName: React.Dispatch>; + setBtReportCreatedAt: React.Dispatch>; + setBtSettings: React.Dispatch>; + saveAppDef: (patch: Partial) => void; + applyChartIndicators: (chartIndicators: IndicatorConfig[]) => void; + onThemeToggle: () => void; + handleTimeframe: (tf: Timeframe) => void; + handleLayoutSelect: (def: LayoutDef) => void; + handleSetActiveSlot: (idx: number) => void; + handleActiveSlotIndicatorsChange: (inds: IndicatorConfig[]) => void; + handleActiveSlotSymbolChange: (sym: string) => void; + handleVisibleRangeChange: (r: { from: number; to: number }) => void; + handleLogicalRangeChange: (r: { from: number; to: number }) => void; + handleSlotFullView: (slotSymbol: string, slotIdx: number) => void; + applyTradeFill: (req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex?: number) => void; + handleOrderbookPick: (price: number, rowType: 'ask' | 'bid') => void; + handleTradeMarketSelect: (s: string) => void; + pushDrawing: (newDrawings: Drawing[]) => void; + handleUndo: () => void; + handleRedo: () => void; + handleHoverPaneLegend: (id: string, sx: number, sy: number) => void; + handleLeavePaneLegend: () => void; + clearHoverTimer: () => void; + handleDrawingClick: (id: string | null, sx: number, sy: number) => void; + handleDrawingDoubleClick: (id: string, sx: number, sy: number) => void; + handleMainChartSave: (style: MainChartStyle) => void; + handleAddIndicator: (type: string, fromConfig?: IndicatorConfig) => void; + handleAddIndicators: (types: string[]) => void; + handleRemoveByType: (type: string) => void; + handleIndicatorListOrderChange: (orderedTypes: string[]) => void; + handleOpenIndicatorSettings: (type: string) => void; + handleIndicatorSave: (updated: IndicatorConfig) => void; + handleBulkIndicatorApply: (result: { chartIndicators: IndicatorConfig[]; allConfigs: IndicatorConfig[] }) => void; + handleToggleIndicatorHidden: (id: string) => void; + handleDuplicateIndicator: (sourceId: string) => void; + handleRemoveIndicatorById: (id: string) => void; + handleScreenshot: () => void; + handleReorderIndicators: (fromId: string, insertBeforeId: string | null) => void; + handleMergeIndicators: (fromId: string, intoId: string) => void; + handleSplitIndicatorPane: (hostId: string) => void; + handleExpandIndicator: (id: string) => void; + handleRestoreIndicators: () => void; + handleToggleCandleOverlay: (key: ChartOverlayToggleKey) => void; + handleCustomOverlayActiveChange: (active: boolean) => void; + handleCustom1OverlayActiveChange: (active: boolean) => void; + handleSaveCustomOverlaySelection: (slot: ChartCustomOverlaySlotId, selection: ChartCustomOverlaySelection) => void; + syncCustomOverlaysToManager: (mgr: ChartManager) => void; + handleCandlesReady: () => void; + applyBacktestMarkersWhenReady: (payload: NonNullable, attempt?: number) => void; + btRun: ReturnType['run']; + refreshPaperAccount: (forMarket?: string) => Promise; + handlePaperOrderFilled: () => void; + handleLiveSettingsChange: (saved: LiveStrategySettingsDto) => void; + clearLiveMarkers: () => void; + goToMarketChart: (market: string) => void; + handleTimezoneChange: (tz: string) => void; +} + +function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { + const { + onOpenNotificationsPage, onOpenAppDownload, + symbol, timeframe, chartType, theme, mode, logScale, showGrid, + toolbarActiveIndicators, layoutDef, syncOptions, activeSlot, + activeSlotSymbol, activeSlotTf, slot0InitProps, slotInitProps, + syncTime, syncVisibleRange, syncLogicalRange, slotRefs, managerRef, + undoStack, redoStack, canUndo, canRedo, drawingTool, drawings, + drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode, + snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch, + showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel, + mobileRightOpen, mobileRightTab, marketTickers, marketLoading, usdRate, + formalLogin, requireFormalLogin, chartVisible, menuPage, isMobile, + indicators, effectiveIndicators, mainChartStyle, showMainChartModal, + showBulkIndSettings, settingsModalId, ctxToolbar, drawingCtxToolbar, + drawingSettingsId, selectedDrawingId, focusedIndicatorId, + overlayVisibilityRef, candleOverlayToggles, customOverlaySelection, + custom1OverlaySelection, customOverlaySettingsSlot, customOverlayActive, + custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels, + chartVolumeVisible, chartPaneSeparator, chartCrosshairInfoVisible, + chartHoverToolbarVisible, chartLiveReceiveHighlight, chartRealtimeSource, + displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit, wsStatus, bars, barsMarket, + barsMarketRef, chartMountKey, chartLiveReadyRef, pendingRealtimeBarRef, + isSingleLoadingMore, singleLoadMoreRef, btMatchesChart, btRunning, + btSettings, btStats, btSignals, btClear, btMarkerPayloadRef, + syncBacktestMarkersRef, showBtSettings, showBtResult, showLivePanel, + liveStrategies, liveStrategySettings, monitoredMarkets, isVirtualActive, + watchlist, chartBtReportModel, chartSymbol, chartScreenActive, orderbook, + obWsStatus, spread, orderbookPrevClose, chartTicker, tradeFill, + paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled, + appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote, + isLoading, error, bulkSettingsIndicators, chartIndicatorsForSettings, + getParams, getVisualConfig, activeSlotIndicators, + setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid, + setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible, + setIndicatorsVisible, setMagnetMode, setSnapToIndicator, setKeepLockedOnDelete, + setShowStats, setShowWatch, setShowAlert, setShowObjectTree, setMagnifierActive, + setShowMarketPanel, setMobileRightOpen, setMobileRightTab, setSyncTime, + setSyncVisibleRange, setSyncLogicalRange, setActiveSlotSymbol, setCanUndo, + setCanRedo, setCtxToolbar, setDrawingCtxToolbar, setDrawingSettingsId, + setSelectedDrawingId, setShowMainChartModal, setShowBulkIndSettings, + setSettingsModalId, setCustomOverlaySettingsSlot, setShowBtSettings, + setShowBtResult, setShowLivePanel, setWatchlist, setAlerts, setIndicators, + setSyncOptions, setPercentScale, setBtStrategyName, setBtReportCreatedAt, setBtSettings, + saveAppDef, applyChartIndicators, + onThemeToggle, handleTimeframe, handleLayoutSelect, handleSetActiveSlot, + handleActiveSlotIndicatorsChange, handleActiveSlotSymbolChange, + handleVisibleRangeChange, handleLogicalRangeChange, handleSlotFullView, + applyTradeFill, handleOrderbookPick, handleTradeMarketSelect, pushDrawing, + handleUndo, handleRedo, handleHoverPaneLegend, handleLeavePaneLegend, + clearHoverTimer, handleDrawingClick, handleDrawingDoubleClick, + handleMainChartSave, handleAddIndicator, handleAddIndicators, + handleRemoveByType, handleIndicatorListOrderChange, handleOpenIndicatorSettings, + handleIndicatorSave, handleBulkIndicatorApply, handleToggleIndicatorHidden, + handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot, + handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane, + handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay, + handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange, + handleSaveCustomOverlaySelection, syncCustomOverlaysToManager, + handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount, + handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers, + handleTimezoneChange, + } = props; + + return ( + <> + 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={onThemeToggle} + 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')} + 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 && formalLogin + ? 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 ?? false; + const payload = { + symbol: runSymbol, + timeframe: runTf, + signals: r.signals, + showPrice, + }; + btMarkerPayloadRef.current = payload; + applyBacktestMarkersWhenReady(payload); + setBtReportCreatedAt(fmtListTimestamp(new Date().toISOString())); + } + if (appDefaults.btAutoPopup) setShowBtResult(true); + } + : undefined} + onOpenBtSettings={() => { + if (!formalLogin) { requireFormalLogin(); return; } + setShowBtSettings(true); + }} + onOpenBtResults={() => { + if (!formalLogin) { requireFormalLogin(); return; } + setShowBtResult(true); + }} + hasBtResult={btMatchesChart} + onClearBtMarkers={() => { + btMarkerPayloadRef.current = null; + btClear(); + managerRef.current?.clearBacktestMarkers(); + }} + onToggleLiveStrategy={() => { + if (!formalLogin) { requireFormalLogin(); return; } + setShowLivePanel(v => !v); + }} + liveStrategyActive={formalLogin && (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} + chartHoverToolbarVisible={chartHoverToolbarVisible} + 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} + chartHoverToolbarVisible={chartHoverToolbarVisible} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} + chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} + displayTimezone={displayTimezone} + marketTickers={marketTickers} + compactMode + chartVisible={chartVisible} + /> +
+ ))} +
+ )} + + {/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */} +
1 ? 'none' : undefined }}> + + + { + const mgr = managerRef.current; + mgr?.setChartOverlayVisibility(overlayVisibilityRef.current); + if (mgr) syncCustomOverlaysToManager(mgr); + 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); + mgr.setChartOverlayVisibility(overlayVisibilityRef.current); + syncCustomOverlaysToManager(mgr); + 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} + candleOverlayToggles={candleOverlayToggles} + onToggleCandleOverlay={handleToggleCandleOverlay} + customOverlayActive={customOverlayActive} + onCustomOverlayActiveChange={handleCustomOverlayActiveChange} + onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')} + custom1OverlayActive={custom1OverlayActive} + onCustom1OverlayActiveChange={handleCustom1OverlayActiveChange} + onOpenCustom1OverlaySettings={() => setCustomOverlaySettingsSlot('custom1')} + showHoverToolbar={chartHoverToolbarVisible} + /> + {customOverlaySettingsSlot === 'custom' && ( + handleSaveCustomOverlaySelection('custom', sel)} + onCancel={() => setCustomOverlaySettingsSlot(null)} + /> + )} + {customOverlaySettingsSlot === 'custom1' && ( + handleSaveCustomOverlaySelection('custom1', sel)} + onCancel={() => setCustomOverlaySettingsSlot(null)} + /> + )} + {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); }} + /> + )} + + {/* 백테스팅 결과 — 투자분석 상세 레포트 (인쇄·PDF) */} + setShowBtResult(false)} + model={chartBtReportModel} + /> + + {/* 시리즈 클릭/호버 컨텍스트 툴바 */} + {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} + /> + + + ); + +} + +export const ChartWorkspaceView = React.memo(ChartWorkspaceViewInner); diff --git a/frontend/src/chart/chartWorkspaceBridge.ts b/frontend/src/chart/chartWorkspaceBridge.ts new file mode 100644 index 0000000..af69271 --- /dev/null +++ b/frontend/src/chart/chartWorkspaceBridge.ts @@ -0,0 +1,7 @@ +import type { IndicatorConfig } from '../types'; + +export interface ChartWorkspaceBridge { + goToMarket(market: string): void; + getSymbol(): string; + getIndicators(): IndicatorConfig[]; +} diff --git a/frontend/src/chart/useChartWorkspace.ts b/frontend/src/chart/useChartWorkspace.ts new file mode 100644 index 0000000..39421bf --- /dev/null +++ b/frontend/src/chart/useChartWorkspace.ts @@ -0,0 +1,1975 @@ +import { 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, 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 { saveState, DEFAULT_MAIN_CHART_STYLE } from '../utils/storage'; +import { useWorkspacePersist } from '../hooks/useWorkspacePersist'; +import type { WatchlistItem } from '../utils/backendApi'; +import { + addWatchlistItem, removeWatchlistItem, + loadWatchlist, + loadPaperSummary, +} 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 { + type ChartOverlayToggleKey, + type ChartOverlayVisibility, + chartOverlayKeyForType, + DEFAULT_CHART_OVERLAY_VISIBILITY, + listChartOverlayToggleItems, +} from '../utils/chartOverlayVisibility'; +import { + type ChartCustomOverlaySelection, + type ChartCustomOverlaySlotId, + createDefaultChartCustomOverlaySelection, + isCustomOverlaySlotActive, + resolveActiveCustomOverlaySlot, + syncChartManagerCustomOverlaysByActiveSlot, +} from '../utils/chartCustomOverlay'; +import { isUpbitMarket, UPBIT_MARKETS } from '../utils/upbitApi'; +import { getFavorites, saveFavorites, initFavoritesFromDb, FAVORITES_CHANGED_EVENT } from '../utils/marketStorage'; +import { useChartRealtimeData } 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 { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; +import { + consumeSkipNextChartDefaultsSync, + markChartIndicatorSaveCommitted, +} from '../utils/indicatorChartDefaultsSync'; +import { + duplicateIndicatorInList, + mergeIndicators, + reorderIndicatorGroup, + removeIndicatorFromList, + removeIndicatorTypeFromList, + splitIndicatorPane, + replaceIndicatorConfigsFromTypes, +} from '../utils/indicatorPaneMerge'; +import { + sortIndicatorsByTypeOrder, +} from '../utils/indicatorListOrder'; +import { getAppSettingsCache } from '../hooks/useAppSettings'; +import type { AppSettingsDto } from '../utils/backendApi'; +import MarketPanel from '../components/MarketPanel'; +import { type MenuPage } from '../components/TopMenuBar'; +import BacktestPanel from '../components/BacktestPanel'; +import { useBacktest } from '../hooks/useBacktest'; +import LiveStrategyPanel from '../components/LiveStrategyPanel'; +import type { LiveMarker } from '../hooks/useLiveStrategyMarkers'; +import type { LiveSignalNotifierHandle } from '../components/LiveSignalNotifier'; +import BacktestSettingsModal from '../components/BacktestSettingsModal'; +import BacktestAnalysisReportModal from '../components/backtest/BacktestAnalysisReportModal'; +import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; +import { fmtListTimestamp } from '../utils/backtestUiUtils'; +import ChartLegendBar from '../components/ChartLegendBar'; +import { resolveChartLiveQuote } from '../utils/chartLiveQuote'; +import RightSidePanel from '../components/RightSidePanel'; +import { + type BacktestSettingsDto, + DEFAULT_BACKTEST_SETTINGS, + loadBacktestSettings, + loadStrategies as loadStrategiesForLive, + loadLiveStrategySettings, + loadActiveLiveStrategySettings, + expandLiveStrategySubscriptions, + 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, useIsCompactDevice } from '../hooks/useMediaQuery'; +import { useMarketTicker, type TickerData } from '../hooks/useMarketTicker'; +import { isTickerFeedPage } from '../utils/tickerFeedPages'; +import ChartCustomOverlaySettingsModal from '../components/ChartCustomOverlaySettingsModal'; +import MobileChartDock from '../components/MobileChartDock'; +import { ChartToolbarNotify } from '../components/NotifyUiBindings'; +import { loadState } from '../utils/storage'; +import { resolveChartLegendOptions } from '../types/chartLegend'; +import { resolveChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; +import type { ChartWorkspaceBridge } from './chartWorkspaceBridge'; +import type { ChartWorkspaceSharedState } from './ChartWorkspaceContext'; +import type { ChartWorkspaceViewProps } from './ChartWorkspaceView'; + +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); +} + +export interface UseChartWorkspaceParams { + sessionKey: number; + menuPage: MenuPage; + formalLogin: boolean; + requireFormalLogin: () => void; + theme: Theme; + onThemeToggle: () => void; + appDefaults: import('../utils/backendApi').AppSettingsDto; + appSettingsLoaded: boolean; + saveAppDef: (patch: Partial) => void; + getParams: ReturnType['getParams']; + saveParams: ReturnType['saveParams']; + getVisualConfig: ReturnType['getVisualConfig']; + saveVisual: ReturnType['saveVisual']; + saveAllIndicatorDefaults: ReturnType['saveAllIndicatorDefaults']; + indicatorSettingsRevision: number; + onOpenNotificationsPage: () => void; + onOpenAppDownload: () => void; +} + +export interface SettingsPageChartBindings { + magnetMode: 'off' | 'weak' | 'strong'; + onMagnetMode: (m: 'off' | 'weak' | 'strong') => void; + liveStrategies: { id: number; name: string }[]; + onClearLiveMarkers: () => void; + liveSettings: LiveStrategySettingsDto; + onLiveSettingsChange: (saved: LiveStrategySettingsDto) => void; + watchlistCount: number; + monitoredMarkets: string[]; + virtualDriven: boolean; + chartIndicators: IndicatorConfig[]; + onAddIndicatorToChart: (type: string, fromConfig?: IndicatorConfig) => void; + onRemoveIndicatorFromChart: (type: string) => void; + onIndicatorListOrderChange: (orderedTypes: string[]) => void; + chartCandleAreaPriceLabels: boolean; + onChartCandleAreaPriceLabels: (v: boolean) => void; + chartSeriesPriceLabels: boolean; + onChartSeriesPriceLabels: (v: boolean) => void; + chartVolumeVisible: boolean; + onChartVolumeVisible: (v: boolean) => void; + chartLiveReceiveHighlight: boolean; + onChartLiveReceiveHighlight: (v: boolean) => void; + chartLegendOptions: import('../types/chartLegend').ChartLegendVisibility | undefined; + onChartLegendOptionsChange: (patch: Partial) => void; + chartCrosshairInfoVisible: boolean; + onChartCrosshairInfoVisible: (v: boolean) => void; + chartHoverToolbarVisible: boolean; + onChartHoverToolbarVisible: (v: boolean) => void; + chartPaneSeparator: import('../types/chartPaneSeparator').ChartPaneSeparatorOptions | undefined; + onChartPaneSeparatorChange: (opts: import('../types/chartPaneSeparator').ChartPaneSeparatorOptions) => void; + onPaperAccountReset: () => Promise; + onDisplayTimezoneChange: (tz: string) => void; + onChartTimeFormatChange: (format: string) => void; + onTradeAlertTimeFormatChange: (format: string) => void; + displayTimezone: string; + chartTimeFormat: string; + tradeAlertTimeFormat: string; +} + +export function useChartWorkspace({ + sessionKey, + menuPage, + formalLogin, + requireFormalLogin, + theme, + onThemeToggle, + appDefaults, + appSettingsLoaded, + saveAppDef, + getParams, + saveParams, + getVisualConfig, + saveVisual, + saveAllIndicatorDefaults: _saveAllIndicatorDefaults, + indicatorSettingsRevision, + onOpenNotificationsPage, + onOpenAppDownload, +}: UseChartWorkspaceParams) { + void _saveAllIndicatorDefaults; + const initial = loadState(); + const chartVisible = menuPage === 'chart'; + const chartTimeFormat = useChartTimeFormat(); + const tradeAlertTimeFormat = useTradeAlertTimeFormat(); + const displayTimezone = useDisplayTimezone(); + const [symbol, setSymbol] = useState(initial.symbol); + const [timeframe, setTimeframe] = useState(initial.timeframe); + const [chartType, setChartType] = useState(initial.chartType); + const [mode, setMode] = useState('chart'); + const [logScale, setLogScale] = useState(false); + const [indicators, setIndicators] = useState(initial.indicators ?? []); + const indicatorsRef = useRef(indicators); + useEffect(() => { indicatorsRef.current = indicators; }, [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 isMobile = useIsMobile(); + const isCompactDevice = useIsCompactDevice(); + const tickerFeedActive = isTickerFeedPage(menuPage); + + // ── 레이아웃 상태 ───────────────────────────────────────────────────── + 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 chartScreenActive = chartVisible; + const orderbookEnabled = chartScreenActive && isUpbitMarket(chartSymbol); + const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol, orderbookEnabled); + + 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.defaultLayoutId) { + const def = LAYOUTS.find(l => l.id === appDefaults.defaultLayoutId); + if (def) setLayoutDef(def); + } + if (appDefaults.syncOptions) { + setSyncOptions(appDefaults.syncOptions as unknown as SyncOptions); + } + }, [appSettingsLoaded, appDefaults.defaultLayoutId, 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 priorityMarkets = useMemo( + () => [symbol, ...watchlist], + [symbol, watchlist], + ); + const getPriorityMarkets = useCallback(() => priorityMarkets, [priorityMarkets]); + const marketTickerOpts = useMemo(() => ({ + enabled: tickerFeedActive, + lite: isCompactDevice, + loadFull: showMarketPanel && tickerFeedActive, + getPriorityMarkets, + }), [tickerFeedActive, isCompactDevice, showMarketPanel, getPriorityMarkets]); + const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts); + const chartTicker = marketTickers.get(chartSymbol); + const orderbookPrevClose = chartTicker + ? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0) + : 0; + + const chartCandleAreaPriceLabels = appDefaults.chartCandleAreaPriceLabels ?? true; + const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true; + const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true; + const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true; + const chartLegendOptions = resolveChartLegendOptions(appDefaults.chartLegendOptions); + const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true; + const chartHoverToolbarVisible = appDefaults.chartHoverToolbarVisible ?? true; + const chartPaneSeparator = resolveChartPaneSeparatorOptions(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 || !formalLogin) return; + refreshPaperAccount(); + }, [appSettingsLoaded, formalLogin, 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 || !formalLogin || !paperTradingEnabled) return; + refreshPaperAccount(symbol); + }, [symbol, appSettingsLoaded, formalLogin, 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('전략'); + const [btReportCreatedAt, setBtReportCreatedAt] = 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(() => { + if (!formalLogin) { + setActiveLiveSettings([]); + return; + } + refreshActiveLiveSettings(); + }, [formalLogin, 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 || !formalLogin) 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, formalLogin, 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 (!formalLogin) return []; + if (isVirtualActive) return virtualMonitoredMarkets; + if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; + const set = new Set(watchlist); + if (symbol) set.add(symbol); + return [...set]; + }, [formalLogin, 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 (!formalLogin) { + setMarketSubscriptions([]); + return; + } + 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 폴백 */ }); + }, [formalLogin, 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 => { + const rows = list + .filter(s => s.id != null) + .map(s => ({ id: s.id as number, name: s.name })); + setLiveStrategies(rows); + const sid = appDefaults.liveStrategyId; + if (sid != null && !rows.some(r => r.id === sid)) { + saveAppDef({ liveStrategyId: undefined, liveStrategyCheck: false }); + } + }) + .catch(() => {}); + }, [appDefaults.liveStrategyId, saveAppDef]); + + useEffect(() => { + if (!formalLogin) { + setLiveStrategies([]); + return; + } + refreshLiveStrategies(); + }, [menuPage, showLivePanel, formalLogin, refreshLiveStrategies]); + + const liveNotifierRef = useRef(null); + const liveMarkersRef = useRef([]); + const clearLiveMarkers = useCallback(() => { + liveMarkersRef.current = []; + 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; + const apply = () => { + managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator); + managerRef.current?.refreshPaneSeparatorOverlay(); + slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator)); + }; + apply(); + if (!chartVisible) return; + const raf = requestAnimationFrame(() => requestAnimationFrame(apply)); + return () => cancelAnimationFrame(raf); + }, [appSettingsLoaded, menuPage, 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 ?? false, + }; + 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]); + + // 금액 표시 토글 변경 시 실시간 시그널 마커 텍스트 재적용 + useEffect(() => { + if (!chartVisible || liveMarkersRef.current.length === 0) return; + managerRef.current?.setLiveStrategyMarkers(liveMarkersRef.current, appDefaults.btShowPrice); + }, [chartVisible, appDefaults.btShowPrice]); + + // ── 시뮬레이션 데이터 (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: chartScreenActive && useUpbit, + source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT', + }), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartRealtimeSource]), + ); + + // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) + const bars = useUpbit ? upbitBars : simBars; + const barsMarket = useUpbit ? upbitBarsMarket : symbol; + barsMarketRef.current = barsMarket; + + const chartBtReportModel = useMemo(() => { + if (!btMatchesChart) return null; + const reportSymbol = btSymbol ?? symbol; + const reportTf = btTimeframe ?? timeframe; + return buildChartBacktestReportModel({ + analysis: btAnalysis, + stats: btStats, + signals: btSignals, + strategyName: btStrategyName, + symbol: reportSymbol, + timeframe: reportTf, + barCount: bars.length, + createdAt: btReportCreatedAt, + }); + }, [ + btMatchesChart, + btAnalysis, + btStats, + btSignals, + btStrategyName, + btSymbol, + btTimeframe, + symbol, + timeframe, + bars.length, + btReportCreatedAt, + ]); + + /** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (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]); + + + // ── DB 워크스페이스 동기화 ───────────────────────────────────────────── + const workspaceState = useMemo(() => ({ + layoutId: layoutDef.id, + syncOptions, + slots: [{ + slotIndex: 0, + symbol, timeframe, chartType, mode, logScale, + indicators, drawings, drawingsLocked, drawingsVisible, + mainChartStyle, + }], + }), [layoutDef.id, syncOptions, symbol, timeframe, chartType, + 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 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.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.defaultLayoutId); + if (def) setLayoutDef(def); + }, [_wsLoaded, dbLoaded, appDefaults.defaultLayoutId]); + + // ── 키보드 단축키 ───────────────────────────────────────────────────── + 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 (!chartVisible) { + 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); + }, []); + + const [overlayVisibility, setOverlayVisibility] = useState( + () => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }), + ); + const overlayVisibilityRef = useRef(overlayVisibility); + useEffect(() => { + overlayVisibilityRef.current = overlayVisibility; + }, [overlayVisibility]); + + const candleOverlayToggles = useMemo( + () => listChartOverlayToggleItems(overlayVisibility), + [overlayVisibility], + ); + + const [customOverlaySelection, setCustomOverlaySelection] = useState( + () => createDefaultChartCustomOverlaySelection(), + ); + const [custom1OverlaySelection, setCustom1OverlaySelection] = useState( + () => createDefaultChartCustomOverlaySelection(), + ); + const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState(null); + const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState(null); + const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom'); + const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1'); + + const syncCustomOverlaysToManager = useCallback((mgr: ChartManager) => { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + activeCustomOverlaySlot, + customOverlaySelection, + custom1OverlaySelection, + ); + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleCustomOverlayActiveChange = useCallback((active: boolean) => { + const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot); + setActiveCustomOverlaySlot(nextSlot); + const mgr = managerRef.current; + if (mgr) { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + nextSlot, + customOverlaySelection, + custom1OverlaySelection, + ); + } + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleCustom1OverlayActiveChange = useCallback((active: boolean) => { + const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot); + setActiveCustomOverlaySlot(nextSlot); + const mgr = managerRef.current; + if (mgr) { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + nextSlot, + customOverlaySelection, + custom1OverlaySelection, + ); + } + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleSaveCustomOverlaySelection = useCallback(( + slot: ChartCustomOverlaySlotId, + selection: ChartCustomOverlaySelection, + ) => { + const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection; + const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection; + if (slot === 'custom') setCustomOverlaySelection(selection); + else setCustom1OverlaySelection(selection); + setCustomOverlaySettingsSlot(null); + const mgr = managerRef.current; + if (!mgr) return; + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + activeCustomOverlaySlot, + nextCustomSel, + nextCustom1Sel, + ); + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { + setOverlayVisibility(prev => { + const next = { ...prev, [key]: !prev[key] }; + const mgr = managerRef.current; + mgr?.setChartOverlayGroupVisible(key, next[key]); + if (mgr) { + for (const info of mgr.getIndicatorPaneInfo()) { + if (chartOverlayKeyForType(info.type) !== key) continue; + const reactInd = indicatorsRef.current.find(i => i.id === info.id); + mgr.applyIndicatorStyle(reactInd + ? { ...info.config, ...reactInd, id: info.id, type: info.type } + : info.config); + } + } + return next; + }); + }, []); + + /** + * 보조지표 순서 변경 (드래그 핸들): 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]); + + 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 goToMarketChartBridge = useCallback((market: string) => { + goToMarketChart(market); + }, [goToMarketChart]); + + const bridge = useMemo(() => ({ + goToMarket: goToMarketChartBridge, + getSymbol: () => (layoutDef.count > 1 ? activeSlotSymbol : symbol), + getIndicators: () => (layoutDef.count > 1 ? activeSlotIndicators : indicators), + }), [goToMarketChartBridge, layoutDef.count, activeSlotSymbol, symbol, activeSlotIndicators, indicators]); + + const settingsPageProps = useMemo(() => ({ + magnetMode, + onMagnetMode: setMagnetMode, + liveStrategies, + onClearLiveMarkers: () => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }, + liveSettings: liveStrategySettings, + onLiveSettingsChange: handleLiveSettingsChange, + watchlistCount: isVirtualActive ? monitoredMarkets.length : watchlist.length, + monitoredMarkets, + virtualDriven: isVirtualActive, + chartIndicators: bulkSettingsIndicators, + onAddIndicatorToChart: handleAddIndicator, + onRemoveIndicatorFromChart: handleRemoveByType, + onIndicatorListOrderChange: handleIndicatorListOrderChange, + chartCandleAreaPriceLabels, + onChartCandleAreaPriceLabels: v => { + saveAppDef({ chartCandleAreaPriceLabels: v }); + managerRef.current?.setCandleAreaPriceLabelsEnabled(v); + slotRefs.current.forEach(slot => slot?.setCandleAreaPriceLabelsEnabled(v)); + }, + chartSeriesPriceLabels, + onChartSeriesPriceLabels: v => { + saveAppDef({ chartSeriesPriceLabels: v }); + managerRef.current?.setIndicatorAreaPriceLabelsEnabled(v); + slotRefs.current.forEach(slot => slot?.setIndicatorAreaPriceLabelsEnabled(v)); + }, + 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, + onChartLiveReceiveHighlight: v => saveAppDef({ chartLiveReceiveHighlight: v }), + chartLegendOptions, + onChartLegendOptionsChange: patch => { + saveAppDef({ + chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record, + }); + }, + chartCrosshairInfoVisible, + onChartCrosshairInfoVisible: v => saveAppDef({ chartCrosshairInfoVisible: v }), + chartHoverToolbarVisible, + onChartHoverToolbarVisible: v => saveAppDef({ chartHoverToolbarVisible: v }), + chartPaneSeparator, + onChartPaneSeparatorChange: opts => { + saveAppDef({ chartPaneSeparator: opts }); + managerRef.current?.setPaneSeparatorOptions(opts); + managerRef.current?.refreshPaneSeparatorOverlay(); + slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts)); + }, + onPaperAccountReset: async () => { + const { resetPaperAccount } = await import('../utils/backendApi'); + await resetPaperAccount(); + await refreshPaperAccount(); + }, + onDisplayTimezoneChange: handleTimezoneChange, + onChartTimeFormatChange: handleChartTimeFormatChange, + onTradeAlertTimeFormatChange: handleTradeAlertTimeFormatChange, + displayTimezone, + chartTimeFormat, + tradeAlertTimeFormat, + }), [ + magnetMode, liveStrategies, liveStrategySettings, handleLiveSettingsChange, + isVirtualActive, monitoredMarkets, watchlist, bulkSettingsIndicators, + handleAddIndicator, handleRemoveByType, handleIndicatorListOrderChange, + chartCandleAreaPriceLabels, chartSeriesPriceLabels, chartVolumeVisible, + chartLiveReceiveHighlight, chartLegendOptions, chartCrosshairInfoVisible, + chartHoverToolbarVisible, chartPaneSeparator, saveAppDef, clearLiveMarkers, + refreshPaperAccount, handleTimezoneChange, handleChartTimeFormatChange, + handleTradeAlertTimeFormatChange, displayTimezone, chartTimeFormat, tradeAlertTimeFormat, + ]); + + const contextValue = useMemo(() => ({ + symbol, + indicators, + goToMarketChart, + refreshPaperAccount, + paperCashBalance, + paperCoinQty, + handlePaperOrderFilled, + paperRefreshKey, + mode, + priorityMarkets, + marketPanelOpen: showMarketPanel, + marketTickers, + marketLoading, + usdRate, + settingsPageProps, + }), [ + symbol, indicators, goToMarketChart, refreshPaperAccount, + paperCashBalance, paperCoinQty, handlePaperOrderFilled, paperRefreshKey, mode, + priorityMarkets, showMarketPanel, marketTickers, marketLoading, usdRate, + settingsPageProps, + ]); + + const viewProps: ChartWorkspaceViewProps = { + onOpenNotificationsPage, + onOpenAppDownload, + symbol, timeframe, chartType, theme, mode, logScale, showGrid, + toolbarActiveIndicators, layoutDef, syncOptions, activeSlot, + activeSlotSymbol, activeSlotTf, slot0InitProps, slotInitProps, + syncTime, syncVisibleRange, syncLogicalRange, slotRefs, managerRef, + undoStack, redoStack, canUndo, canRedo, drawingTool, drawings, + drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode, + snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch, + showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel, + mobileRightOpen, mobileRightTab, marketTickers, marketLoading, usdRate, + formalLogin, requireFormalLogin, chartVisible, menuPage, + isMobile, indicators, effectiveIndicators, mainChartStyle, + showMainChartModal, showBulkIndSettings, settingsModalId, ctxToolbar, + drawingCtxToolbar, drawingSettingsId, selectedDrawingId, + focusedIndicatorId, overlayVisibility, overlayVisibilityRef, + candleOverlayToggles, customOverlaySelection, custom1OverlaySelection, + activeCustomOverlaySlot, customOverlaySettingsSlot, customOverlayActive, + custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels, + chartVolumeVisible, chartPaneSeparator, chartCrosshairInfoVisible, + chartHoverToolbarVisible, chartLiveReceiveHighlight, + chartRealtimeSource: (chartRealtimeSource ?? 'BACKEND_STOMP') as 'BACKEND_STOMP' | 'UPBIT_DIRECT', + displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit, + wsStatus: (wsStatus ?? 'disconnected') as import('../hooks/useChartRealtimeData').WsStatus, + bars, barsMarket: barsMarket ?? symbol, barsMarketRef, chartMountKey, chartLiveReadyRef, + pendingRealtimeBarRef, isSingleLoadingMore, singleLoadMoreRef, + btMatchesChart, btRunning, btSettings, btStats, btSignals, btClear, + btMarkerPayloadRef, syncBacktestMarkersRef, showBtSettings, showBtResult, + showLivePanel, liveStrategies, liveStrategySettings, monitoredMarkets, + isVirtualActive, watchlist, chartBtReportModel, chartSymbol, chartScreenActive, + orderbook, obWsStatus, spread, orderbookPrevClose, chartTicker, tradeFill, + paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled, + appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote, + isLoading, error: error ?? null, bulkSettingsIndicators, chartIndicatorsForSettings, + getParams, getVisualConfig, activeSlotIndicators, + setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid, + setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible, + setIndicatorsVisible, setMagnetMode, setSnapToIndicator, setKeepLockedOnDelete, + setShowStats, setShowWatch, setShowAlert, setShowObjectTree, setMagnifierActive, + setShowMarketPanel, setMobileRightOpen, setMobileRightTab, setSyncTime, + setSyncVisibleRange, setSyncLogicalRange, setActiveSlotSymbol, setCanUndo, + setCanRedo, setCtxToolbar, setDrawingCtxToolbar, setDrawingSettingsId, + setSelectedDrawingId, setShowMainChartModal, setShowBulkIndSettings, + setSettingsModalId, setCustomOverlaySettingsSlot, setShowBtSettings, + setShowBtResult, setShowLivePanel, setWatchlist, setAlerts, setIndicators, + setSyncOptions, setPercentScale, setBtStrategyName, setBtReportCreatedAt, setBtSettings, + saveAppDef, applyChartIndicators, + onThemeToggle, handleTimeframe, handleLayoutSelect, handleSetActiveSlot, + handleActiveSlotIndicatorsChange, handleActiveSlotSymbolChange, + handleVisibleRangeChange, handleLogicalRangeChange, handleSlotFullView, + applyTradeFill, handleOrderbookPick, handleTradeMarketSelect, pushDrawing, + handleUndo, handleRedo, handleHoverPaneLegend, handleLeavePaneLegend, + clearHoverTimer, handleDrawingClick, handleDrawingDoubleClick, + handleMainChartSave, handleAddIndicator, handleAddIndicators, + handleRemoveByType, handleIndicatorListOrderChange, handleOpenIndicatorSettings, + handleIndicatorSave, handleBulkIndicatorApply, handleToggleIndicatorHidden, + handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot, + handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane, + handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay, + handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange, + handleSaveCustomOverlaySelection, syncCustomOverlaysToManager, + handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount, + handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers, + goToMarketChart, handleTimezoneChange, + }; + + const liveNotifierProps = { + markets: monitoredMarkets, + marketSubscriptions: liveStompSubscriptions, + activeMarket: symbol, + enabled: formalLogin && monitoredMarkets.length > 0 && appSettingsLoaded, + popupEnabled: appDefaults.tradeAlertPopup ?? true, + chartMarkersActive: chartVisible, + onMarkersChange: (markers: LiveMarker[]) => { + liveMarkersRef.current = markers; + managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice); + }, + }; + + return { + bridge, + contextValue, + viewProps, + liveNotifierProps, + liveNotifierRef, + }; +}