Files
goldenChart/frontend/src/chart/useChartWorkspace.ts
T
2026-06-16 08:53:35 +09:00

2068 lines
88 KiB
TypeScript

import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react';
import DrawingToolbar from '../components/DrawingToolbar';
import BottomBar from '../components/BottomBar';
import TradingChart from '../components/TradingChart';
import StatsPanel from '../components/StatsPanel';
import WatchList from '../components/WatchList';
import AlertManager from '../components/AlertManager';
import ObjectTree from '../components/ObjectTree';
import IndicatorContextToolbar from '../components/IndicatorContextToolbar';
import IndicatorSettingsModal from '../components/IndicatorSettingsModal';
import IndicatorBulkSettingsModal from '../components/IndicatorBulkSettingsModal';
import MainChartSettingsModal from '../components/MainChartSettingsModal';
import DrawingContextToolbar from '../components/DrawingContextToolbar';
import DrawingSettingsModal from '../components/DrawingSettingsModal';
import FibTZSettingsModal from '../components/FibTZSettingsModal';
import ChartSlot, { type ChartSlotHandle } from '../components/ChartSlot';
import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { generateBars, SYMBOLS } from '../utils/dataGenerator';
import {
normalizeChartTimeFormat,
setChartTimeFormat,
useChartTimeFormat,
} from '../utils/chartTimeFormat';
import {
setTradeAlertTimeFormat,
useTradeAlertTimeFormat,
} from '../utils/tradeAlertTimeFormat';
import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from '../utils/timezone';
import { calcStats, type PriceStats } from '../utils/calculations';
import { saveState, DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
import { useWorkspacePersist } from '../hooks/useWorkspacePersist';
import type { WatchlistItem } from '../utils/backendApi';
import {
addWatchlistItem, removeWatchlistItem,
loadWatchlist,
loadPaperSummary,
} from '../utils/backendApi';
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
import { initializeIndicatorConfigForEditor } from '../utils/indicatorSettingsEditor';
import {
buildMainIndicatorConfig,
chartIndicatorsNeedDefaultsRefresh,
indicatorSettingsTemplateId,
indicatorTypeFromSettingsId,
isIndicatorSettingsTemplateId,
mergeGlobalDefaultsOntoChartIndicators,
} from '../utils/indicatorMainConfig';
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
import {
type ChartOverlayToggleKey,
type ChartOverlayVisibility,
chartOverlayKeyForType,
DEFAULT_CHART_OVERLAY_VISIBILITY,
listChartOverlayToggleItems,
} from '../utils/chartOverlayVisibility';
import {
loadChartPriceExtremeLabelsVisible,
saveChartPriceExtremeLabelsVisible,
} from '../utils/chartPriceExtremeLabelsPref';
import {
type ChartCustomOverlaySelection,
type ChartCustomOverlaySlotId,
createDefaultChartCustomOverlaySelection,
isCustomOverlaySlotActive,
resolveActiveCustomOverlaySlot,
syncChartManagerCustomOverlaysByActiveSlot,
} from '../utils/chartCustomOverlay';
import { isUpbitMarket, UPBIT_MARKETS } from '../utils/upbitApi';
import { getFavorites, saveFavorites, initFavoritesFromDb, FAVORITES_CHANGED_EVENT } from '../utils/marketStorage';
import { useChartRealtimeData } from '../hooks/useChartRealtimeData';
import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from '../types';
import type { ChartManager } from '../utils/ChartManager';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import {
consumeSkipNextChartDefaultsSync,
markChartIndicatorSaveCommitted,
} from '../utils/indicatorChartDefaultsSync';
import {
duplicateIndicatorInList,
mergeIndicators,
reorderIndicatorGroup,
removeIndicatorFromList,
removeIndicatorTypeFromList,
splitIndicatorPane,
replaceIndicatorConfigsFromTypes,
} from '../utils/indicatorPaneMerge';
import {
sortIndicatorsByTypeOrder,
} from '../utils/indicatorListOrder';
import { getAppSettingsCache } from '../hooks/useAppSettings';
import type { AppSettingsDto } from '../utils/backendApi';
import MarketPanel from '../components/MarketPanel';
import { type MenuPage } from '../components/TopMenuBar';
import BacktestPanel from '../components/BacktestPanel';
import { useBacktest } from '../hooks/useBacktest';
import LiveStrategyPanel from '../components/LiveStrategyPanel';
import type { LiveMarker } from '../hooks/useLiveStrategyMarkers';
import type { LiveSignalNotifierHandle } from '../components/LiveSignalNotifier';
import BacktestSettingsModal from '../components/BacktestSettingsModal';
import BacktestAnalysisReportModal from '../components/backtest/BacktestAnalysisReportModal';
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
import { fmtListTimestamp } from '../utils/backtestUiUtils';
import ChartLegendBar from '../components/ChartLegendBar';
import { resolveChartLiveQuote } from '../utils/chartLiveQuote';
import RightSidePanel from '../components/RightSidePanel';
import {
type BacktestSettingsDto,
DEFAULT_BACKTEST_SETTINGS,
loadBacktestSettings,
loadStrategies as loadStrategiesForLive,
loadLiveStrategySettings,
loadActiveLiveStrategySettings,
expandLiveStrategySubscriptions,
type LiveStrategySettingsDto,
type BacktestSignal,
} from '../utils/backendApi';
import { useVirtualLiveStrategy } from '../hooks/useVirtualLiveStrategy';
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
import { VIRTUAL_SESSION_CHANGED_EVENT } from '../utils/virtualTradingStorage';
import { notifyPaperTradesChanged } from '../utils/paperTradeEvents';
import { useIsMobile, useIsCompactDevice } from '../hooks/useMediaQuery';
import { useMarketTicker, type TickerData } from '../hooks/useMarketTicker';
import { isTickerFeedPage } from '../utils/tickerFeedPages';
import ChartCustomOverlaySettingsModal from '../components/ChartCustomOverlaySettingsModal';
import MobileChartDock from '../components/MobileChartDock';
import { ChartToolbarNotify } from '../components/NotifyUiBindings';
import { loadState } from '../utils/storage';
import { resolveChartLegendOptions } from '../types/chartLegend';
import { resolveChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import type { ChartWorkspaceBridge } from './chartWorkspaceBridge';
import type { ChartWorkspaceSharedState } from './ChartWorkspaceContext';
import type { ChartWorkspaceViewProps } from './ChartWorkspaceView';
let _indCounter = 0;
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
/** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */
let _wsFlashTimer: ReturnType<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);
}
export interface UseChartWorkspaceParams {
sessionKey: number;
menuPage: MenuPage;
formalLogin: boolean;
requireFormalLogin: () => void;
theme: Theme;
onThemeToggle: () => void;
appDefaults: import('../utils/backendApi').AppSettingsDto;
appSettingsLoaded: boolean;
saveAppDef: (patch: Partial<AppSettingsDto>) => void;
getParams: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getParams'];
saveParams: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['saveParams'];
getVisualConfig: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getVisualConfig'];
saveVisual: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['saveVisual'];
saveAllIndicatorDefaults: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['saveAllIndicatorDefaults'];
indicatorSettingsRevision: number;
onOpenNotificationsPage: () => void;
}
export interface SettingsPageChartBindings {
magnetMode: 'off' | 'weak' | 'strong';
onMagnetMode: (m: 'off' | 'weak' | 'strong') => void;
liveStrategies: { id: number; name: string }[];
onClearLiveMarkers: () => void;
liveSettings: LiveStrategySettingsDto;
onLiveSettingsChange: (saved: LiveStrategySettingsDto) => void;
watchlistCount: number;
monitoredMarkets: string[];
virtualDriven: boolean;
chartIndicators: IndicatorConfig[];
onAddIndicatorToChart: (type: string, fromConfig?: IndicatorConfig) => void;
onRemoveIndicatorFromChart: (type: string) => void;
onIndicatorListOrderChange: (orderedTypes: string[]) => void;
chartCandleAreaPriceLabels: boolean;
onChartCandleAreaPriceLabels: (v: boolean) => void;
chartSeriesPriceLabels: boolean;
onChartSeriesPriceLabels: (v: boolean) => void;
chartVolumeVisible: boolean;
onChartVolumeVisible: (v: boolean) => void;
chartLiveReceiveHighlight: boolean;
onChartLiveReceiveHighlight: (v: boolean) => void;
chartLegendOptions: import('../types/chartLegend').ChartLegendVisibility | undefined;
onChartLegendOptionsChange: (patch: Partial<import('../types/chartLegend').ChartLegendVisibility>) => void;
chartCrosshairInfoVisible: boolean;
onChartCrosshairInfoVisible: (v: boolean) => void;
chartHoverToolbarVisible: boolean;
onChartHoverToolbarVisible: (v: boolean) => void;
chartPaneSeparator: import('../types/chartPaneSeparator').ChartPaneSeparatorOptions | undefined;
onChartPaneSeparatorChange: (opts: import('../types/chartPaneSeparator').ChartPaneSeparatorOptions) => void;
onPaperAccountReset: () => Promise<void>;
onDisplayTimezoneChange: (tz: string) => void;
onChartTimeFormatChange: (format: string) => void;
onTradeAlertTimeFormatChange: (format: string) => void;
displayTimezone: string;
chartTimeFormat: string;
tradeAlertTimeFormat: string;
}
export function useChartWorkspace({
sessionKey,
menuPage,
formalLogin,
requireFormalLogin,
theme,
onThemeToggle,
appDefaults,
appSettingsLoaded,
saveAppDef,
getParams,
saveParams,
getVisualConfig,
saveVisual,
saveAllIndicatorDefaults: _saveAllIndicatorDefaults,
indicatorSettingsRevision,
onOpenNotificationsPage,
}: UseChartWorkspaceParams) {
void _saveAllIndicatorDefaults;
const initial = loadState();
const chartVisible = menuPage === 'chart';
const chartTimeFormat = useChartTimeFormat();
const tradeAlertTimeFormat = useTradeAlertTimeFormat();
const displayTimezone = useDisplayTimezone();
const [symbol, setSymbol] = useState(initial.symbol);
const [timeframe, setTimeframe] = useState<Timeframe>(initial.timeframe);
const [chartType, setChartType] = useState<ChartType>(initial.chartType);
const [mode, setMode] = useState<ChartMode>('chart');
const [logScale, setLogScale] = useState(false);
const [indicators, setIndicators] = useState<IndicatorConfig[]>(initial.indicators ?? []);
const indicatorsRef = useRef(indicators);
useEffect(() => { indicatorsRef.current = indicators; }, [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);
/** 종목검색 패널 열림 — 전 종목 ticker loadFull 트리거 */
const [marketSearchOpen, setMarketSearchOpen] = useState(false);
const [mobileRightOpen, setMobileRightOpen] = useState(false);
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
const isMobile = useIsMobile();
const isCompactDevice = useIsCompactDevice();
const tickerFeedActive = isTickerFeedPage(menuPage);
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
const [layoutDef, setLayoutDef] = useState<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 = chartVisible;
const orderbookEnabled = chartScreenActive && isUpbitMarket(chartSymbol);
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol, orderbookEnabled);
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.defaultLayoutId) {
const def = LAYOUTS.find(l => l.id === appDefaults.defaultLayoutId);
if (def) setLayoutDef(def);
}
if (appDefaults.syncOptions) {
setSyncOptions(appDefaults.syncOptions as unknown as SyncOptions);
}
}, [appSettingsLoaded, appDefaults.defaultLayoutId, appDefaults.syncOptions]);
// 다중 → 싱글 전환 시 activeSlotIndicators 초기화
useEffect(() => {
if (layoutDef.count <= 1) setActiveSlotIndicators([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutDef.count]);
// ── 시리즈 클릭/호버 컨텍스트 툴바 ────────────────────────────────────
const [ctxToolbar, setCtxToolbar] = useState<{
entryId: string;
screenX: number;
screenY: number;
} | null>(null);
// 호버 hide 지연 타이머 (레이블 → 툴바 이동 시 깜빡임 방지)
const hoverHideTimer = useRef<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()])];
});
/** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */
const [favoritesList, setFavoritesList] = useState<string[]>(getFavorites);
// DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작)
const [dbLoaded, setDbLoaded] = useState(false);
/** DB·앱설정·지표 기본값 반영 완료 — 이후 STOMP 초기 로딩 시작 (이중 페인트 방지) */
const [chartBootstrapComplete, setChartBootstrapComplete] = useState(false);
/** 싱글 레이아웃 차트 paint 완료 */
const [singleChartPaintReady, setSingleChartPaintReady] = useState(false);
/** 멀티 슬롯별 paint 완료 */
const slotPaintReadyRef = useRef<Record<number, boolean>>({});
const [slotPaintEpoch, setSlotPaintEpoch] = useState(0);
const priorityMarkets = useMemo(
() => [symbol, ...watchlist],
[symbol, watchlist],
);
const getPriorityMarkets = useCallback(() => priorityMarkets, [priorityMarkets]);
const marketTickerOpts = useMemo(() => ({
enabled: tickerFeedActive,
lite: isCompactDevice,
loadFull: tickerFeedActive && (showMarketPanel || marketSearchOpen),
getPriorityMarkets,
}), [tickerFeedActive, isCompactDevice, showMarketPanel, marketSearchOpen, getPriorityMarkets]);
const {
tickers: marketTickers,
marketInfos,
loading: marketLoading,
usdRate,
refreshAllTickers,
} = useMarketTicker(marketTickerOpts);
const chartTicker = marketTickers.get(chartSymbol);
const orderbookPrevClose = chartTicker
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
: 0;
const chartCandleAreaPriceLabels = appDefaults.chartCandleAreaPriceLabels ?? true;
const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true;
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
const chartLegendOptions = resolveChartLegendOptions(appDefaults.chartLegendOptions);
const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true;
const chartHoverToolbarVisible = appDefaults.chartHoverToolbarVisible ?? true;
const chartPaneSeparator = resolveChartPaneSeparatorOptions(appDefaults.chartPaneSeparator);
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
const [paperCashBalance, setPaperCashBalance] = useState(0);
const [paperCoinQty, setPaperCoinQty] = useState(0);
const [paperRefreshKey, setPaperRefreshKey] = useState(0);
const handlePaperOrderFilled = useCallback(() => {
setPaperRefreshKey(k => k + 1);
notifyPaperTradesChanged();
}, []);
const refreshPaperAccount = useCallback(async (forMarket?: string) => {
const summary = await loadPaperSummary();
if (!summary) return;
setPaperCashBalance(summary.cashBalance);
const m = forMarket ?? symbol;
const pos = summary.positions?.find(p => p.symbol === m);
setPaperCoinQty(pos?.quantity ?? 0);
}, [symbol]);
useEffect(() => {
if (!appSettingsLoaded || !formalLogin) return;
refreshPaperAccount();
}, [appSettingsLoaded, formalLogin, refreshPaperAccount]);
useEffect(() => {
if (!appSettingsLoaded || !appDefaults.fcmPushEnabled) return;
import('../utils/fcmPush').then(({ initFcmPush }) => {
void initFcmPush((title, body) => {
console.info('[FCM]', title, body);
});
});
}, [appSettingsLoaded, appDefaults.fcmPushEnabled]);
useEffect(() => {
if (!appSettingsLoaded || !formalLogin || !paperTradingEnabled) return;
refreshPaperAccount(symbol);
}, [symbol, appSettingsLoaded, formalLogin, paperTradingEnabled, refreshPaperAccount]);
const [alerts, setAlerts] = useState<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('전략');
const [btReportCreatedAt, setBtReportCreatedAt] = useState<string | undefined>();
/** 현재 차트와 일치하는 백테스팅 결과만 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 (!chartVisible) return;
const raf = requestAnimationFrame(() => requestAnimationFrame(apply));
return () => cancelAnimationFrame(raf);
}, [appSettingsLoaded, menuPage, chartPaneSeparator]);
// mainChartStyle 변경 → ChartManager 즉시 적용
useEffect(() => {
managerRef.current?.updateMainSeriesStyle(mainChartStyle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mainChartStyle]);
// 종목·타임프레임 변경 시 이전 백테스팅 결과·마커 제거 + 진행 중 run 무효화
useEffect(() => {
btInvalidateRuns();
btMarkerPayloadRef.current = null;
btClear();
managerRef.current?.clearBacktestMarkers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [symbol, timeframe]);
// 백테스팅 시그널 → ref 갱신 후 ChartManager 마커 동기화
useEffect(() => {
if (btMatchesChart && btSignals.length > 0) {
const payload = {
symbol,
timeframe,
signals: btSignals,
showPrice: appDefaults.btShowPrice ?? false,
};
btMarkerPayloadRef.current = payload;
applyBacktestMarkersWhenReady(payload);
} else {
btMarkerPayloadRef.current = null;
managerRef.current?.clearBacktestMarkers();
}
let innerRaf = 0;
const outerRaf = requestAnimationFrame(() => {
innerRaf = requestAnimationFrame(syncBacktestMarkers);
});
return () => {
cancelAnimationFrame(outerRaf);
if (innerRaf) cancelAnimationFrame(innerRaf);
};
}, [btMatchesChart, btSignals, btRunSeq, symbol, timeframe, appDefaults.btShowPrice, applyBacktestMarkersWhenReady, syncBacktestMarkers]);
// 금액 표시 토글 변경 시 실시간 시그널 마커 텍스트 재적용
useEffect(() => {
if (!chartVisible || liveMarkersRef.current.length === 0) return;
managerRef.current?.setLiveStrategyMarkers(liveMarkersRef.current, appDefaults.btShowPrice);
}, [chartVisible, appDefaults.btShowPrice]);
// ── 시뮬레이션 데이터 (Upbit 아닐 때) ──────────────────────────────────
const [simBars, setSimBars] = useState<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;
setSingleChartPaintReady(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 handleSingleChartPaintReadyChange = useCallback((ready: boolean) => {
setSingleChartPaintReady(ready);
}, []);
const handleSlotPaintReadyChange = useCallback((slotIndex: number, ready: boolean) => {
slotPaintReadyRef.current[slotIndex] = ready;
setSlotPaintEpoch(v => v + 1);
}, []);
const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP';
const chartConfigReady = appSettingsLoaded && dbLoaded && chartBootstrapComplete;
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData(
symbol,
timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: chartScreenActive && useUpbit && chartConfigReady,
source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
}), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartConfigReady, chartRealtimeSource]),
);
// 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음)
const bars = useUpbit ? upbitBars : simBars;
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
barsMarketRef.current = barsMarket;
const chartBtReportModel = useMemo(() => {
if (!btMatchesChart) return null;
const reportSymbol = btSymbol ?? symbol;
const reportTf = btTimeframe ?? timeframe;
return buildChartBacktestReportModel({
analysis: btAnalysis,
stats: btStats,
signals: btSignals,
strategyName: btStrategyName,
symbol: reportSymbol,
timeframe: reportTf,
barCount: bars.length,
createdAt: btReportCreatedAt,
});
}, [
btMatchesChart,
btAnalysis,
btStats,
btSignals,
btStrategyName,
btSymbol,
btTimeframe,
symbol,
timeframe,
bars.length,
btReportCreatedAt,
]);
/** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (paint 전에 실행) */
useLayoutEffect(() => {
chartLiveReadyRef.current = false;
pendingRealtimeBarRef.current = null;
setSingleChartPaintReady(false);
}, [symbol, timeframe]);
useEffect(() => {
slotPaintReadyRef.current = {};
setSlotPaintEpoch(v => v + 1);
setSingleChartPaintReady(false);
}, [layoutDef.count, sessionKey]);
// Upbit 초기 로딩 완료 시 stats 업데이트
useEffect(() => {
if (useUpbit && upbitBars.length > 0) {
setStats(calcStats(upbitBars));
setLegend(null);
}
}, [useUpbit, upbitBars]);
// ── Watchlist 가격 맵 ──────────────────────────────────────────────────
const priceMap = useMemo(() => {
// 시세 패널이 열려 있고 ticker가 활성 상태일 때만 계산 (데모 심볼용)
if (!showMarketPanel || !tickerFeedActive) return {} as Record<string, { price: number; changePct: number }>;
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, showMarketPanel, tickerFeedActive]);
// ── DB 워크스페이스 동기화 ─────────────────────────────────────────────
const workspaceState = useMemo(() => ({
layoutId: layoutDef.id,
syncOptions,
slots: [{
slotIndex: 0,
symbol, timeframe, chartType, mode, logScale,
indicators, drawings, drawingsLocked, drawingsVisible,
mainChartStyle,
}],
}), [layoutDef.id, syncOptions, symbol, timeframe, chartType,
mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle]);
const { isLoaded: _wsLoaded } = useWorkspacePersist({
sessionKey,
state: workspaceState,
onLoad: (data) => {
try {
if (data.layoutId) {
const def = LAYOUTS.find(l => l.id === data.layoutId);
if (def) setLayoutDef(def);
}
if (data.syncOptions) setSyncOptions(data.syncOptions as SyncOptions);
const slot0 = Array.isArray(data.slots) && data.slots.length > 0
? (data.slots[0] as Record<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.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.defaultLayoutId);
if (def) setLayoutDef(def);
}, [_wsLoaded, dbLoaded, appDefaults.defaultLayoutId]);
// ── 키보드 단축키 ─────────────────────────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'Escape') {
setDrawingTool('cursor');
setSelectedDrawingId(null);
setDrawingCtxToolbar(null);
}
if (e.key === 'f' || e.key === 'F') managerRef.current?.fitContent();
if (e.key === 'l' || e.key === 'L') setLogScale(v => !v);
if (e.altKey && e.key === 's') handleScreenshot();
if (mode === 'trading') {
if (e.key === 'h' || e.key === 'H') setDrawingTool('hline');
if (e.key === 'v' || e.key === 'V') setDrawingTool('vline');
if (e.key === 't' || e.key === 'T') setDrawingTool('trendline');
if (e.key === 'm' || e.key === 'M') setDrawingTool('measure');
if (e.key === 'r' || e.key === 'R') setDrawingTool('rect');
if (e.key === 'z' && !e.ctrlKey) setDrawingTool('fibtz');
if (e.ctrlKey && e.key === 'z') { e.preventDefault(); handleUndo(); }
if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); handleRedo(); }
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
useEffect(() => {
if (mode === 'chart' && drawingTool !== 'zoom' && drawingTool !== 'magnifier') {
setDrawingTool('cursor');
}
}, [mode]);
useEffect(() => {
if (!chartVisible) {
setShowMarketPanel(false);
setMobileRightOpen(false);
}
}, [menuPage]);
useEffect(() => {
if (!isMobile) setMobileRightOpen(false);
}, [isMobile]);
// percentScale / showGrid 변경 시 ChartManager 에 즉시 반영
useEffect(() => { managerRef.current?.setPercentScale(percentScale); }, [percentScale]);
useEffect(() => { managerRef.current?.setGrid(showGrid); }, [showGrid]);
// ── 핸들러 ────────────────────────────────────────────────────────────
/** Time sync: 한 슬롯의 가시 시간 범위가 바뀌면 나머지에 전파 */
const handleVisibleRangeChange = useCallback((r: { from: number; to: number }) => {
if (syncOptions.time) setSyncVisibleRange(r);
}, [syncOptions.time]);
/** Date range sync: 한 슬롯의 가시 논리 범위가 바뀌면 나머지에 전파 */
const handleLogicalRangeChange = useCallback((r: { from: number; to: number }) => {
if (syncOptions.dateRange) setSyncLogicalRange(r);
}, [syncOptions.dateRange]);
/**
* 멀티차트 슬롯 전체 보기:
* 해당 슬롯의 심볼/타임프레임/지표를 싱글 차트에 적용하고 싱글 레이아웃으로 전환한다.
*/
const handleSlotFullView = useCallback((slotSymbol: string, slotIdx: number) => {
const slot = slotRefs.current[slotIdx];
if (slot) {
setSymbol(slot.getSymbol());
setTimeframe(slot.getTimeframe());
setIndicators(slot.getIndicators().map(i => ({ ...i })));
} else {
setSymbol(slotSymbol);
}
setLegend(null);
setSlot0InitProps(null);
setLayoutDef(LAYOUTS[0]); // 싱글 레이아웃(id: '1')
}, []);
// ── 보조지표 단독 전체화면 모드 ────────────────────────────────────────────
const [focusedIndicatorId, setFocusedIndicatorId] = useState<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]);
useEffect(() => {
setSingleChartPaintReady(false);
}, [effectiveIndicators]);
const handleExpandIndicator = useCallback((id: string) => {
setFocusedIndicatorId(id);
}, []);
const handleRestoreIndicators = useCallback(() => {
setFocusedIndicatorId(null);
}, []);
const [overlayVisibility, setOverlayVisibility] = useState<ChartOverlayVisibility>(
() => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }),
);
const overlayVisibilityRef = useRef(overlayVisibility);
useEffect(() => {
overlayVisibilityRef.current = overlayVisibility;
}, [overlayVisibility]);
const candleOverlayToggles = useMemo(
() => listChartOverlayToggleItems(overlayVisibility),
[overlayVisibility],
);
const [chartPriceExtremeLabelsVisible, setChartPriceExtremeLabelsVisible] = useState(
() => loadChartPriceExtremeLabelsVisible(),
);
useEffect(() => {
managerRef.current?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible);
slotRefs.current.forEach(slot => slot?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible));
}, [chartPriceExtremeLabelsVisible]);
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
() => createDefaultChartCustomOverlaySelection(),
);
const [custom1OverlaySelection, setCustom1OverlaySelection] = useState<ChartCustomOverlaySelection>(
() => createDefaultChartCustomOverlaySelection(),
);
const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState<ChartCustomOverlaySlotId | null>(null);
const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState<ChartCustomOverlaySlotId | null>(null);
const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom');
const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1');
const syncCustomOverlaysToManager = useCallback((mgr: ChartManager) => {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
activeCustomOverlaySlot,
customOverlaySelection,
custom1OverlaySelection,
);
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleCustomOverlayActiveChange = useCallback((active: boolean) => {
const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot);
setActiveCustomOverlaySlot(nextSlot);
const mgr = managerRef.current;
if (mgr) {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
nextSlot,
customOverlaySelection,
custom1OverlaySelection,
);
}
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleCustom1OverlayActiveChange = useCallback((active: boolean) => {
const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot);
setActiveCustomOverlaySlot(nextSlot);
const mgr = managerRef.current;
if (mgr) {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
nextSlot,
customOverlaySelection,
custom1OverlaySelection,
);
}
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleSaveCustomOverlaySelection = useCallback((
slot: ChartCustomOverlaySlotId,
selection: ChartCustomOverlaySelection,
) => {
const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection;
const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection;
if (slot === 'custom') setCustomOverlaySelection(selection);
else setCustom1OverlaySelection(selection);
setCustomOverlaySettingsSlot(null);
const mgr = managerRef.current;
if (!mgr) return;
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
activeCustomOverlaySlot,
nextCustomSel,
nextCustom1Sel,
);
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
setOverlayVisibility(prev => {
const next = { ...prev, [key]: !prev[key] };
const mgr = managerRef.current;
mgr?.setChartOverlayGroupVisible(key, next[key]);
if (mgr) {
for (const info of mgr.getIndicatorPaneInfo()) {
if (chartOverlayKeyForType(info.type) !== key) continue;
const reactInd = indicatorsRef.current.find(i => i.id === info.id);
mgr.applyIndicatorStyle(reactInd
? { ...info.config, ...reactInd, id: info.id, type: info.type }
: info.config);
}
}
return next;
});
}, []);
const handleTogglePriceExtremeLabels = useCallback(() => {
setChartPriceExtremeLabelsVisible(prev => {
const next = !prev;
saveChartPriceExtremeLabelsVisible(next);
managerRef.current?.setPriceExtremeLabelsVisible(next);
slotRefs.current.forEach(slot => slot?.setPriceExtremeLabelsVisible(next));
return next;
});
}, []);
/**
* 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동.
* overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨.
*/
const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => {
setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev));
}, []);
const handleMergeIndicators = useCallback((fromId: string, intoId: string) => {
setIndicators(prev => mergeIndicators(fromId, intoId, prev));
}, []);
const handleSplitIndicatorPane = useCallback((hostId: string) => {
setIndicators(prev => splitIndicatorPane(hostId, prev));
}, []);
/**
* 지표 추가:
* - 싱글 레이아웃: 메인 차트에 추가
* - 멀티 레이아웃: 활성 슬롯(ChartSlot ref)에 추가
*/
const applyChartIndicators = useCallback((chartIndicators: IndicatorConfig[]) => {
if (layoutDef.count > 1) {
slotRefs.current[activeSlot]?.setIndicators(chartIndicators);
setActiveSlotIndicators(chartIndicators.map(i => ({ ...i })));
} else {
setIndicators(chartIndicators);
}
}, [layoutDef.count, activeSlot]);
/** 설정 목록 순서 변경 → 차트 pane 순서 동기화 */
const handleIndicatorListOrderChange = useCallback((orderedTypes: string[]) => {
const resort = (inds: IndicatorConfig[]) => sortIndicatorsByTypeOrder(inds, orderedTypes);
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (!slotRef) return;
const next = resort(slotRef.getIndicators());
slotRef.setIndicators(next);
setActiveSlotIndicators(next.map(i => ({ ...i })));
} else {
setIndicators(prev => resort(prev));
}
}, [layoutDef.count, activeSlot]);
const handleAddIndicator = useCallback((type: string, fromConfig?: IndicatorConfig) => {
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (slotRef) { slotRef.addIndicator(type, fromConfig); return; }
}
setIndicators(prev => {
if (prev.some(i => i.type === type)) return prev;
const def = getIndicatorDef(type);
if (!def) return prev;
let cfg: IndicatorConfig;
if (fromConfig) {
cfg = { ...fromConfig, id: newIndId(), type: def.type };
} else {
const params = getParams(type, def.defaultParams);
const { plots, hlines, cloudColors, bandBackground, plotVisibility } = getVisualConfig(type, def.plots, def.hlines);
cfg = {
id: newIndId(),
type: def.type,
params,
plots,
hlines,
...(plotVisibility ? { plotVisibility } : {}),
...(cloudColors ? { cloudColors } : {}),
...(bandBackground ? { bandBackground } : {}),
};
}
if (type === 'SMA') {
cfg = normalizeSmaConfig({
...cfg,
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
});
} else if (type === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg);
}
return sortIndicatorsByTypeOrder([...prev, cfg]);
});
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
/** 지표 추가 팝업 — 탭 전체 추가 (탭 목록 순서 유지) */
const handleAddIndicators = useCallback((types: string[]) => {
if (!types.length) return;
const orderedTypes = types.filter(t => getIndicatorDef(t));
if (orderedTypes.length === 0) return;
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (slotRef) {
slotRef.addIndicators(orderedTypes);
return;
}
}
setIndicators(
sortIndicatorsByTypeOrder(
replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId),
orderedTypes,
),
);
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
/**
* 지표 제거 (type 기준):
* - 싱글: 메인 차트에서 제거
* - 멀티: 활성 슬롯에서 제거
*/
const handleRemoveByType = useCallback((type: string) => {
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (slotRef) { slotRef.removeIndicatorByType(type); return; }
}
setIndicators(prev => removeIndicatorTypeFromList(prev, type));
}, [layoutDef.count, activeSlot]);
/**
* 타임프레임 변경:
* - 싱글: 메인 차트 변경
* - 멀티: 활성 슬롯 변경 (Interval Sync 시 전체 슬롯)
*/
// magnetMode 변경 → 싱글차트 + 멀티슬롯 전체 크로스헤어 모드 즉시 반영
useEffect(() => {
const enabled = magnetMode !== 'off';
// 싱글 레이아웃 메인 차트
managerRef.current?.setCrosshairMagnet(enabled);
// 멀티 레이아웃 슬롯 전체
slotRefs.current.forEach(slot => slot?.setCrosshairMagnet(enabled));
}, [magnetMode]);
const handleTimeframe = useCallback((tf: Timeframe) => {
if (layoutDef.count > 1) {
slotRefs.current[activeSlot]?.setTimeframe(tf);
setActiveSlotTf(tf);
if (syncOptions.interval) {
slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setTimeframe(tf));
}
} else {
setTimeframe(tf);
}
// 타임프레임 변경 → DB 기본값 갱신
saveAppDef({ defaultTimeframe: tf });
}, [layoutDef.count, activeSlot, syncOptions.interval, saveAppDef]);
const chartIndicatorsForSettings = layoutDef.count > 1 ? activeSlotIndicators : indicators;
/** 지표 추가 팝업 — 항목별 설정 모달 */
const handleOpenIndicatorSettings = useCallback((type: string) => {
const onChart = chartIndicatorsForSettings.find(i => i.type === type);
setSettingsModalId(onChart ? onChart.id : indicatorSettingsTemplateId(type));
setCtxToolbar(null);
}, [chartIndicatorsForSettings]);
/** 지표 설정 저장 (params/plots/plotVisibility 변경) → 자동으로 재렌더링 */
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
const fromTemplate =
isIndicatorSettingsTemplateId(settingsModalId ?? '')
|| updated.id.startsWith('template_');
markChartIndicatorSaveCommitted();
saveParams(updated.type, updated.params);
saveVisual(
updated.type,
updated.plots,
updated.hlines,
updated.cloudColors,
updated.bandBackground,
updated.plotVisibility,
);
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(-1);
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 (!appSettingsLoaded || !dbLoaded) {
setChartBootstrapComplete(false);
return;
}
if (chartVisible && indicatorSettingsRevision > lastSyncedSettingsRevisionRef.current) {
if (consumeSkipNextChartDefaultsSync()) {
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
} else {
syncChartsFromSavedDefaults();
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
}
}
setChartBootstrapComplete(true);
}, [appSettingsLoaded, dbLoaded, chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
useEffect(() => {
setChartBootstrapComplete(false);
lastSyncedSettingsRevisionRef.current = -1;
}, [sessionKey]);
/** 보조지표 일괄 설정 적용 — 전체 지표 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, ind.plotVisibility);
});
setShowBulkIndSettings(false);
}, [applyChartIndicators, saveParams, saveVisual]);
/** 지표 숨김/표시 토글 */
const handleToggleIndicatorHidden = useCallback((id: string) => {
setIndicators(prev => prev.map(ind =>
ind.id === id ? { ...ind, hidden: !ind.hidden } : ind
));
}, []);
/** 지표 ID로 삭제 */
const handleDuplicateIndicator = useCallback((sourceId: string) => {
if (layoutDef.count > 1) {
slotRefs.current[activeSlot]?.duplicateIndicator(sourceId);
return;
}
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
}, [layoutDef.count, activeSlot]);
const handleRemoveIndicatorById = useCallback((id: string) => {
setIndicators(prev => removeIndicatorFromList(prev, id));
setFocusedIndicatorId(prev => prev === id ? null : prev);
setCtxToolbar(null);
setSettingsModalId(null);
}, []);
const handleScreenshot = () => {
const url = managerRef.current?.screenshot();
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = `chart_${symbol}_${timeframe}_${Date.now()}.png`;
a.click();
};
const handleFullscreen = () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
else document.exitFullscreen().catch(() => {});
};
// 실시간 모드: latestBar 우선, 없으면 마지막 bars 사용
const lastKnownBar = useUpbit
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
: (bars.length > 0 ? bars[bars.length - 1] : null);
/** 크로스헤어 OHLC·지표 범례 */
const hoverBar = legend ?? lastKnownBar;
/** 종목명 옆·우측 상단 시세 — 실시간 티커/최신 봉 (크로스헤어 무관) */
const quoteBar = lastKnownBar;
const liveQuote = useMemo(
() => resolveChartLiveQuote(chartTicker, quoteBar, bars),
[chartTicker, quoteBar, bars],
);
const goToMarketChartBridge = useCallback((market: string) => {
goToMarketChart(market);
}, [goToMarketChart]);
const bridge = useMemo<ChartWorkspaceBridge>(() => ({
goToMarket: goToMarketChartBridge,
getSymbol: () => (layoutDef.count > 1 ? activeSlotSymbol : symbol),
getIndicators: () => (layoutDef.count > 1 ? activeSlotIndicators : indicators),
}), [goToMarketChartBridge, layoutDef.count, activeSlotSymbol, symbol, activeSlotIndicators, indicators]);
const settingsPageProps = useMemo<SettingsPageChartBindings>(() => ({
magnetMode,
onMagnetMode: setMagnetMode,
liveStrategies,
onClearLiveMarkers: () => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); },
liveSettings: liveStrategySettings,
onLiveSettingsChange: handleLiveSettingsChange,
watchlistCount: isVirtualActive ? monitoredMarkets.length : watchlist.length,
monitoredMarkets,
virtualDriven: isVirtualActive,
chartIndicators: bulkSettingsIndicators,
onAddIndicatorToChart: handleAddIndicator,
onRemoveIndicatorFromChart: handleRemoveByType,
onIndicatorListOrderChange: handleIndicatorListOrderChange,
chartCandleAreaPriceLabels,
onChartCandleAreaPriceLabels: v => {
saveAppDef({ chartCandleAreaPriceLabels: v });
managerRef.current?.setCandleAreaPriceLabelsEnabled(v);
slotRefs.current.forEach(slot => slot?.setCandleAreaPriceLabelsEnabled(v));
},
chartSeriesPriceLabels,
onChartSeriesPriceLabels: v => {
saveAppDef({ chartSeriesPriceLabels: v });
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(v);
slotRefs.current.forEach(slot => slot?.setIndicatorAreaPriceLabelsEnabled(v));
},
chartVolumeVisible,
onChartVolumeVisible: v => {
saveAppDef({ chartVolumeVisible: v });
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
managerRef.current?.setVolumeVisible(v, wrapper?.clientHeight);
slotRefs.current.forEach(slot => slot?.setVolumeVisible(v));
},
chartLiveReceiveHighlight,
onChartLiveReceiveHighlight: v => saveAppDef({ chartLiveReceiveHighlight: v }),
chartLegendOptions,
onChartLegendOptionsChange: patch => {
saveAppDef({
chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record<string, boolean>,
});
},
chartCrosshairInfoVisible,
onChartCrosshairInfoVisible: v => saveAppDef({ chartCrosshairInfoVisible: v }),
chartHoverToolbarVisible,
onChartHoverToolbarVisible: v => saveAppDef({ chartHoverToolbarVisible: v }),
chartPaneSeparator,
onChartPaneSeparatorChange: opts => {
saveAppDef({ chartPaneSeparator: opts });
managerRef.current?.setPaneSeparatorOptions(opts);
managerRef.current?.refreshPaneSeparatorOverlay();
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts));
},
onPaperAccountReset: async () => {
const { resetPaperAccount } = await import('../utils/backendApi');
await resetPaperAccount();
await refreshPaperAccount();
},
onDisplayTimezoneChange: handleTimezoneChange,
onChartTimeFormatChange: handleChartTimeFormatChange,
onTradeAlertTimeFormatChange: handleTradeAlertTimeFormatChange,
displayTimezone,
chartTimeFormat,
tradeAlertTimeFormat,
}), [
magnetMode, liveStrategies, liveStrategySettings, handleLiveSettingsChange,
isVirtualActive, monitoredMarkets, watchlist, bulkSettingsIndicators,
handleAddIndicator, handleRemoveByType, handleIndicatorListOrderChange,
chartCandleAreaPriceLabels, chartSeriesPriceLabels, chartVolumeVisible,
chartLiveReceiveHighlight, chartLegendOptions, chartCrosshairInfoVisible,
chartHoverToolbarVisible, chartPaneSeparator, saveAppDef, clearLiveMarkers,
refreshPaperAccount, handleTimezoneChange, handleChartTimeFormatChange,
handleTradeAlertTimeFormatChange, displayTimezone, chartTimeFormat, tradeAlertTimeFormat,
]);
const contextValue = useMemo<ChartWorkspaceSharedState>(() => ({
symbol,
indicators,
goToMarketChart,
refreshPaperAccount,
paperCashBalance,
paperCoinQty,
handlePaperOrderFilled,
paperRefreshKey,
mode,
priorityMarkets,
marketPanelOpen: showMarketPanel,
marketTickers,
marketInfos,
marketLoading,
usdRate,
settingsPageProps,
}), [
symbol, indicators, goToMarketChart, refreshPaperAccount,
paperCashBalance, paperCoinQty, handlePaperOrderFilled, paperRefreshKey, mode,
priorityMarkets, showMarketPanel, marketTickers, marketInfos, marketLoading, usdRate,
settingsPageProps,
]);
const allSlotsPaintReady = useMemo(() => {
void slotPaintEpoch;
for (let i = 0; i < layoutDef.count; i++) {
if (!slotPaintReadyRef.current[i]) return false;
}
return layoutDef.count > 0;
}, [layoutDef.count, slotPaintEpoch]);
const chartDataFetched = !useUpbit || (!isLoading && bars.length >= 2 && barsMarket === symbol);
const chartPaintComplete = layoutDef.count > 1 ? allSlotsPaintReady : singleChartPaintReady;
const chartDataLoading = useUpbit && chartVisible && (!chartConfigReady || !chartDataFetched);
/** paint 완료 = reloadAll setData까지 끝남 — chartDataFetched 와 이중 게이트 시 영구 로딩 가능 */
const chartAreaLoading = useUpbit && chartVisible && (
!chartConfigReady || !chartPaintComplete
);
const viewProps: ChartWorkspaceViewProps = {
onOpenNotificationsPage,
symbol, timeframe, chartType, theme, mode, logScale, showGrid,
toolbarActiveIndicators, layoutDef, syncOptions, activeSlot,
activeSlotSymbol, activeSlotTf, slot0InitProps, slotInitProps,
syncTime, syncVisibleRange, syncLogicalRange, slotRefs, managerRef,
undoStack, redoStack, canUndo, canRedo, drawingTool, drawings,
drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode,
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
refreshAllTickers, setMarketSearchOpen,
formalLogin, requireFormalLogin, chartVisible, menuPage,
isMobile, indicators, effectiveIndicators, mainChartStyle,
showMainChartModal, showBulkIndSettings, settingsModalId, ctxToolbar,
drawingCtxToolbar, drawingSettingsId, selectedDrawingId,
focusedIndicatorId, overlayVisibility, overlayVisibilityRef,
candleOverlayToggles, customOverlaySelection, custom1OverlaySelection,
activeCustomOverlaySlot, customOverlaySettingsSlot, customOverlayActive,
custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels,
chartVolumeVisible, chartPriceExtremeLabelsVisible, chartPaneSeparator, chartCrosshairInfoVisible,
chartHoverToolbarVisible, chartLiveReceiveHighlight,
chartRealtimeSource: (chartRealtimeSource ?? 'BACKEND_STOMP') as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit,
wsStatus: (wsStatus ?? 'disconnected') as import('../hooks/useChartRealtimeData').WsStatus,
bars, barsMarket: barsMarket ?? symbol, barsMarketRef, chartMountKey, chartLiveReadyRef,
pendingRealtimeBarRef, isSingleLoadingMore, singleLoadMoreRef,
btMatchesChart, btRunning, btSettings, btStats, btSignals, btClear,
btMarkerPayloadRef, syncBacktestMarkersRef, showBtSettings, showBtResult,
showLivePanel, liveStrategies, liveStrategySettings, monitoredMarkets,
isVirtualActive, watchlist, chartBtReportModel, chartSymbol, chartScreenActive,
orderbook, obWsStatus, spread, orderbookPrevClose, chartTicker, tradeFill,
paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled,
appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote,
isLoading, error: error ?? null, bulkSettingsIndicators, chartIndicatorsForSettings,
chartConfigReady, chartAreaLoading, chartDataLoading,
getParams, getVisualConfig, activeSlotIndicators,
setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid,
setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible,
setIndicatorsVisible, setMagnetMode, setSnapToIndicator, setKeepLockedOnDelete,
setShowStats, setShowWatch, setShowAlert, setShowObjectTree, setMagnifierActive,
setShowMarketPanel, setMobileRightOpen, setMobileRightTab, setSyncTime,
setSyncVisibleRange, setSyncLogicalRange, setActiveSlotSymbol, setCanUndo,
setCanRedo, setCtxToolbar, setDrawingCtxToolbar, setDrawingSettingsId,
setSelectedDrawingId, setShowMainChartModal, setShowBulkIndSettings,
setSettingsModalId, setCustomOverlaySettingsSlot, setShowBtSettings,
setShowBtResult, setShowLivePanel, setWatchlist, setAlerts, setIndicators,
setSyncOptions, setPercentScale, setBtStrategyName, setBtReportCreatedAt, setBtSettings,
saveAppDef, applyChartIndicators,
onThemeToggle, handleTimeframe, handleLayoutSelect, handleSetActiveSlot,
handleActiveSlotIndicatorsChange, handleActiveSlotSymbolChange,
handleVisibleRangeChange, handleLogicalRangeChange, handleSlotFullView,
applyTradeFill, handleOrderbookPick, handleTradeMarketSelect, pushDrawing,
handleUndo, handleRedo, handleHoverPaneLegend, handleLeavePaneLegend,
clearHoverTimer, handleDrawingClick, handleDrawingDoubleClick,
handleMainChartSave, handleAddIndicator, handleAddIndicators,
handleRemoveByType, handleIndicatorListOrderChange, handleOpenIndicatorSettings,
handleIndicatorSave, handleBulkIndicatorApply, handleToggleIndicatorHidden,
handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot,
handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane,
handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay,
handleTogglePriceExtremeLabels,
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
handleCandlesReady, handleSingleChartPaintReadyChange, handleSlotPaintReadyChange,
applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers,
goToMarketChart, handleTimezoneChange,
};
const liveNotifierProps = {
markets: monitoredMarkets,
marketSubscriptions: liveStompSubscriptions,
activeMarket: symbol,
enabled: formalLogin && monitoredMarkets.length > 0 && appSettingsLoaded,
popupEnabled: appDefaults.tradeAlertPopup ?? true,
chartMarkersActive: chartVisible,
onMarkersChange: (markers: LiveMarker[]) => {
liveMarkersRef.current = markers;
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
},
};
return {
bridge,
contextValue,
viewProps,
liveNotifierProps,
liveNotifierRef,
};
}