From 1ec22d6ea2b49f6ec8bc5e637cc803a95691fb8e Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 10 Jun 2026 09:57:38 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=EB=AA=A9=EB=A1=9D=20obv=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EB=AC=B8=EC=A0=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TradeNotificationListRow.tsx | 24 +- .../TradeSignalMiniChart.tsx | 61 ++- frontend/src/hooks/useIndicatorSettings.ts | 6 +- frontend/src/utils/ChartManager.ts | 464 ++++++++++++++---- frontend/src/utils/customIndicators.ts | 70 ++- frontend/src/utils/indicatorMainConfig.ts | 61 ++- frontend/src/utils/indicatorRegistry.ts | 38 +- frontend/src/utils/indicatorSettingsEditor.ts | 11 +- .../src/utils/notificationChartIndicators.ts | 60 +++ .../src/utils/strategyToChartIndicators.ts | 39 +- frontend/src/utils/timezone.ts | 25 + .../utils/workspaceChartIndicatorsCache.ts | 48 ++ 12 files changed, 745 insertions(+), 162 deletions(-) create mode 100644 frontend/src/utils/notificationChartIndicators.ts create mode 100644 frontend/src/utils/workspaceChartIndicatorsCache.ts diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index d7cd8de..d1b63cd 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -2,7 +2,7 @@ * 매매 시그널 알림 목록 행 — 4칸이 목록 너비를 꽉 채움, 보조지표 2개↑ 시 우측 슬롯만 스크롤 */ import React, { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'; -import type { Theme } from '../../types'; +import type { Theme, IndicatorConfig } from '../../types'; import type { TickerData } from '../../hooks/useMarketTicker'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot'; @@ -23,8 +23,10 @@ import { buildStrategyChartIndicators, candleTypeToTimeframe, } from '../../utils/strategyToChartIndicators'; +import { buildNotificationChartIndicators } from '../../utils/notificationChartIndicators'; import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import { ensureWorkspaceChartIndicators, invalidateWorkspaceChartIndicatorsCache } from '../../utils/workspaceChartIndicatorsCache'; import TradeSignalChartCard from './TradeSignalChartCard'; import TradeNotificationSignalCard from './TradeNotificationSignalCard'; import TradeNotificationHScrollPane from './TradeNotificationHScrollPane'; @@ -109,7 +111,9 @@ const TradeNotificationListRow: React.FC = ({ const chartTf = candleTypeToTimeframe(item.candleType ?? '1m'); const candleKo = formatCandleTypeKo(item.candleType); - const { getParams, getVisualConfig } = useIndicatorSettings(); + const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings(); + const [wsIndicators, setWsIndicators] = useState([]); + const [wsRevision, setWsRevision] = useState(0); const [strategy, setStrategy] = useState(); const [strategies, setStrategies] = useState([]); const strategyLabel = item.strategyName?.trim() @@ -166,16 +170,28 @@ const TradeNotificationListRow: React.FC = ({ setExpandedChartKey(null); }, [item.id, layoutMode]); + useEffect(() => { + let cancelled = false; + invalidateWorkspaceChartIndicatorsCache(); + void ensureWorkspaceChartIndicators().then(inds => { + if (cancelled) return; + setWsIndicators(inds); + setWsRevision(r => r + 1); + }); + return () => { cancelled = true; }; + }, [settingsRevision]); + const indicatorCards = useMemo(() => { if (!isListLayout || !strategy) return []; - const all = buildStrategyChartIndicators(strategy, getParams, getVisualConfig); + const strategyBuilt = buildStrategyChartIndicators(strategy, getParams, getVisualConfig, wsIndicators); + const all = buildNotificationChartIndicators(strategyBuilt, wsIndicators); return all.slice(0, MAX_INDICATOR_CARDS).map(ind => ({ id: ind.id, type: ind.type, label: formatIndicatorDisplayLabel(ind.type), config: [ind], })); - }, [strategy, getParams, getVisualConfig, isListLayout]); + }, [strategy, getParams, getVisualConfig, settingsRevision, wsRevision, wsIndicators, isListLayout]); /** 통합 차트용: 전략의 모든 보조지표를 하나의 차트에 넘긴다 */ const allIndicatorConfigs = useMemo( diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx index 001932a..50ce71a 100644 --- a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -17,13 +17,13 @@ import { useAppSettings } from '../../hooks/useAppSettings'; import type { ChartManager } from '../../utils/ChartManager'; import { pinCandleWatch } from '../../utils/backendApi'; import { timeframeToCandleType } from '../../utils/chartCandleType'; -import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useSessionCandleOverlayControls } from '../../hooks/useSessionCandleOverlayControls'; -import { ensurePaperChartOverlays } from '../../utils/strategyToChartIndicators'; import { mergeGlobalDefaultsOntoChartIndicators } from '../../utils/indicatorMainConfig'; -import { barsMeetObvVolumeRequirement } from '../../utils/obvChartBars'; -import { enrichIndicatorConfig } from '../../utils/indicatorRegistry'; +import { buildNotificationChartIndicators } from '../../utils/notificationChartIndicators'; +import { ensurePaperChartOverlays } from '../../utils/strategyToChartIndicators'; +import { ensureWorkspaceChartIndicators, invalidateWorkspaceChartIndicatorsCache } from '../../utils/workspaceChartIndicatorsCache'; +import { indicatorDefaultsFingerprint } from '../../utils/indicatorDefaultsFingerprint'; import { applyNotificationSignalMarkers, type TradeSignalChartMarker, @@ -70,21 +70,42 @@ const TradeSignalMiniChart: React.FC = ({ const signalMarkerRef = useRef(signalMarker); signalMarkerRef.current = signalMarker; - const baseIndicators = useMemo( - () => mergeGlobalDefaultsOntoChartIndicators( + const [wsIndicators, setWsIndicators] = useState([]); + const [wsRevision, setWsRevision] = useState(0); + + useEffect(() => { + let cancelled = false; + invalidateWorkspaceChartIndicatorsCache(); + void ensureWorkspaceChartIndicators().then(inds => { + if (cancelled) return; + setWsIndicators(inds); + setWsRevision(r => r + 1); + }); + return () => { cancelled = true; }; + }, [settingsRevision]); + + const baseIndicators = useMemo(() => { + const strategyMerged = mergeGlobalDefaultsOntoChartIndicators( ensurePaperChartOverlays(indicators, getParams, getVisualConfig), getParams, getVisualConfig, - ).map(enrichIndicatorConfig), - [indicators, getParams, getVisualConfig, settingsRevision], + wsIndicators, + ); + return buildNotificationChartIndicators(strategyMerged, wsIndicators); + }, [indicators, getParams, getVisualConfig, settingsRevision, wsRevision, wsIndicators]); + + const indicatorStyleKey = useMemo( + () => baseIndicators.map(i => indicatorDefaultsFingerprint(i)).join('|'), + [baseIndicators], ); - const needsDeepObv = useMemo( + /** OBV 등 워크스페이스 지표가 있으면 ws config 로드 완료 후 렌더 (maLength=1 플래시 방지) */ + const needsWorkspaceConfig = useMemo( () => baseIndicators.some(i => i.type === 'OBV'), [baseIndicators], ); - /** 메인 차트와 동일 소스 — STOMP(+ volume 없으면 Upbit 폴백). deepObvHistory 로 장기 봉 확보 */ + /** 실시간(메인) 차트와 동일한 데이터 소스 사용 */ const chartRealtimeSource: ChartRealtimeSource = appChartSource; const { @@ -162,28 +183,23 @@ const TradeSignalMiniChart: React.FC = ({ onNewCandle: handleNewCandle, enabled: enabled && useUpbit, source: chartRealtimeSource, - deepObvHistory: needsDeepObv, - }), [handleTickUpdate, handleNewCandle, enabled, useUpbit, needsDeepObv, chartRealtimeSource]), + }), [handleTickUpdate, handleNewCandle, enabled, useUpbit, chartRealtimeSource]), ); const { isLoadingMore } = useHistoryLoader({ symbol: market, timeframe, - /** OBV — 백엔드 history volume=0 봉 prepend 시 본선 왜곡 방지 */ - isUpbit: useUpbit && enabled && !needsDeepObv, + isUpbit: useUpbit && enabled, managerRef, }); barsMarketRef.current = barsMarket; const barsReady = bars.length >= 2 && barsMarket === market; - const obvBarsReady = useMemo(() => { - if (!needsDeepObv) return true; - if (bars.length < 2) return false; - return barsMeetObvVolumeRequirement(bars); - }, [needsDeepObv, bars]); + // 워크스페이스 지표(OBV maLength 등) 로드 완료 전에는 렌더 보류 → 잘못된 기본값 플래시 방지 + const wsConfigReady = !needsWorkspaceConfig || wsRevision > 0; - const chartDataReady = barsReady && obvBarsReady && settingsLoaded; + const chartDataReady = barsReady && wsConfigReady && settingsLoaded; useLayoutEffect(() => { chartLiveReadyRef.current = false; @@ -265,9 +281,6 @@ const TradeSignalMiniChart: React.FC = ({ ].filter(Boolean).join(' ')} > {(!settingsLoaded || isLoading) &&
로딩…
} - {needsDeepObv && !isLoading && !obvBarsReady && ( -
OBV 거래량 데이터 로딩…
- )} {isLoadingMore && (
@@ -275,7 +288,7 @@ const TradeSignalMiniChart: React.FC = ({ )} {chartDataReady && ( i.id).join(',')}`} + key={`${market}-${timeframe}-${chartReloadTick}-${chartRealtimeSource}-${settingsRevision}-${wsRevision}-${indicatorStyleKey}-${chartIndicators.map(i => i.id).join(',')}`} chartVisible bars={bars} barsMarket={barsMarket} diff --git a/frontend/src/hooks/useIndicatorSettings.ts b/frontend/src/hooks/useIndicatorSettings.ts index be1d644..89b9a94 100644 --- a/frontend/src/hooks/useIndicatorSettings.ts +++ b/frontend/src/hooks/useIndicatorSettings.ts @@ -22,7 +22,7 @@ */ import { useEffect, useRef, useCallback, useState } from 'react'; -import { getIndicatorDef, mergePlotDefs, normalizeHLines, normalizeObvParams, enrichIndicatorConfig, PlotDef, HLineDef } from '../utils/indicatorRegistry'; +import { getIndicatorDef, mergePlotDefs, mergePlotDefsFromSavedVisual, normalizeHLines, normalizeObvParams, enrichIndicatorConfig, PlotDef, HLineDef } from '../utils/indicatorRegistry'; import { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig'; import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig'; import { @@ -39,6 +39,7 @@ import { loadIndicatorVisualSettings, saveIndicatorVisualSettings, } from '../utils/backendApi'; +import { invalidateWorkspaceChartIndicatorsCache } from '../utils/workspaceChartIndicatorsCache'; type ParamMap = Record; type AllSettings = Record; @@ -114,6 +115,7 @@ export function invalidateIndicatorSettingsCache() { _visualCache = null; _visualLoadPromise = null; _loadedSessionKey = null; + invalidateWorkspaceChartIndicatorsCache(); } // ───────────────────────────────────────────────────────────────────────────── @@ -278,7 +280,7 @@ export function useIndicatorSettings(sessionKey = 0) { const saved = _visualCache?.[type]; const def = getIndicatorDef(type); const registryPlots = defaultPlots ?? def?.plots ?? []; - let plots = mergePlotDefs(saved?.plots as PlotDef[] | undefined, registryPlots); + let plots = mergePlotDefsFromSavedVisual(saved?.plots as PlotDef[] | undefined, registryPlots, type); const registryHlines = defaultHlines ?? def?.hlines ?? []; const rawHlines = (saved?.hlines as HLineDef[] | undefined) ?? registryHlines; const hlines = normalizeHLines(rawHlines, type, registryHlines); diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index ffe7898..96b2477 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -19,6 +19,7 @@ import { ColorType, LineStyle, CrosshairMode, + TickMarkType, } from 'lightweight-charts'; import { IndicatorFillPrimitive } from './IndicatorFillPrimitive'; import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive'; @@ -29,14 +30,14 @@ import { formatUpbitKrwPrice } from './safeFormat'; import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors'; import { DEFAULT_CHART_TIME_FORMAT, - formatUnixWithChartPattern, formatLwcTimeWithPattern, formatLwcTickMarkWithPattern, normalizeChartTimeFormat, } from './chartTimeFormat'; -import { DEFAULT_DISPLAY_TIMEZONE } from './timezone'; +import { DEFAULT_DISPLAY_TIMEZONE, getTimezoneOffsetSec } from './timezone'; import { getPaneHostId, sortIndicatorsForPaneLoad } from './indicatorPaneMerge'; import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry'; +import { filterObvPlotDataForChart } from './customIndicators'; import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin'; const MAIN_PRICE_FORMAT = { @@ -226,18 +227,10 @@ function sortDualLinePlotDefs(plotDefs: PlotDef[]): PlotDef[] { } /** - * OBV 등 이중 plot — 설정 lineWidth 기준 굵은 선 먼저(하단), 가는 선 나중(상단). - * 값이 겹쳐도 점선·실선·색상(설정값)이 함께 보이도록 z-order만 조정. + * OBV 등 이중 plot — 본선(plot0) 먼저·신호(plot1) 나중 (신호 점선이 본선 위). */ function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] { - const sorted = sortDualLinePlotDefs(plotDefs); - if (type !== 'OBV') return sorted; - return [...sorted].sort((a, b) => { - const wA = a.lineWidth ?? 1; - const wB = b.lineWidth ?? 1; - if (wA !== wB) return wB - wA; - return DUAL_LINE_PLOT_ORDER.indexOf(a.id) - DUAL_LINE_PLOT_ORDER.indexOf(b.id); - }); + return sortDualLinePlotDefs(plotDefs); } export class ChartManager { @@ -783,6 +776,40 @@ export class ChartManager { .filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false); } + /** + * OBV 시리즈 추가 순서 — plot0(본선) 먼저·plot1(신호) 나중. + * 신호선은 기본 점선 → 겹쳐도 파란 본선+주황 신호가 동시에 보임. + */ + private _obvSeriesAddOrder(): readonly ['plot0', 'plot1'] { + return ['plot0', 'plot1']; + } + + /** 레전드·크로스헤어 — plot0→plot1 순서 (seriesList z-order 와 무관) */ + private _plotOrderForEntry(entry: IndicatorEntry): string[] { + const config = entry.config; + const def = getIndicatorDef(entry.type); + const enriched = config ? enrichIndicatorConfig(config) : null; + const plots = enriched?.plots ?? def?.plots ?? []; + return plots + .filter(p => enriched?.plotVisibility?.[p.id] !== false) + .map(p => p.id); + } + + private _indicatorValuesInPlotOrder( + entry: IndicatorEntry, + params: MouseEventParams