전략편집기 시간봉 선택오류 수정

This commit is contained in:
Macbook
2026-06-12 21:32:19 +09:00
parent 046bcc6379
commit 6118230471
8 changed files with 164 additions and 97 deletions
+4
View File
@@ -1276,6 +1276,10 @@ html.theme-blue {
background: var(--bg, #131722); background: var(--bg, #131722);
pointer-events: none; pointer-events: none;
} }
.multi-chart-grid .chart-loading--workspace,
.tv-chart-col .chart-loading--workspace {
z-index: 930;
}
.loading-spinner { .loading-spinner {
width: 32px; height: 32px; width: 32px; height: 32px;
border: 3px solid var(--bg4); border: 3px solid var(--bg4);
+28 -4
View File
@@ -200,6 +200,9 @@ export interface ChartWorkspaceViewProps {
liveQuote: ReturnType<typeof import('../utils/chartLiveQuote').resolveChartLiveQuote>; liveQuote: ReturnType<typeof import('../utils/chartLiveQuote').resolveChartLiveQuote>;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
chartConfigReady: boolean;
chartAreaLoading: boolean;
chartDataLoading: boolean;
bulkSettingsIndicators: IndicatorConfig[]; bulkSettingsIndicators: IndicatorConfig[];
chartIndicatorsForSettings: IndicatorConfig[]; chartIndicatorsForSettings: IndicatorConfig[];
getParams: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getParams']; getParams: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getParams'];
@@ -298,6 +301,8 @@ export interface ChartWorkspaceViewProps {
handleSaveCustomOverlaySelection: (slot: ChartCustomOverlaySlotId, selection: ChartCustomOverlaySelection) => void; handleSaveCustomOverlaySelection: (slot: ChartCustomOverlaySlotId, selection: ChartCustomOverlaySelection) => void;
syncCustomOverlaysToManager: (mgr: ChartManager) => void; syncCustomOverlaysToManager: (mgr: ChartManager) => void;
handleCandlesReady: () => void; handleCandlesReady: () => void;
handleSingleChartPaintReadyChange: (ready: boolean) => void;
handleSlotPaintReadyChange: (slotIndex: number, ready: boolean) => void;
applyBacktestMarkersWhenReady: (payload: NonNullable<ChartWorkspaceViewProps['btMarkerPayloadRef']['current']>, attempt?: number) => void; applyBacktestMarkersWhenReady: (payload: NonNullable<ChartWorkspaceViewProps['btMarkerPayloadRef']['current']>, attempt?: number) => void;
btRun: ReturnType<typeof import('../hooks/useBacktest').useBacktest>['run']; btRun: ReturnType<typeof import('../hooks/useBacktest').useBacktest>['run'];
refreshPaperAccount: (forMarket?: string) => Promise<void>; refreshPaperAccount: (forMarket?: string) => Promise<void>;
@@ -339,7 +344,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
obWsStatus, spread, orderbookPrevClose, chartTicker, tradeFill, obWsStatus, spread, orderbookPrevClose, chartTicker, tradeFill,
paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled, paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled,
appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote, appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote,
isLoading, error, bulkSettingsIndicators, chartIndicatorsForSettings, isLoading, error, chartConfigReady, chartAreaLoading, chartDataLoading, bulkSettingsIndicators, chartIndicatorsForSettings,
getParams, getVisualConfig, activeSlotIndicators, getParams, getVisualConfig, activeSlotIndicators,
setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid, setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid,
setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible, setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible,
@@ -368,7 +373,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
handleTogglePriceExtremeLabels, handleTogglePriceExtremeLabels,
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange, handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager, handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount, handleCandlesReady, handleSingleChartPaintReadyChange, handleSlotPaintReadyChange,
applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers, handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers,
handleTimezoneChange, handleTimezoneChange,
} = props; } = props;
@@ -587,8 +593,15 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
gridTemplateColumns: layoutDef.cols, gridTemplateColumns: layoutDef.cols,
gridTemplateRows: layoutDef.rows, gridTemplateRows: layoutDef.rows,
gridTemplateAreas: layoutDef.areas, 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) */} {/* 슬롯 0 = 메인 차트 영역 (a) */}
<div className="multi-slot-wrap" style={{ gridArea: 'a', minHeight: 0, minWidth: 0 }}> <div className="multi-slot-wrap" style={{ gridArea: 'a', minHeight: 0, minWidth: 0 }}>
<ChartSlot <ChartSlot
@@ -629,6 +642,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
marketTickers={marketTickers} marketTickers={marketTickers}
compactMode compactMode
chartVisible={chartVisible} chartVisible={chartVisible}
chartConfigReady={chartConfigReady}
onPaintReadyChange={handleSlotPaintReadyChange}
/> />
</div> </div>
{/* 슬롯 1~7 */} {/* 슬롯 1~7 */}
@@ -673,6 +688,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
marketTickers={marketTickers} marketTickers={marketTickers}
compactMode compactMode
chartVisible={chartVisible} chartVisible={chartVisible}
chartConfigReady={chartConfigReady}
onPaintReadyChange={handleSlotPaintReadyChange}
/> />
</div> </div>
))} ))}
@@ -680,7 +697,13 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
)} )}
{/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */} {/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */}
<div className="tv-chart-col" style={{ display: layoutDef.count > 1 ? 'none' : undefined }}> <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 <ChartLegendBar
visibility={chartLegendOptions} visibility={chartLegendOptions}
crosshairInfoVisible={chartCrosshairInfoVisible} crosshairInfoVisible={chartCrosshairInfoVisible}
@@ -698,7 +721,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
<TradingChart <TradingChart
key={chartMountKey} key={chartMountKey}
chartVisible={chartVisible} chartVisible={chartVisible}
dataLoading={useUpbit && isLoading} dataLoading={chartDataLoading}
onChartPaintReadyChange={handleSingleChartPaintReadyChange}
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone} bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
chartType={chartType} theme={theme} mode={mode} chartType={chartType} theme={theme} mode={mode}
indicators={effectiveIndicators} drawingTool={drawingTool} indicators={effectiveIndicators} drawingTool={drawingTool}
+67 -9
View File
@@ -573,6 +573,13 @@ export function useChartWorkspace({
const [favoritesList, setFavoritesList] = useState<string[]>(getFavorites); const [favoritesList, setFavoritesList] = useState<string[]>(getFavorites);
// DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작) // DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작)
const [dbLoaded, setDbLoaded] = useState(false); const [dbLoaded, setDbLoaded] = useState(false);
/** DB·앱설정·지표 기본값 반영 완료 — 이후 STOMP 초기 로딩 시작 (이중 페인트 방지) */
const [chartBootstrapComplete, setChartBootstrapComplete] = useState(false);
/** 싱글 레이아웃 차트 paint 완료 */
const [singleChartPaintReady, setSingleChartPaintReady] = useState(false);
/** 멀티 슬롯별 paint 완료 */
const slotPaintReadyRef = useRef<Record<number, boolean>>({});
const [slotPaintEpoch, setSlotPaintEpoch] = useState(0);
const priorityMarkets = useMemo( const priorityMarkets = useMemo(
() => [symbol, ...watchlist], () => [symbol, ...watchlist],
@@ -1111,6 +1118,7 @@ export function useChartWorkspace({
const handleCandlesReady = useCallback(() => { const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true; chartLiveReadyRef.current = true;
setSingleChartPaintReady(true);
const pending = pendingRealtimeBarRef.current; const pending = pendingRealtimeBarRef.current;
const mgr = managerRef.current; const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== chartContextRef.current.symbol) return; if (!pending || !mgr || barsMarketRef.current !== chartContextRef.current.symbol) return;
@@ -1118,16 +1126,26 @@ export function useChartWorkspace({
mgr.updateBar(pending); mgr.updateBar(pending);
}, []); }, []);
const handleSingleChartPaintReadyChange = useCallback((ready: boolean) => {
setSingleChartPaintReady(ready);
}, []);
const handleSlotPaintReadyChange = useCallback((slotIndex: number, ready: boolean) => {
slotPaintReadyRef.current[slotIndex] = ready;
setSlotPaintEpoch(v => v + 1);
}, []);
const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP'; const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP';
const chartConfigReady = appSettingsLoaded && dbLoaded && chartBootstrapComplete;
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData( const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData(
symbol, symbol,
timeframe, timeframe,
useMemo(() => ({ useMemo(() => ({
onTickUpdate: handleTickUpdate, onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle, onNewCandle: handleNewCandle,
enabled: chartScreenActive && useUpbit, enabled: chartScreenActive && useUpbit && chartConfigReady,
source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT', source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
}), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartRealtimeSource]), }), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartConfigReady, chartRealtimeSource]),
); );
// 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음)
@@ -1167,8 +1185,15 @@ export function useChartWorkspace({
useLayoutEffect(() => { useLayoutEffect(() => {
chartLiveReadyRef.current = false; chartLiveReadyRef.current = false;
pendingRealtimeBarRef.current = null; pendingRealtimeBarRef.current = null;
setSingleChartPaintReady(false);
}, [symbol, timeframe]); }, [symbol, timeframe]);
useEffect(() => {
slotPaintReadyRef.current = {};
setSlotPaintEpoch(v => v + 1);
setSingleChartPaintReady(false);
}, [layoutDef.count, sessionKey]);
// Upbit 초기 로딩 완료 시 stats 업데이트 // Upbit 초기 로딩 완료 시 stats 업데이트
useEffect(() => { useEffect(() => {
if (useUpbit && upbitBars.length > 0) { if (useUpbit && upbitBars.length > 0) {
@@ -1379,6 +1404,10 @@ export function useChartWorkspace({
}); });
}, [focusedIndicatorId, indicators, layoutDef.count]); }, [focusedIndicatorId, indicators, layoutDef.count]);
useEffect(() => {
setSingleChartPaintReady(false);
}, [effectiveIndicators]);
const handleExpandIndicator = useCallback((id: string) => { const handleExpandIndicator = useCallback((id: string) => {
setFocusedIndicatorId(id); setFocusedIndicatorId(id);
}, []); }, []);
@@ -1701,7 +1730,7 @@ export function useChartWorkspace({
const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators; const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators;
/** 설정 화면에서 DB 기본값 저장 후 차트 복귀 시 — 전역 기본값을 차트 인스턴스에 일괄 반영 */ /** 설정 화면에서 DB 기본값 저장 후 차트 복귀 시 — 전역 기본값을 차트 인스턴스에 일괄 반영 */
const lastSyncedSettingsRevisionRef = useRef(indicatorSettingsRevision); const lastSyncedSettingsRevisionRef = useRef(-1);
const syncChartsFromSavedDefaults = useCallback(() => { const syncChartsFromSavedDefaults = useCallback(() => {
const merge = (inds: IndicatorConfig[]) => const merge = (inds: IndicatorConfig[]) =>
mergeGlobalDefaultsOntoChartIndicators(inds, getParams, getVisualConfig); mergeGlobalDefaultsOntoChartIndicators(inds, getParams, getVisualConfig);
@@ -1729,15 +1758,25 @@ export function useChartWorkspace({
}, [layoutDef.count, activeSlot, activeSlotIndicators, getParams, getVisualConfig]); }, [layoutDef.count, activeSlot, activeSlotIndicators, getParams, getVisualConfig]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!chartVisible) return; if (!appSettingsLoaded || !dbLoaded) {
if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return; setChartBootstrapComplete(false);
if (consumeSkipNextChartDefaultsSync()) {
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
return; return;
} }
if (chartVisible && indicatorSettingsRevision > lastSyncedSettingsRevisionRef.current) {
if (consumeSkipNextChartDefaultsSync()) {
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
} else {
syncChartsFromSavedDefaults(); syncChartsFromSavedDefaults();
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision; lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
}, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]); }
}
setChartBootstrapComplete(true);
}, [appSettingsLoaded, dbLoaded, chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
useEffect(() => {
setChartBootstrapComplete(false);
lastSyncedSettingsRevisionRef.current = -1;
}, [sessionKey]);
/** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */ /** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */
const handleBulkIndicatorApply = useCallback((result: { const handleBulkIndicatorApply = useCallback((result: {
@@ -1911,6 +1950,23 @@ export function useChartWorkspace({
settingsPageProps, settingsPageProps,
]); ]);
const allSlotsPaintReady = useMemo(() => {
void slotPaintEpoch;
for (let i = 0; i < layoutDef.count; i++) {
if (!slotPaintReadyRef.current[i]) return false;
}
return layoutDef.count > 0;
}, [layoutDef.count, slotPaintEpoch]);
const chartDataFetched = !useUpbit || (!isLoading && bars.length >= 2 && barsMarket === symbol);
const chartPaintComplete = layoutDef.count > 1 ? allSlotsPaintReady : singleChartPaintReady;
const chartDataLoading = useUpbit && chartVisible && (!chartConfigReady || !chartDataFetched);
const chartAreaLoading = useUpbit && chartVisible && (
!chartConfigReady
|| !chartPaintComplete
|| (layoutDef.count === 1 && !chartDataFetched)
);
const viewProps: ChartWorkspaceViewProps = { const viewProps: ChartWorkspaceViewProps = {
onOpenNotificationsPage, onOpenNotificationsPage,
onOpenAppDownload, onOpenAppDownload,
@@ -1946,6 +2002,7 @@ export function useChartWorkspace({
paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled, paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled,
appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote, appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote,
isLoading, error: error ?? null, bulkSettingsIndicators, chartIndicatorsForSettings, isLoading, error: error ?? null, bulkSettingsIndicators, chartIndicatorsForSettings,
chartConfigReady, chartAreaLoading, chartDataLoading,
getParams, getVisualConfig, activeSlotIndicators, getParams, getVisualConfig, activeSlotIndicators,
setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid, setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid,
setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible, setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible,
@@ -1974,7 +2031,8 @@ export function useChartWorkspace({
handleTogglePriceExtremeLabels, handleTogglePriceExtremeLabels,
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange, handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager, handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount, handleCandlesReady, handleSingleChartPaintReadyChange, handleSlotPaintReadyChange,
applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers, handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers,
goToMarketChart, handleTimezoneChange, goToMarketChart, handleTimezoneChange,
}; };
+29 -33
View File
@@ -206,6 +206,10 @@ export interface ChartSlotProps {
chartLiveReceiveHighlight?: boolean; chartLiveReceiveHighlight?: boolean;
/** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */ /** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */
chartVisible?: boolean; chartVisible?: boolean;
/** 워크스페이스·지표 설정 반영 완료 — STOMP 시작 게이트 */
chartConfigReady?: boolean;
/** paint 완료 알림 (멀티차트 전체 로딩용) */
onPaintReadyChange?: (slotIndex: number, ready: boolean) => void;
/** pane 구분선 */ /** pane 구분선 */
chartPaneSeparator?: ChartPaneSeparatorOptions; chartPaneSeparator?: ChartPaneSeparatorOptions;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 */ /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 */
@@ -241,6 +245,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
compactMode = false, compactMode = false,
chartLiveReceiveHighlight = true, chartLiveReceiveHighlight = true,
chartVisible = true, chartVisible = true,
chartConfigReady = true,
onPaintReadyChange,
chartPaneSeparator, chartPaneSeparator,
chartCrosshairInfoVisible = true, chartCrosshairInfoVisible = true,
chartHoverToolbarVisible = true, chartHoverToolbarVisible = true,
@@ -508,17 +514,6 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// ※ TradingChart를 재마운트(key 변경)하면 managerRef가 초기화되어 MarketSearchPanel 등 // ※ TradingChart를 재마운트(key 변경)하면 managerRef가 초기화되어 MarketSearchPanel 등
// 다른 상태에 영향을 줄 수 있으므로, 재마운트 대신 resetPaneHeights 재시도만 수행한다. // 다른 상태에 영향을 줄 수 있으므로, 재마운트 대신 resetPaneHeights 재시도만 수행한다.
const slotContainerRef = useRef<HTMLDivElement | null>(null); const slotContainerRef = useRef<HTMLDivElement | null>(null);
// 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard)
const [reloadTick, setReloadTick] = useState(0);
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
useLayoutEffect(() => {
reloadTriggeredRef.current = false;
chartLiveReadyRef.current = false;
pendingRealtimeBarRef.current = null;
pendingSyncVisibleRef.current = null;
pendingSyncLogicalRef.current = null;
skipSyncApplyRef.current = true;
}, [symbol, timeframe]);
useEffect(() => { useEffect(() => {
const checks = [200, 500, 1000, 2000].map(delay => const checks = [200, 500, 1000, 2000].map(delay =>
setTimeout(() => { setTimeout(() => {
@@ -528,21 +523,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
const h = el.clientHeight; const h = el.clientHeight;
if (h <= 0) return; if (h <= 0) return;
if (mgr.hasMainSeries()) { if (mgr.hasMainSeries()) {
// 차트 데이터 있음 → pane 높이만 재적용
mgr.resetPaneHeights(h); mgr.resetPaneHeights(h);
} else if (!reloadTriggeredRef.current) {
// 차트 비어 있고 아직 재시도 안 했음 → 1회만 재마운트
// (bars 로딩 지연일 수 있으므로, 늦은 타이머에서만 트리거)
if (delay >= 1000) {
reloadTriggeredRef.current = true;
setReloadTick(t => t + 1);
}
} }
}, delay) }, delay)
); );
return () => checks.forEach(clearTimeout); return () => checks.forEach(clearTimeout);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [symbol, timeframe]);
// ── 데이터 ──────────────────────────────────────────────────────────── // ── 데이터 ────────────────────────────────────────────────────────────
const useUpbit = isUpbitMarket(symbol); const useUpbit = isUpbitMarket(symbol);
@@ -567,12 +554,26 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
const handleCandlesReady = useCallback(() => { const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true; chartLiveReadyRef.current = true;
onPaintReadyChange?.(slotIndex, true);
const pending = pendingRealtimeBarRef.current; const pending = pendingRealtimeBarRef.current;
const mgr = managerRef.current; const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== symbolRef.current) return; if (!pending || !mgr || barsMarketRef.current !== symbolRef.current) return;
pendingRealtimeBarRef.current = null; pendingRealtimeBarRef.current = null;
mgr.updateBar(pending); mgr.updateBar(pending);
}, []); }, [slotIndex, onPaintReadyChange]);
const handleSlotPaintReadyChange = useCallback((ready: boolean) => {
onPaintReadyChange?.(slotIndex, ready);
}, [slotIndex, onPaintReadyChange]);
useLayoutEffect(() => {
chartLiveReadyRef.current = false;
pendingRealtimeBarRef.current = null;
pendingSyncVisibleRef.current = null;
pendingSyncLogicalRef.current = null;
skipSyncApplyRef.current = true;
onPaintReadyChange?.(slotIndex, false);
}, [symbol, timeframe, slotIndex, onPaintReadyChange]);
const handleTickUpdate = useCallback((bar: OHLCVBar) => { const handleTickUpdate = useCallback((bar: OHLCVBar) => {
if (barsMarketRef.current !== symbolRef.current) return; if (barsMarketRef.current !== symbolRef.current) return;
@@ -604,15 +605,18 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
useMemo(() => ({ useMemo(() => ({
onTickUpdate: handleTickUpdate, onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle, onNewCandle: handleNewCandle,
enabled: useUpbit && chartVisible, enabled: useUpbit && chartVisible && chartConfigReady,
source: chartRealtimeSource, source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartVisible, chartRealtimeSource]), }), [handleTickUpdate, handleNewCandle, useUpbit, chartVisible, chartConfigReady, chartRealtimeSource]),
); );
const bars = useUpbit ? upbitBars : simBars; const bars = useUpbit ? upbitBars : simBars;
const barsMarket = useUpbit ? upbitBarsMarket : symbol; const barsMarket = useUpbit ? upbitBarsMarket : symbol;
barsMarketRef.current = barsMarket; barsMarketRef.current = barsMarket;
const slotDataFetched = !useUpbit || (!isLoading && bars.length >= 2 && barsMarket === symbol);
const slotChartLoading = useUpbit && chartVisible && (!chartConfigReady || !slotDataFetched);
const lastKnownBar = useUpbit const lastKnownBar = useUpbit
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
: (bars.length > 0 ? bars[bars.length - 1] : null); : (bars.length > 0 ? bars[bars.length - 1] : null);
@@ -966,15 +970,6 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
className={compactMode ? 'vtd-card-chart-canvas slot-chart-wrap slot-chart-wrap--compact' : 'slot-chart-wrap'} className={compactMode ? 'vtd-card-chart-canvas slot-chart-wrap slot-chart-wrap--compact' : 'slot-chart-wrap'}
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }} style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
> >
{useUpbit && isLoading && (
compactMode ? (
<div className="vtd-card-chart-loading"> </div>
) : (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" />
</div>
)
)}
{isLoadingMore && ( {isLoadingMore && (
<div className="chart-history-loading"> <div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} /> <div className="loading-spinner" style={{ width: 14, height: 14 }} />
@@ -983,8 +978,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
)} )}
<TradingChart <TradingChart
key={reloadTick}
chartVisible={chartVisible} chartVisible={chartVisible}
dataLoading={slotChartLoading}
onChartPaintReadyChange={handleSlotPaintReadyChange}
bars={bars} bars={bars}
barsMarket={barsMarket} barsMarket={barsMarket}
market={symbol} market={symbol}
+2 -31
View File
@@ -28,7 +28,6 @@ import {
decodeConditionForEditor, decodeConditionForEditor,
alignBuySellStartCandleTypesForSave, alignBuySellStartCandleTypesForSave,
encodeConditionForSave, encodeConditionForSave,
syncBuySellPrimaryStartCandleTypes,
mergeStartMetaForLoad, mergeStartMetaForLoad,
addExtraStartSection, addExtraStartSection,
hasMultipleStartSections, hasMultipleStartSections,
@@ -726,36 +725,8 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]); }, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => { const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
if (startId === START_NODE_ID) { handleEditorStateChange(updateStartCandleTypes(currentEditorState, startId, candleTypes));
const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes); }, [handleEditorStateChange, currentEditorState]);
setBuyCondition(synced.buy.root);
setBuyLayout(prev => ({
...prev,
startMeta: synced.buy.startMeta,
extraStartIds: synced.buy.extraStartIds,
extraRoots: synced.buy.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.buy.startCombineOp),
}));
setSellCondition(synced.sell.root);
setSellLayout(prev => ({
...prev,
startMeta: synced.sell.startMeta,
extraStartIds: synced.sell.extraStartIds,
extraRoots: synced.sell.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
}));
scheduleStrategyPersist();
return;
}
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
}, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
scheduleStrategyPersist,
signalTab,
]);
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => { const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
handleEditorStateChange(updateStartCombineOp(currentEditorState, op)); handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
+20 -3
View File
@@ -167,6 +167,8 @@ interface TradingChartProps {
crosshairInfoVisible?: boolean; crosshairInfoVisible?: boolean;
/** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */ /** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
dataLoading?: boolean; dataLoading?: boolean;
/** chartPaintReady 변경 알림 (워크스페이스 전체 로딩 오버레이용) */
onChartPaintReadyChange?: (ready: boolean) => void;
/** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */ /** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */
defaultCandleOnly?: boolean; defaultCandleOnly?: boolean;
/** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */ /** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */
@@ -236,6 +238,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
showCandleOverlayControls = false, showCandleOverlayControls = false,
crosshairInfoVisible = true, crosshairInfoVisible = true,
dataLoading = false, dataLoading = false,
onChartPaintReadyChange,
defaultCandleOnly = false, defaultCandleOnly = false,
showCandlePaneControls = true, showCandlePaneControls = true,
candleOverlayToggles, candleOverlayToggles,
@@ -399,6 +402,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
chartVisibleRef.current = chartVisible; chartVisibleRef.current = chartVisible;
}, [chartVisible]); }, [chartVisible]);
useEffect(() => {
if (dataLoading) setChartPaintReady(false);
}, [dataLoading]);
useEffect(() => {
onChartPaintReadyChange?.(chartPaintReady && !dataLoading);
}, [chartPaintReady, dataLoading, onChartPaintReadyChange]);
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
@@ -976,12 +987,18 @@ const TradingChart: React.FC<TradingChartProps> = ({
} while (reloadCoalesceRef.current); } while (reloadCoalesceRef.current);
} finally { } finally {
reloadInFlightRef.current = false; reloadInFlightRef.current = false;
if (managerRef.current?.hasMainSeries()) {
setChartPaintReady(true);
}
if (reloadCoalesceRef.current) { if (reloadCoalesceRef.current) {
reloadCoalesceRef.current = false; reloadCoalesceRef.current = false;
queueFullReload(); queueFullReload();
return;
}
const m = managerRef.current;
if (m?.hasMainSeries()) {
const expected = countExpectedVisibleIndicators(indicatorsRef.current);
const loaded = m.getLoadedIndicatorCount();
if (expected === 0 || loaded >= expected) {
setChartPaintReady(true);
}
} }
} }
}, [normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); }, [normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]);
+4 -14
View File
@@ -204,11 +204,6 @@ function normalizeCandleTypesList(types: string[]): string[] {
} }
} }
if (out.length === 0) return [DEFAULT_START_CANDLE]; if (out.length === 0) return [DEFAULT_START_CANDLE];
// 레거시/불일치로 multi 분봉에 stale 1m 이 섞인 경우가 있어,
// multi 인데 non-1m 이 있으면 1m 을 기본적으로 제외한다.
if (out.length > 1 && out.includes('1m') && out.some(ct => ct !== '1m')) {
return out.filter(ct => ct !== '1m');
}
return out; return out;
} }
@@ -414,7 +409,7 @@ export function collectTimeframesFromEditorState(state: EditorConditionState): s
return [...set]; return [...set];
} }
/** 매수·매도 START(기본) 평가 분봉을 동일하게 맞춤 — 한쪽만 3m·5m 체크 시 다른 쪽 1m 잔존 방지 */ /** @deprecated 매수·매도 시간봉은 각각 독립 설정 — updateStartCandleTypes 사용 */
export function syncBuySellPrimaryStartCandleTypes( export function syncBuySellPrimaryStartCandleTypes(
buy: EditorConditionState, buy: EditorConditionState,
sell: EditorConditionState, sell: EditorConditionState,
@@ -436,19 +431,14 @@ export function syncEditorStateCandleTypesForSave(state: EditorConditionState):
return next; return next;
} }
/** 저장 직전 — 양쪽 START 분봉 합집합을 각 탭에 반영 (매수만 설정한 경우 매도 DSL도 동기화) */ /** 저장 직전 — 각 탭 START 분봉을 해당 조건 트리 left/rightCandleType 에 반영 (매수·매도 독립) */
export function alignBuySellStartCandleTypesForSave( export function alignBuySellStartCandleTypesForSave(
buy: EditorConditionState, buy: EditorConditionState,
sell: EditorConditionState, sell: EditorConditionState,
): { buy: EditorConditionState; sell: EditorConditionState } { ): { buy: EditorConditionState; sell: EditorConditionState } {
const merged = normalizeCandleTypesList([
...getStartCandleTypes(buy.startMeta[START_NODE_ID]),
...getStartCandleTypes(sell.startMeta[START_NODE_ID]),
]);
const synced = syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
return { return {
buy: syncEditorStateCandleTypesForSave(synced.buy), buy: syncEditorStateCandleTypesForSave(buy),
sell: syncEditorStateCandleTypesForSave(synced.sell), sell: syncEditorStateCandleTypesForSave(sell),
}; };
} }
+8 -1
View File
@@ -2,6 +2,8 @@ import { generateNodeId } from './strategyTypes';
export const START_NODE_ID = '__strategy_start__'; export const START_NODE_ID = '__strategy_start__';
export const DEFAULT_START_CANDLE = '1m'; export const DEFAULT_START_CANDLE = '1m';
/** 새 전략 START 노드 기본 평가 분봉 */
export const DEFAULT_NEW_STRATEGY_START_CANDLE = '3m';
export const STRATEGY_CANDLE_TYPES = [ export const STRATEGY_CANDLE_TYPES = [
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1w', '1d', '1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1w', '1d',
@@ -36,7 +38,12 @@ export function createStartNodeId(): string {
} }
export function defaultStartMeta(): Record<string, StartNodeMeta> { export function defaultStartMeta(): Record<string, StartNodeMeta> {
return { [START_NODE_ID]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE } }; return {
[START_NODE_ID]: {
candleTypes: [DEFAULT_NEW_STRATEGY_START_CANDLE],
candleType: DEFAULT_NEW_STRATEGY_START_CANDLE,
},
};
} }
/** START 메타에서 정렬된 평가 분봉 목록 (최소 1개) */ /** START 메타에서 정렬된 평가 분봉 목록 (최소 1개) */