2631 lines
112 KiB
TypeScript
2631 lines
112 KiB
TypeScript
import React, { 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, formatPrice, SYMBOLS } from './utils/dataGenerator';
|
|
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, 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 {
|
|
duplicateIndicatorInList,
|
|
mergeIndicators,
|
|
reorderIndicatorGroup,
|
|
removeIndicatorFromList,
|
|
removeIndicatorTypeFromList,
|
|
splitIndicatorPane,
|
|
replaceIndicatorConfigsFromTypes,
|
|
} from './utils/indicatorPaneMerge';
|
|
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 StrategyPage from './components/StrategyPage';
|
|
import StrategyEditorPage from './components/StrategyEditorPage';
|
|
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 { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings';
|
|
import { AppNotificationLayer } from './components/AppNotificationLayer';
|
|
import TradeNotificationListPage from './components/TradeNotificationListPage';
|
|
import BacktestSettingsModal from './components/BacktestSettingsModal';
|
|
import { BacktestResultModal } from './components/BacktestResultModal';
|
|
import { BacktestHistoryPage } from './components/BacktestHistoryPage';
|
|
import SettingsPage from './components/SettingsPage';
|
|
import PaperTradingPage from './components/PaperTradingPage';
|
|
import VirtualTradingPage from './components/VirtualTradingPage';
|
|
import TrendSearchPage from './components/TrendSearchPage';
|
|
import VerificationBoardPage from './components/VerificationBoardPage';
|
|
import DashboardPage from './components/DashboardPage';
|
|
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
|
import ChartLegendBar from './components/ChartLegendBar';
|
|
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 } from './hooks/useMediaQuery';
|
|
import MobileChartDock from './components/MobileChartDock';
|
|
import LoginModal from './components/LoginModal';
|
|
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 } 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/virtualTradingDashboard.css';
|
|
import './styles/backtestDashboard.css';
|
|
|
|
let _indCounter = 0;
|
|
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
|
|
|
|
// ─── 연결 상태 배지 ────────────────────────────────────────────────────────
|
|
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
|
if (!isUpbit) return null;
|
|
const map: Record<WsStatus, { dot: string; label: string }> = {
|
|
connecting: { dot: 'dot-yellow', label: '연결 중...' },
|
|
connected: { dot: 'dot-lime', label: '실시간' },
|
|
disconnected: { dot: 'dot-red', label: '재연결 중' },
|
|
error: { dot: 'dot-red', label: '오류' },
|
|
};
|
|
const { dot, label } = map[status];
|
|
return <span className={`ws-badge ${dot}`}><span className="ws-dot" />{label}</span>;
|
|
};
|
|
|
|
/** 모든 .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 [loginOpen, setLoginOpen] = 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();
|
|
|
|
// 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 [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(null);
|
|
const chartVisible = menuPage === 'chart';
|
|
const isMobile = useIsMobile();
|
|
|
|
// ── 마켓 패널 시세 데이터 ─────────────────────────────────────────────
|
|
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker();
|
|
|
|
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
|
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => {
|
|
const saved = localStorage.getItem('chartLayout');
|
|
return LAYOUTS.find(l => l.id === saved) ?? LAYOUTS[0];
|
|
});
|
|
const [syncOptions, setSyncOptions] = useState<SyncOptions>(() => {
|
|
try {
|
|
const s = localStorage.getItem('chartLayoutSync');
|
|
return s ? JSON.parse(s) : DEFAULT_SYNC;
|
|
} catch { return 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);
|
|
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe]);
|
|
|
|
const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol;
|
|
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol);
|
|
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]);
|
|
|
|
// 레이아웃/싱크 변경 시 localStorage 저장
|
|
useEffect(() => {
|
|
localStorage.setItem('chartLayout', layoutDef.id);
|
|
}, [layoutDef]);
|
|
useEffect(() => {
|
|
localStorage.setItem('chartLayoutSync', JSON.stringify(syncOptions));
|
|
}, [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);
|
|
|
|
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 (page === 'notifications' && !canMenu('notifications')) return;
|
|
if (page !== 'notifications' && !canMenu(page)) {
|
|
setMenuPage(firstAllowedTopMenu(menuPermissions));
|
|
return;
|
|
}
|
|
setMenuPage(page);
|
|
}, [canMenu, menuPermissions]);
|
|
|
|
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 chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true;
|
|
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
|
|
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
|
|
const chartLegendOptions = appDefaults.chartLegendOptions;
|
|
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) refreshPaperAccount();
|
|
}, [appSettingsLoaded, 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 && paperTradingEnabled) refreshPaperAccount(symbol);
|
|
}, [symbol, appSettingsLoaded, 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(() => {
|
|
refreshActiveLiveSettings();
|
|
}, [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) 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, 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 (isVirtualActive) return virtualMonitoredMarkets;
|
|
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
|
|
const set = new Set(watchlist);
|
|
if (symbol) set.add(symbol);
|
|
return [...set];
|
|
}, [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;
|
|
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' }));
|
|
}, [monitoredMarkets, activeLiveSettings, marketSubscriptions]);
|
|
|
|
useEffect(() => {
|
|
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 폴백 */ });
|
|
}, [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 => setLiveStrategies(
|
|
list
|
|
.filter(s => s.id != null)
|
|
.map(s => ({ id: s.id as number, name: s.name }))
|
|
))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refreshLiveStrategies();
|
|
}, [menuPage, showLivePanel, refreshLiveStrategies]);
|
|
|
|
const liveNotifierRef = useRef<LiveSignalNotifierHandle>(null);
|
|
const clearLiveMarkers = useCallback(() => {
|
|
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?.setDisplayTimezone(displayTimezone, tf);
|
|
}, [appSettingsLoaded, displayTimezone, timeframe, activeSlotTf, layoutDef.count]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
managerRef.current?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
|
slotRefs.current.forEach(slot => slot?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels));
|
|
}, [appSettingsLoaded, 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;
|
|
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
|
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator));
|
|
}, [appSettingsLoaded, 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]);
|
|
|
|
// ── 시뮬레이션 데이터 (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: useUpbit,
|
|
source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
|
|
}), [handleTickUpdate, handleNewCandle, 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]);
|
|
|
|
// ── 상태 저장 (localStorage) ───────────────────────────────────────────
|
|
useEffect(() => {
|
|
saveState({ symbol, timeframe, chartType, theme, indicators, watchlist, alertPrices: alerts.map(a => a.price) });
|
|
}, [symbol, timeframe, chartType, theme, indicators, watchlist, alerts]);
|
|
|
|
// 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)) {
|
|
setIndicators((slot0.indicators as IndicatorConfig[]).map(ind => {
|
|
if (ind.type === 'SMA') return normalizeSmaConfig(ind);
|
|
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind);
|
|
return ind;
|
|
}));
|
|
}
|
|
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)
|
|
? (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);
|
|
if (syms.length > 0) {
|
|
setWatchlist(syms);
|
|
setFavoritesList(syms);
|
|
saveFavorites(syms, false);
|
|
}
|
|
},
|
|
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]);
|
|
|
|
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 } = getVisualConfig(type, def.plots, def.hlines);
|
|
cfg = {
|
|
id: newIndId(),
|
|
type: def.type,
|
|
params,
|
|
plots,
|
|
hlines,
|
|
...(cloudColors ? { cloudColors } : {}),
|
|
};
|
|
}
|
|
if (type === 'SMA') {
|
|
cfg = normalizeSmaConfig({
|
|
...cfg,
|
|
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
|
|
});
|
|
} else if (type === 'IchimokuCloud') {
|
|
cfg = normalizeIchimokuConfig(cfg);
|
|
}
|
|
return [...prev, cfg];
|
|
});
|
|
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
|
|
|
/** 지표 추가 팝업 — 탭 전체 추가 (기존 보조지표 제거 후 탭 지표만 적용) */
|
|
const handleAddIndicators = useCallback((types: string[]) => {
|
|
if (!types.length) return;
|
|
if (layoutDef.count > 1) {
|
|
const slotRef = slotRefs.current[activeSlot];
|
|
if (slotRef) {
|
|
slotRef.addIndicators(types);
|
|
return;
|
|
}
|
|
}
|
|
setIndicators(
|
|
replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId),
|
|
);
|
|
}, [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_');
|
|
saveParams(updated.type, updated.params);
|
|
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
|
|
|
|
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;
|
|
syncChartsFromSavedDefaults();
|
|
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
|
}, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
|
|
|
|
/** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */
|
|
const handleBulkIndicatorApply = useCallback((result: {
|
|
chartIndicators: IndicatorConfig[];
|
|
allConfigs: IndicatorConfig[];
|
|
}) => {
|
|
const { chartIndicators, allConfigs } = result;
|
|
applyChartIndicators(chartIndicators);
|
|
allConfigs.forEach(ind => {
|
|
saveParams(ind.type, ind.params);
|
|
saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors);
|
|
});
|
|
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);
|
|
const currentBar = legend ?? lastKnownBar;
|
|
const isUp = currentBar ? currentBar.close >= currentBar.open : true;
|
|
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0;
|
|
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
|
|
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
|
|
|
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'}
|
|
>
|
|
<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={monitoredMarkets.length > 0}
|
|
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
|
strategyId={liveStrategySettings.strategyId ?? undefined}
|
|
executionType={liveStrategySettings.executionType ?? 'CANDLE_CLOSE'}
|
|
strategyName={liveStrategyName}
|
|
strategyNameByMarket={liveStrategyNameByMarket}
|
|
executionTypeByMarket={Object.fromEntries(
|
|
activeLiveSettings.map(s => [s.market, s.executionType ?? 'CANDLE_CLOSE']),
|
|
)}
|
|
chartMarkersActive={menuPage === 'chart'}
|
|
onMarkersChange={markers => {
|
|
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
|
|
}}
|
|
/>
|
|
|
|
{/* ── 최상단 글로벌 메뉴바 ─────────────────────────────────────── */}
|
|
<NotifyTopMenuBar
|
|
activePage={menuPage}
|
|
theme={theme}
|
|
onPage={guardedSetMenuPage}
|
|
onTheme={handleThemeToggle}
|
|
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}
|
|
/>
|
|
|
|
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
|
|
{menuPage === 'dashboard' && (
|
|
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
|
|
)}
|
|
|
|
{menuPage === 'strategy' && (
|
|
<StrategyPage theme={theme} activeIndicators={indicators} />
|
|
)}
|
|
|
|
{menuPage === 'strategy-editor' && (
|
|
<StrategyEditorPage theme={theme} />
|
|
)}
|
|
|
|
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
|
|
{menuPage === 'backtest' && (
|
|
<BacktestHistoryPage theme={theme} />
|
|
)}
|
|
|
|
{menuPage === 'paper' && (
|
|
<PaperTradingPage
|
|
theme={theme}
|
|
tickers={marketTickers}
|
|
refreshKey={paperRefreshKey}
|
|
defaultMarket={symbol}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={handlePaperOrderFilled}
|
|
onGoChart={m => {
|
|
goToMarketChart(m);
|
|
setMenuPage('chart');
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{menuPage === 'virtual' && (
|
|
<VirtualTradingPage
|
|
theme={theme}
|
|
tickers={marketTickers}
|
|
defaultMarket={symbol}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
|
|
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
|
onPaperOrderFilled={handlePaperOrderFilled}
|
|
/>
|
|
)}
|
|
|
|
{menuPage === 'trend-search' && (
|
|
<TrendSearchPage
|
|
theme={theme}
|
|
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
|
|
chartSeriesPriceLabels={appDefaults.chartSeriesPriceLabels}
|
|
tickers={marketTickers}
|
|
/>
|
|
)}
|
|
|
|
{menuPage === 'verification-board' && (
|
|
<VerificationBoardPage
|
|
theme={theme}
|
|
focusIssueId={verificationFocusIssueId}
|
|
onFocusIssueConsumed={() => setVerificationFocusIssueId(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
|
|
{menuPage === 'notifications' && (
|
|
<TradeNotificationListPage
|
|
theme={theme}
|
|
tickers={marketTickers}
|
|
defaultMarket={symbol}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={handlePaperOrderFilled}
|
|
onGoToChart={market => {
|
|
goToMarketChart(market);
|
|
setMenuPage('chart');
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{menuPage === 'settings' && canMenu('settings') && (
|
|
<SettingsPage
|
|
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}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
onChartSeriesPriceLabels={v => {
|
|
saveAppDef({ chartSeriesPriceLabels: v });
|
|
managerRef.current?.setSeriesPriceLabelsEnabled(v);
|
|
slotRefs.current.forEach(slot => slot?.setSeriesPriceLabelsEnabled(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>,
|
|
});
|
|
}}
|
|
chartPaneSeparator={chartPaneSeparator}
|
|
onChartPaneSeparatorChange={opts => {
|
|
saveAppDef({ chartPaneSeparator: opts });
|
|
managerRef.current?.setPaneSeparatorOptions(opts);
|
|
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}
|
|
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 서버가 비활성 상태입니다.');
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* ── 실시간 차트 화면 ────────────────────────────────────────────── */}
|
|
<div style={{ display: menuPage === 'chart' ? 'contents' : 'none' }}>
|
|
|
|
<ChartToolbarNotify
|
|
onOpenNotifyList={openNotificationsPage}
|
|
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}
|
|
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
|
|
? 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={() => setShowBtSettings(true)}
|
|
onOpenBtResults={() => setShowBtResult(true)}
|
|
hasBtResult={btMatchesChart}
|
|
onClearBtMarkers={() => {
|
|
btMarkerPayloadRef.current = null;
|
|
btClear();
|
|
managerRef.current?.clearBacktestMarkers();
|
|
}}
|
|
onToggleLiveStrategy={() => setShowLivePanel(v => !v)}
|
|
liveStrategyActive={showLivePanel || monitoredMarkets.length > 0}
|
|
/>
|
|
|
|
{/* 업비트 연결 상태 바 */}
|
|
{useUpbit && (
|
|
<div className={`upbit-bar ${wsStatus}`}>
|
|
<WsBadge status={wsStatus} isUpbit={useUpbit} />
|
|
<span className="upbit-market">{getKoreanName(symbol)} <span style={{opacity:0.5, fontSize:'11px'}}>{symbol}</span></span>
|
|
{isLoading && <span className="upbit-loading">데이터 로딩 중...</span>}
|
|
{error && <span className="upbit-error">⚠ {error}</span>}
|
|
{!isLoading && !error && currentBar && (
|
|
<>
|
|
<span className="upbit-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
|
{currentBar.close.toLocaleString()} KRW
|
|
</span>
|
|
<span className={`upbit-change ${isUp ? 'up' : 'down'}`}>
|
|
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className="upbit-hint">💡 업비트 마켓: KRW-BTC, KRW-ETH, KRW-XRP ...</span>
|
|
</div>
|
|
)}
|
|
|
|
{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)}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
chartVolumeVisible={chartVolumeVisible}
|
|
chartPaneSeparator={chartPaneSeparator}
|
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
|
displayTimezone={displayTimezone}
|
|
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)}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
chartVolumeVisible={chartVolumeVisible}
|
|
chartPaneSeparator={chartPaneSeparator}
|
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
|
displayTimezone={displayTimezone}
|
|
compactMode
|
|
chartVisible={chartVisible}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */}
|
|
<div className="tv-chart-col" style={{ display: layoutDef.count > 1 ? 'none' : undefined }}>
|
|
<ChartLegendBar
|
|
visibility={chartLegendOptions}
|
|
symbol={symbol}
|
|
timeframe={timeframe}
|
|
useUpbit={useUpbit}
|
|
wsStatus={wsStatus}
|
|
mode={mode}
|
|
currentBar={currentBar}
|
|
isUp={isUp}
|
|
dailyChange={dailyChange}
|
|
dailyChangePct={dailyChangePct}
|
|
indicators={indicators}
|
|
legend={legend}
|
|
/>
|
|
|
|
{/* 로딩 오버레이 */}
|
|
{useUpbit && isLoading && (
|
|
<div className="chart-loading">
|
|
<div className="loading-spinner" />
|
|
<span>업비트 데이터 로딩 중...</span>
|
|
</div>
|
|
)}
|
|
{isSingleLoadingMore && (
|
|
<div className="chart-history-loading">
|
|
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
|
<span>과거 데이터 로딩...</span>
|
|
</div>
|
|
)}
|
|
|
|
<TradingChart
|
|
key={chartMountKey}
|
|
chartVisible={chartVisible}
|
|
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}
|
|
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.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
|
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
|
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
|
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
|
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
|
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}
|
|
volumeVisible={chartVolumeVisible}
|
|
paneSeparatorOptions={chartPaneSeparator}
|
|
/>
|
|
|
|
{/* 백테스팅 결과 통계 배지 */}
|
|
{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}
|
|
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={paperTradingEnabled}
|
|
paperAutoTradeEnabled={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={currentBar?.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;
|