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 { 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 { 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 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 { 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 LoginModal from './components/LoginModal';
import AppDownloadModal from './components/AppDownloadModal';
import SplashScreen from './components/SplashScreen';
import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth';
import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry';
import { syncDocumentTheme } from './utils/documentTheme';
import {
loadLocalTheme,
migrateDbThemeToLocalIfNeeded,
saveLocalTheme,
} from './utils/localThemeStorage';
import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions';
import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions';
import { clearAdminUnlock } from './utils/adminUnlock';
import type { LoginResponse } from './utils/backendApi';
import { invalidateAppSettingsCache } from './hooks/useAppSettings';
import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import './App.css';
import './styles/appPopup.css';
import './styles/paperDashboard.css';
import './styles/tradeRightPanel.css';
import './styles/virtualTradingDashboard.css';
import './styles/backtestDashboard.css';
let _indCounter = 0;
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
/** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */
let _wsFlashTimer: ReturnType | null = null;
function flashWsDot() {
document.querySelectorAll('.ws-dot').forEach(el => el.classList.add('receiving'));
if (_wsFlashTimer) clearTimeout(_wsFlashTimer);
_wsFlashTimer = setTimeout(() => {
document.querySelectorAll('.ws-dot').forEach(el => el.classList.remove('receiving'));
_wsFlashTimer = null;
}, 600);
}
// ─── App ───────────────────────────────────────────────────────────────────
function App() {
const initial = loadState();
const [sessionKey, setSessionKey] = useState(0);
const [appEntered, setAppEnteredState] = useState(() => isAppEntered());
const [authUser, setAuthUser] = useState(() =>
isAppEntered() ? getAuthSession() : null,
);
const [guestMode, setGuestMode] = useState(() =>
isAppEntered() && !getAuthSession(),
);
const formalLogin = isFormalLogin(authUser, guestMode);
const [loginOpen, setLoginOpen] = useState(false);
const requireFormalLogin = useCallback(() => {
window.alert(FORMAL_LOGIN_REQUIRED_MSG);
setLoginOpen(true);
}, []);
const [appDownloadOpen, setAppDownloadOpen] = useState(false);
// 전역 지표 파라미터 + 시각 설정 DB 동기화
const {
getParams: getIndicatorParams,
saveParams: saveIndicatorParams,
getVisualConfig: getIndicatorVisual,
saveVisual: saveIndicatorVisual,
saveAllIndicatorDefaults,
settingsRevision: indicatorSettingsRevision,
} = useIndicatorSettings(sessionKey);
const getParams = getIndicatorParams;
const saveParams = saveIndicatorParams;
const getVisualConfig = getIndicatorVisual;
const saveVisual = saveIndicatorVisual;
// 앱 전역 기본 설정 DB 동기화 (하드코딩 대체)
const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey);
const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey);
const displayTimezone = useDisplayTimezone();
const chartTimeFormat = useChartTimeFormat();
const tradeAlertTimeFormat = useTradeAlertTimeFormat();
// DB 로드 전에는 localStorage 기본값, 로드 후 DB 값으로 오버라이드됨
const [symbol, setSymbol] = useState(initial.symbol);
const [timeframe, setTimeframe] = useState(initial.timeframe);
const [chartType, setChartType] = useState(initial.chartType);
const [theme, setTheme] = useState(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 [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();
setAppEnteredState(true);
}, []);
const handleLoginSuccess = useCallback((res: LoginResponse) => {
const session: AuthSession = {
userId: res.userId,
username: res.username,
displayName: res.displayName,
role: normalizeRole(res.role),
};
setAuthSession(session);
setAuthUser(session);
setGuestMode(false);
setDbLoaded(false);
clearAdminUnlock();
invalidateAppSettingsCache();
invalidateIndicatorSettingsCache();
invalidateMenuPermissionsCache();
setSessionKey(k => k + 1);
enterMainApp();
}, [enterMainApp]);
const handleGuestEnter = useCallback(() => {
clearAuthSession();
setAuthUser(null);
setGuestMode(true);
setDbLoaded(false);
clearAdminUnlock();
invalidateAppSettingsCache();
invalidateIndicatorSettingsCache();
invalidateMenuPermissionsCache();
setSessionKey(k => k + 1);
enterMainApp();
}, [enterMainApp]);
const handleLogout = useCallback(() => {
clearAuthSession();
setAuthUser(null);
setGuestMode(false);
setDbLoaded(false);
clearAdminUnlock();
invalidateAppSettingsCache();
invalidateIndicatorSettingsCache();
invalidateMenuPermissionsCache();
setSessionKey(k => k + 1);
clearAppEntered();
setAppEnteredState(false);
setLoginOpen(false);
}, []);
const guardedSetMenuPage = useCallback((page: MenuPage) => {
if (!formalLogin && isTradingMenuPage(page)) {
requireFormalLogin();
return;
}
if (page === 'notifications' && !canMenu('notifications')) return;
if (page !== 'notifications' && !canMenu(page)) {
setMenuPage(firstAllowedTopMenu(menuPermissions));
return;
}
setMenuPage(page);
}, [canMenu, menuPermissions, formalLogin, requireFormalLogin]);
useEffect(() => {
if (!menuPermsLoaded) return;
if (menuPage === 'notifications') {
if (!canMenu('notifications')) setMenuPage(firstAllowedTopMenu(menuPermissions));
return;
}
if (!canMenu(menuPage)) {
setMenuPage(firstAllowedTopMenu(menuPermissions));
}
}, [menuPage, menuPermsLoaded, menuPermissions, canMenu]);
// 앱 설정 DB 로드 완료 시: 워크스페이스 로드 전 localStorage 기본값 일부만 교체
// layoutId 는 워크스페이스가 우선 — 로그인 직후 멀티 레이아웃으로 잠깐 전환되며 캔들이 비는 현상 방지
useEffect(() => {
if (!appSettingsLoaded) return;
if (dbLoaded) return;
if (appDefaults.mainChartStyle) setMainChartStyle(appDefaults.mainChartStyle);
if (appDefaults.syncOptions) setSyncOptions(appDefaults.syncOptions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appSettingsLoaded, dbLoaded]);
const chartCandleAreaPriceLabels = appDefaults.chartCandleAreaPriceLabels ?? true;
const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true;
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
const chartLegendOptions = appDefaults.chartLegendOptions;
const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true;
const 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';
saveLocalTheme(next);
return next;
});
}, []);
// 최초 이 기기: DB defaultTheme → localStorage 1회만 (이후 기기별 독립)
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]);
if (!appEntered) {
return (
);
}
return (
0}
popupEnabled={appDefaults.tradeAlertPopup ?? true}
chartMarkersActive={menuPage === 'chart'}
onMarkersChange={markers => {
liveMarkersRef.current = markers;
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
}}
/>
{/* ── 최상단 글로벌 메뉴바 ─────────────────────────────────────── */}
setAppDownloadOpen(true)}
authUser={authUser}
guestMode={guestMode}
menuPermissions={menuPermissions}
onLoginClick={() => setLoginOpen(true)}
onLogout={handleLogout}
tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition}
tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout}
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })}
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
/>
setLoginOpen(false)}
onSuccess={handleLoginSuccess}
/>
{appDownloadOpen && (
setAppDownloadOpen(false)} />
)}
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
{menuPage === 'dashboard' && (
}>
{ goToMarketChart(m); setMenuPage('chart'); }} />
)}
{menuPage === 'strategy' && (
formalLogin
? (
}>
)
:
)}
{menuPage === 'strategy-editor' && (
formalLogin
? (
}>
)
:
)}
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
{menuPage === 'backtest' && (
formalLogin
? (
}>
)
:
)}
{menuPage === 'paper' && (
formalLogin ? (
}>
{
if (!formalLogin) { requireFormalLogin(); return; }
setSettingsInitialCategory('paper');
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 })}
virtualTargetMaxCount={appDefaults.virtualTargetMaxCount}
onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })}
trendSearchSettings={appDefaults.trendSearchSettings}
onTrendSearchSettingsChange={s => saveAppDef({
trendSearchSettings: resolveTrendSearchAppSettings(s),
})}
onPaperAccountReset={async () => {
await resetPaperAccount();
await refreshPaperAccount();
}}
tradingMode={appDefaults.tradingMode}
onTradingMode={v => saveAppDef({ tradingMode: v })}
liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled}
onLiveAutoTradeEnabled={v => saveAppDef({ liveAutoTradeEnabled: v })}
hasUpbitKeys={appDefaults.hasUpbitKeys}
upbitAccessKeyMasked={appDefaults.upbitAccessKeyMasked}
onUpbitKeys={(access, secret) => {
const patch: Parameters[0] = {};
const a = access.trim();
const s = secret.trim();
const isMasked = (v: string) => v.startsWith('····') || v.startsWith('****');
if (a && !isMasked(a)) patch.upbitAccessKey = a;
if (s && !isMasked(s)) patch.upbitSecretKey = s;
if (Object.keys(patch).length) saveAppDef(patch);
}}
chartRealtimeSource={appDefaults.chartRealtimeSource}
onChartRealtimeSource={v => saveAppDef({ chartRealtimeSource: v })}
liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct}
onLiveAutoTradeBudgetPct={v => saveAppDef({ liveAutoTradeBudgetPct: v })}
fcmPushEnabled={appDefaults.fcmPushEnabled}
onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })}
displayTimezone={displayTimezone}
onDisplayTimezoneChange={handleTimezoneChange}
chartTimeFormat={chartTimeFormat}
onChartTimeFormatChange={handleChartTimeFormatChange}
tradeAlertTimeFormat={tradeAlertTimeFormat}
onTradeAlertTimeFormatChange={handleTradeAlertTimeFormatChange}
verificationIssueNotify={appDefaults.verificationIssueNotify}
onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })}
onFcmTest={async () => {
const { sendFcmTest } = await import('./utils/backendApi');
const r = await sendFcmTest();
window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.');
}}
initialCategory={settingsInitialCategory}
/>
)}
{/* ── 실시간 차트 화면 ────────────────────────────────────────────── */}
setAppDownloadOpen(true)}
symbol={symbol} timeframe={layoutDef.count > 1 ? activeSlotTf : timeframe} chartType={chartType}
theme={theme} mode={mode} logScale={logScale} showGrid={showGrid}
activeIndicators={toolbarActiveIndicators}
onSymbol={s => {
if (layoutDef.count > 1) {
// 멀티 레이아웃: 활성 슬롯의 심볼 변경
slotRefs.current[activeSlot]?.setSymbol(s);
// symbol sync 활성화된 경우 모든 슬롯 일괄 변경
if (syncOptions.symbol) {
slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setSymbol(s));
}
} else {
setSymbol(s); setLegend(null);
}
}}
onTimeframe={handleTimeframe}
onChartType={setChartType}
onTheme={handleThemeToggle}
onMode={() => setMode(m => m === 'chart' ? 'trading' : 'chart')}
onLogScale={() => setLogScale(v => !v)}
onToggleGrid={() => setShowGrid(v => !v)}
onFitContent={() => managerRef.current?.fitContent()}
onScrollToNow={() => managerRef.current?.scrollToRealTime()}
onAddIndicator={handleAddIndicator}
onAddIndicators={handleAddIndicators}
onIndicatorOrderChange={handleIndicatorListOrderChange}
onRemoveByType={handleRemoveByType}
onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)}
onOpenIndicatorSettings={handleOpenIndicatorSettings}
onAutoFib={() => managerRef.current?.drawAutoFib(80)}
onClearFib={() => managerRef.current?.clearFib()}
onScreenshot={handleScreenshot}
onToggleStats={() => setShowStats(v => !v)}
onToggleWatch={() => setShowWatch(v => !v)}
onToggleAlert={() => setShowAlert(v => !v)}
magnetMode={magnetMode}
onToggleMagnet={() => setMagnetMode(m => m === 'off' ? 'strong' : 'off')}
onFullscreen={handleFullscreen}
onUndo={handleUndo}
onRedo={handleRedo}
canUndo={canUndo}
canRedo={canRedo}
onClearDrawings={() => {
// 모든 드로잉 삭제 + 피보나치 삭제 + undo/redo 스택 초기화
undoStack.current = [];
redoStack.current = [];
setCanUndo(false);
setCanRedo(false);
setDrawings([]);
managerRef.current?.clearFib();
managerRef.current?.clearDrawings();
}}
layoutId={layoutDef.id}
syncOptions={syncOptions}
onLayoutSelect={handleLayoutSelect}
onSyncChange={(key, val) => {
setSyncOptions(prev => {
const next = { ...prev, [key]: val };
saveAppDef({ syncOptions: next as unknown as Record });
return next;
});
}}
magnifierActive={magnifierActive}
onToggleMagnifier={() => setMagnifierActive(v => !v)}
marketPanelActive={showMarketPanel}
onToggleMarketPanel={() => {
setShowMarketPanel(v => {
const next = !v;
if (isMobile && next) setMobileRightOpen(false);
return next;
});
}}
backtestActive={btMatchesChart}
backtestRunning={btRunning}
backtestRunningId={undefined}
onRunBacktest={menuPage === 'chart' && layoutDef.count === 1 && 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();
}}
/>
);
}
export default App;