Files
goldenChart/frontend/src/App.tsx
T
2026-06-01 01:35:31 +09:00

2846 lines
120 KiB
TypeScript

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 { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from './types';
import type { ChartManager } from './utils/ChartManager';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from './hooks/useHistoryLoader';
import { useMarketTicker } from './hooks/useMarketTicker';
import { useUpbitOrderbook } from './hooks/useUpbitOrderbook';
import { useIndicatorSettings } from './hooks/useIndicatorSettings';
import {
consumeSkipNextChartDefaultsSync,
markChartIndicatorSaveCommitted,
} from './utils/indicatorChartDefaultsSync';
import {
duplicateIndicatorInList,
mergeIndicators,
reorderIndicatorGroup,
removeIndicatorFromList,
removeIndicatorTypeFromList,
splitIndicatorPane,
replaceIndicatorConfigsFromTypes,
} from './utils/indicatorPaneMerge';
import {
getOrderedSettingsIndicatorTypes,
sortIndicatorsByTypeOrder,
} from './utils/indicatorListOrder';
import { useAppSettings } from './hooks/useAppSettings';
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
import MarketPanel from './components/MarketPanel';
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
import 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 { BacktestResultModal } from './components/BacktestResultModal';
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 <div className="page-load-fallback" role="status">화면 로딩 중…</div>;
}
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 { 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<typeof setTimeout> | 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<AuthSession | null>(() =>
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<Timeframe>(initial.timeframe);
const [chartType, setChartType] = useState<ChartType>(initial.chartType);
const [theme, setTheme] = useState<Theme>(initial.theme);
const [mode, setMode] = useState<ChartMode>('chart');
const [logScale, setLogScale] = useState(false);
const [indicators, setIndicators] = useState<IndicatorConfig[]>(initial.indicators ?? []);
const [drawingTool, setDrawingTool] = useState('cursor');
const [drawings, setDrawings] = useState<Drawing[]>([]);
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<LegendData | null>(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<MenuPage>('chart');
const [settingsInitialCategory, setSettingsInitialCategory] = useState<SettingsCategoryId | undefined>();
const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(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<string[]>([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<LayoutDef>(() => LAYOUTS[0]);
const [syncOptions, setSyncOptions] = useState<SyncOptions>(DEFAULT_SYNC);
const [activeSlot, setActiveSlot] = useState(0);
// 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용)
const [activeSlotIndicators, setActiveSlotIndicators] = useState<IndicatorConfig[]>([]);
/**
* 단일→다중 전환 시 슬롯 0 의 마운트 초기값으로 전달할 설정.
* React 18 배치 업데이트 덕분에 setLayoutDef 와 동일 렌더에 반영되어
* ChartSlot 이 처음 마운트될 때 이 값이 useState 초기값으로 사용된다.
*/
const [slot0InitProps, setSlot0InitProps] = useState<{
symbol: string;
timeframe: Timeframe;
indicators: IndicatorConfig[];
} | null>(null);
/** 멀티슬롯용: 슬롯 1+ 초기 props (슬롯 인덱스 → 초기값 맵) */
const [slotInitProps, setSlotInitProps] = useState<Record<number, {
symbol?: string;
timeframe?: Timeframe;
indicators?: IndicatorConfig[];
mainChartStyle?: MainChartStyle;
}>>({});
const [syncTime, setSyncTime] = useState<number>(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>(timeframe);
const [activeSlotSymbol, setActiveSlotSymbol] = useState<string>(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<TradeOrderFillRequest | null>(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<ReturnType<typeof setTimeout> | 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<string | null>(null);
const [drawingCtxToolbar, setDrawingCtxToolbar] = useState<{
id: string; screenX: number; screenY: number;
} | null>(null);
const [drawingSettingsId, setDrawingSettingsId] = useState<string | null>(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<string | null>(null);
const [showBulkIndSettings, setShowBulkIndSettings] = useState(false);
// ── 메인 차트 설정 ─────────────────────────────────────────────────────
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(
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<string, string> });
}, [saveAppDef]);
// ── 드로잉 Undo/Redo 스택 ────────────────────────────────────────────────
const undoStack = useRef<Drawing[][]>([]);
const redoStack = useRef<Drawing[][]>([]);
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<PriceStats | null>(null);
const [watchlist, setWatchlist] = useState<string[]>(() => {
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<string[]>(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 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<Array<{ price: number; label: string }>>(
(initial.alertPrices ?? []).map(p => ({ price: p, label: '' }))
);
const managerRef = useRef<ChartManager | null>(null);
/** 차트 매니저 준비 전 도착한 STOMP 틱 (준비 후 1회 적용) */
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(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<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
const [showBtSettings, setShowBtSettings] = useState(false);
const [showBtResult, setShowBtResult] = useState(false);
const [btStrategyName, setBtStrategyName] = useState('전략');
/** 현재 차트와 일치하는 백테스팅 결과만 UI·마커에 반영 */
const btMatchesChart = useMemo(
() =>
btSignals.length > 0 &&
btSymbol != null &&
btTimeframe != null &&
btSymbol === symbol &&
btTimeframe === timeframe,
[btSignals.length, btSymbol, btTimeframe, symbol, timeframe],
);
const btMarkerPayloadRef = useRef<{
symbol: string;
timeframe: string;
signals: BacktestSignal[];
showPrice: boolean;
} | null>(null);
/** applyBacktestMarkersWhenReady rAF 재시도 시 최신 차트 종목·타임프레임 검증용 */
const chartContextRef = useRef({ symbol, timeframe });
chartContextRef.current = { symbol, timeframe };
const barsMarketRef = useRef<string | null>(null);
const syncBacktestMarkersRef = useRef<() => void>(() => {});
/** ChartManager 메인 시리즈 준비 후 백테스트 마커 적용 (로딩·remount 직후 대응) */
const applyBacktestMarkersWhenReady = useCallback((
payload: NonNullable<typeof btMarkerPayloadRef.current>,
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<string[] | undefined>();
const [activeLiveSettings, setActiveLiveSettings] = useState<LiveStrategySettingsDto[]>([]);
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<string[]>(getFavorites());
/** 별표 토글 → localStorage·워치리스트 동기화 */
useEffect(() => {
const onFavoritesChanged = (e: Event) => {
const markets = (e as CustomEvent<string[]>).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<string, string> = {};
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<LiveSignalNotifierHandle>(null);
const liveMarkersRef = useRef<LiveMarker[]>([]);
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<string, unknown>)['__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<OHLCVBar[]>(() =>
!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;
/** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (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<string, { price: number; changePct: number }> = {};
for (const sym of watchlist) {
const b = isUpbitMarket(sym) ? [] : generateBars(sym, '1D');
if (b.length >= 2) {
map[sym] = { price: b[b.length-1].close, changePct: (b[b.length-1].close - b[b.length-2].close) / b[b.length-2].close * 100 };
}
}
return map;
}, [watchlist]);
// body 포털(알림 팝업 등) 테마 CSS 변수 동기화
useEffect(() => {
syncDocumentTheme(theme);
}, [theme]);
// ── DB 워크스페이스 동기화 ─────────────────────────────────────────────
const workspaceState = useMemo(() => ({
layoutId: layoutDef.id,
syncOptions,
slots: [{
slotIndex: 0,
symbol, timeframe, chartType, theme, mode, logScale,
indicators, drawings, drawingsLocked, drawingsVisible,
mainChartStyle,
}],
}), [layoutDef.id, syncOptions, symbol, timeframe, chartType, theme,
mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle]);
const { isLoaded: _wsLoaded } = useWorkspacePersist({
sessionKey,
state: workspaceState,
onLoad: (data) => {
try {
if (data.layoutId) {
const def = LAYOUTS.find(l => l.id === data.layoutId);
if (def) setLayoutDef(def);
}
if (data.syncOptions) setSyncOptions(data.syncOptions as typeof syncOptions);
const slot0 = Array.isArray(data.slots) && data.slots.length > 0
? (data.slots[0] as Record<string, unknown>)
: null;
if (slot0) {
if (slot0.symbol) setSymbol(slot0.symbol as string);
if (slot0.timeframe) setTimeframe(slot0.timeframe as Timeframe);
if (slot0.chartType) setChartType(slot0.chartType as ChartType);
if (slot0.theme) setTheme(slot0.theme as Theme);
if (slot0.mode) setMode(slot0.mode as ChartMode);
if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale));
if (Array.isArray(slot0.indicators)) {
const loaded = (slot0.indicators as IndicatorConfig[]).map(ind => {
if (ind.type === 'SMA') return normalizeSmaConfig(ind);
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind);
return ind;
});
setIndicators(sortIndicatorsByTypeOrder(loaded));
}
if (Array.isArray(slot0.drawings)) setDrawings(slot0.drawings as Drawing[]);
if (slot0.drawingsLocked != null) setDrawingsLocked(Boolean(slot0.drawingsLocked));
if (slot0.drawingsVisible != null) setDrawingsVisible(Boolean(slot0.drawingsVisible));
if (slot0.mainChartStyle) setMainChartStyle(slot0.mainChartStyle as MainChartStyle);
}
// 슬롯 1+ 초기 props 수집 (멀티슬롯 전환 시 각 ChartSlot 에 전달)
if (Array.isArray(data.slots) && data.slots.length > 1) {
const extra: typeof slotInitProps = {};
for (let i = 1; i < data.slots.length; i++) {
const s = data.slots[i] as Record<string, unknown>;
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<string | null>(null);
useEffect(() => {
setFocusedIndicatorId(null);
}, [sessionKey]);
/** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) */
const effectiveIndicators = useMemo(() => {
if (!focusedIndicatorId || layoutDef.count > 1) return indicators;
return indicators.filter(ind => {
const def = getIndicatorDef(ind.type);
return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId;
});
}, [focusedIndicatorId, indicators, layoutDef.count]);
const handleExpandIndicator = useCallback((id: string) => {
setFocusedIndicatorId(id);
}, []);
const handleRestoreIndicators = useCallback(() => {
setFocusedIndicatorId(null);
}, []);
/**
* 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동.
* overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨.
*/
const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => {
setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev));
}, []);
const handleMergeIndicators = useCallback((fromId: string, intoId: string) => {
setIndicators(prev => mergeIndicators(fromId, intoId, prev));
}, []);
const handleSplitIndicatorPane = useCallback((hostId: string) => {
setIndicators(prev => splitIndicatorPane(hostId, prev));
}, []);
/**
* 지표 추가:
* - 싱글 레이아웃: 메인 차트에 추가
* - 멀티 레이아웃: 활성 슬롯(ChartSlot ref)에 추가
*/
const applyChartIndicators = useCallback((chartIndicators: IndicatorConfig[]) => {
if (layoutDef.count > 1) {
slotRefs.current[activeSlot]?.setIndicators(chartIndicators);
setActiveSlotIndicators(chartIndicators.map(i => ({ ...i })));
} else {
setIndicators(chartIndicators);
}
}, [layoutDef.count, activeSlot]);
/** 설정 목록 순서 변경 → 차트 pane 순서 동기화 */
const handleIndicatorListOrderChange = useCallback((orderedTypes: string[]) => {
const resort = (inds: IndicatorConfig[]) => sortIndicatorsByTypeOrder(inds, orderedTypes);
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (!slotRef) return;
const next = resort(slotRef.getIndicators());
slotRef.setIndicators(next);
setActiveSlotIndicators(next.map(i => ({ ...i })));
} else {
setIndicators(prev => resort(prev));
}
}, [layoutDef.count, activeSlot]);
const handleAddIndicator = useCallback((type: string, fromConfig?: IndicatorConfig) => {
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (slotRef) { slotRef.addIndicator(type, fromConfig); return; }
}
setIndicators(prev => {
if (prev.some(i => i.type === type)) return prev;
const def = getIndicatorDef(type);
if (!def) return prev;
let cfg: IndicatorConfig;
if (fromConfig) {
cfg = { ...fromConfig, id: newIndId(), type: def.type };
} else {
const params = getParams(type, def.defaultParams);
const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(type, def.plots, def.hlines);
cfg = {
id: newIndId(),
type: def.type,
params,
plots,
hlines,
...(cloudColors ? { cloudColors } : {}),
...(bandBackground ? { bandBackground } : {}),
};
}
if (type === 'SMA') {
cfg = normalizeSmaConfig({
...cfg,
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
});
} else if (type === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg);
}
return sortIndicatorsByTypeOrder([...prev, cfg]);
});
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
/** 지표 추가 팝업 — 탭 전체 추가 (탭 목록 순서 유지) */
const handleAddIndicators = useCallback((types: string[]) => {
if (!types.length) return;
const orderedTypes = types.filter(t => getIndicatorDef(t));
if (orderedTypes.length === 0) return;
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (slotRef) {
slotRef.addIndicators(orderedTypes);
return;
}
}
setIndicators(
sortIndicatorsByTypeOrder(
replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId),
orderedTypes,
),
);
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
/**
* 지표 제거 (type 기준):
* - 싱글: 메인 차트에서 제거
* - 멀티: 활성 슬롯에서 제거
*/
const handleRemoveByType = useCallback((type: string) => {
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (slotRef) { slotRef.removeIndicatorByType(type); return; }
}
setIndicators(prev => removeIndicatorTypeFromList(prev, type));
}, [layoutDef.count, activeSlot]);
/**
* 타임프레임 변경:
* - 싱글: 메인 차트 변경
* - 멀티: 활성 슬롯 변경 (Interval Sync 시 전체 슬롯)
*/
// magnetMode 변경 → 싱글차트 + 멀티슬롯 전체 크로스헤어 모드 즉시 반영
useEffect(() => {
const enabled = magnetMode !== 'off';
// 싱글 레이아웃 메인 차트
managerRef.current?.setCrosshairMagnet(enabled);
// 멀티 레이아웃 슬롯 전체
slotRefs.current.forEach(slot => slot?.setCrosshairMagnet(enabled));
}, [magnetMode]);
// 테마 순환: dark → blue → light → dark, DB 에 저장
const handleThemeToggle = useCallback(() => {
setTheme(t => {
const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark';
saveAppDef({ defaultTheme: next });
return next;
});
}, [saveAppDef]);
const handleTimeframe = useCallback((tf: Timeframe) => {
if (layoutDef.count > 1) {
slotRefs.current[activeSlot]?.setTimeframe(tf);
setActiveSlotTf(tf);
if (syncOptions.interval) {
slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setTimeframe(tf));
}
} else {
setTimeframe(tf);
}
// 타임프레임 변경 → DB 기본값 갱신
saveAppDef({ defaultTimeframe: tf });
}, [layoutDef.count, activeSlot, syncOptions.interval, saveAppDef]);
const chartIndicatorsForSettings = layoutDef.count > 1 ? activeSlotIndicators : indicators;
/** 지표 추가 팝업 — 항목별 설정 모달 */
const handleOpenIndicatorSettings = useCallback((type: string) => {
const onChart = chartIndicatorsForSettings.find(i => i.type === type);
setSettingsModalId(onChart ? onChart.id : indicatorSettingsTemplateId(type));
setCtxToolbar(null);
}, [chartIndicatorsForSettings]);
/** 지표 설정 저장 (params/plots/plotVisibility 변경) → 자동으로 재렌더링 */
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
const fromTemplate =
isIndicatorSettingsTemplateId(settingsModalId ?? '')
|| updated.id.startsWith('template_');
markChartIndicatorSaveCommitted();
saveParams(updated.type, updated.params);
saveVisual(
updated.type,
updated.plots,
updated.hlines,
updated.cloudColors,
updated.bandBackground,
);
const applyUpdate = (prev: IndicatorConfig[]): IndicatorConfig[] => {
if (fromTemplate) {
const merged = enrichIndicatorConfig(updated);
return prev.map(ind =>
ind.type === updated.type
? { ...merged, id: ind.id, hidden: ind.hidden }
: ind,
);
}
return prev.map(ind => (ind.id === updated.id ? updated : ind));
};
if (layoutDef.count > 1) {
applyChartIndicators(applyUpdate(activeSlotIndicators));
} else {
setIndicators(applyUpdate);
}
setSettingsModalId(null);
setCtxToolbar(null);
}, [
saveParams,
saveVisual,
settingsModalId,
layoutDef.count,
activeSlotIndicators,
applyChartIndicators,
]);
const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators;
/** 설정 화면에서 DB 기본값 저장 후 차트 복귀 시 — 전역 기본값을 차트 인스턴스에 일괄 반영 */
const lastSyncedSettingsRevisionRef = useRef(indicatorSettingsRevision);
const syncChartsFromSavedDefaults = useCallback(() => {
const merge = (inds: IndicatorConfig[]) =>
mergeGlobalDefaultsOntoChartIndicators(inds, getParams, getVisualConfig);
if (layoutDef.count > 1) {
slotRefs.current.slice(0, layoutDef.count).forEach(slot => {
if (!slot) return;
const current = slot.getIndicators();
const next = merge(current);
if (chartIndicatorsNeedDefaultsRefresh(current, next)) {
slot.setIndicators(next);
}
});
const activeInds = slotRefs.current[activeSlot]?.getIndicators() ?? activeSlotIndicators;
const mergedActive = merge(activeInds);
if (chartIndicatorsNeedDefaultsRefresh(activeInds, mergedActive)) {
setActiveSlotIndicators(mergedActive.map(i => ({ ...i })));
}
} else {
setIndicators(prev => {
const next = merge(prev);
return chartIndicatorsNeedDefaultsRefresh(prev, next) ? next : prev;
});
}
}, [layoutDef.count, activeSlot, activeSlotIndicators, getParams, getVisualConfig]);
useLayoutEffect(() => {
if (!chartVisible) return;
if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return;
if (consumeSkipNextChartDefaultsSync()) {
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
return;
}
syncChartsFromSavedDefaults();
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
}, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
/** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */
const handleBulkIndicatorApply = useCallback((result: {
chartIndicators: IndicatorConfig[];
allConfigs: IndicatorConfig[];
}) => {
const { chartIndicators, allConfigs } = result;
applyChartIndicators(sortIndicatorsByTypeOrder(chartIndicators));
allConfigs.forEach(ind => {
saveParams(ind.type, ind.params);
saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors, ind.bandBackground);
});
setShowBulkIndSettings(false);
}, [applyChartIndicators, saveParams, saveVisual]);
/** 지표 숨김/표시 토글 */
const handleToggleIndicatorHidden = useCallback((id: string) => {
setIndicators(prev => prev.map(ind =>
ind.id === id ? { ...ind, hidden: !ind.hidden } : ind
));
}, []);
/** 지표 ID로 삭제 */
const handleDuplicateIndicator = useCallback((sourceId: string) => {
if (layoutDef.count > 1) {
slotRefs.current[activeSlot]?.duplicateIndicator(sourceId);
return;
}
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
}, [layoutDef.count, activeSlot]);
const handleRemoveIndicatorById = useCallback((id: string) => {
setIndicators(prev => removeIndicatorFromList(prev, id));
setFocusedIndicatorId(prev => prev === id ? null : prev);
setCtxToolbar(null);
setSettingsModalId(null);
}, []);
const handleScreenshot = () => {
const url = managerRef.current?.screenshot();
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = `chart_${symbol}_${timeframe}_${Date.now()}.png`;
a.click();
};
const handleFullscreen = () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
else document.exitFullscreen().catch(() => {});
};
// 실시간 모드: latestBar 우선, 없으면 마지막 bars 사용
const lastKnownBar = useUpbit
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
: (bars.length > 0 ? bars[bars.length - 1] : null);
/** 크로스헤어 OHLC·지표 범례 */
const hoverBar = legend ?? lastKnownBar;
/** 종목명 옆·우측 상단 시세 — 실시간 티커/최신 봉 (크로스헤어 무관) */
const quoteBar = lastKnownBar;
const liveQuote = useMemo(
() => resolveChartLiveQuote(chartTicker, quoteBar, bars),
[chartTicker, quoteBar, bars],
);
const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]);
if (!appEntered) {
return (
<SplashScreen
onLoginSuccess={handleLoginSuccess}
onGuest={handleGuestEnter}
/>
);
}
return (
<TradeNotificationProvider
popupEnabled={appDefaults.tradeAlertPopup ?? true}
soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true}
soundId={appDefaults.tradeAlertSound ?? 'bell'}
settingsSessionKey={authUser?.userId ?? 0}
>
<VerificationIssueNotificationProvider
enabled={canMenu('verification-board')}
notifyEnabled={appDefaults.verificationIssueNotify}
>
<div className={`app ${theme} mode-${mode}${isMobile ? ' app--mobile' : ''}`}>
<LiveSignalNotifier
ref={liveNotifierRef}
markets={monitoredMarkets}
marketSubscriptions={liveStompSubscriptions}
activeMarket={symbol}
enabled={formalLogin && monitoredMarkets.length > 0}
popupEnabled={appDefaults.tradeAlertPopup ?? true}
chartMarkersActive={menuPage === 'chart'}
onMarkersChange={markers => {
liveMarkersRef.current = markers;
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
}}
/>
{/* ── 최상단 글로벌 메뉴바 ─────────────────────────────────────── */}
<NotifyTopMenuBar
activePage={menuPage}
theme={theme}
onPage={guardedSetMenuPage}
onTheme={handleThemeToggle}
onOpenAppDownload={() => 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 })}
/>
<LoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
onSuccess={handleLoginSuccess}
/>
{appDownloadOpen && (
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
)}
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
{menuPage === 'dashboard' && (
<Suspense fallback={<PageLoadFallback />}>
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
</Suspense>
)}
{menuPage === 'strategy' && (
formalLogin
? (
<Suspense fallback={<PageLoadFallback />}>
<StrategyPage theme={theme} activeIndicators={indicators} />
</Suspense>
)
: <FormalLoginGate onLogin={requireFormalLogin} />
)}
{menuPage === 'strategy-editor' && (
formalLogin
? (
<Suspense fallback={<PageLoadFallback />}>
<StrategyEditorPage theme={theme} />
</Suspense>
)
: <FormalLoginGate onLogin={requireFormalLogin} />
)}
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
{menuPage === 'backtest' && (
formalLogin
? (
<Suspense fallback={<PageLoadFallback />}>
<BacktestHistoryPage theme={theme} />
</Suspense>
)
: <FormalLoginGate onLogin={requireFormalLogin} />
)}
{menuPage === 'paper' && (
formalLogin ? (
<Suspense fallback={<PageLoadFallback />}>
<PaperTradingPage
theme={theme}
tickers={marketTickers}
refreshKey={paperRefreshKey}
defaultMarket={symbol}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={handlePaperOrderFilled}
onOpenSettings={() => {
if (!formalLogin) { requireFormalLogin(); return; }
setSettingsInitialCategory('paper');
setMenuPage('settings');
}}
onGoChart={m => {
goToMarketChart(m);
setMenuPage('chart');
}}
/>
</Suspense>
) : (
<FormalLoginGate onLogin={requireFormalLogin} title="투자관리" />
)
)}
{menuPage === 'virtual' && (
formalLogin ? (
<Suspense fallback={<PageLoadFallback />}>
<VirtualTradingPage
theme={theme}
tickers={marketTickers}
defaultMarket={symbol}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
onPaperOrderFilled={handlePaperOrderFilled}
/>
</Suspense>
) : (
<FormalLoginGate onLogin={requireFormalLogin} title="가상매매" />
)
)}
{menuPage === 'trend-search' && (
<Suspense fallback={<PageLoadFallback />}>
<TrendSearchPage
theme={theme}
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
chartCandleAreaPriceLabels={appDefaults.chartCandleAreaPriceLabels}
chartSeriesPriceLabels={appDefaults.chartSeriesPriceLabels}
tickers={marketTickers}
/>
</Suspense>
)}
{menuPage === 'verification-board' && (
<Suspense fallback={<PageLoadFallback />}>
<VerificationBoardPage
theme={theme}
focusIssueId={verificationFocusIssueId}
onFocusIssueConsumed={() => setVerificationFocusIssueId(null)}
/>
</Suspense>
)}
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
{menuPage === 'notifications' && canMenu('notifications') && (
formalLogin ? (
<Suspense fallback={<PageLoadFallback />}>
<TradeNotificationListPage
theme={theme}
tickers={marketTickers}
defaultMarket={symbol}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={handlePaperOrderFilled}
onGoToChart={market => {
goToMarketChart(market);
setMenuPage('chart');
}}
/>
</Suspense>
) : (
<FormalLoginGate onLogin={requireFormalLogin} title="알림목록" />
)
)}
{menuPage === 'settings' && canMenu('settings') && (
<Suspense fallback={<PageLoadFallback />}>
<SettingsPage
isFormalLogin={formalLogin}
onRequireFormalLogin={requireFormalLogin}
theme={theme}
menuPermissions={menuPermissions}
magnetMode={magnetMode}
onMagnetMode={m => 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<string, boolean>,
});
}}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
onChartCrosshairInfoVisible={v => saveAppDef({ chartCrosshairInfoVisible: 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<typeof saveAppDef>[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}
/>
</Suspense>
)}
{/* ── 실시간 차트 화면 ────────────────────────────────────────────── */}
<div style={{ display: menuPage === 'chart' ? 'contents' : 'none' }}>
<ChartToolbarNotify
onOpenNotifyList={openNotificationsPage}
onOpenAppDownload={() => 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<string, boolean> });
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);
}
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) && (
<button
type="button"
className="mobile-panel-backdrop"
aria-label="패널 닫기"
onClick={() => { setShowMarketPanel(false); setMobileRightOpen(false); }}
/>
)}
<div className="tv-main">
{/* ── 좌측 마켓 패널 ─────────────────────────────────────────── */}
<MarketPanel
open={showMarketPanel}
tickers={marketTickers}
loading={marketLoading}
usdRate={usdRate}
activeSymbol={layoutDef.count > 1 ? activeSlotSymbol : symbol}
onSymbolSelect={s => {
if (layoutDef.count > 1) {
// 다중차트: 현재 활성 슬롯에만 적용, 호가창 심볼도 즉시 갱신
slotRefs.current[activeSlot]?.setSymbol(s);
setActiveSlotSymbol(s);
} else {
setSymbol(s);
setLegend(null);
}
}}
/>
{/* 마켓 패널 접기/펼치기 핸들 – tv-main flex 자식으로 항상 표시 */}
<button
className={`mp-panel-handle ${showMarketPanel ? 'mp-panel-handle--open' : ''}`}
onClick={() => setShowMarketPanel(v => !v)}
title={showMarketPanel ? '마켓 패널 닫기' : '마켓 패널 열기'}
>
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{showMarketPanel
? <polyline points="6,1 2,7 6,13" />
: <polyline points="2,1 6,7 2,13" />}
</svg>
</button>
<DrawingToolbar
activeTool={drawingTool} mode={mode}
onTool={setDrawingTool}
onClearAll={() => { 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 && (
<div
className="multi-chart-grid"
style={{
gridTemplateColumns: layoutDef.cols,
gridTemplateRows: layoutDef.rows,
gridTemplateAreas: layoutDef.areas,
}}
>
{/* 슬롯 0 = 메인 차트 영역 (a) */}
<div className="multi-slot-wrap" style={{ gridArea: 'a', minHeight: 0, minWidth: 0 }}>
<ChartSlot
ref={el => { slotRefs.current[0] = el; }}
slotIndex={0}
theme={theme}
active={activeSlot === 0}
onActivate={() => handleSetActiveSlot(0)}
initialSymbol={slot0InitProps?.symbol}
initialTimeframe={slot0InitProps?.timeframe}
initialIndicators={slot0InitProps?.indicators}
forcedSymbol={syncOptions.symbol ? symbol : undefined}
forcedTf={syncOptions.interval ? timeframe : undefined}
syncTime={syncOptions.crosshair ? syncTime : undefined}
onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }}
syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined}
onVisibleRangeChange={handleVisibleRangeChange}
syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined}
onLogicalRangeChange={handleLogicalRangeChange}
onFullView={sym => handleSlotFullView(sym, 0)}
magnifierEnabled={magnifierActive && activeSlot === 0}
onMagnifierClose={() => setMagnifierActive(false)}
onIndicatorsChange={activeSlot === 0 ? handleActiveSlotIndicatorsChange : undefined}
onSymbolChange={activeSlot === 0 ? handleActiveSlotSymbolChange : undefined}
magnetMode={magnetMode}
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartVolumeVisible={chartVolumeVisible}
chartPaneSeparator={chartPaneSeparator}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
marketTickers={marketTickers}
compactMode
chartVisible={chartVisible}
/>
</div>
{/* 슬롯 1~7 */}
{(['b','c','d','e','f','g','h'] as const)
.slice(0, layoutDef.count - 1)
.map((area, i) => (
<div key={area} className="multi-slot-wrap" style={{ gridArea: area, minHeight: 0, minWidth: 0 }}>
<ChartSlot
ref={el => { slotRefs.current[i + 1] = el; }}
slotIndex={i + 1}
theme={theme}
active={activeSlot === i + 1}
onActivate={() => handleSetActiveSlot(i + 1)}
initialSymbol={slotInitProps[i + 1]?.symbol}
initialTimeframe={slotInitProps[i + 1]?.timeframe}
initialIndicators={slotInitProps[i + 1]?.indicators}
forcedSymbol={syncOptions.symbol ? symbol : undefined}
forcedTf={syncOptions.interval ? timeframe : undefined}
syncTime={syncOptions.crosshair ? syncTime : undefined}
onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }}
syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined}
onVisibleRangeChange={handleVisibleRangeChange}
syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined}
onLogicalRangeChange={handleLogicalRangeChange}
onFullView={sym => handleSlotFullView(sym, i + 1)}
magnifierEnabled={magnifierActive && activeSlot === i + 1}
onMagnifierClose={() => setMagnifierActive(false)}
onIndicatorsChange={activeSlot === i + 1 ? handleActiveSlotIndicatorsChange : undefined}
onSymbolChange={activeSlot === i + 1 ? handleActiveSlotSymbolChange : undefined}
magnetMode={magnetMode}
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartVolumeVisible={chartVolumeVisible}
chartPaneSeparator={chartPaneSeparator}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
marketTickers={marketTickers}
compactMode
chartVisible={chartVisible}
/>
</div>
))}
</div>
)}
{/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */}
<div className="tv-chart-col" style={{ display: layoutDef.count > 1 ? 'none' : undefined }}>
<ChartLegendBar
visibility={chartLegendOptions}
crosshairInfoVisible={chartCrosshairInfoVisible}
symbol={symbol}
timeframe={timeframe}
useUpbit={useUpbit}
wsStatus={wsStatus}
mode={mode}
currentBar={hoverBar}
liveQuote={liveQuote}
indicators={indicators}
legend={legend}
/>
<TradingChart
key={chartMountKey}
chartVisible={chartVisible}
dataLoading={useUpbit && isLoading}
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
chartType={chartType} theme={theme} mode={mode}
indicators={effectiveIndicators} drawingTool={drawingTool}
drawings={drawings} logScale={logScale}
drawingsLocked={drawingsLocked}
drawingsVisible={drawingsVisible}
crosshairInfoVisible={chartCrosshairInfoVisible}
onCrosshair={setLegend}
onTradeOrderRequest={applyTradeFill}
onDataLoaded={() => syncBacktestMarkersRef.current()}
onCandlesReady={handleCandlesReady}
onManagerReady={mgr => {
managerRef.current = mgr;
const pending = pendingRealtimeBarRef.current;
if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbol && bars.length > 0) {
pendingRealtimeBarRef.current = null;
const last = bars[bars.length - 1];
if (last && pending.time === last.time) mgr.updateBar(pending);
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
else mgr.updateBar({ ...pending, time: last.time });
} else {
pendingRealtimeBarRef.current = null;
}
mgr.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
mgr.setPaneSeparatorOptions(chartPaneSeparator);
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
syncBacktestMarkersRef.current();
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
mgr.subscribeVisibleLogicalRange(r => {
if (r && r.from < LOAD_MORE_TRIGGER) {
singleLoadMoreRef.current();
}
});
}}
onAddDrawing={d => pushDrawing([...drawings, d])}
onSeriesClick={(entryId, point) => {
if (!entryId) { setCtxToolbar(null); return; }
setCtxToolbar({ entryId, screenX: point.x, screenY: point.y });
setDrawingCtxToolbar(null); // 드로잉 툴바 닫기
}}
onSeriesDoubleClick={(entryId) => {
setCtxToolbar(null);
if (!entryId) return;
if (entryId === '__main__') {
setShowMainChartModal(true);
} else {
setSettingsModalId(entryId);
}
}}
onHoverPaneLegend={handleHoverPaneLegend}
onLeavePaneLegend={handleLeavePaneLegend}
selectedDrawingId={selectedDrawingId}
onDrawingClick={handleDrawingClick}
onDrawingDoubleClick={handleDrawingDoubleClick}
magnifierEnabled={magnifierActive}
onMagnifierClose={() => setMagnifierActive(false)}
onReorderIndicators={handleReorderIndicators}
onMergeIndicators={handleMergeIndicators}
onSplitIndicatorPane={handleSplitIndicatorPane}
onExpandIndicator={handleExpandIndicator}
onRemoveIndicator={handleRemoveIndicatorById}
onDuplicateIndicator={handleDuplicateIndicator}
focusedIndicatorId={focusedIndicatorId}
onRestoreIndicators={handleRestoreIndicators}
onToggleIndicatorHidden={handleToggleIndicatorHidden}
onOpenIndicatorSettings={id => setSettingsModalId(id)}
volumeVisible={chartVolumeVisible}
candleAreaPriceLabelsEnabled={chartCandleAreaPriceLabels}
indicatorAreaPriceLabelsEnabled={chartSeriesPriceLabels}
paneSeparatorOptions={chartPaneSeparator}
/>
{isSingleLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span>과거 데이터 로딩...</span>
</div>
)}
{/* 백테스팅 결과 통계 배지 */}
{btMatchesChart && layoutDef.count === 1 && (
<BacktestPanel
stats={btStats}
signalCount={btSignals.length}
onClear={() => {
btMarkerPayloadRef.current = null;
btClear();
managerRef.current?.clearBacktestMarkers();
}}
/>
)}
{/* 실시간 전략 체크 패널 */}
{showLivePanel && menuPage === 'chart' && layoutDef.count === 1 && (
<LiveStrategyPanel
theme={theme}
market={symbol}
strategies={liveStrategies}
settings={liveStrategySettings}
watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length}
monitoredMarkets={monitoredMarkets}
virtualDriven={isVirtualActive}
onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }}
onSettingsChange={handleLiveSettingsChange}
onClose={() => setShowLivePanel(false)}
/>
)}
{/* 백테스팅 설정 모달 */}
{showBtSettings && (
<BacktestSettingsModal
onClose={() => setShowBtSettings(false)}
onSettingsChange={s => { setBtSettings(s); setShowBtSettings(false); }}
/>
)}
{/* 백테스팅 결과 모달 */}
{showBtResult && (
<BacktestResultModal
analysis={btAnalysis ?? null}
stats={btStats ?? null}
signals={btSignals}
strategyName={btStrategyName}
symbol={symbol}
timeframe={timeframe}
barCount={bars.length}
onClose={() => setShowBtResult(false)}
/>
)}
{/* 시리즈 클릭/호버 컨텍스트 툴바 */}
{ctxToolbar && (() => {
const cfg = ctxToolbar.entryId !== '__main__'
? indicators.find(i => i.id === ctxToolbar.entryId) ?? null
: null;
return (
<IndicatorContextToolbar
indicatorId={ctxToolbar.entryId}
config={cfg}
chartType={chartType}
screenX={ctxToolbar.screenX}
screenY={ctxToolbar.screenY}
onMouseEnterToolbar={clearHoverTimer}
onMouseLeaveToolbar={handleLeavePaneLegend}
onSettings={() => {
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 (
<DrawingContextToolbar
drawing={d}
screenX={drawingCtxToolbar.screenX}
screenY={drawingCtxToolbar.screenY}
onSettings={() => {
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 (
<FibTZSettingsModal
drawing={d}
onSave={handleSaveDrawing}
onCancel={handleCancelDrawing}
/>
);
}
return (
<DrawingSettingsModal
drawing={d}
onSave={updated => {
setDrawings(prev => prev.map(dr => dr.id === updated.id ? updated : dr));
setDrawingSettingsId(null);
setSelectedDrawingId(updated.id);
}}
onCancel={() => {
setDrawingSettingsId(null);
}}
/>
);
})()}
{/* 메인 차트 설정 모달 */}
{showMainChartModal && (
<MainChartSettingsModal
style={mainChartStyle}
onSave={handleMainChartSave}
onCancel={() => setShowMainChartModal(false)}
/>
)}
{/* 보조지표 일괄 설정 */}
{showBulkIndSettings && (
<IndicatorBulkSettingsModal
open={showBulkIndSettings}
indicators={bulkSettingsIndicators}
chartMarket={symbol}
getParams={getParams}
getVisualConfig={getVisualConfig}
onApply={handleBulkIndicatorApply}
onLiveChartChange={applyChartIndicators}
onClose={() => 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 (
<IndicatorSettingsModal
config={cfg}
chartMarket={symbol}
onSave={handleIndicatorSave}
onCancel={() => setSettingsModalId(null)}
/>
);
})()}
</div>
{/* ── 우측 슬라이드 패널 (매수/매도 + 호가) ───────────────────── */}
<RightSidePanel
open={isMobile ? mobileRightOpen : undefined}
onOpenChange={isMobile ? setMobileRightOpen : undefined}
activeTab={isMobile ? mobileRightTab : undefined}
onTabChange={isMobile ? setMobileRightTab : undefined}
wsFeedEnabled={chartScreenActive && useUpbit}
market={chartSymbol}
tradePrice={chartTicker?.tradePrice ?? null}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={chartTicker ? {
tradePrice: chartTicker.tradePrice,
changeRate: chartTicker.changeRate,
changePrice: chartTicker.changePrice,
accTradePrice24: chartTicker.accTradePrice24,
accTradeVolume24: chartTicker.accTradeVolume24,
highPrice: chartTicker.highPrice,
lowPrice: chartTicker.lowPrice,
openingPrice: chartTicker.openingPrice,
} : undefined}
fillRequest={tradeFill}
onOrderbookPick={handleOrderbookPick}
onMarketSelect={handleTradeMarketSelect}
availableKrw={paperCashBalance}
availableCoinQty={paperCoinQty}
paperTradingEnabled={formalLogin && paperTradingEnabled}
paperAutoTradeEnabled={formalLogin && paperAutoTradeEnabled}
onPaperOrderFilled={() => {
refreshPaperAccount(symbol);
handlePaperOrderFilled();
}}
/>
</div>
{/* ── 플로팅 패널: position:fixed로 항상 표시 (싱글/멀티 모드 공통) ─── */}
{showObjectTree && (
<ObjectTree
drawings={drawings}
onToggleVisible={id => 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 && <StatsPanel stats={stats} symbol={symbol} onClose={() => setShowStats(false)} />}
{showWatch && (
<WatchList
watchlist={watchlist} currentSymbol={symbol} priceMap={priceMap}
onSelect={s => { 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 && (
<AlertManager
alerts={alerts} currentPrice={liveQuote.price ?? quoteBar?.close ?? 0}
onAdd={(price, label) => { 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 && (
<MobileChartDock
marketOpen={showMarketPanel}
rightOpen={mobileRightOpen}
rightTab={mobileRightTab}
drawingTool={drawingTool}
onToggleMarket={() => {
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}
/>
)}
<BottomBar
timeframe={layoutDef.count > 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}
/>
</div>{/* chart-screen */}
<AppNotificationLayer
menuPage={menuPage}
onPage={guardedSetMenuPage}
onGoToChart={goToMarketChart}
onGoToVerificationIssue={issueId => {
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();
}}
/>
</div>
</VerificationIssueNotificationProvider>
</TradeNotificationProvider>
);
}
export default App;