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) && (
- {/* 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) && (
+ { setShowMarketPanel(false); setMobileRightOpen(false); }}
+ />
+ )}
+
+
+ {/* ── 좌측 마켓 패널 ─────────────────────────────────────────── */}
+
1 ? activeSlotSymbol : symbol}
+ onSymbolSelect={s => {
+ if (layoutDef.count > 1) {
+ // 다중차트: 현재 활성 슬롯에만 적용, 호가창 심볼도 즉시 갱신
+ slotRefs.current[activeSlot]?.setSymbol(s);
+ setActiveSlotSymbol(s);
+ } else {
+ setSymbol(s);
+ setLegend(null);
+ }
+ }}
+ />
+
+ {/* 마켓 패널 접기/펼치기 핸들 – tv-main flex 자식으로 항상 표시 */}
+ setShowMarketPanel(v => !v)}
+ title={showMarketPanel ? '마켓 패널 닫기' : '마켓 패널 열기'}
+ >
+
+
+
+ { 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,
+ };
+}