알림목록 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개↑ 시 우측 슬롯만 스크롤
*/
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<Props> = ({
const chartTf = candleTypeToTimeframe(item.candleType ?? '1m');
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 [strategies, setStrategies] = useState<StrategyDto[]>([]);
const strategyLabel = item.strategyName?.trim()
@@ -166,16 +170,28 @@ const TradeNotificationListRow: React.FC<Props> = ({
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(
@@ -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<Props> = ({
const signalMarkerRef = useRef(signalMarker);
signalMarkerRef.current = signalMarker;
const baseIndicators = useMemo(
() => mergeGlobalDefaultsOntoChartIndicators(
const [wsIndicators, setWsIndicators] = useState<IndicatorConfig[]>([]);
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<Props> = ({
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<Props> = ({
].filter(Boolean).join(' ')}
>
{(!settingsLoaded || isLoading) && <div className="tnl-mini-chart-loading"></div>}
{needsDeepObv && !isLoading && !obvBarsReady && (
<div className="tnl-mini-chart-loading">OBV </div>
)}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 12, height: 12 }} />
@@ -275,7 +288,7 @@ const TradeSignalMiniChart: React.FC<Props> = ({
)}
{chartDataReady && (
<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
bars={bars}
barsMarket={barsMarket}
+4 -2
View File
@@ -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<string, unknown>;
type AllSettings = Record<string, ParamMap>;
@@ -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);
+358 -64
View File
@@ -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<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 경로 회피) */
private _addObvIndicatorSeries(
seriesList: ISeriesApi<SeriesType>[],
@@ -799,40 +826,104 @@ export class ChartManager {
);
const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
for (const plotId of ['plot0', 'plot1'] as const) {
if (enriched.plotVisibility?.[plotId] === false) continue;
// 본선(plot0)·신호선(plot1) 을 같은 가격 범위로 고정.
// 재렌더·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);
if (!plotDef || plotDef.type === 'histogram') continue;
const plotData = result.plots[plotId] as PlotData | undefined;
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;
const isPlotVisible = !indicatorHidden;
const showPriceLabel = this.shouldShowSeriesPriceLabel(enriched, true, pane);
const showSeriesTitle = pane < 2 && showPriceLabel;
const regPlot = def?.plots?.find(p => p.id === plotId);
const fallbackColor = plotId === 'plot0'
? (regPlot?.color ?? '#2196F3')
: (regPlot?.color ?? plotDef.color ?? '#2962FF');
const resolvedColor = resolvePlotLineColor(
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, {
color: resolvePlotLineColor(plotDef.color, fallbackColor),
lineWidth: (plotId === 'plot0'
? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2)
: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4,
color: resolvedColor,
lineWidth: resolvedWidth,
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false,
lastValueVisible: showPriceLabel,
crosshairMarkerVisible: true,
crosshairMarkerRadius: plotId === 'plot0' ? 4 : 3,
title: showSeriesTitle ? this.seriesTitleForPlot(enriched, plotDef) : '',
visible: isPlotVisible,
priceScaleId: obvScaleId,
...(obvAutoscaleProvider ? { autoscaleInfoProvider: obvAutoscaleProvider } : {}),
...(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 })));
seriesList.push(series);
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 순서로 시리즈를 한 번에 생성 */
@@ -1157,6 +1248,9 @@ export class ChartManager {
}
this.indicators.set(config.id, entry);
if (config.type === 'OBV') {
if (entry.seriesMeta.length < 2 && !indicatorHidden && def) {
await this._ensureObvSeriesForEntry(entry);
}
if (entry.seriesList.length > 0 && !indicatorHidden) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
@@ -1186,6 +1280,7 @@ export class ChartManager {
if (this._activeIndicatorPaneIndices().size > 0) {
this._resyncIndicatorPaneIndices();
this._splitCollidingIndicatorPanes();
this._consolidateMultiSeriesPanes();
this._resyncIndicatorPaneIndices();
this._applyAllSeriesPriceFormats();
} else {
@@ -1211,6 +1306,7 @@ export class ChartManager {
this._removeOrphanSubPanes();
if (this._activeIndicatorPaneIndices().size > 0) {
this._resyncIndicatorPaneIndices();
this._consolidateMultiSeriesPanes();
} else {
this._trimTrailingEmptySubPanes();
}
@@ -1221,19 +1317,60 @@ export class ChartManager {
this._notifyPaneLayout();
}
/** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 + 전체 plot setData + 스타일(DB) 반영 */
async repairDualLineSeries(): Promise<void> {
for (const entry of this.indicators.values()) {
if (!entry.config) continue;
/** 누락된 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;
if (entry.type === 'OBV') {
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 && def && this.rawBars.length > 0) {
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 */ }
}
@@ -1249,14 +1386,26 @@ export class ChartManager {
}
entry.config = config;
} catch (e) {
console.error('[Indicator] OBV repair error:', e);
console.error('[Indicator] OBV ensure error:', e);
}
} else if (entry.seriesList.length > 0 && this.rawBars.length > 0) {
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) 반영 */
async repairDualLineSeries(): Promise<void> {
for (const entry of this.indicators.values()) {
if (!entry.config) continue;
if (entry.type === 'OBV') {
const config = enrichIndicatorConfig(entry.config);
await this._ensureObvSeriesForEntry(entry);
this.applyIndicatorStyle(config, { skipDualLineRefresh: true });
continue;
}
@@ -1497,16 +1646,19 @@ export class ChartManager {
plots: Record<string, PlotData>,
config: IndicatorConfig,
): 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]));
for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i];
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;
if (!plot || !plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (!plotData?.length) continue;
const filtered = config.type === 'OBV'
? filterObvPlotDataForChart(plotData, plots)
: plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
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> {
const minSub = this._minIndicatorSubPane();
const indices = new Set<number>();
for (const entry of this.indicators.values()) {
let pane = -1;
for (const series of entry.seriesList) {
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;
}
if (pane >= 2) indices.add(pane);
if (pane >= minSub) indices.add(pane);
}
return indices;
}
@@ -1954,9 +2121,61 @@ export class ChartManager {
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 {
const used = this._collectUsedSubPaneIndices();
let pane = 2;
let pane = this._minIndicatorSubPane();
while (used.has(pane)) pane++;
return pane;
}
@@ -1965,7 +2184,8 @@ export class ChartManager {
private _resolveSubPane(config: IndicatorConfig): number {
if (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;
}
}
@@ -2081,7 +2301,11 @@ export class ChartManager {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config) continue;
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 def = getIndicatorDef(entry.type);
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
@@ -2099,6 +2323,12 @@ export class ChartManager {
this._refreshIchimokuSeriesData(entry, plots);
continue;
}
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
} catch { /* 계산 실패 무시 */ }
}
@@ -2313,7 +2543,11 @@ export class ChartManager {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config) continue;
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 def = getIndicatorDef(entry.type);
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
@@ -2330,6 +2564,12 @@ export class ChartManager {
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
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() 전체 재설정
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ true);
} catch { /* 인디케이터 계산 실패 무시 */ }
@@ -2799,7 +3039,10 @@ export class ChartManager {
type: entry.type,
config: entry.config,
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;
@@ -2812,14 +3055,7 @@ export class ChartManager {
getIndicatorValuesByIdFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
const result: Record<string, number[]> = {};
for (const [id, entry] of this.indicators) {
const vals: number[] = [];
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);
}
const vals = this._indicatorValuesInPlotOrder(entry, params);
if (vals.some(v => !isNaN(v))) result[id] = vals;
}
return result;
@@ -3120,7 +3356,9 @@ export class ChartManager {
let plot: PlotDef | undefined;
const pid = entry.seriesMeta[i]?.plotId;
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) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions({ visible } as any);
@@ -3138,8 +3376,16 @@ export class ChartManager {
}
if (!entry.seriesMeta[i]?.isHistogram) {
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);
const defaultObvColor = pid === 'plot0' ? '#2196F3' : pid === 'plot1' ? '#FF9800' : '#2962FF';
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);
let plotAllows = true;
if (entry.type === 'IchimokuCloud') {
@@ -3489,9 +3735,9 @@ export class ChartManager {
this.autoScale();
}
/** pane 2+ 하단 보조지표 여부 (pane 0 오버레이 제외) */
/** 하단 보조지표 pane 여부 (pane 0 오버레이 제외, 볼륨 숨김 시 pane 1 포함) */
private _isSubPaneIndicator(entry: IndicatorEntry): boolean {
return (entry.paneIndex ?? 0) >= 2;
return (entry.paneIndex ?? 0) >= this._minIndicatorSubPane();
}
/** 캔들 전체보기 — 하단 보조지표 pane 시리즈·기준선 숨김 */
@@ -3687,23 +3933,57 @@ export class ChartManager {
const lrSpan = lr.to - lr.from;
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 tz = this.displayTimezone;
const tf = this.displayTimeframe;
const startLogical = Math.max(0, Math.ceil(lr.from));
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];
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);
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({
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;
}
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
// 일반 지표: 각 시리즈에 전체 데이터 setData
for (let i = 0; i < entry.seriesList.length; i++) {
const series = entry.seriesList[i];
@@ -4448,7 +4735,14 @@ export class ChartManager {
if (paneIndex === 0) {
panes[i].setStretchFactor(mainWeight);
} 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)) {
panes[i].setStretchFactor(indWeight);
} else {
+54 -12
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 */
export function calcDisparityUpbit(
bars: OHLCVBar[],
@@ -338,35 +375,40 @@ export function calcOBVWithMA(
const maType = raw === 'EMA' || raw === 'WMA' ? raw : 'SMA';
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;
for (let i = 0; i < bars.length; i++) {
if (i === 0) {
obv.push(0);
continue;
}
if (start >= 0) {
for (let i = start; i < bars.length; i++) {
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);
obv[i] = acc;
}
}
const obvClean = finalizeObvSeries(obv);
let ma: number[];
switch (maType) {
case 'EMA':
ma = ema(obv, maLen);
ma = ema(obvClean, maLen);
break;
case 'WMA':
ma = wma(obv, maLen);
ma = wma(obvClean, maLen);
break;
default:
ma = sma(obv, maLen);
ma = sma(obvClean, maLen);
}
return {
plot0: toPlotData(bars, obv),
const plots: Record<string, PlotData> = {
plot0: toPlotData(bars, obvClean),
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 { PlotDef, HLineDef } from './indicatorRegistry';
import { getIndicatorDef, enrichIndicatorConfig } from './indicatorRegistry';
import { getIndicatorDef, enrichIndicatorConfig, mergePlotDefs } from './indicatorRegistry';
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
import { getSettingsIndicatorTypes } from './indicatorSettingsList';
import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor';
@@ -15,6 +15,38 @@ import { normalizeIchimokuConfig } from './ichimokuConfig';
import type { IchimokuCloudColors } from './ichimokuConfig';
import { isMainIndicatorType } from './indicatorMainTab';
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>;
@@ -103,11 +135,29 @@ export function mergeGlobalDefaultsOntoChartIndicators(
defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
workspaceIndicators?: IndicatorConfig[],
): IndicatorConfig[] {
const workspaceByType = workspaceIndicators?.length
? new Map(workspaceIndicators.map(i => [i.type, i]))
: null;
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);
return finalizeMainIndicatorConfig(
let out = finalizeMainIndicatorConfig(
{
...cfg,
id: ind.id,
@@ -116,6 +166,11 @@ export function mergeGlobalDefaultsOntoChartIndicators(
},
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 병합·표시 보장 */
const SIGNAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']);
@@ -474,16 +505,15 @@ function normalizeSignalLineIndicatorPlots(
const nextPlots = merged.map(p => {
const reg = registryPlots.find(r => r.id === p.id);
if (!reg) return p;
const defaultLineStyle = p.id === 'plot1' ? 'solid' as const : undefined;
return normalizeDualLinePlotDef(p, reg, defaultLineStyle);
return normalizeDualLinePlotDef(p, reg);
});
nextPlots.sort((a, b) => {
const order = ['plot0', 'plot1'];
return order.indexOf(a.id) - order.indexOf(b.id);
});
const nextVis = { ...plotVisibility };
if (nextVis.plot0 !== false) nextVis.plot0 = true;
if (nextVis.plot1 !== false) nextVis.plot1 = true;
nextVis.plot0 = true;
nextVis.plot1 = true;
return { plots: nextPlots, plotVisibility: nextVis };
}
@@ -10,6 +10,7 @@ import {
normalizeObvParams,
enrichIndicatorConfig,
} from './indicatorRegistry';
import { isStrategyChartIndicatorId } from './strategyToChartIndicators';
import {
normalizeBollingerParams,
DEFAULT_BB_BAND_BACKGROUND,
@@ -64,7 +65,9 @@ export function initializeIndicatorConfigForEditor(
: { ...raw.params };
const visual = getVisualConfig?.(raw.type, def.plots, def.hlines);
const mergedPlots = mergePlotDefs(
const mergedPlots = isStrategyChartIndicatorId(raw.id)
? mergePlotDefs(undefined, mergePlotDefs(visual?.plots, def.plots))
: mergePlotDefs(
raw.plots?.length ? raw.plots : undefined,
mergePlotDefs(visual?.plots, def.plots),
);
@@ -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 { mergeGlobalDefaultsOntoChartIndicators } from './indicatorMainConfig';
import { buildMainIndicatorConfig } from './indicatorMainConfig';
import { normalizeSmaConfig, smaPeriodKey } from './smaConfig';
import { normalizeSmaConfig } from './smaConfig';
import { normalizeIchimokuConfig } from './ichimokuConfig';
import { extractVirtualConditions } from './virtualStrategyConditions';
@@ -68,6 +68,11 @@ type GetVisual = (
cloudColors?: import('./ichimokuConfig').IchimokuCloudColors;
};
/** 알림·가상투자 등 전략에서 생성된 차트 지표 id (워크스페이스 ind_* 와 구분) */
export function isStrategyChartIndicatorId(id: string): boolean {
return id.startsWith('strat_');
}
/** 전략 DSL 기준 안정 id — getVisualConfig/settingsRevision 변경 시 id 재발급 방지 */
export function stableStrategyIndicatorId(ref: IndicatorRef): string {
const parts = [ref.registryType];
@@ -192,37 +197,25 @@ function buildOneIndicator(
...(cloudColors ? { cloudColors } : {}),
};
/**
* (period) (getParams, DB ) .
* DSL period/leftPeriod/rightPeriod .
* ( 1 ) SMA(1)=
* . (: OBV 9) .
* cfg.params getParams( ) .
*/
if (ref.registryType === 'SMA') {
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,
params: p,
params: mainSma?.params ?? cfg.params,
plots: mainSma?.plots ?? cfg.plots,
plotVisibility: mainSma?.plotVisibility ?? cfg.plotVisibility,
});
} 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,
params: p,
plots: mergePlotDefs(cfg.plots, def.plots),
};
} else if (ref.period != null && ref.period > 0) {
cfg = {
...cfg,
params: { ...cfg.params, length: ref.period },
params: normalizeObvParams({ ...cfg.params }),
};
}
@@ -246,6 +239,7 @@ export function buildStrategyChartIndicators(
strategy: StrategyDto | undefined,
getParams: GetParams,
getVisual: GetVisual,
workspaceIndicators?: IndicatorConfig[],
): IndicatorConfig[] {
if (!strategy) return [];
@@ -276,6 +270,7 @@ export function buildStrategyChartIndicators(
result,
getParams,
(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';
}
/**
* (). .
* (: 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 */
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
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;
}