알림목록 obv 그래프 문제 최종수정

This commit is contained in:
Macbook
2026-06-10 09:57:38 +09:00
parent 490db298b6
commit 1ec22d6ea2
12 changed files with 745 additions and 162 deletions
@@ -2,7 +2,7 @@
* 매매 시그널 알림 목록 행 — 4칸이 목록 너비를 꽉 채움, 보조지표 2개↑ 시 우측 슬롯만 스크롤 * 매매 시그널 알림 목록 행 — 4칸이 목록 너비를 꽉 채움, 보조지표 2개↑ 시 우측 슬롯만 스크롤
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'; 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 type { TickerData } from '../../hooks/useMarketTicker';
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot'; import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
@@ -23,8 +23,10 @@ import {
buildStrategyChartIndicators, buildStrategyChartIndicators,
candleTypeToTimeframe, candleTypeToTimeframe,
} from '../../utils/strategyToChartIndicators'; } from '../../utils/strategyToChartIndicators';
import { buildNotificationChartIndicators } from '../../utils/notificationChartIndicators';
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry'; import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { ensureWorkspaceChartIndicators, invalidateWorkspaceChartIndicatorsCache } from '../../utils/workspaceChartIndicatorsCache';
import TradeSignalChartCard from './TradeSignalChartCard'; import TradeSignalChartCard from './TradeSignalChartCard';
import TradeNotificationSignalCard from './TradeNotificationSignalCard'; import TradeNotificationSignalCard from './TradeNotificationSignalCard';
import TradeNotificationHScrollPane from './TradeNotificationHScrollPane'; import TradeNotificationHScrollPane from './TradeNotificationHScrollPane';
@@ -109,7 +111,9 @@ const TradeNotificationListRow: React.FC<Props> = ({
const chartTf = candleTypeToTimeframe(item.candleType ?? '1m'); const chartTf = candleTypeToTimeframe(item.candleType ?? '1m');
const candleKo = formatCandleTypeKo(item.candleType); const candleKo = formatCandleTypeKo(item.candleType);
const { getParams, getVisualConfig } = useIndicatorSettings(); const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
const [wsIndicators, setWsIndicators] = useState<IndicatorConfig[]>([]);
const [wsRevision, setWsRevision] = useState(0);
const [strategy, setStrategy] = useState<StrategyDto | undefined>(); const [strategy, setStrategy] = useState<StrategyDto | undefined>();
const [strategies, setStrategies] = useState<StrategyDto[]>([]); const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const strategyLabel = item.strategyName?.trim() const strategyLabel = item.strategyName?.trim()
@@ -166,16 +170,28 @@ const TradeNotificationListRow: React.FC<Props> = ({
setExpandedChartKey(null); setExpandedChartKey(null);
}, [item.id, layoutMode]); }, [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(() => { const indicatorCards = useMemo(() => {
if (!isListLayout || !strategy) return []; 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 => ({ return all.slice(0, MAX_INDICATOR_CARDS).map(ind => ({
id: ind.id, id: ind.id,
type: ind.type, type: ind.type,
label: formatIndicatorDisplayLabel(ind.type), label: formatIndicatorDisplayLabel(ind.type),
config: [ind], config: [ind],
})); }));
}, [strategy, getParams, getVisualConfig, isListLayout]); }, [strategy, getParams, getVisualConfig, settingsRevision, wsRevision, wsIndicators, isListLayout]);
/** 통합 차트용: 전략의 모든 보조지표를 하나의 차트에 넘긴다 */ /** 통합 차트용: 전략의 모든 보조지표를 하나의 차트에 넘긴다 */
const allIndicatorConfigs = useMemo( const allIndicatorConfigs = useMemo(
@@ -17,13 +17,13 @@ import { useAppSettings } from '../../hooks/useAppSettings';
import type { ChartManager } from '../../utils/ChartManager'; import type { ChartManager } from '../../utils/ChartManager';
import { pinCandleWatch } from '../../utils/backendApi'; import { pinCandleWatch } from '../../utils/backendApi';
import { timeframeToCandleType } from '../../utils/chartCandleType'; import { timeframeToCandleType } from '../../utils/chartCandleType';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useSessionCandleOverlayControls } from '../../hooks/useSessionCandleOverlayControls'; import { useSessionCandleOverlayControls } from '../../hooks/useSessionCandleOverlayControls';
import { ensurePaperChartOverlays } from '../../utils/strategyToChartIndicators';
import { mergeGlobalDefaultsOntoChartIndicators } from '../../utils/indicatorMainConfig'; import { mergeGlobalDefaultsOntoChartIndicators } from '../../utils/indicatorMainConfig';
import { barsMeetObvVolumeRequirement } from '../../utils/obvChartBars'; import { buildNotificationChartIndicators } from '../../utils/notificationChartIndicators';
import { enrichIndicatorConfig } from '../../utils/indicatorRegistry'; import { ensurePaperChartOverlays } from '../../utils/strategyToChartIndicators';
import { ensureWorkspaceChartIndicators, invalidateWorkspaceChartIndicatorsCache } from '../../utils/workspaceChartIndicatorsCache';
import { indicatorDefaultsFingerprint } from '../../utils/indicatorDefaultsFingerprint';
import { import {
applyNotificationSignalMarkers, applyNotificationSignalMarkers,
type TradeSignalChartMarker, type TradeSignalChartMarker,
@@ -70,21 +70,42 @@ const TradeSignalMiniChart: React.FC<Props> = ({
const signalMarkerRef = useRef(signalMarker); const signalMarkerRef = useRef(signalMarker);
signalMarkerRef.current = signalMarker; signalMarkerRef.current = signalMarker;
const baseIndicators = useMemo( const [wsIndicators, setWsIndicators] = useState<IndicatorConfig[]>([]);
() => mergeGlobalDefaultsOntoChartIndicators( 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), ensurePaperChartOverlays(indicators, getParams, getVisualConfig),
getParams, getParams,
getVisualConfig, getVisualConfig,
).map(enrichIndicatorConfig), wsIndicators,
[indicators, getParams, getVisualConfig, settingsRevision], );
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.some(i => i.type === 'OBV'),
[baseIndicators], [baseIndicators],
); );
/** 메인 차트와 동일 소스 — STOMP(+ volume 없으면 Upbit 폴백). deepObvHistory 로 장기 봉 확보 */ /** 실시간(메인) 차트와 동일한 데이터 소스 사용 */
const chartRealtimeSource: ChartRealtimeSource = appChartSource; const chartRealtimeSource: ChartRealtimeSource = appChartSource;
const { const {
@@ -162,28 +183,23 @@ const TradeSignalMiniChart: React.FC<Props> = ({
onNewCandle: handleNewCandle, onNewCandle: handleNewCandle,
enabled: enabled && useUpbit, enabled: enabled && useUpbit,
source: chartRealtimeSource, source: chartRealtimeSource,
deepObvHistory: needsDeepObv, }), [handleTickUpdate, handleNewCandle, enabled, useUpbit, chartRealtimeSource]),
}), [handleTickUpdate, handleNewCandle, enabled, useUpbit, needsDeepObv, chartRealtimeSource]),
); );
const { isLoadingMore } = useHistoryLoader({ const { isLoadingMore } = useHistoryLoader({
symbol: market, symbol: market,
timeframe, timeframe,
/** OBV — 백엔드 history volume=0 봉 prepend 시 본선 왜곡 방지 */ isUpbit: useUpbit && enabled,
isUpbit: useUpbit && enabled && !needsDeepObv,
managerRef, managerRef,
}); });
barsMarketRef.current = barsMarket; barsMarketRef.current = barsMarket;
const barsReady = bars.length >= 2 && barsMarket === market; const barsReady = bars.length >= 2 && barsMarket === market;
const obvBarsReady = useMemo(() => { // 워크스페이스 지표(OBV maLength 등) 로드 완료 전에는 렌더 보류 → 잘못된 기본값 플래시 방지
if (!needsDeepObv) return true; const wsConfigReady = !needsWorkspaceConfig || wsRevision > 0;
if (bars.length < 2) return false;
return barsMeetObvVolumeRequirement(bars);
}, [needsDeepObv, bars]);
const chartDataReady = barsReady && obvBarsReady && settingsLoaded; const chartDataReady = barsReady && wsConfigReady && settingsLoaded;
useLayoutEffect(() => { useLayoutEffect(() => {
chartLiveReadyRef.current = false; chartLiveReadyRef.current = false;
@@ -265,9 +281,6 @@ const TradeSignalMiniChart: React.FC<Props> = ({
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
> >
{(!settingsLoaded || isLoading) && <div className="tnl-mini-chart-loading"></div>} {(!settingsLoaded || isLoading) && <div className="tnl-mini-chart-loading"></div>}
{needsDeepObv && !isLoading && !obvBarsReady && (
<div className="tnl-mini-chart-loading">OBV </div>
)}
{isLoadingMore && ( {isLoadingMore && (
<div className="chart-history-loading"> <div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 12, height: 12 }} /> <div className="loading-spinner" style={{ width: 12, height: 12 }} />
@@ -275,7 +288,7 @@ const TradeSignalMiniChart: React.FC<Props> = ({
)} )}
{chartDataReady && ( {chartDataReady && (
<TradingChart <TradingChart
key={`${market}-${timeframe}-${chartReloadTick}-${chartRealtimeSource}-${needsDeepObv ? 'obv' : 'std'}-${settingsRevision}-${chartIndicators.map(i => i.id).join(',')}`} key={`${market}-${timeframe}-${chartReloadTick}-${chartRealtimeSource}-${settingsRevision}-${wsRevision}-${indicatorStyleKey}-${chartIndicators.map(i => i.id).join(',')}`}
chartVisible chartVisible
bars={bars} bars={bars}
barsMarket={barsMarket} barsMarket={barsMarket}
+4 -2
View File
@@ -22,7 +22,7 @@
*/ */
import { useEffect, useRef, useCallback, useState } from 'react'; 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 { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig';
import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig'; import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig';
import { import {
@@ -39,6 +39,7 @@ import {
loadIndicatorVisualSettings, loadIndicatorVisualSettings,
saveIndicatorVisualSettings, saveIndicatorVisualSettings,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { invalidateWorkspaceChartIndicatorsCache } from '../utils/workspaceChartIndicatorsCache';
type ParamMap = Record<string, unknown>; type ParamMap = Record<string, unknown>;
type AllSettings = Record<string, ParamMap>; type AllSettings = Record<string, ParamMap>;
@@ -114,6 +115,7 @@ export function invalidateIndicatorSettingsCache() {
_visualCache = null; _visualCache = null;
_visualLoadPromise = null; _visualLoadPromise = null;
_loadedSessionKey = null; _loadedSessionKey = null;
invalidateWorkspaceChartIndicatorsCache();
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -278,7 +280,7 @@ export function useIndicatorSettings(sessionKey = 0) {
const saved = _visualCache?.[type]; const saved = _visualCache?.[type];
const def = getIndicatorDef(type); const def = getIndicatorDef(type);
const registryPlots = defaultPlots ?? def?.plots ?? []; 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 registryHlines = defaultHlines ?? def?.hlines ?? [];
const rawHlines = (saved?.hlines as HLineDef[] | undefined) ?? registryHlines; const rawHlines = (saved?.hlines as HLineDef[] | undefined) ?? registryHlines;
const hlines = normalizeHLines(rawHlines, type, registryHlines); const hlines = normalizeHLines(rawHlines, type, registryHlines);
+379 -85
View File
@@ -19,6 +19,7 @@ import {
ColorType, ColorType,
LineStyle, LineStyle,
CrosshairMode, CrosshairMode,
TickMarkType,
} from 'lightweight-charts'; } from 'lightweight-charts';
import { IndicatorFillPrimitive } from './IndicatorFillPrimitive'; import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive'; import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
@@ -29,14 +30,14 @@ import { formatUpbitKrwPrice } from './safeFormat';
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors'; import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
import { import {
DEFAULT_CHART_TIME_FORMAT, DEFAULT_CHART_TIME_FORMAT,
formatUnixWithChartPattern,
formatLwcTimeWithPattern, formatLwcTimeWithPattern,
formatLwcTickMarkWithPattern, formatLwcTickMarkWithPattern,
normalizeChartTimeFormat, normalizeChartTimeFormat,
} from './chartTimeFormat'; } from './chartTimeFormat';
import { DEFAULT_DISPLAY_TIMEZONE } from './timezone'; import { DEFAULT_DISPLAY_TIMEZONE, getTimezoneOffsetSec } from './timezone';
import { getPaneHostId, sortIndicatorsForPaneLoad } from './indicatorPaneMerge'; import { getPaneHostId, sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry'; import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
import { filterObvPlotDataForChart } from './customIndicators';
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin'; import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
const MAIN_PRICE_FORMAT = { const MAIN_PRICE_FORMAT = {
@@ -226,18 +227,10 @@ function sortDualLinePlotDefs(plotDefs: PlotDef[]): PlotDef[] {
} }
/** /**
* OBV 등 이중 plot — 설정 lineWidth 기준 굵은 선 먼저(하단), 가는 선 나중(상단). * OBV 등 이중 plot — 본선(plot0) 먼저·신호(plot1) 나중 (신호 점선이 본선 위).
* 값이 겹쳐도 점선·실선·색상(설정값)이 함께 보이도록 z-order만 조정.
*/ */
function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] { function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] {
const sorted = sortDualLinePlotDefs(plotDefs); return 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);
});
} }
export class ChartManager { export class ChartManager {
@@ -783,6 +776,40 @@ export class ChartManager {
.filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false); .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<Time>,
): number[] {
return this._plotOrderForEntry(entry).map(plotId => {
const idx = entry.seriesMeta.findIndex(m => m.plotId === plotId);
if (idx < 0) return NaN;
const series = entry.seriesList[idx];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = params.seriesData.get(series) as any;
if (!d) return NaN;
return typeof d.value === 'number' ? d.value : NaN;
});
}
/** OBV — plot0·plot1 항상 쌍으로 생성 (메인 차트와 동일, DUAL_LINE repair 경로 회피) */ /** OBV — plot0·plot1 항상 쌍으로 생성 (메인 차트와 동일, DUAL_LINE repair 경로 회피) */
private _addObvIndicatorSeries( private _addObvIndicatorSeries(
seriesList: ISeriesApi<SeriesType>[], seriesList: ISeriesApi<SeriesType>[],
@@ -799,40 +826,104 @@ export class ChartManager {
); );
const indicatorPriceFormat = priceFormatForIndicatorPane(pane); const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
for (const plotId of ['plot0', 'plot1'] as const) { // 본선(plot0)·신호선(plot1) 을 같은 가격 범위로 고정.
if (enriched.plotVisibility?.[plotId] === false) continue; // 재렌더·pane 재배치 과정에서 스케일이 오염되면 본선이 납작하게 눌릴 수 있어,
// OBV 데이터(plot0+plot1) 의 실제 min/max 로 autoscale 을 강제한다.
let obvMin = Infinity, obvMax = -Infinity;
for (const id of ['plot0', 'plot1'] as const) {
for (const p of (result.plots[id] ?? []) as PlotData) {
if (Number.isFinite(p.value)) {
obvMin = Math.min(obvMin, p.value);
obvMax = Math.max(obvMax, p.value);
}
}
}
// 본선·신호선을 같은 전용 가격축에 격리 → 같은 pane 의 다른(오염된) 'right' 축이나
// 고아 시리즈의 거대값에 휩쓸려 본선이 납작하게 눌리는 것을 차단한다.
const obvScaleId = `obv-${config.id}`;
const obvAutoscaleProvider = (Number.isFinite(obvMin) && Number.isFinite(obvMax) && obvMax > obvMin)
? (baseImpl: () => { priceRange: { minValue: number; maxValue: number } } | null) => {
// plot0·plot1 둘 다 동일 범위(union)를 쓰도록 해 실제 값 차이가 세로로 분리돼 보인다.
// 실시간 봉 추가로 값이 커지면 base(현재 시리즈 범위)와 union 해서 추적.
let lo = obvMin, hi = obvMax;
const base = baseImpl?.();
if (base?.priceRange) {
lo = Math.min(lo, base.priceRange.minValue);
hi = Math.max(hi, base.priceRange.maxValue);
}
const pad = (hi - lo) * 0.08 || Math.abs(hi) * 0.08 || 1;
return { priceRange: { minValue: lo - pad, maxValue: hi + pad } };
}
: undefined;
// lightweight-charts 는 존재하지 않는 pane 을 한 번에 하나씩만 만들기 때문에,
// 볼륨 숨김 차트(pane 0 만 존재)에서 paneIndex=2 로 두 시리즈를 추가하면
// 첫 시리즈는 pane 1, 두 번째는 pane 2 로 갈라진다. 첫 시리즈가 실제로 배치된
// pane 을 읽어 이후 시리즈를 같은 pane 에 강제 → plot0/plot1 분리(축 분리) 차단.
let targetPane = pane;
for (const plotId of this._obvSeriesAddOrder()) {
const plotDef = plotById[plotId] ?? def?.plots?.find(p => p.id === plotId); const plotDef = plotById[plotId] ?? def?.plots?.find(p => p.id === plotId);
if (!plotDef || plotDef.type === 'histogram') continue; if (!plotDef || plotDef.type === 'histogram') continue;
const plotData = result.plots[plotId] as PlotData | undefined; const plotData = result.plots[plotId] as PlotData | undefined;
if (!plotData?.length) continue; if (!plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value)); const filtered = filterObvPlotDataForChart(
plotData,
result.plots as Record<string, PlotData>,
);
if (filtered.length === 0) continue; if (filtered.length === 0) continue;
const isPlotVisible = !indicatorHidden; const isPlotVisible = !indicatorHidden;
const showPriceLabel = this.shouldShowSeriesPriceLabel(enriched, true, pane); const showPriceLabel = this.shouldShowSeriesPriceLabel(enriched, true, pane);
const showSeriesTitle = pane < 2 && showPriceLabel; const showSeriesTitle = pane < 2 && showPriceLabel;
const regPlot = def?.plots?.find(p => p.id === plotId); const regPlot = def?.plots?.find(p => p.id === plotId);
const fallbackColor = plotId === 'plot0'
? (regPlot?.color ?? '#2196F3') const resolvedColor = resolvePlotLineColor(
: (regPlot?.color ?? plotDef.color ?? '#2962FF'); plotDef.color,
regPlot?.color ?? plotDef.color ?? (plotId === 'plot0' ? '#2196F3' : '#FF9800'),
);
const resolvedWidth = (plotId === 'plot0'
? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2)
: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4;
const series = this.chart.addSeries(LineSeries, { const series = this.chart.addSeries(LineSeries, {
color: resolvePlotLineColor(plotDef.color, fallbackColor), color: resolvedColor,
lineWidth: (plotId === 'plot0' lineWidth: resolvedWidth,
? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2)
: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4,
lineStyle: plotSeriesLineStyle(plotDef.lineStyle), lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: showPriceLabel, lastValueVisible: showPriceLabel,
crosshairMarkerVisible: true,
crosshairMarkerRadius: plotId === 'plot0' ? 4 : 3,
title: showSeriesTitle ? this.seriesTitleForPlot(enriched, plotDef) : '', title: showSeriesTitle ? this.seriesTitleForPlot(enriched, plotDef) : '',
visible: isPlotVisible, visible: isPlotVisible,
priceScaleId: obvScaleId,
...(obvAutoscaleProvider ? { autoscaleInfoProvider: obvAutoscaleProvider } : {}),
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
}, pane); }, targetPane);
// 첫 시리즈가 실제로 배치된 pane 으로 이후 시리즈를 고정
if (seriesList.length === 0) {
const actual = this._readSeriesPaneIndex(series);
if (actual >= 0) targetPane = actual;
}
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
seriesList.push(series); seriesList.push(series);
seriesMeta.push({ plotId, isHistogram: false, color: plotDef.color }); seriesMeta.push({ plotId, isHistogram: false, color: plotDef.color });
} }
// 전용 축을 우측에 표시 (눈금 라벨 유지) + 여백
if (seriesList.length > 0) {
try {
this.chart.priceScale(obvScaleId, pane).applyOptions({
visible: true,
scaleMargins: { top: 0.12, bottom: 0.12 },
});
} catch { /* ok */ }
}
if (!indicatorHidden && seriesMeta.length < 2) {
console.warn('[OBV] incomplete series — expected plot0+plot1, got', seriesMeta.map(m => m.plotId));
}
} }
/** RSI·CCI·TRIX — plot0→plot1 순서로 시리즈를 한 번에 생성 */ /** RSI·CCI·TRIX — plot0→plot1 순서로 시리즈를 한 번에 생성 */
@@ -1157,6 +1248,9 @@ export class ChartManager {
} }
this.indicators.set(config.id, entry); this.indicators.set(config.id, entry);
if (config.type === 'OBV') { if (config.type === 'OBV') {
if (entry.seriesMeta.length < 2 && !indicatorHidden && def) {
await this._ensureObvSeriesForEntry(entry);
}
if (entry.seriesList.length > 0 && !indicatorHidden) { if (entry.seriesList.length > 0 && !indicatorHidden) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config); this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
} }
@@ -1186,6 +1280,7 @@ export class ChartManager {
if (this._activeIndicatorPaneIndices().size > 0) { if (this._activeIndicatorPaneIndices().size > 0) {
this._resyncIndicatorPaneIndices(); this._resyncIndicatorPaneIndices();
this._splitCollidingIndicatorPanes(); this._splitCollidingIndicatorPanes();
this._consolidateMultiSeriesPanes();
this._resyncIndicatorPaneIndices(); this._resyncIndicatorPaneIndices();
this._applyAllSeriesPriceFormats(); this._applyAllSeriesPriceFormats();
} else { } else {
@@ -1211,6 +1306,7 @@ export class ChartManager {
this._removeOrphanSubPanes(); this._removeOrphanSubPanes();
if (this._activeIndicatorPaneIndices().size > 0) { if (this._activeIndicatorPaneIndices().size > 0) {
this._resyncIndicatorPaneIndices(); this._resyncIndicatorPaneIndices();
this._consolidateMultiSeriesPanes();
} else { } else {
this._trimTrailingEmptySubPanes(); this._trimTrailingEmptySubPanes();
} }
@@ -1221,6 +1317,87 @@ export class ChartManager {
this._notifyPaneLayout(); this._notifyPaneLayout();
} }
/** 누락된 OBV plot 시리즈만 추가 (기존 plot1 유지 등) */
private _appendMissingObvPlotSeries(
entry: IndicatorEntry,
config: IndicatorConfig,
result: { plots: Record<string, PlotData> },
pane: number,
indicatorHidden: boolean,
def: NonNullable<ReturnType<typeof getIndicatorDef>>,
): void {
const existing = new Set(entry.seriesMeta.map(m => m.plotId));
if (existing.has('plot0') && existing.has('plot1')) return;
const scratchList: ISeriesApi<SeriesType>[] = [];
const scratchMeta: IndicatorEntry['seriesMeta'] = [];
this._addObvIndicatorSeries(
scratchList, scratchMeta, config, result, pane, indicatorHidden, def,
);
for (let i = 0; i < scratchList.length; i++) {
const meta = scratchMeta[i];
if (!meta || existing.has(meta.plotId)) {
try { this.chart.removeSeries(scratchList[i]); } catch { /* ok */ }
continue;
}
entry.seriesList.push(scratchList[i]);
entry.seriesMeta.push(meta);
}
}
/** OBV plot0/plot1 누락·데이터 길이 불일치 시 재생성 또는 전체 setData */
private async _ensureObvSeriesForEntry(entry: IndicatorEntry): Promise<void> {
if (entry.type !== 'OBV' || !entry.config || this.rawBars.length === 0) return;
const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef('OBV');
if (!def) return;
const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
if (missingPlot) {
const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2;
const hidden = !this._isIndicatorEffectivelyVisible(config);
if (entry.seriesList.length > 0) {
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._appendMissingObvPlotSeries(entry, config, result, pane, hidden, def);
if (entry.seriesList.length > 0) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
entry.config = config;
} catch (e) {
console.error('[Indicator] OBV append missing error:', e);
}
return;
}
for (const s of entry.seriesList) {
try { this.chart.removeSeries(s); } catch { /* ok */ }
}
entry.seriesList = [];
entry.seriesMeta = [];
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._addObvIndicatorSeries(
entry.seriesList, entry.seriesMeta, config, result, pane, hidden, def,
);
if (entry.seriesList.length > 0) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
entry.config = config;
} catch (e) {
console.error('[Indicator] OBV ensure error:', e);
}
return;
}
if (entry.seriesList.length === 0) return;
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
} catch { /* ok */ }
}
/** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 + 전체 plot setData + 스타일(DB) 반영 */ /** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 + 전체 plot setData + 스타일(DB) 반영 */
async repairDualLineSeries(): Promise<void> { async repairDualLineSeries(): Promise<void> {
for (const entry of this.indicators.values()) { for (const entry of this.indicators.values()) {
@@ -1228,35 +1405,7 @@ export class ChartManager {
if (entry.type === 'OBV') { if (entry.type === 'OBV') {
const config = enrichIndicatorConfig(entry.config); const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef('OBV'); await this._ensureObvSeriesForEntry(entry);
const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
if (missingPlot && def && this.rawBars.length > 0) {
const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2;
const hidden = !this._isIndicatorEffectivelyVisible(config);
for (const s of entry.seriesList) {
try { this.chart.removeSeries(s); } catch { /* ok */ }
}
entry.seriesList = [];
entry.seriesMeta = [];
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._addObvIndicatorSeries(
entry.seriesList, entry.seriesMeta, config, result, pane, hidden, def,
);
if (entry.seriesList.length > 0) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
entry.config = config;
} catch (e) {
console.error('[Indicator] OBV repair error:', e);
}
} else if (entry.seriesList.length > 0 && this.rawBars.length > 0) {
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
} catch { /* ok */ }
}
this.applyIndicatorStyle(config, { skipDualLineRefresh: true }); this.applyIndicatorStyle(config, { skipDualLineRefresh: true });
continue; continue;
} }
@@ -1497,16 +1646,19 @@ export class ChartManager {
plots: Record<string, PlotData>, plots: Record<string, PlotData>,
config: IndicatorConfig, config: IndicatorConfig,
): void { ): void {
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? []; const def = getIndicatorDef(config.type);
const plotDefs = config.plots ?? def?.plots ?? [];
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p])); const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
for (let i = 0; i < entry.seriesList.length; i++) { for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i]; const meta = entry.seriesMeta[i];
if (!meta) continue; if (!meta) continue;
const plot = plotById[meta.plotId]; const plot = plotById[meta.plotId] ?? def?.plots?.find(p => p.id === meta.plotId);
const plotData = plots[meta.plotId] as PlotData | undefined; const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plot || !plotData?.length) continue; if (!plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value)); const filtered = config.type === 'OBV'
? filterObvPlotDataForChart(plotData, plots)
: plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue; if (filtered.length === 0) continue;
const series = entry.seriesList[i]; const series = entry.seriesList[i];
@@ -1885,19 +2037,34 @@ export class ChartManager {
} }
} }
/** LWC 시리즈가 실제로 붙어 있는 sub-pane index (2+) */ /** 볼륨 pane(1)이 실제로 표시되는지 여부 */
private _volumePaneShown(): boolean {
return this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
}
/**
* 보조지표가 들어갈 수 있는 첫 sub-pane index.
* - 볼륨 표시: 2 (pane 1 = 볼륨)
* - 볼륨 숨김: 1 (볼륨이 없으므로 pane 1 이 곧 첫 지표 pane)
*/
private _minIndicatorSubPane(): number {
return this._volumePaneShown() ? 2 : 1;
}
/** LWC 시리즈가 실제로 붙어 있는 sub-pane index (볼륨 숨김 시 1+, 표시 시 2+) */
private _collectUsedSubPaneIndices(): Set<number> { private _collectUsedSubPaneIndices(): Set<number> {
const minSub = this._minIndicatorSubPane();
const indices = new Set<number>(); const indices = new Set<number>();
for (const entry of this.indicators.values()) { for (const entry of this.indicators.values()) {
let pane = -1; let pane = -1;
for (const series of entry.seriesList) { for (const series of entry.seriesList) {
const pi = this._readSeriesPaneIndex(series); const pi = this._readSeriesPaneIndex(series);
if (pi >= 2) pane = Math.max(pane, pi); if (pi >= minSub) pane = Math.max(pane, pi);
} }
if (pane < 2 && entry.paneIndex != null && entry.paneIndex >= 2) { if (pane < minSub && entry.paneIndex != null && entry.paneIndex >= minSub) {
pane = entry.paneIndex; pane = entry.paneIndex;
} }
if (pane >= 2) indices.add(pane); if (pane >= minSub) indices.add(pane);
} }
return indices; return indices;
} }
@@ -1954,9 +2121,61 @@ export class ChartManager {
if (changed) this._removeOrphanSubPanes(); if (changed) this._removeOrphanSubPanes();
} }
/**
* 같은 지표(entry)의 모든 plot 시리즈를 반드시 한 pane 으로 통일한다.
*
* lightweight-charts 의 가격축(price scale)은 pane 단위라서, 같은 priceScaleId
* 문자열이라도 plot0/plot1 이 서로 다른 pane 에 흩어지면 서로 다른 축 객체가 되어
* 본선이 신호선과 다른 스케일로 눌리는 문제가 발생한다. 레이아웃/리사이즈 직후
* 호출해 분리를 복구한다.
*/
private _consolidateMultiSeriesPanes(): void {
for (const entry of this.indicators.values()) {
if (entry.seriesList.length < 2) continue;
const def = getIndicatorDef(entry.type);
// 오버레이(BB 등)·마커 지표는 캔들 pane(0)을 공유하므로 통일 대상에서 제외
if (def?.overlay || def?.returnsMarkers) continue;
// 서브패널 지표: 모든 plot 을 동일한 실제 지표 pane 으로 통일.
// 시리즈가 흩어졌을 때 가장 깊은 pane(=정상 배치된 plot 의 pane)을 타깃으로 삼고,
// 모두 0/1(캔들·볼륨 슬롯)에 갇혀 있으면 첫 실제 sub-pane 으로 끌어올린다.
// (볼륨 숨김 시 minSub=1 이므로 pane 1 이 정상 지표 pane 이 된다.)
const minSub = this._minIndicatorSubPane();
let target = -1;
for (const s of entry.seriesList) {
const pi = this._readSeriesPaneIndex(s);
if (pi > target) target = pi;
}
if (target < minSub) target = minSub;
let moved = false;
for (const s of entry.seriesList) {
try {
if (this._readSeriesPaneIndex(s) !== target) {
s.moveToPane(target);
moved = true;
}
} catch { /* ok */ }
}
if (!moved && entry.paneIndex === target) continue;
entry.paneIndex = target;
// 통일된 pane 에서 OBV 전용 축 옵션 재확정 (이동 중 손실 방지)
if (entry.type === 'OBV' && entry.config) {
try {
this.chart.priceScale(`obv-${entry.config.id}`, target).applyOptions({
visible: true,
scaleMargins: { top: 0.12, bottom: 0.12 },
});
} catch { /* ok */ }
}
}
}
private _getNextSubPane(): number { private _getNextSubPane(): number {
const used = this._collectUsedSubPaneIndices(); const used = this._collectUsedSubPaneIndices();
let pane = 2; let pane = this._minIndicatorSubPane();
while (used.has(pane)) pane++; while (used.has(pane)) pane++;
return pane; return pane;
} }
@@ -1965,7 +2184,8 @@ export class ChartManager {
private _resolveSubPane(config: IndicatorConfig): number { private _resolveSubPane(config: IndicatorConfig): number {
if (config.mergedWith) { if (config.mergedWith) {
const host = this.indicators.get(config.mergedWith); const host = this.indicators.get(config.mergedWith);
if (host?.paneIndex != null && host.paneIndex >= 2) { const minSub = this._minIndicatorSubPane();
if (host?.paneIndex != null && host.paneIndex >= minSub) {
return host.paneIndex; return host.paneIndex;
} }
} }
@@ -2081,7 +2301,11 @@ export class ChartManager {
if (dataGenAtStart !== this._dataGeneration) return; if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config) continue; if (!entry.config) continue;
try { try {
if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) { if (entry.type === 'OBV') {
const missing = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
if (missing) await this._ensureObvSeriesForEntry(entry);
} else if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
const config = enrichIndicatorConfig(entry.config); const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef(entry.type); const def = getIndicatorDef(entry.type);
if (this._dualLineSeriesOutOfSync(entry, config, def)) { if (this._dualLineSeriesOutOfSync(entry, config, def)) {
@@ -2099,6 +2323,12 @@ export class ChartManager {
this._refreshIchimokuSeriesData(entry, plots); this._refreshIchimokuSeriesData(entry, plots);
continue; continue;
} }
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false); this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
} catch { /* 계산 실패 무시 */ } } catch { /* 계산 실패 무시 */ }
} }
@@ -2313,7 +2543,11 @@ export class ChartManager {
if (dataGenAtStart !== this._dataGeneration) return; if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config) continue; if (!entry.config) continue;
try { try {
if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) { if (entry.type === 'OBV') {
const missing = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
if (missing) await this._ensureObvSeriesForEntry(entry);
} else if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
const config = enrichIndicatorConfig(entry.config); const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef(entry.type); const def = getIndicatorDef(entry.type);
if (this._dualLineSeriesOutOfSync(entry, config, def)) { if (this._dualLineSeriesOutOfSync(entry, config, def)) {
@@ -2330,6 +2564,12 @@ export class ChartManager {
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) { if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config); this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
} }
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
// updateFutureSpans=true → 새 캔들 추가 시 SpanA/B setData() 전체 재설정 // updateFutureSpans=true → 새 캔들 추가 시 SpanA/B setData() 전체 재설정
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ true); this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ true);
} catch { /* 인디케이터 계산 실패 무시 */ } } catch { /* 인디케이터 계산 실패 무시 */ }
@@ -2799,7 +3039,10 @@ export class ChartManager {
type: entry.type, type: entry.type,
config: entry.config, config: entry.config,
paneIndex: entry.paneIndex ?? 0, paneIndex: entry.paneIndex ?? 0,
plotColors: entry.seriesMeta.map(m => m.color), plotColors: this._plotOrderForEntry(entry).map(plotId => {
const meta = entry.seriesMeta.find(m => m.plotId === plotId);
return (meta?.color ?? '#888888').slice(0, 7);
}),
}); });
} }
return result; return result;
@@ -2812,14 +3055,7 @@ export class ChartManager {
getIndicatorValuesByIdFromParams(params: MouseEventParams<Time>): Record<string, number[]> { getIndicatorValuesByIdFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
const result: Record<string, number[]> = {}; const result: Record<string, number[]> = {};
for (const [id, entry] of this.indicators) { for (const [id, entry] of this.indicators) {
const vals: number[] = []; const vals = this._indicatorValuesInPlotOrder(entry, params);
for (const series of entry.seriesList) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = params.seriesData.get(series) as any;
if (!d) { vals.push(NaN); continue; }
const v = typeof d.value === 'number' ? d.value : NaN;
vals.push(v);
}
if (vals.some(v => !isNaN(v))) result[id] = vals; if (vals.some(v => !isNaN(v))) result[id] = vals;
} }
return result; return result;
@@ -3120,7 +3356,9 @@ export class ChartManager {
let plot: PlotDef | undefined; let plot: PlotDef | undefined;
const pid = entry.seriesMeta[i]?.plotId; const pid = entry.seriesMeta[i]?.plotId;
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i]; plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
const visible = this._resolveSeriesVisible(config, pid); const visible = entry.type === 'OBV'
? indicatorVisible
: this._resolveSeriesVisible(config, pid);
if (!plot) { if (!plot) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions({ visible } as any); series.applyOptions({ visible } as any);
@@ -3138,8 +3376,16 @@ export class ChartManager {
} }
if (!entry.seriesMeta[i]?.isHistogram) { if (!entry.seriesMeta[i]?.isHistogram) {
const regPlot = def?.plots?.find(p => p.id === pid); const regPlot = def?.plots?.find(p => p.id === pid);
opts['color'] = resolvePlotLineColor(plot.color, regPlot?.color ?? plot.color ?? '#aaa'); const defaultObvColor = pid === 'plot0' ? '#2196F3' : pid === 'plot1' ? '#FF9800' : '#2962FF';
opts['lineWidth'] = Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1); opts['color'] = resolvePlotLineColor(
plot.color,
regPlot?.color ?? plot.color ?? (entry.type === 'OBV' ? defaultObvColor : '#aaa'),
);
opts['lineWidth'] = entry.type === 'OBV'
? ((pid === 'plot0'
? Math.max(2, plot.lineWidth ?? regPlot?.lineWidth ?? 2)
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4)
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1);
opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle); opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle);
let plotAllows = true; let plotAllows = true;
if (entry.type === 'IchimokuCloud') { if (entry.type === 'IchimokuCloud') {
@@ -3489,9 +3735,9 @@ export class ChartManager {
this.autoScale(); this.autoScale();
} }
/** pane 2+ 하단 보조지표 여부 (pane 0 오버레이 제외) */ /** 하단 보조지표 pane 여부 (pane 0 오버레이 제외, 볼륨 숨김 시 pane 1 포함) */
private _isSubPaneIndicator(entry: IndicatorEntry): boolean { private _isSubPaneIndicator(entry: IndicatorEntry): boolean {
return (entry.paneIndex ?? 0) >= 2; return (entry.paneIndex ?? 0) >= this._minIndicatorSubPane();
} }
/** 캔들 전체보기 — 하단 보조지표 pane 시리즈·기준선 숨김 */ /** 캔들 전체보기 — 하단 보조지표 pane 시리즈·기준선 숨김 */
@@ -3687,23 +3933,57 @@ export class ChartManager {
const lrSpan = lr.to - lr.from; const lrSpan = lr.to - lr.from;
if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null; if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null;
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
const step = Math.max(1, Math.ceil((lr.to - Math.max(0, lr.from)) / targetCount));
const labels: Array<{ x: number; text: string }> = [];
const fmt = this.chartTimeFormat; const fmt = this.chartTimeFormat;
const tz = this.displayTimezone; const tz = this.displayTimezone;
const tf = this.displayTimeframe;
const startLogical = Math.max(0, Math.ceil(lr.from)); const startLogical = Math.max(0, Math.ceil(lr.from));
const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to)); const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
if (endLogical <= startLogical) return null;
for (let logical = startLogical; logical <= endLogical; logical += step) { const firstT = this.rawBars[startLogical]?.time;
const lastT = this.rawBars[endLogical]?.time;
if (!firstT || !lastT || lastT <= firstT) return null;
// 하단 네이티브 시간축과 동일하게 "정시 경계"에 눈금 배치 → x·텍스트 정렬.
// (네이티브 LWC 도 실제 바 위치에 round time 눈금을 찍는다)
const NICE_STEPS_SEC = [
60, 120, 300, 600, 900, 1800, // 분 단위
3600, 7200, 10800, 21600, 43200, // 시간 단위
86400, 172800, 604800, // 일·주 단위
];
const targetCount = Math.max(3, Math.min(10, Math.floor(plotWidth / 92)));
const visSpanSec = lastT - firstT;
let stepSec = NICE_STEPS_SEC[NICE_STEPS_SEC.length - 1];
for (const s of NICE_STEPS_SEC) {
if (visSpanSec / s <= targetCount) { stepSec = s; break; }
}
// 표시 시간대 기준 벽시계 정렬용 오프셋
const offsetSec = getTimezoneOffsetSec(firstT, tz);
const labels: Array<{ x: number; text: string }> = [];
let prevBucket: number | null = null;
let prevDayKey: number | null = null;
for (let logical = startLogical; logical <= endLogical; logical++) {
const bar = this.rawBars[logical]; const bar = this.rawBars[logical];
if (!bar?.time) continue; if (!bar?.time) continue;
const bucket = Math.floor((bar.time + offsetSec) / stepSec);
if (bucket === prevBucket) continue; // 같은 경계 버킷 → 첫 바만 눈금
prevBucket = bucket;
const x = this._logicalToAxisX(logical, lr, plotWidth); const x = this._logicalToAxisX(logical, lr, plotWidth);
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue; if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
const dayKey = Math.floor((bar.time + offsetSec) / 86400);
const isDayChange = prevDayKey !== null && dayKey !== prevDayKey;
prevDayKey = dayKey;
const tickType = stepSec >= 86400 || isDayChange
? TickMarkType.DayOfMonth
: TickMarkType.Time;
labels.push({ labels.push({
x, x,
text: formatUnixWithChartPattern(bar.time, fmt, tz), text: formatLwcTickMarkWithPattern(bar.time as Time, tickType, tf, tz, fmt),
}); });
} }
@@ -4141,6 +4421,13 @@ export class ChartManager {
continue; continue;
} }
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
// 일반 지표: 각 시리즈에 전체 데이터 setData // 일반 지표: 각 시리즈에 전체 데이터 setData
for (let i = 0; i < entry.seriesList.length; i++) { for (let i = 0; i < entry.seriesList.length; i++) {
const series = entry.seriesList[i]; const series = entry.seriesList[i];
@@ -4448,7 +4735,14 @@ export class ChartManager {
if (paneIndex === 0) { if (paneIndex === 0) {
panes[i].setStretchFactor(mainWeight); panes[i].setStretchFactor(mainWeight);
} else if (paneIndex === 1) { } else if (paneIndex === 1) {
panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH); if (volumeShown) {
panes[i].setStretchFactor(volWeight);
} else if (activeIndPanes.has(1)) {
// 볼륨 숨김 + pane 1 에 보조지표가 배치된 경우 → 지표 pane 으로 취급해 높이 부여
panes[i].setStretchFactor(indWeight);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
} else if (activeIndPanes.has(paneIndex)) { } else if (activeIndPanes.has(paneIndex)) {
panes[i].setStretchFactor(indWeight); panes[i].setStretchFactor(indWeight);
} else { } else {
+56 -14
View File
@@ -81,6 +81,43 @@ function toPlotData(bars: OHLCVBar[], values: number[]): PlotData {
})); }));
} }
/**
* STOMP volume=0 봉이 섞이면 acc=0 이 plot 에 남아 Y축(0~800만)이 벌어지고
* 본선이 pane 최상단에 납작해짐. 차트 setData 직전에도 한 번 더 걸러낸다.
*/
export function filterObvPlotDataForChart(
plotData: PlotData,
allPlots: Record<string, PlotData>,
): PlotData {
let maxAbs = 0;
for (const id of ['plot0', 'plot1'] as const) {
for (const p of allPlots[id] ?? []) {
if (Number.isFinite(p.value)) maxAbs = Math.max(maxAbs, Math.abs(p.value));
}
}
return plotData.filter(p => {
if (p.value === null || Number.isNaN(p.value)) return false;
if (maxAbs >= 100 && p.value === 0) return false;
return true;
});
}
/** OBV 누적 시작 — 첫 volume>0 봉 이전은 NaN (STOMP volume=0 history 제외) */
function firstObvTradeBarIndex(bars: OHLCVBar[]): number {
for (let i = 1; i < bars.length; i++) {
if ((Number(bars[i].volume) || 0) > 0) return i;
}
return -1;
}
function finalizeObvSeries(values: number[]): number[] {
const finite = values.filter(v => Number.isFinite(v));
if (finite.length === 0) return values;
const maxAbs = Math.max(...finite.map(v => Math.abs(v)));
if (maxAbs < 100) return values;
return values.map(v => (Number.isFinite(v) && v === 0 ? NaN : v));
}
/** 업비트 이격도: 종가 ÷ 이동평균 × 100 */ /** 업비트 이격도: 종가 ÷ 이동평균 × 100 */
export function calcDisparityUpbit( export function calcDisparityUpbit(
bars: OHLCVBar[], bars: OHLCVBar[],
@@ -338,35 +375,40 @@ export function calcOBVWithMA(
const maType = raw === 'EMA' || raw === 'WMA' ? raw : 'SMA'; const maType = raw === 'EMA' || raw === 'WMA' ? raw : 'SMA';
const maLen = Math.max(1, Number(params.maLength ?? 9) || 9); const maLen = Math.max(1, Number(params.maLength ?? 9) || 9);
const obv: number[] = []; const start = firstObvTradeBarIndex(bars);
const obv: number[] = new Array(bars.length).fill(NaN);
let acc = 0; let acc = 0;
for (let i = 0; i < bars.length; i++) {
if (i === 0) { if (start >= 0) {
obv.push(0); for (let i = start; i < bars.length; i++) {
continue; const vol = Number(bars[i].volume) || 0;
if (bars[i].close > bars[i - 1].close) acc += vol;
else if (bars[i].close < bars[i - 1].close) acc -= vol;
obv[i] = acc;
} }
const vol = Number(bars[i].volume) || 0;
if (bars[i].close > bars[i - 1].close) acc += vol;
else if (bars[i].close < bars[i - 1].close) acc -= vol;
obv.push(acc);
} }
const obvClean = finalizeObvSeries(obv);
let ma: number[]; let ma: number[];
switch (maType) { switch (maType) {
case 'EMA': case 'EMA':
ma = ema(obv, maLen); ma = ema(obvClean, maLen);
break; break;
case 'WMA': case 'WMA':
ma = wma(obv, maLen); ma = wma(obvClean, maLen);
break; break;
default: default:
ma = sma(obv, maLen); ma = sma(obvClean, maLen);
} }
return { const plots: Record<string, PlotData> = {
plot0: toPlotData(bars, obv), plot0: toPlotData(bars, obvClean),
plot1: toPlotData(bars, ma), plot1: toPlotData(bars, ma),
}; };
plots.plot0 = filterObvPlotDataForChart(plots.plot0, plots);
plots.plot1 = filterObvPlotDataForChart(plots.plot1, plots);
return plots;
} }
/** /**
+58 -3
View File
@@ -3,7 +3,7 @@
*/ */
import type { IndicatorConfig } from '../types'; import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from './indicatorRegistry'; import type { PlotDef, HLineDef } from './indicatorRegistry';
import { getIndicatorDef, enrichIndicatorConfig } from './indicatorRegistry'; import { getIndicatorDef, enrichIndicatorConfig, mergePlotDefs } from './indicatorRegistry';
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab'; import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
import { getSettingsIndicatorTypes } from './indicatorSettingsList'; import { getSettingsIndicatorTypes } from './indicatorSettingsList';
import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor'; import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor';
@@ -15,6 +15,38 @@ import { normalizeIchimokuConfig } from './ichimokuConfig';
import type { IchimokuCloudColors } from './ichimokuConfig'; import type { IchimokuCloudColors } from './ichimokuConfig';
import { isMainIndicatorType } from './indicatorMainTab'; import { isMainIndicatorType } from './indicatorMainTab';
import { indicatorDefaultsFingerprint } from './indicatorDefaultsFingerprint'; import { indicatorDefaultsFingerprint } from './indicatorDefaultsFingerprint';
import { isStrategyChartIndicatorId } from './strategyToChartIndicators';
function overlayWorkspaceVisualOntoStrategyIndicator(
ind: IndicatorConfig,
workspaceByType: Map<string, IndicatorConfig>,
): IndicatorConfig {
if (!isStrategyChartIndicatorId(ind.id)) return ind;
const ws = workspaceByType.get(ind.type);
if (!ws) return ind;
/** OBV — slot0 워크스페이스 인스턴스와 동일 params·plots (maLength·실선 신호선 등) */
if (ind.type === 'OBV') {
return enrichIndicatorConfig({
...ws,
id: ind.id,
hidden: ind.hidden,
mergedWith: ind.mergedWith,
plotVisibility: { plot0: true, plot1: true, ...ws.plotVisibility },
});
}
const def = getIndicatorDef(ind.type);
return {
...ind,
plots: ws.plots?.length
? mergePlotDefs(ws.plots, ind.plots ?? def?.plots ?? [])
: ind.plots,
hlines: ws.hlines?.length ? ws.hlines : ind.hlines,
cloudColors: ws.cloudColors ?? ind.cloudColors,
bandBackground: ws.bandBackground ?? ind.bandBackground,
};
}
export type MainIndicatorDraftMap = Record<string, IndicatorConfig>; export type MainIndicatorDraftMap = Record<string, IndicatorConfig>;
@@ -103,11 +135,29 @@ export function mergeGlobalDefaultsOntoChartIndicators(
defaultPlots?: PlotDef[], defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[], defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }, ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
workspaceIndicators?: IndicatorConfig[],
): IndicatorConfig[] { ): IndicatorConfig[] {
const workspaceByType = workspaceIndicators?.length
? new Map(workspaceIndicators.map(i => [i.type, i]))
: null;
return activeIndicators.map(ind => { return activeIndicators.map(ind => {
const cfg = buildMainIndicatorConfig(ind.type, [ind], getParams, getVisualConfig); /**
* strat_* 지표(알림 미니 차트 등) — raw.plots 가 registry 기본값(점선·주황)으로
* 고정되면 getVisualConfig(DB 전역 설정)를 덮어씀. 워크스페이스 ind_* 만 인스턴스 plots 유지.
*/
const baseInd = isStrategyChartIndicatorId(ind.id)
? {
...ind,
plots: [],
hlines: [],
cloudColors: undefined,
bandBackground: undefined,
}
: ind;
const cfg = buildMainIndicatorConfig(ind.type, [baseInd], getParams, getVisualConfig);
if (!cfg) return enrichIndicatorConfig(ind); if (!cfg) return enrichIndicatorConfig(ind);
return finalizeMainIndicatorConfig( let out = finalizeMainIndicatorConfig(
{ {
...cfg, ...cfg,
id: ind.id, id: ind.id,
@@ -116,6 +166,11 @@ export function mergeGlobalDefaultsOntoChartIndicators(
}, },
false, false,
); );
if (workspaceByType) {
out = overlayWorkspaceVisualOntoStrategyIndicator(out, workspaceByType);
out = finalizeMainIndicatorConfig(out, false);
}
return out;
}); });
} }
+34 -4
View File
@@ -442,6 +442,37 @@ export function mergePlotDefs(saved: PlotDef[] | undefined, defaults: PlotDef[])
}); });
} }
/**
* DB visual PATCH 저장 plots — registry 필드를 무분별히 상속하지 않음.
* (저장 객체에 lineStyle·color 키가 없으면 registry 점선·주황이 끼어드는 문제 방지)
*/
export function mergePlotDefsFromSavedVisual(
saved: PlotDef[] | undefined,
registryPlots: PlotDef[],
indicatorType?: string,
): PlotDef[] {
if (!registryPlots.length) return (saved ?? []).map(p => ({ ...p }));
const savedMap = new Map((saved ?? []).map(p => [p.id, p]));
return registryPlots.map(def => {
const s = savedMap.get(def.id);
if (!s) return { ...def };
const out: PlotDef = {
...def,
color: s.color ?? def.color,
lineWidth: s.lineWidth ?? def.lineWidth,
lineStyle: Object.prototype.hasOwnProperty.call(s, 'lineStyle')
? s.lineStyle
: def.lineStyle,
type: s.type ?? def.type,
histogramUpColor: s.histogramUpColor ?? def.histogramUpColor,
histogramDownColor: s.histogramDownColor ?? def.histogramDownColor,
id: def.id,
title: def.title,
};
return out;
});
}
/** OBV·CCI·RSI·TRIX — 이중 plot 병합·표시 보장 */ /** OBV·CCI·RSI·TRIX — 이중 plot 병합·표시 보장 */
const SIGNAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']); const SIGNAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']);
@@ -474,16 +505,15 @@ function normalizeSignalLineIndicatorPlots(
const nextPlots = merged.map(p => { const nextPlots = merged.map(p => {
const reg = registryPlots.find(r => r.id === p.id); const reg = registryPlots.find(r => r.id === p.id);
if (!reg) return p; if (!reg) return p;
const defaultLineStyle = p.id === 'plot1' ? 'solid' as const : undefined; return normalizeDualLinePlotDef(p, reg);
return normalizeDualLinePlotDef(p, reg, defaultLineStyle);
}); });
nextPlots.sort((a, b) => { nextPlots.sort((a, b) => {
const order = ['plot0', 'plot1']; const order = ['plot0', 'plot1'];
return order.indexOf(a.id) - order.indexOf(b.id); return order.indexOf(a.id) - order.indexOf(b.id);
}); });
const nextVis = { ...plotVisibility }; const nextVis = { ...plotVisibility };
if (nextVis.plot0 !== false) nextVis.plot0 = true; nextVis.plot0 = true;
if (nextVis.plot1 !== false) nextVis.plot1 = true; nextVis.plot1 = true;
return { plots: nextPlots, plotVisibility: nextVis }; return { plots: nextPlots, plotVisibility: nextVis };
} }
@@ -10,6 +10,7 @@ import {
normalizeObvParams, normalizeObvParams,
enrichIndicatorConfig, enrichIndicatorConfig,
} from './indicatorRegistry'; } from './indicatorRegistry';
import { isStrategyChartIndicatorId } from './strategyToChartIndicators';
import { import {
normalizeBollingerParams, normalizeBollingerParams,
DEFAULT_BB_BAND_BACKGROUND, DEFAULT_BB_BAND_BACKGROUND,
@@ -64,10 +65,12 @@ export function initializeIndicatorConfigForEditor(
: { ...raw.params }; : { ...raw.params };
const visual = getVisualConfig?.(raw.type, def.plots, def.hlines); const visual = getVisualConfig?.(raw.type, def.plots, def.hlines);
const mergedPlots = mergePlotDefs( const mergedPlots = isStrategyChartIndicatorId(raw.id)
raw.plots?.length ? raw.plots : undefined, ? mergePlotDefs(undefined, mergePlotDefs(visual?.plots, def.plots))
mergePlotDefs(visual?.plots, def.plots), : mergePlotDefs(
); raw.plots?.length ? raw.plots : undefined,
mergePlotDefs(visual?.plots, def.plots),
);
return enrichIndicatorConfig({ return enrichIndicatorConfig({
...raw, ...raw,
@@ -0,0 +1,60 @@
/**
* 알림 미니 차트 — 메인 실시간 차트(slot0)와 동일한 지표 설정 사용
*
* strat_* + buildStrategyChartIndicators 경로는 period=1 → maLength 오염 등
* 메인 ind_* 워크스페이스 설정과 어긋난다. 워크스페이스에 동일 type 이 있으면
* ind_* 인스턴스를 그대로 쓴다 (params·plots·maLength·id 모두 동일).
*/
import type { IndicatorConfig } from '../types';
import { enrichIndicatorConfig } from './indicatorRegistry';
/**
* @param strategyIndicators mergeGlobalDefaults·ensurePaperChartOverlays 적용 후 목록
* @param workspaceIndicators slot0.indicators (메인 차트와 동일)
*/
export function resolveNotificationChartIndicators(
strategyIndicators: IndicatorConfig[],
workspaceIndicators: IndicatorConfig[],
): IndicatorConfig[] {
if (!workspaceIndicators.length) {
return strategyIndicators.map(enrichIndicatorConfig);
}
const wsByType = new Map(workspaceIndicators.map(i => [i.type, i]));
return strategyIndicators.map(stratInd => {
const ws = wsByType.get(stratInd.type);
if (!ws) return enrichIndicatorConfig(stratInd);
return enrichIndicatorConfig({
...ws,
hidden: stratInd.hidden ?? ws.hidden,
mergedWith: stratInd.mergedWith ?? ws.mergedWith,
});
});
}
/**
* 워크스페이스에 필요한 type 이 모두 있으면 strat 빌드 없이 slot0 지표만 사용.
* (메인 ChartSlot 과 동일한 ind_* 설정·순서)
*/
export function buildNotificationChartIndicators(
withOverlays: IndicatorConfig[],
workspaceIndicators: IndicatorConfig[],
): IndicatorConfig[] {
const neededTypes = new Set(withOverlays.map(i => i.type));
if (workspaceIndicators.length > 0
&& [...neededTypes].every(t => workspaceIndicators.some(w => w.type === t))) {
const wsByType = new Map(workspaceIndicators.map(w => [w.type, w]));
return withOverlays.map(ind => {
const ws = wsByType.get(ind.type)!;
return enrichIndicatorConfig({
...ws,
hidden: ind.hidden ?? ws.hidden,
mergedWith: ind.mergedWith ?? ws.mergedWith,
});
});
}
return resolveNotificationChartIndicators(withOverlays, workspaceIndicators);
}
+17 -22
View File
@@ -15,7 +15,7 @@ import {
import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor'; import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor';
import { mergeGlobalDefaultsOntoChartIndicators } from './indicatorMainConfig'; import { mergeGlobalDefaultsOntoChartIndicators } from './indicatorMainConfig';
import { buildMainIndicatorConfig } from './indicatorMainConfig'; import { buildMainIndicatorConfig } from './indicatorMainConfig';
import { normalizeSmaConfig, smaPeriodKey } from './smaConfig'; import { normalizeSmaConfig } from './smaConfig';
import { normalizeIchimokuConfig } from './ichimokuConfig'; import { normalizeIchimokuConfig } from './ichimokuConfig';
import { extractVirtualConditions } from './virtualStrategyConditions'; import { extractVirtualConditions } from './virtualStrategyConditions';
@@ -68,6 +68,11 @@ type GetVisual = (
cloudColors?: import('./ichimokuConfig').IchimokuCloudColors; cloudColors?: import('./ichimokuConfig').IchimokuCloudColors;
}; };
/** 알림·가상투자 등 전략에서 생성된 차트 지표 id (워크스페이스 ind_* 와 구분) */
export function isStrategyChartIndicatorId(id: string): boolean {
return id.startsWith('strat_');
}
/** 전략 DSL 기준 안정 id — getVisualConfig/settingsRevision 변경 시 id 재발급 방지 */ /** 전략 DSL 기준 안정 id — getVisualConfig/settingsRevision 변경 시 id 재발급 방지 */
export function stableStrategyIndicatorId(ref: IndicatorRef): string { export function stableStrategyIndicatorId(ref: IndicatorRef): string {
const parts = [ref.registryType]; const parts = [ref.registryType];
@@ -192,37 +197,25 @@ function buildOneIndicator(
...(cloudColors ? { cloudColors } : {}), ...(cloudColors ? { cloudColors } : {}),
}; };
/**
* 모든 보조지표의 기간(period)은 반드시 지표 설정(getParams, DB 저장값)을 사용한다.
* 전략 DSL 조건의 period/leftPeriod/rightPeriod 로 덮어쓰지 않는다.
* 조건의 기간 필드는 부정확(미설정 시 1 등)할 수 있어 SMA(1)=본선 겹침 등
* 잘못된 그래프를 만든다. 설정에 저장된 값(예: OBV 신호선 9일)이 유일한 기준.
* cfg.params 는 이미 getParams(저장 설정값) 결과이므로 그대로 사용.
*/
if (ref.registryType === 'SMA') { if (ref.registryType === 'SMA') {
const mainSma = buildMainIndicatorConfig('SMA', [], getParams, getVisual as Parameters<typeof buildMainIndicatorConfig>[3]); const mainSma = buildMainIndicatorConfig('SMA', [], getParams, getVisual as Parameters<typeof buildMainIndicatorConfig>[3]);
const periods = [ref.period, ref.leftPeriod, ref.rightPeriod]
.filter((p): p is number => typeof p === 'number' && p > 0);
const p = { ...cfg.params };
if (periods.length > 0) {
periods.slice(0, 11).forEach((val, i) => {
p[smaPeriodKey(i + 1)] = val;
});
}
cfg = normalizeSmaConfig({ cfg = normalizeSmaConfig({
...cfg, ...cfg,
params: p, params: mainSma?.params ?? cfg.params,
plots: mainSma?.plots ?? cfg.plots, plots: mainSma?.plots ?? cfg.plots,
plotVisibility: mainSma?.plotVisibility ?? cfg.plotVisibility, plotVisibility: mainSma?.plotVisibility ?? cfg.plotVisibility,
}); });
} else if (ref.registryType === 'OBV') { } else if (ref.registryType === 'OBV') {
const p = normalizeObvParams({ ...cfg.params });
const signalLen = ref.rightPeriod ?? ref.period;
if (signalLen != null && signalLen > 0) {
p.maLength = signalLen;
}
cfg = { cfg = {
...cfg, ...cfg,
params: p, params: normalizeObvParams({ ...cfg.params }),
plots: mergePlotDefs(cfg.plots, def.plots),
};
} else if (ref.period != null && ref.period > 0) {
cfg = {
...cfg,
params: { ...cfg.params, length: ref.period },
}; };
} }
@@ -246,6 +239,7 @@ export function buildStrategyChartIndicators(
strategy: StrategyDto | undefined, strategy: StrategyDto | undefined,
getParams: GetParams, getParams: GetParams,
getVisual: GetVisual, getVisual: GetVisual,
workspaceIndicators?: IndicatorConfig[],
): IndicatorConfig[] { ): IndicatorConfig[] {
if (!strategy) return []; if (!strategy) return [];
@@ -276,6 +270,7 @@ export function buildStrategyChartIndicators(
result, result,
getParams, getParams,
(type, defaultPlots, defaultHlines) => getVisual(type, defaultPlots ?? [], defaultHlines ?? []), (type, defaultPlots, defaultHlines) => getVisual(type, defaultPlots ?? [], defaultHlines ?? []),
workspaceIndicators,
); );
} }
+25
View File
@@ -73,6 +73,31 @@ export function getTimezoneAbbr(tz: string): string {
return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ'; return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ';
} }
/**
* 주어진 시각의 표시 시간대 오프셋(초). 벽시계 정시 경계 정렬에 사용.
* (예: Asia/Seoul → +32400)
*/
export function getTimezoneOffsetSec(unixSec: number, tz?: string): number {
const zone = normalizeTimezone(tz ?? _tz);
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).formatToParts(new Date(unixSec * 1000));
const map: Record<string, string> = {};
for (const p of parts) {
if (p.type !== 'literal') map[p.type] = p.value;
}
let hour = Number(map.hour);
if (hour === 24) hour = 0;
const asUTC = Date.UTC(
Number(map.year), Number(map.month) - 1, Number(map.day),
hour, Number(map.minute), Number(map.second),
) / 1000;
return asUTC - unixSec;
}
/** 하단 바·시계 등 HH:mm:ss */ /** 하단 바·시계 등 HH:mm:ss */
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string { export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
if (!unixSec) return ''; if (!unixSec) return '';
@@ -0,0 +1,48 @@
/**
* 워크스페이스 slot0 보조지표 — 알림 미니 차트가 메인 차트 인스턴스 스타일과 맞추도록
*/
import type { IndicatorConfig } from '../types';
import { loadWorkspace } from './backendApi';
let cached: IndicatorConfig[] | null = null;
let loadPromise: Promise<IndicatorConfig[]> | null = null;
let loadedSessionKey: number | null = null;
export function invalidateWorkspaceChartIndicatorsCache(): void {
cached = null;
loadPromise = null;
loadedSessionKey = null;
}
export function ensureWorkspaceChartIndicators(sessionKey = 0): Promise<IndicatorConfig[]> {
if (cached !== null && loadedSessionKey === sessionKey) {
return Promise.resolve(cached);
}
if (loadedSessionKey !== sessionKey) {
cached = null;
loadPromise = null;
}
if (!loadPromise) {
loadPromise = loadWorkspace()
.then(ws => {
const slot0 = (Array.isArray(ws?.slots) ? ws.slots[0] : null) as
| { indicators?: IndicatorConfig[] }
| null;
cached = Array.isArray(slot0?.indicators) ? slot0!.indicators! : [];
loadedSessionKey = sessionKey;
loadPromise = null;
return cached;
})
.catch(() => {
cached = [];
loadedSessionKey = sessionKey;
loadPromise = null;
return [];
});
}
return loadPromise;
}
export function getWorkspaceChartIndicatorsCache(): IndicatorConfig[] | null {
return cached;
}