diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx index bbd4eab..6721c4a 100644 --- a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -68,6 +68,11 @@ const TradeSignalMiniChart: React.FC = ({ [indicators, getParams, getVisualConfig], ); + const needsDeepObv = useMemo( + () => baseIndicators.some(i => i.type === 'OBV'), + [baseIndicators], + ); + const { indicators: chartIndicators, candleOverlayToggles, @@ -143,7 +148,8 @@ const TradeSignalMiniChart: React.FC = ({ onNewCandle: handleNewCandle, enabled: enabled && useUpbit, source: 'BACKEND_STOMP' as const, - }), [handleTickUpdate, handleNewCandle, enabled, useUpbit]), + deepObvHistory: needsDeepObv, + }), [handleTickUpdate, handleNewCandle, enabled, useUpbit, needsDeepObv]), ); const { isLoadingMore } = useHistoryLoader({ diff --git a/frontend/src/hooks/useChartRealtimeData.ts b/frontend/src/hooks/useChartRealtimeData.ts index 3cd7e17..bc3738a 100644 --- a/frontend/src/hooks/useChartRealtimeData.ts +++ b/frontend/src/hooks/useChartRealtimeData.ts @@ -22,6 +22,7 @@ export function useChartRealtimeData( onTickUpdate: opts.onTickUpdate, onNewCandle: opts.onNewCandle, enabled: opts.enabled !== false && useStomp, + deepObvHistory: opts.deepObvHistory, }); const upbit = useUpbitData(market, timeframe, { onTickUpdate: opts.onTickUpdate, diff --git a/frontend/src/hooks/useIndicatorSettings.ts b/frontend/src/hooks/useIndicatorSettings.ts index 5e1f95f..be1d644 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, PlotDef, HLineDef } from '../utils/indicatorRegistry'; +import { getIndicatorDef, mergePlotDefs, normalizeHLines, normalizeObvParams, enrichIndicatorConfig, PlotDef, HLineDef } from '../utils/indicatorRegistry'; import { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig'; import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig'; import { @@ -296,6 +296,15 @@ export function useIndicatorSettings(sessionKey = 0) { plots = mergeIchimokuPlots(plots, registryPlots); } + if (type === 'OBV') { + plots = enrichIndicatorConfig({ + id: '', + type: 'OBV', + params: (def?.defaultParams ?? {}) as Record, + plots, + }).plots ?? plots; + } + const cloudColors = type === 'IchimokuCloud' ? resolveIchimokuCloudColors(saved?.cloudColors ?? DEFAULT_ICHIMOKU_CLOUD_COLORS) : undefined; diff --git a/frontend/src/hooks/useStompChartData.ts b/frontend/src/hooks/useStompChartData.ts index f739bbd..2cf73e1 100644 --- a/frontend/src/hooks/useStompChartData.ts +++ b/frontend/src/hooks/useStompChartData.ts @@ -7,7 +7,7 @@ import { parseStompJson } from '../utils/stompMessage'; import { subscribeStompTopic } from '../utils/stompChartBroker'; import { timeframeToCandleType } from '../utils/chartCandleType'; import { getCandleStart, TF_SECONDS } from '../utils/upbitApi'; -import { DISPLAY_COUNT, WARMUP_COUNT } from './useUpbitData'; +import { DISPLAY_COUNT, WARMUP_COUNT, OBV_DEEP_HISTORY } from './useUpbitData'; import type { WsStatus } from './useUpbitData'; import { API_BASE } from '../utils/backendApi'; import { fetchUpbitCandlesCached } from '../utils/requestCache'; @@ -53,6 +53,8 @@ interface Options { onTickUpdate: (bar: OHLCVBar) => void; onNewCandle: (bar: OHLCVBar) => void; enabled?: boolean; + /** OBV 누적 정확도 — 더 많은 초기 봉 로드 */ + deepObvHistory?: boolean; } interface Return { @@ -177,7 +179,9 @@ export function useStompChartData( const marketChanged = prevMarketRef.current !== '' && prevMarketRef.current !== market; prevMarketRef.current = market; - const count = DISPLAY_COUNT + WARMUP_COUNT; + const count = opts.deepObvHistory + ? OBV_DEEP_HISTORY + : DISPLAY_COUNT + WARMUP_COUNT; loadInitialBars(market, timeframe, count, marketChanged) .then(newBars => { if (cancelled) return; @@ -202,7 +206,7 @@ export function useStompChartData( setIsLoading(false); }); return () => { cancelled = true; }; - }, [market, candleType, timeframe, opts.enabled]); + }, [market, candleType, timeframe, opts.enabled, opts.deepObvHistory]); const syncBarsToReactIfNeeded = useCallback((marketId: string) => { if (!bootstrapFromStompRef.current) return; diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 150e580..3582931 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -76,7 +76,7 @@ import { isCustomPlotSelected, } from './chartCustomOverlay'; import type { PlotDef, HLineStyle } from './indicatorRegistry'; -import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils'; +import { mapHistogramSeriesData, histogramBarColor, resolvePlotLineColor } from './plotColorUtils'; import { applyPaneSeparatorToChartOptions, cloneChartPaneSeparatorOptions, @@ -789,8 +789,9 @@ export class ChartManager { : config.plotVisibility?.[plotDef.id] !== false); const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showSeriesTitle = pane < 2 && showPriceLabel; + const regPlot = def?.plots?.find(p => p.id === plotDef.id); series = this.chart.addSeries(LineSeries, { - color: plotDef.color, + color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), lineWidth: Math.max(1, plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4, lineStyle: plotSeriesLineStyle(plotDef.lineStyle), priceLineVisible: false, @@ -2747,8 +2748,9 @@ export class ChartManager { opts['priceFormat'] = indicatorPriceFormat; } if (!entry.seriesMeta[i]?.isHistogram) { - opts['color'] = plot.color ?? '#aaa'; - if (plot.lineWidth != null) opts['lineWidth'] = plot.lineWidth; + const regPlot = def?.plots?.find(p => p.id === pid); + opts['color'] = resolvePlotLineColor(plot.color, regPlot?.color ?? plot.color ?? '#aaa'); + opts['lineWidth'] = Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1); opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle); let plotAllows = true; if (entry.type === 'IchimokuCloud') { diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index 35c7a26..89f3f9b 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -11,6 +11,7 @@ import { mergeIchimokuPlots, resolveIchimokuDisplacements, } from './ichimokuConfig'; +import { resolvePlotLineColor } from './plotColorUtils'; import { normalizePsychologicalParams, resolvePsychologicalIndicatorType, @@ -441,9 +442,21 @@ export function mergePlotDefs(saved: PlotDef[] | undefined, defaults: PlotDef[]) }); } -/** OBV·CCI·RSI·TRIX — registry 신호선(plot1) 병합·표시 보장 (DB에 plot0만 저장된 경우 대비) */ +/** OBV·CCI·RSI·TRIX — 이중 plot 병합·표시 보장 */ const SIGNAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']); +function normalizeDualLinePlotDef(saved: PlotDef, reg: PlotDef): PlotDef { + return { + ...reg, + ...saved, + id: reg.id, + title: reg.title, + type: 'line', + lineWidth: Math.max(1, saved.lineWidth ?? reg.lineWidth ?? 1), + color: resolvePlotLineColor(saved.color, reg.color), + }; +} + function normalizeSignalLineIndicatorPlots( type: string, plots: PlotDef[], @@ -455,24 +468,27 @@ function normalizeSignalLineIndicatorPlots( return { plots, plotVisibility }; } const merged = mergePlotDefs(plots, registryPlots); + + if (type === 'OBV') { + const nextPlots = merged.map(p => { + const reg = registryPlots.find(r => r.id === p.id); + return reg ? normalizeDualLinePlotDef(p, reg) : p; + }); + return { + plots: nextPlots, + plotVisibility: { ...plotVisibility, plot0: true, plot1: true }, + }; + } + const regSignal = registryPlots.find(p => p.id === 'plot1'); const nextPlots = regSignal ? merged.map(p => { if (p.id !== 'plot1') return p; - return { - ...regSignal, - ...p, - id: 'plot1', - title: regSignal.title, - type: 'line' as const, - lineWidth: Math.max(1, p.lineWidth ?? regSignal.lineWidth ?? 1), - }; + return normalizeDualLinePlotDef(p, regSignal); }) : merged; const nextVis = { ...plotVisibility }; - if (type === 'OBV') { - nextVis.plot1 = true; - } else if ((type === 'CCI' || type === 'RSI') && params.maType === 'None') { + if ((type === 'CCI' || type === 'RSI') && params.maType === 'None') { nextVis.plot1 = false; } else if (nextVis.plot1 !== false) { nextVis.plot1 = true; diff --git a/frontend/src/utils/plotColorUtils.ts b/frontend/src/utils/plotColorUtils.ts index b1278b1..765cd3e 100644 --- a/frontend/src/utils/plotColorUtils.ts +++ b/frontend/src/utils/plotColorUtils.ts @@ -45,6 +45,15 @@ export function plotColorCss(value: string): string { return `rgba(${r},${g},${b},${(alpha / 100).toFixed(2)})`; } +/** line 시리즈 색 — DB에 alpha 0·8자리 hex 저장 시 registry 기본색으로 복원 */ +export function resolvePlotLineColor(color: string | undefined, fallback: string): string { + const raw = (color ?? fallback).trim(); + const fb = fallback.length >= 7 && fallback.startsWith('#') ? fallback.slice(0, 7) : '#2962FF'; + const { alpha } = parsePlotColor(raw); + if (alpha < 10) return fb; + return plotColorCss(raw); +} + export const LINE_WIDTH_OPTIONS = [1, 2, 3, 4] as const; export const LINE_STYLE_OPTIONS: HLineStyle[] = ['solid', 'dashed', 'dotted'];