1199 lines
56 KiB
TypeScript
1199 lines
56 KiB
TypeScript
import '../styles/paperDashboard.css';
|
|
import '../styles/tradeRightPanel.css';
|
|
import React from 'react';
|
|
import DrawingToolbar from '../components/DrawingToolbar';
|
|
import BottomBar from '../components/BottomBar';
|
|
import TradingChart from '../components/TradingChart';
|
|
import StatsPanel from '../components/StatsPanel';
|
|
import WatchList from '../components/WatchList';
|
|
import AlertManager from '../components/AlertManager';
|
|
import ObjectTree from '../components/ObjectTree';
|
|
import IndicatorContextToolbar from '../components/IndicatorContextToolbar';
|
|
import IndicatorSettingsModal from '../components/IndicatorSettingsModal';
|
|
import IndicatorBulkSettingsModal from '../components/IndicatorBulkSettingsModal';
|
|
import MainChartSettingsModal from '../components/MainChartSettingsModal';
|
|
import DrawingContextToolbar from '../components/DrawingContextToolbar';
|
|
import DrawingSettingsModal from '../components/DrawingSettingsModal';
|
|
import FibTZSettingsModal from '../components/FibTZSettingsModal';
|
|
import ChartSlot, { type ChartSlotHandle } from '../components/ChartSlot';
|
|
import { type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
|
|
import type {
|
|
OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing,
|
|
ChartMode, MainChartStyle, TradeOrderFillRequest,
|
|
} from '../types';
|
|
import type { ChartManager } from '../utils/ChartManager';
|
|
import { LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
|
import type { WsStatus } from '../hooks/useChartRealtimeData';
|
|
import type { TickerData } from '../hooks/useMarketTicker';
|
|
import type { OrderbookState } from '../hooks/useUpbitOrderbook';
|
|
import type { BacktestStats } from '../utils/backendApi';
|
|
import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal';
|
|
import BacktestAnalysisReportModal from '../components/backtest/BacktestAnalysisReportModal';
|
|
import { type MenuPage } from '../components/TopMenuBar';
|
|
import BacktestPanel from '../components/BacktestPanel';
|
|
import LiveStrategyPanel from '../components/LiveStrategyPanel';
|
|
import BacktestSettingsModal from '../components/BacktestSettingsModal';
|
|
import MarketPanel from '../components/MarketPanel';
|
|
import ChartLegendBar from '../components/ChartLegendBar';
|
|
import RightSidePanel from '../components/RightSidePanel';
|
|
import ChartCustomOverlaySettingsModal from '../components/ChartCustomOverlaySettingsModal';
|
|
import MobileChartDock from '../components/MobileChartDock';
|
|
import { ChartToolbarNotify } from '../components/NotifyUiBindings';
|
|
import {
|
|
buildMainIndicatorConfig,
|
|
indicatorSettingsTemplateId,
|
|
indicatorTypeFromSettingsId,
|
|
isIndicatorSettingsTemplateId,
|
|
} from '../utils/indicatorMainConfig';
|
|
import { initializeIndicatorConfigForEditor } from '../utils/indicatorSettingsEditor';
|
|
import type { PriceStats } from '../utils/calculations';
|
|
import type { ChartOverlayToggleKey, ChartOverlayVisibility } from '../utils/chartOverlayVisibility';
|
|
import type { ChartCustomOverlaySelection, ChartCustomOverlaySlotId } from '../utils/chartCustomOverlay';
|
|
import type { ChartLegendVisibility } from '../types/chartLegend';
|
|
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
|
import type { LiveStrategySettingsDto } from '../utils/backendApi';
|
|
import type { BacktestSettingsDto } from '../utils/backendApi';
|
|
import { addWatchlistItem, removeWatchlistItem } from '../utils/backendApi';
|
|
import { fmtListTimestamp } from '../utils/backtestUiUtils';
|
|
|
|
export interface ChartWorkspaceViewProps {
|
|
onOpenNotificationsPage: () => void;
|
|
symbol: string;
|
|
timeframe: Timeframe;
|
|
chartType: ChartType;
|
|
theme: Theme;
|
|
mode: ChartMode;
|
|
logScale: boolean;
|
|
showGrid: boolean;
|
|
toolbarActiveIndicators: IndicatorConfig[];
|
|
layoutDef: LayoutDef;
|
|
syncOptions: SyncOptions;
|
|
activeSlot: number;
|
|
activeSlotSymbol: string;
|
|
activeSlotTf: Timeframe;
|
|
slot0InitProps: { symbol: string; timeframe: Timeframe; indicators: IndicatorConfig[] } | null;
|
|
slotInitProps: Record<number, {
|
|
symbol?: string;
|
|
timeframe?: Timeframe;
|
|
indicators?: IndicatorConfig[];
|
|
mainChartStyle?: MainChartStyle;
|
|
}>;
|
|
syncTime: number;
|
|
syncVisibleRange: { from: number; to: number } | null;
|
|
syncLogicalRange: { from: number; to: number } | null;
|
|
slotRefs: React.MutableRefObject<(ChartSlotHandle | null)[]>;
|
|
managerRef: React.MutableRefObject<ChartManager | null>;
|
|
undoStack: React.MutableRefObject<Drawing[][]>;
|
|
redoStack: React.MutableRefObject<Drawing[][]>;
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
drawingTool: string;
|
|
drawings: Drawing[];
|
|
drawingsLocked: boolean;
|
|
drawingsVisible: boolean;
|
|
indicatorsVisible: boolean;
|
|
magnetMode: 'off' | 'weak' | 'strong';
|
|
snapToIndicator: boolean;
|
|
keepLockedOnDelete: boolean;
|
|
legend: LegendData | null;
|
|
showStats: boolean;
|
|
showWatch: boolean;
|
|
showAlert: boolean;
|
|
showObjectTree: boolean;
|
|
percentScale: boolean;
|
|
magnifierActive: boolean;
|
|
showMarketPanel: boolean;
|
|
mobileRightOpen: boolean;
|
|
mobileRightTab: 'trade' | 'orderbook';
|
|
marketTickers: Map<string, TickerData>;
|
|
marketInfos: import('../hooks/useMarketTicker').MarketInfo[];
|
|
marketLoading: boolean;
|
|
usdRate: number;
|
|
refreshAllTickers: () => Promise<void>;
|
|
setMarketSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
formalLogin: boolean;
|
|
requireFormalLogin: () => void;
|
|
chartVisible: boolean;
|
|
menuPage: MenuPage;
|
|
isMobile: boolean;
|
|
indicators: IndicatorConfig[];
|
|
effectiveIndicators: IndicatorConfig[];
|
|
mainChartStyle: MainChartStyle;
|
|
showMainChartModal: boolean;
|
|
showBulkIndSettings: boolean;
|
|
settingsModalId: string | null;
|
|
ctxToolbar: { entryId: string; screenX: number; screenY: number } | null;
|
|
drawingCtxToolbar: { id: string; screenX: number; screenY: number } | null;
|
|
drawingSettingsId: string | null;
|
|
selectedDrawingId: string | null;
|
|
focusedIndicatorId: string | null;
|
|
overlayVisibility: ChartOverlayVisibility;
|
|
overlayVisibilityRef: React.MutableRefObject<ChartOverlayVisibility>;
|
|
candleOverlayToggles: ReturnType<typeof import('../utils/chartOverlayVisibility').listChartOverlayToggleItems>;
|
|
customOverlaySelection: ChartCustomOverlaySelection;
|
|
custom1OverlaySelection: ChartCustomOverlaySelection;
|
|
activeCustomOverlaySlot: ChartCustomOverlaySlotId | null;
|
|
customOverlaySettingsSlot: ChartCustomOverlaySlotId | null;
|
|
customOverlayActive: boolean;
|
|
custom1OverlayActive: boolean;
|
|
chartCandleAreaPriceLabels: boolean;
|
|
chartSeriesPriceLabels: boolean;
|
|
chartVolumeVisible: boolean;
|
|
chartPriceExtremeLabelsVisible: boolean;
|
|
chartPaneSeparator: ChartPaneSeparatorOptions;
|
|
chartCrosshairInfoVisible: boolean;
|
|
chartHoverToolbarVisible: boolean;
|
|
chartLiveReceiveHighlight: boolean;
|
|
chartRealtimeSource: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
|
|
displayTimezone: string;
|
|
chartTimeFormat: string;
|
|
chartLegendOptions: ChartLegendVisibility;
|
|
useUpbit: boolean;
|
|
wsStatus: WsStatus;
|
|
bars: OHLCVBar[];
|
|
barsMarket: string;
|
|
barsMarketRef: React.MutableRefObject<string | null>;
|
|
chartMountKey: number;
|
|
chartLiveReadyRef: React.MutableRefObject<boolean>;
|
|
pendingRealtimeBarRef: React.MutableRefObject<OHLCVBar | null>;
|
|
isSingleLoadingMore: boolean;
|
|
singleLoadMoreRef: React.MutableRefObject<() => void>;
|
|
btMatchesChart: boolean;
|
|
btRunning: boolean;
|
|
btSettings: BacktestSettingsDto;
|
|
btStats: BacktestStats | null;
|
|
btSignals: import('../utils/backendApi').BacktestSignal[];
|
|
btClear: () => void;
|
|
btMarkerPayloadRef: React.MutableRefObject<{
|
|
symbol: string;
|
|
timeframe: string;
|
|
signals: import('../utils/backendApi').BacktestSignal[];
|
|
showPrice: boolean;
|
|
} | null>;
|
|
syncBacktestMarkersRef: React.MutableRefObject<() => void>;
|
|
showBtSettings: boolean;
|
|
showBtResult: boolean;
|
|
showLivePanel: boolean;
|
|
liveStrategies: { id: number; name: string }[];
|
|
liveStrategySettings: LiveStrategySettingsDto;
|
|
monitoredMarkets: string[];
|
|
isVirtualActive: boolean;
|
|
watchlist: string[];
|
|
chartBtReportModel: BacktestAnalysisReportModel | null;
|
|
chartSymbol: string;
|
|
chartScreenActive: boolean;
|
|
orderbook: OrderbookState;
|
|
obWsStatus: WsStatus;
|
|
spread: { bestAsk: number; bestBid: number; spread: number; pct: number };
|
|
orderbookPrevClose: number;
|
|
chartTicker: TickerData | undefined;
|
|
tradeFill: TradeOrderFillRequest | null;
|
|
paperCashBalance: number;
|
|
paperCoinQty: number;
|
|
paperTradingEnabled: boolean;
|
|
paperAutoTradeEnabled: boolean;
|
|
appDefaults: import('../utils/backendApi').AppSettingsDto;
|
|
stats: PriceStats | null;
|
|
priceMap: Record<string, { price: number; changePct: number }>;
|
|
alerts: Array<{ price: number; label: string }>;
|
|
hoverBar: LegendData | OHLCVBar | null;
|
|
quoteBar: OHLCVBar | null;
|
|
liveQuote: ReturnType<typeof import('../utils/chartLiveQuote').resolveChartLiveQuote>;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
chartConfigReady: boolean;
|
|
chartAreaLoading: boolean;
|
|
chartDataLoading: boolean;
|
|
bulkSettingsIndicators: IndicatorConfig[];
|
|
chartIndicatorsForSettings: IndicatorConfig[];
|
|
getParams: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getParams'];
|
|
getVisualConfig: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getVisualConfig'];
|
|
activeSlotIndicators: IndicatorConfig[];
|
|
setSymbol: React.Dispatch<React.SetStateAction<string>>;
|
|
setLegend: React.Dispatch<React.SetStateAction<LegendData | null>>;
|
|
setChartType: React.Dispatch<React.SetStateAction<ChartType>>;
|
|
setMode: React.Dispatch<React.SetStateAction<ChartMode>>;
|
|
setLogScale: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setDrawingTool: React.Dispatch<React.SetStateAction<string>>;
|
|
setDrawings: React.Dispatch<React.SetStateAction<Drawing[]>>;
|
|
setDrawingsLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setDrawingsVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setIndicatorsVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setMagnetMode: React.Dispatch<React.SetStateAction<'off' | 'weak' | 'strong'>>;
|
|
setSnapToIndicator: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setKeepLockedOnDelete: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowStats: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowWatch: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowAlert: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowObjectTree: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setMagnifierActive: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowMarketPanel: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setMobileRightOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setMobileRightTab: React.Dispatch<React.SetStateAction<'trade' | 'orderbook'>>;
|
|
setSyncTime: React.Dispatch<React.SetStateAction<number>>;
|
|
setSyncVisibleRange: React.Dispatch<React.SetStateAction<{ from: number; to: number } | null>>;
|
|
setSyncLogicalRange: React.Dispatch<React.SetStateAction<{ from: number; to: number } | null>>;
|
|
setActiveSlotSymbol: React.Dispatch<React.SetStateAction<string>>;
|
|
setCanUndo: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setCanRedo: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setCtxToolbar: React.Dispatch<React.SetStateAction<{ entryId: string; screenX: number; screenY: number } | null>>;
|
|
setDrawingCtxToolbar: React.Dispatch<React.SetStateAction<{ id: string; screenX: number; screenY: number } | null>>;
|
|
setDrawingSettingsId: React.Dispatch<React.SetStateAction<string | null>>;
|
|
setSelectedDrawingId: React.Dispatch<React.SetStateAction<string | null>>;
|
|
setShowMainChartModal: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowBulkIndSettings: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setSettingsModalId: React.Dispatch<React.SetStateAction<string | null>>;
|
|
setCustomOverlaySettingsSlot: React.Dispatch<React.SetStateAction<ChartCustomOverlaySlotId | null>>;
|
|
setShowBtSettings: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowBtResult: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowLivePanel: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setWatchlist: React.Dispatch<React.SetStateAction<string[]>>;
|
|
setAlerts: React.Dispatch<React.SetStateAction<Array<{ price: number; label: string }>>>;
|
|
setIndicators: React.Dispatch<React.SetStateAction<IndicatorConfig[]>>;
|
|
setSyncOptions: React.Dispatch<React.SetStateAction<SyncOptions>>;
|
|
setPercentScale: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setBtStrategyName: React.Dispatch<React.SetStateAction<string>>;
|
|
setBtReportCreatedAt: React.Dispatch<React.SetStateAction<string | undefined>>;
|
|
setBtSettings: React.Dispatch<React.SetStateAction<BacktestSettingsDto>>;
|
|
saveAppDef: (patch: Partial<import('../utils/backendApi').AppSettingsDto>) => void;
|
|
applyChartIndicators: (chartIndicators: IndicatorConfig[]) => void;
|
|
onThemeToggle: () => void;
|
|
handleTimeframe: (tf: Timeframe) => void;
|
|
handleLayoutSelect: (def: LayoutDef) => void;
|
|
handleSetActiveSlot: (idx: number) => void;
|
|
handleActiveSlotIndicatorsChange: (inds: IndicatorConfig[]) => void;
|
|
handleActiveSlotSymbolChange: (sym: string) => void;
|
|
handleVisibleRangeChange: (r: { from: number; to: number }) => void;
|
|
handleLogicalRangeChange: (r: { from: number; to: number }) => void;
|
|
handleSlotFullView: (slotSymbol: string, slotIdx: number) => void;
|
|
applyTradeFill: (req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex?: number) => void;
|
|
handleOrderbookPick: (price: number, rowType: 'ask' | 'bid') => void;
|
|
handleTradeMarketSelect: (s: string) => void;
|
|
pushDrawing: (newDrawings: Drawing[]) => void;
|
|
handleUndo: () => void;
|
|
handleRedo: () => void;
|
|
handleHoverPaneLegend: (id: string, sx: number, sy: number) => void;
|
|
handleLeavePaneLegend: () => void;
|
|
clearHoverTimer: () => void;
|
|
handleDrawingClick: (id: string | null, sx: number, sy: number) => void;
|
|
handleDrawingDoubleClick: (id: string, sx: number, sy: number) => void;
|
|
handleMainChartSave: (style: MainChartStyle) => void;
|
|
handleAddIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
|
handleAddIndicators: (types: string[]) => void;
|
|
handleRemoveByType: (type: string) => void;
|
|
handleIndicatorListOrderChange: (orderedTypes: string[]) => void;
|
|
handleOpenIndicatorSettings: (type: string) => void;
|
|
handleIndicatorSave: (updated: IndicatorConfig) => void;
|
|
handleBulkIndicatorApply: (result: { chartIndicators: IndicatorConfig[]; allConfigs: IndicatorConfig[] }) => void;
|
|
handleToggleIndicatorHidden: (id: string) => void;
|
|
handleDuplicateIndicator: (sourceId: string) => void;
|
|
handleRemoveIndicatorById: (id: string) => void;
|
|
handleScreenshot: () => void;
|
|
handleReorderIndicators: (fromId: string, insertBeforeId: string | null) => void;
|
|
handleMergeIndicators: (fromId: string, intoId: string) => void;
|
|
handleSplitIndicatorPane: (hostId: string) => void;
|
|
handleExpandIndicator: (id: string) => void;
|
|
handleRestoreIndicators: () => void;
|
|
handleToggleCandleOverlay: (key: ChartOverlayToggleKey) => void;
|
|
handleTogglePriceExtremeLabels: () => void;
|
|
handleCustomOverlayActiveChange: (active: boolean) => void;
|
|
handleCustom1OverlayActiveChange: (active: boolean) => void;
|
|
handleSaveCustomOverlaySelection: (slot: ChartCustomOverlaySlotId, selection: ChartCustomOverlaySelection) => void;
|
|
syncCustomOverlaysToManager: (mgr: ChartManager) => void;
|
|
handleCandlesReady: () => void;
|
|
handleSingleChartPaintReadyChange: (ready: boolean) => void;
|
|
handleSlotPaintReadyChange: (slotIndex: number, ready: boolean) => void;
|
|
applyBacktestMarkersWhenReady: (payload: NonNullable<ChartWorkspaceViewProps['btMarkerPayloadRef']['current']>, attempt?: number) => void;
|
|
btRun: ReturnType<typeof import('../hooks/useBacktest').useBacktest>['run'];
|
|
refreshPaperAccount: (forMarket?: string) => Promise<void>;
|
|
handlePaperOrderFilled: () => void;
|
|
handleLiveSettingsChange: (saved: LiveStrategySettingsDto) => void;
|
|
clearLiveMarkers: () => void;
|
|
goToMarketChart: (market: string) => void;
|
|
handleTimezoneChange: (tz: string) => void;
|
|
}
|
|
|
|
function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|
const {
|
|
onOpenNotificationsPage,
|
|
symbol, timeframe, chartType, theme, mode, logScale, showGrid,
|
|
toolbarActiveIndicators, layoutDef, syncOptions, activeSlot,
|
|
activeSlotSymbol, activeSlotTf, slot0InitProps, slotInitProps,
|
|
syncTime, syncVisibleRange, syncLogicalRange, slotRefs, managerRef,
|
|
undoStack, redoStack, canUndo, canRedo, drawingTool, drawings,
|
|
drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode,
|
|
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
|
|
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
|
|
mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
|
|
refreshAllTickers, setMarketSearchOpen,
|
|
formalLogin, requireFormalLogin, chartVisible, menuPage, isMobile,
|
|
indicators, effectiveIndicators, mainChartStyle, showMainChartModal,
|
|
showBulkIndSettings, settingsModalId, ctxToolbar, drawingCtxToolbar,
|
|
drawingSettingsId, selectedDrawingId, focusedIndicatorId,
|
|
overlayVisibilityRef, candleOverlayToggles, customOverlaySelection,
|
|
custom1OverlaySelection, customOverlaySettingsSlot, customOverlayActive,
|
|
custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels,
|
|
chartVolumeVisible, chartPriceExtremeLabelsVisible, chartPaneSeparator, chartCrosshairInfoVisible,
|
|
chartHoverToolbarVisible, chartLiveReceiveHighlight, chartRealtimeSource,
|
|
displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit, wsStatus, bars, barsMarket,
|
|
barsMarketRef, chartMountKey, chartLiveReadyRef, pendingRealtimeBarRef,
|
|
isSingleLoadingMore, singleLoadMoreRef, btMatchesChart, btRunning,
|
|
btSettings, btStats, btSignals, btClear, btMarkerPayloadRef,
|
|
syncBacktestMarkersRef, showBtSettings, showBtResult, showLivePanel,
|
|
liveStrategies, liveStrategySettings, monitoredMarkets, isVirtualActive,
|
|
watchlist, chartBtReportModel, chartSymbol, chartScreenActive, orderbook,
|
|
obWsStatus, spread, orderbookPrevClose, chartTicker, tradeFill,
|
|
paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled,
|
|
appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote,
|
|
isLoading, error, chartConfigReady, chartAreaLoading, chartDataLoading, bulkSettingsIndicators, chartIndicatorsForSettings,
|
|
getParams, getVisualConfig, activeSlotIndicators,
|
|
setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid,
|
|
setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible,
|
|
setIndicatorsVisible, setMagnetMode, setSnapToIndicator, setKeepLockedOnDelete,
|
|
setShowStats, setShowWatch, setShowAlert, setShowObjectTree, setMagnifierActive,
|
|
setShowMarketPanel, setMobileRightOpen, setMobileRightTab, setSyncTime,
|
|
setSyncVisibleRange, setSyncLogicalRange, setActiveSlotSymbol, setCanUndo,
|
|
setCanRedo, setCtxToolbar, setDrawingCtxToolbar, setDrawingSettingsId,
|
|
setSelectedDrawingId, setShowMainChartModal, setShowBulkIndSettings,
|
|
setSettingsModalId, setCustomOverlaySettingsSlot, setShowBtSettings,
|
|
setShowBtResult, setShowLivePanel, setWatchlist, setAlerts, setIndicators,
|
|
setSyncOptions, setPercentScale, setBtStrategyName, setBtReportCreatedAt, setBtSettings,
|
|
saveAppDef, applyChartIndicators,
|
|
onThemeToggle, handleTimeframe, handleLayoutSelect, handleSetActiveSlot,
|
|
handleActiveSlotIndicatorsChange, handleActiveSlotSymbolChange,
|
|
handleVisibleRangeChange, handleLogicalRangeChange, handleSlotFullView,
|
|
applyTradeFill, handleOrderbookPick, handleTradeMarketSelect, pushDrawing,
|
|
handleUndo, handleRedo, handleHoverPaneLegend, handleLeavePaneLegend,
|
|
clearHoverTimer, handleDrawingClick, handleDrawingDoubleClick,
|
|
handleMainChartSave, handleAddIndicator, handleAddIndicators,
|
|
handleRemoveByType, handleIndicatorListOrderChange, handleOpenIndicatorSettings,
|
|
handleIndicatorSave, handleBulkIndicatorApply, handleToggleIndicatorHidden,
|
|
handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot,
|
|
handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane,
|
|
handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay,
|
|
handleTogglePriceExtremeLabels,
|
|
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
|
|
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
|
|
handleCandlesReady, handleSingleChartPaintReadyChange, handleSlotPaintReadyChange,
|
|
applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
|
|
handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers,
|
|
handleTimezoneChange,
|
|
} = props;
|
|
|
|
return (
|
|
<>
|
|
<ChartToolbarNotify
|
|
onOpenNotifyList={onOpenNotificationsPage}
|
|
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={onThemeToggle}
|
|
onMode={() => setMode(m => m === 'chart' ? 'trading' : 'chart')}
|
|
onLogScale={() => setLogScale(v => !v)}
|
|
onToggleGrid={() => setShowGrid(v => !v)}
|
|
onFitContent={() => managerRef.current?.fitContent()}
|
|
onScrollToNow={() => managerRef.current?.scrollToRealTime()}
|
|
onAddIndicator={handleAddIndicator}
|
|
onAddIndicators={handleAddIndicators}
|
|
onIndicatorOrderChange={handleIndicatorListOrderChange}
|
|
onRemoveByType={handleRemoveByType}
|
|
onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)}
|
|
onOpenIndicatorSettings={handleOpenIndicatorSettings}
|
|
onAutoFib={() => managerRef.current?.drawAutoFib(80)}
|
|
onClearFib={() => managerRef.current?.clearFib()}
|
|
onScreenshot={handleScreenshot}
|
|
onToggleStats={() => setShowStats(v => !v)}
|
|
onToggleWatch={() => setShowWatch(v => !v)}
|
|
onToggleAlert={() => setShowAlert(v => !v)}
|
|
magnetMode={magnetMode}
|
|
onToggleMagnet={() => setMagnetMode(m => m === 'off' ? 'strong' : 'off')}
|
|
onUndo={handleUndo}
|
|
onRedo={handleRedo}
|
|
canUndo={canUndo}
|
|
canRedo={canRedo}
|
|
onClearDrawings={() => {
|
|
// 모든 드로잉 삭제 + 피보나치 삭제 + undo/redo 스택 초기화
|
|
undoStack.current = [];
|
|
redoStack.current = [];
|
|
setCanUndo(false);
|
|
setCanRedo(false);
|
|
setDrawings([]);
|
|
managerRef.current?.clearFib();
|
|
managerRef.current?.clearDrawings();
|
|
}}
|
|
layoutId={layoutDef.id}
|
|
syncOptions={syncOptions}
|
|
onLayoutSelect={handleLayoutSelect}
|
|
onSyncChange={(key, val) => {
|
|
setSyncOptions(prev => {
|
|
const next = { ...prev, [key]: val };
|
|
saveAppDef({ syncOptions: next as unknown as Record<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;
|
|
});
|
|
}}
|
|
marketTickers={marketTickers}
|
|
marketInfos={marketInfos}
|
|
marketLoading={marketLoading}
|
|
refreshAllTickers={refreshAllTickers}
|
|
onMarketSearchOpenChange={setMarketSearchOpen}
|
|
backtestActive={btMatchesChart}
|
|
backtestRunning={btRunning}
|
|
backtestRunningId={undefined}
|
|
onRunBacktest={menuPage === 'chart' && layoutDef.count === 1 && formalLogin
|
|
? async (opts) => {
|
|
if (opts.strategyName) setBtStrategyName(opts.strategyName);
|
|
if (useUpbit && (isLoading || bars.length === 0)) return;
|
|
const runSymbol = symbol;
|
|
const runTf = timeframe;
|
|
const r = await btRun({
|
|
...opts,
|
|
bars,
|
|
timeframe: runTf,
|
|
settings: btSettings,
|
|
symbol: runSymbol,
|
|
});
|
|
if (
|
|
r &&
|
|
r.symbol === runSymbol &&
|
|
r.timeframe === runTf &&
|
|
r.signals.length > 0
|
|
) {
|
|
const showPrice = appDefaults.btShowPrice ?? false;
|
|
const payload = {
|
|
symbol: runSymbol,
|
|
timeframe: runTf,
|
|
signals: r.signals,
|
|
showPrice,
|
|
};
|
|
btMarkerPayloadRef.current = payload;
|
|
applyBacktestMarkersWhenReady(payload);
|
|
setBtReportCreatedAt(fmtListTimestamp(new Date().toISOString()));
|
|
}
|
|
if (appDefaults.btAutoPopup) setShowBtResult(true);
|
|
}
|
|
: undefined}
|
|
onOpenBtSettings={() => {
|
|
if (!formalLogin) { requireFormalLogin(); return; }
|
|
setShowBtSettings(true);
|
|
}}
|
|
onOpenBtResults={() => {
|
|
if (!formalLogin) { requireFormalLogin(); return; }
|
|
setShowBtResult(true);
|
|
}}
|
|
hasBtResult={btMatchesChart}
|
|
onClearBtMarkers={() => {
|
|
btMarkerPayloadRef.current = null;
|
|
btClear();
|
|
managerRef.current?.clearBacktestMarkers();
|
|
}}
|
|
onToggleLiveStrategy={() => {
|
|
if (!formalLogin) { requireFormalLogin(); return; }
|
|
setShowLivePanel(v => !v);
|
|
}}
|
|
liveStrategyActive={formalLogin && (showLivePanel || monitoredMarkets.length > 0)}
|
|
showChartQuote={useUpbit}
|
|
chartLiveQuote={liveQuote}
|
|
chartQuoteLoading={isLoading}
|
|
chartQuoteError={error}
|
|
priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
|
onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels}
|
|
tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition}
|
|
tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout}
|
|
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
|
|
onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })}
|
|
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
|
|
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
|
|
/>
|
|
|
|
{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}
|
|
marketInfos={marketInfos}
|
|
loading={marketLoading}
|
|
usdRate={usdRate}
|
|
refreshAllTickers={refreshAllTickers}
|
|
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,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{chartAreaLoading && (
|
|
<div className="chart-loading chart-loading--chart-area chart-loading--workspace" role="status" aria-live="polite">
|
|
<div className="loading-spinner" />
|
|
<span>차트 로딩중...</span>
|
|
</div>
|
|
)}
|
|
{/* 슬롯 0 = 메인 차트 영역 (a) */}
|
|
<div className="multi-slot-wrap" style={{ gridArea: 'a', minHeight: 0, minWidth: 0 }}>
|
|
<ChartSlot
|
|
ref={el => { slotRefs.current[0] = el; }}
|
|
slotIndex={0}
|
|
theme={theme}
|
|
active={activeSlot === 0}
|
|
onActivate={() => handleSetActiveSlot(0)}
|
|
initialSymbol={slot0InitProps?.symbol}
|
|
initialTimeframe={slot0InitProps?.timeframe}
|
|
initialIndicators={slot0InitProps?.indicators}
|
|
forcedSymbol={syncOptions.symbol ? symbol : undefined}
|
|
forcedTf={syncOptions.interval ? timeframe : undefined}
|
|
syncTime={syncOptions.crosshair ? syncTime : undefined}
|
|
onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }}
|
|
syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined}
|
|
onVisibleRangeChange={handleVisibleRangeChange}
|
|
syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined}
|
|
onLogicalRangeChange={handleLogicalRangeChange}
|
|
onFullView={sym => handleSlotFullView(sym, 0)}
|
|
magnifierEnabled={magnifierActive && activeSlot === 0}
|
|
onMagnifierClose={() => setMagnifierActive(false)}
|
|
onIndicatorsChange={activeSlot === 0 ? handleActiveSlotIndicatorsChange : undefined}
|
|
onSymbolChange={activeSlot === 0 ? handleActiveSlotSymbolChange : undefined}
|
|
magnetMode={magnetMode}
|
|
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
|
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
chartVolumeVisible={chartVolumeVisible}
|
|
chartPaneSeparator={chartPaneSeparator}
|
|
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
|
chartHoverToolbarVisible={chartHoverToolbarVisible}
|
|
chartPriceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
|
onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels}
|
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
|
displayTimezone={displayTimezone}
|
|
marketTickers={marketTickers}
|
|
marketInfos={marketInfos}
|
|
marketLoading={marketLoading}
|
|
refreshAllTickers={refreshAllTickers}
|
|
onMarketSearchOpenChange={setMarketSearchOpen}
|
|
compactMode
|
|
chartVisible={chartVisible}
|
|
chartConfigReady={chartConfigReady}
|
|
onPaintReadyChange={handleSlotPaintReadyChange}
|
|
/>
|
|
</div>
|
|
{/* 슬롯 1~7 */}
|
|
{(['b','c','d','e','f','g','h'] as const)
|
|
.slice(0, layoutDef.count - 1)
|
|
.map((area, i) => (
|
|
<div key={area} className="multi-slot-wrap" style={{ gridArea: area, minHeight: 0, minWidth: 0 }}>
|
|
<ChartSlot
|
|
ref={el => { slotRefs.current[i + 1] = el; }}
|
|
slotIndex={i + 1}
|
|
theme={theme}
|
|
active={activeSlot === i + 1}
|
|
onActivate={() => handleSetActiveSlot(i + 1)}
|
|
initialSymbol={slotInitProps[i + 1]?.symbol}
|
|
initialTimeframe={slotInitProps[i + 1]?.timeframe}
|
|
initialIndicators={slotInitProps[i + 1]?.indicators}
|
|
forcedSymbol={syncOptions.symbol ? symbol : undefined}
|
|
forcedTf={syncOptions.interval ? timeframe : undefined}
|
|
syncTime={syncOptions.crosshair ? syncTime : undefined}
|
|
onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }}
|
|
syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined}
|
|
onVisibleRangeChange={handleVisibleRangeChange}
|
|
syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined}
|
|
onLogicalRangeChange={handleLogicalRangeChange}
|
|
onFullView={sym => handleSlotFullView(sym, i + 1)}
|
|
magnifierEnabled={magnifierActive && activeSlot === i + 1}
|
|
onMagnifierClose={() => setMagnifierActive(false)}
|
|
onIndicatorsChange={activeSlot === i + 1 ? handleActiveSlotIndicatorsChange : undefined}
|
|
onSymbolChange={activeSlot === i + 1 ? handleActiveSlotSymbolChange : undefined}
|
|
magnetMode={magnetMode}
|
|
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
|
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
chartVolumeVisible={chartVolumeVisible}
|
|
chartPaneSeparator={chartPaneSeparator}
|
|
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
|
chartHoverToolbarVisible={chartHoverToolbarVisible}
|
|
chartPriceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
|
displayTimezone={displayTimezone}
|
|
marketTickers={marketTickers}
|
|
marketInfos={marketInfos}
|
|
marketLoading={marketLoading}
|
|
refreshAllTickers={refreshAllTickers}
|
|
onMarketSearchOpenChange={setMarketSearchOpen}
|
|
compactMode
|
|
chartVisible={chartVisible}
|
|
chartConfigReady={chartConfigReady}
|
|
onPaintReadyChange={handleSlotPaintReadyChange}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */}
|
|
<div className="tv-chart-col" style={{ display: layoutDef.count > 1 ? 'none' : undefined, position: 'relative' }}>
|
|
{chartAreaLoading && (
|
|
<div className="chart-loading chart-loading--chart-area chart-loading--workspace" role="status" aria-live="polite">
|
|
<div className="loading-spinner" />
|
|
<span>차트 로딩중...</span>
|
|
</div>
|
|
)}
|
|
<ChartLegendBar
|
|
visibility={chartLegendOptions}
|
|
crosshairInfoVisible={chartCrosshairInfoVisible}
|
|
symbol={symbol}
|
|
timeframe={timeframe}
|
|
useUpbit={useUpbit}
|
|
wsStatus={wsStatus}
|
|
mode={mode}
|
|
currentBar={hoverBar}
|
|
liveQuote={liveQuote}
|
|
indicators={indicators}
|
|
legend={legend}
|
|
/>
|
|
|
|
<TradingChart
|
|
key={chartMountKey}
|
|
chartVisible={chartVisible}
|
|
dataLoading={chartDataLoading}
|
|
onChartPaintReadyChange={handleSingleChartPaintReadyChange}
|
|
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
|
|
chartType={chartType} theme={theme} mode={mode}
|
|
indicators={effectiveIndicators} drawingTool={drawingTool}
|
|
drawings={drawings} logScale={logScale}
|
|
drawingsLocked={drawingsLocked}
|
|
drawingsVisible={drawingsVisible}
|
|
crosshairInfoVisible={chartCrosshairInfoVisible}
|
|
onCrosshair={setLegend}
|
|
onTradeOrderRequest={applyTradeFill}
|
|
onDataLoaded={() => {
|
|
const mgr = managerRef.current;
|
|
mgr?.setChartOverlayVisibility(overlayVisibilityRef.current);
|
|
if (mgr) syncCustomOverlaysToManager(mgr);
|
|
syncBacktestMarkersRef.current();
|
|
}}
|
|
onCandlesReady={handleCandlesReady}
|
|
onManagerReady={mgr => {
|
|
managerRef.current = mgr;
|
|
const pending = pendingRealtimeBarRef.current;
|
|
if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbol && bars.length > 0) {
|
|
pendingRealtimeBarRef.current = null;
|
|
const last = bars[bars.length - 1];
|
|
if (last && pending.time === last.time) mgr.updateBar(pending);
|
|
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
|
|
else mgr.updateBar({ ...pending, time: last.time });
|
|
} else if (chartLiveReadyRef.current) {
|
|
pendingRealtimeBarRef.current = null;
|
|
}
|
|
mgr.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
|
|
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
|
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
|
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
|
mgr.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible);
|
|
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
|
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
|
syncCustomOverlaysToManager(mgr);
|
|
syncBacktestMarkersRef.current();
|
|
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
|
mgr.subscribeVisibleLogicalRange(r => {
|
|
if (r && r.from < LOAD_MORE_TRIGGER) {
|
|
singleLoadMoreRef.current();
|
|
}
|
|
});
|
|
}}
|
|
onAddDrawing={d => pushDrawing([...drawings, d])}
|
|
onSeriesClick={(entryId, point) => {
|
|
if (!entryId) { setCtxToolbar(null); return; }
|
|
setCtxToolbar({ entryId, screenX: point.x, screenY: point.y });
|
|
setDrawingCtxToolbar(null); // 드로잉 툴바 닫기
|
|
}}
|
|
onSeriesDoubleClick={(entryId) => {
|
|
setCtxToolbar(null);
|
|
if (!entryId) return;
|
|
if (entryId === '__main__') {
|
|
setShowMainChartModal(true);
|
|
} else {
|
|
setSettingsModalId(entryId);
|
|
}
|
|
}}
|
|
onHoverPaneLegend={handleHoverPaneLegend}
|
|
onLeavePaneLegend={handleLeavePaneLegend}
|
|
selectedDrawingId={selectedDrawingId}
|
|
onDrawingClick={handleDrawingClick}
|
|
onDrawingDoubleClick={handleDrawingDoubleClick}
|
|
magnifierEnabled={magnifierActive}
|
|
onMagnifierClose={() => setMagnifierActive(false)}
|
|
onReorderIndicators={handleReorderIndicators}
|
|
onMergeIndicators={handleMergeIndicators}
|
|
onSplitIndicatorPane={handleSplitIndicatorPane}
|
|
onExpandIndicator={handleExpandIndicator}
|
|
onRemoveIndicator={handleRemoveIndicatorById}
|
|
onDuplicateIndicator={handleDuplicateIndicator}
|
|
focusedIndicatorId={focusedIndicatorId}
|
|
onRestoreIndicators={handleRestoreIndicators}
|
|
onToggleIndicatorHidden={handleToggleIndicatorHidden}
|
|
onOpenIndicatorSettings={id => setSettingsModalId(id)}
|
|
volumeVisible={chartVolumeVisible}
|
|
candleAreaPriceLabelsEnabled={chartCandleAreaPriceLabels}
|
|
indicatorAreaPriceLabelsEnabled={chartSeriesPriceLabels}
|
|
paneSeparatorOptions={chartPaneSeparator}
|
|
candleOverlayToggles={candleOverlayToggles}
|
|
onToggleCandleOverlay={handleToggleCandleOverlay}
|
|
priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
|
onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels}
|
|
customOverlayActive={customOverlayActive}
|
|
onCustomOverlayActiveChange={handleCustomOverlayActiveChange}
|
|
onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')}
|
|
custom1OverlayActive={custom1OverlayActive}
|
|
onCustom1OverlayActiveChange={handleCustom1OverlayActiveChange}
|
|
onOpenCustom1OverlaySettings={() => setCustomOverlaySettingsSlot('custom1')}
|
|
showHoverToolbar={chartHoverToolbarVisible}
|
|
/>
|
|
{customOverlaySettingsSlot === 'custom' && (
|
|
<ChartCustomOverlaySettingsModal
|
|
title="Custom 표시 설정"
|
|
selection={customOverlaySelection}
|
|
onSave={sel => handleSaveCustomOverlaySelection('custom', sel)}
|
|
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
|
smaParams={indicators.find(i => i.type === 'SMA')?.params}
|
|
/>
|
|
)}
|
|
{customOverlaySettingsSlot === 'custom1' && (
|
|
<ChartCustomOverlaySettingsModal
|
|
title="Custom1 표시 설정"
|
|
selection={custom1OverlaySelection}
|
|
onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)}
|
|
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
|
smaParams={indicators.find(i => i.type === 'SMA')?.params}
|
|
/>
|
|
)}
|
|
{isSingleLoadingMore && (
|
|
<div className="chart-history-loading">
|
|
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
|
<span>과거 데이터 로딩...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 백테스팅 결과 통계 배지 */}
|
|
{btMatchesChart && layoutDef.count === 1 && (
|
|
<BacktestPanel
|
|
stats={btStats}
|
|
signalCount={btSignals.length}
|
|
onClear={() => {
|
|
btMarkerPayloadRef.current = null;
|
|
btClear();
|
|
managerRef.current?.clearBacktestMarkers();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 실시간 전략 체크 패널 */}
|
|
{showLivePanel && menuPage === 'chart' && layoutDef.count === 1 && (
|
|
<LiveStrategyPanel
|
|
theme={theme}
|
|
market={symbol}
|
|
strategies={liveStrategies}
|
|
settings={liveStrategySettings}
|
|
watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length}
|
|
monitoredMarkets={monitoredMarkets}
|
|
virtualDriven={isVirtualActive}
|
|
onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }}
|
|
onSettingsChange={handleLiveSettingsChange}
|
|
onClose={() => setShowLivePanel(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* 백테스팅 설정 모달 */}
|
|
{showBtSettings && (
|
|
<BacktestSettingsModal
|
|
onClose={() => setShowBtSettings(false)}
|
|
onSettingsChange={s => { setBtSettings(s); setShowBtSettings(false); }}
|
|
/>
|
|
)}
|
|
|
|
{/* 백테스팅 결과 — 투자분석 상세 레포트 (인쇄·PDF) */}
|
|
<BacktestAnalysisReportModal
|
|
open={showBtResult && chartBtReportModel != null}
|
|
onClose={() => setShowBtResult(false)}
|
|
model={chartBtReportModel}
|
|
/>
|
|
|
|
{/* 시리즈 클릭/호버 컨텍스트 툴바 */}
|
|
{ctxToolbar && (() => {
|
|
const cfg = ctxToolbar.entryId !== '__main__'
|
|
? indicators.find(i => i.id === ctxToolbar.entryId) ?? null
|
|
: null;
|
|
return (
|
|
<IndicatorContextToolbar
|
|
indicatorId={ctxToolbar.entryId}
|
|
config={cfg}
|
|
chartType={chartType}
|
|
screenX={ctxToolbar.screenX}
|
|
screenY={ctxToolbar.screenY}
|
|
onMouseEnterToolbar={clearHoverTimer}
|
|
onMouseLeaveToolbar={handleLeavePaneLegend}
|
|
onSettings={() => {
|
|
const id = ctxToolbar.entryId;
|
|
setCtxToolbar(null);
|
|
if (id === '__main__') {
|
|
setShowMainChartModal(true);
|
|
} else {
|
|
setSettingsModalId(id);
|
|
}
|
|
}}
|
|
onToggleHidden={() => {
|
|
handleToggleIndicatorHidden(ctxToolbar.entryId);
|
|
}}
|
|
onRemove={() => handleRemoveIndicatorById(ctxToolbar.entryId)}
|
|
onClose={() => setCtxToolbar(null)}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{/* 드로잉 컨텍스트 툴바 */}
|
|
{drawingCtxToolbar && (() => {
|
|
const d = drawings.find(dr => dr.id === drawingCtxToolbar.id);
|
|
if (!d) return null;
|
|
return (
|
|
<DrawingContextToolbar
|
|
drawing={d}
|
|
screenX={drawingCtxToolbar.screenX}
|
|
screenY={drawingCtxToolbar.screenY}
|
|
onSettings={() => {
|
|
setDrawingCtxToolbar(null);
|
|
setDrawingSettingsId(d.id);
|
|
}}
|
|
onToggleVisible={() => {
|
|
setDrawings(prev =>
|
|
prev.map(dr =>
|
|
dr.id === d.id
|
|
? { ...dr, visible: dr.visible !== false ? false : true }
|
|
: dr
|
|
)
|
|
);
|
|
setDrawingCtxToolbar(null);
|
|
}}
|
|
onRemove={() => {
|
|
pushDrawing(drawings.filter(dr => dr.id !== d.id));
|
|
setDrawingCtxToolbar(null);
|
|
setSelectedDrawingId(null);
|
|
}}
|
|
onClose={() => {
|
|
setDrawingCtxToolbar(null);
|
|
setSelectedDrawingId(null);
|
|
}}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{/* 드로잉 설정 모달 */}
|
|
{drawingSettingsId && (() => {
|
|
const d = drawings.find(dr => dr.id === drawingSettingsId);
|
|
if (!d) return null;
|
|
const handleSaveDrawing = (updated: typeof d) => {
|
|
setDrawings(prev => prev.map(dr => dr.id === updated!.id ? updated! : dr));
|
|
setDrawingSettingsId(null);
|
|
setSelectedDrawingId(updated!.id);
|
|
};
|
|
const handleCancelDrawing = () => { setDrawingSettingsId(null); };
|
|
if (d.type === 'fibtz') {
|
|
return (
|
|
<FibTZSettingsModal
|
|
drawing={d}
|
|
onSave={handleSaveDrawing}
|
|
onCancel={handleCancelDrawing}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<DrawingSettingsModal
|
|
drawing={d}
|
|
onSave={updated => {
|
|
setDrawings(prev => prev.map(dr => dr.id === updated.id ? updated : dr));
|
|
setDrawingSettingsId(null);
|
|
setSelectedDrawingId(updated.id);
|
|
}}
|
|
onCancel={() => {
|
|
setDrawingSettingsId(null);
|
|
}}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{/* 메인 차트 설정 모달 */}
|
|
{showMainChartModal && (
|
|
<MainChartSettingsModal
|
|
style={mainChartStyle}
|
|
onSave={handleMainChartSave}
|
|
onCancel={() => setShowMainChartModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* 보조지표 일괄 설정 */}
|
|
{showBulkIndSettings && (
|
|
<IndicatorBulkSettingsModal
|
|
open={showBulkIndSettings}
|
|
indicators={bulkSettingsIndicators}
|
|
chartMarket={symbol}
|
|
getParams={getParams}
|
|
getVisualConfig={getVisualConfig}
|
|
onApply={handleBulkIndicatorApply}
|
|
onLiveChartChange={applyChartIndicators}
|
|
onClose={() => setShowBulkIndSettings(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* 인디케이터 설정 모달 */}
|
|
{settingsModalId && settingsModalId !== '__main__' && (() => {
|
|
let cfg: IndicatorConfig | null = null;
|
|
if (isIndicatorSettingsTemplateId(settingsModalId)) {
|
|
const type = indicatorTypeFromSettingsId(settingsModalId);
|
|
if (!type) return null;
|
|
cfg = buildMainIndicatorConfig(
|
|
type,
|
|
chartIndicatorsForSettings,
|
|
getParams,
|
|
getVisualConfig,
|
|
);
|
|
} else {
|
|
const onChart = chartIndicatorsForSettings.find(i => i.id === settingsModalId);
|
|
if (onChart) {
|
|
cfg = initializeIndicatorConfigForEditor(onChart, getParams, getVisualConfig);
|
|
}
|
|
}
|
|
if (!cfg) return null;
|
|
return (
|
|
<IndicatorSettingsModal
|
|
config={cfg}
|
|
chartMarket={symbol}
|
|
onSave={handleIndicatorSave}
|
|
onCancel={() => setSettingsModalId(null)}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
</div>
|
|
|
|
{/* ── 우측 슬라이드 패널 (매수/매도 + 호가) ───────────────────── */}
|
|
<RightSidePanel
|
|
open={isMobile ? mobileRightOpen : undefined}
|
|
onOpenChange={isMobile ? setMobileRightOpen : undefined}
|
|
activeTab={isMobile ? mobileRightTab : undefined}
|
|
onTabChange={isMobile ? setMobileRightTab : undefined}
|
|
wsFeedEnabled={chartScreenActive && useUpbit}
|
|
market={chartSymbol}
|
|
tradePrice={chartTicker?.tradePrice ?? null}
|
|
asks={orderbook.asks}
|
|
bids={orderbook.bids}
|
|
totalAskSize={orderbook.totalAskSize}
|
|
totalBidSize={orderbook.totalBidSize}
|
|
wsStatus={obWsStatus}
|
|
bestAsk={spread.bestAsk}
|
|
bestBid={spread.bestBid}
|
|
spread={spread.spread}
|
|
spreadPct={spread.pct}
|
|
prevClose={orderbookPrevClose}
|
|
tickerInfo={chartTicker ? {
|
|
tradePrice: chartTicker.tradePrice,
|
|
changeRate: chartTicker.changeRate,
|
|
changePrice: chartTicker.changePrice,
|
|
accTradePrice24: chartTicker.accTradePrice24,
|
|
accTradeVolume24: chartTicker.accTradeVolume24,
|
|
highPrice: chartTicker.highPrice,
|
|
lowPrice: chartTicker.lowPrice,
|
|
openingPrice: chartTicker.openingPrice,
|
|
} : undefined}
|
|
fillRequest={tradeFill}
|
|
onOrderbookPick={handleOrderbookPick}
|
|
onMarketSelect={handleTradeMarketSelect}
|
|
availableKrw={paperCashBalance}
|
|
availableCoinQty={paperCoinQty}
|
|
paperTradingEnabled={formalLogin && paperTradingEnabled}
|
|
paperAutoTradeEnabled={formalLogin && paperAutoTradeEnabled}
|
|
onPaperOrderFilled={() => {
|
|
refreshPaperAccount(symbol);
|
|
handlePaperOrderFilled();
|
|
}}
|
|
/>
|
|
|
|
</div>
|
|
|
|
{/* ── 플로팅 패널: position:fixed로 항상 표시 (싱글/멀티 모드 공통) ─── */}
|
|
{showObjectTree && (
|
|
<ObjectTree
|
|
drawings={drawings}
|
|
onToggleVisible={id => setDrawings(prev =>
|
|
prev.map(d => d.id === id ? { ...d, visible: d.visible !== false ? false : true } : d)
|
|
)}
|
|
onRemove={id => setDrawings(prev => prev.filter(d => d.id !== id))}
|
|
onClose={() => setShowObjectTree(false)}
|
|
/>
|
|
)}
|
|
{showStats && <StatsPanel stats={stats} symbol={symbol} onClose={() => setShowStats(false)} />}
|
|
{showWatch && (
|
|
<WatchList
|
|
watchlist={watchlist} currentSymbol={symbol} priceMap={priceMap}
|
|
onSelect={s => { setSymbol(s); setShowWatch(false); }}
|
|
onAdd={s => {
|
|
setWatchlist(prev => [...new Set([...prev, s])]);
|
|
addWatchlistItem({ symbol: s }).catch(() => {});
|
|
}}
|
|
onRemove={s => {
|
|
setWatchlist(prev => prev.filter(x => x !== s));
|
|
removeWatchlistItem(s).catch(() => {});
|
|
}}
|
|
onClose={() => setShowWatch(false)}
|
|
/>
|
|
)}
|
|
{showAlert && (
|
|
<AlertManager
|
|
alerts={alerts} currentPrice={liveQuote.price ?? quoteBar?.close ?? 0}
|
|
onAdd={(price, label) => { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }}
|
|
onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }}
|
|
onClose={() => setShowAlert(false)}
|
|
/>
|
|
)}
|
|
|
|
{isMobile && (
|
|
<MobileChartDock
|
|
marketOpen={showMarketPanel}
|
|
rightOpen={mobileRightOpen}
|
|
rightTab={mobileRightTab}
|
|
drawingTool={drawingTool}
|
|
onToggleMarket={() => {
|
|
setShowMarketPanel(v => {
|
|
const next = !v;
|
|
if (next) setMobileRightOpen(false);
|
|
return next;
|
|
});
|
|
}}
|
|
onOpenTrade={() => {
|
|
setShowMarketPanel(false);
|
|
setMobileRightTab('trade');
|
|
setMobileRightOpen(true);
|
|
}}
|
|
onOpenOrderbook={() => {
|
|
setShowMarketPanel(false);
|
|
setMobileRightTab('orderbook');
|
|
setMobileRightOpen(true);
|
|
}}
|
|
onClosePanels={() => {
|
|
setShowMarketPanel(false);
|
|
setMobileRightOpen(false);
|
|
}}
|
|
onTool={setDrawingTool}
|
|
/>
|
|
)}
|
|
|
|
<BottomBar
|
|
timeframe={layoutDef.count > 1 ? activeSlotTf : timeframe}
|
|
logScale={logScale}
|
|
percentScale={percentScale} showGrid={showGrid}
|
|
lastTime={bars[bars.length - 1]?.time ?? 0}
|
|
displayTimezone={displayTimezone}
|
|
onTimezoneChange={handleTimezoneChange}
|
|
onTimeframe={handleTimeframe}
|
|
onLogScale={() => setLogScale(v => !v)}
|
|
onPercentScale={() => setPercentScale(v => !v)}
|
|
onAutoScale={() => managerRef.current?.autoScale()}
|
|
onFitContent={() => managerRef.current?.fitContent()}
|
|
onToggleGrid={() => setShowGrid(v => !v)}
|
|
onAddIndicators={handleAddIndicators}
|
|
onAddIndicator={handleAddIndicator}
|
|
/>
|
|
|
|
</>
|
|
);
|
|
|
|
}
|
|
|
|
export const ChartWorkspaceView = React.memo(ChartWorkspaceViewInner);
|