알림목록 obv 그래프 문제 최종수정
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,19 +1317,60 @@ export class ChartManager {
|
|||||||
this._notifyPaneLayout();
|
this._notifyPaneLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 + 전체 plot setData + 스타일(DB) 반영 */
|
/** 누락된 OBV plot 시리즈만 추가 (기존 plot1 유지 등) */
|
||||||
async repairDualLineSeries(): Promise<void> {
|
private _appendMissingObvPlotSeries(
|
||||||
for (const entry of this.indicators.values()) {
|
entry: IndicatorEntry,
|
||||||
if (!entry.config) continue;
|
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 config = enrichIndicatorConfig(entry.config);
|
||||||
const def = getIndicatorDef('OBV');
|
const def = getIndicatorDef('OBV');
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|
const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|
||||||
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
|
|| !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 pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2;
|
||||||
const hidden = !this._isIndicatorEffectivelyVisible(config);
|
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) {
|
for (const s of entry.seriesList) {
|
||||||
try { this.chart.removeSeries(s); } catch { /* ok */ }
|
try { this.chart.removeSeries(s); } catch { /* ok */ }
|
||||||
}
|
}
|
||||||
@@ -1249,14 +1386,26 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
entry.config = config;
|
entry.config = config;
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
|
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
|
||||||
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
||||||
} catch { /* ok */ }
|
} 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 });
|
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 {
|
||||||
|
|||||||
@@ -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;
|
const vol = Number(bars[i].volume) || 0;
|
||||||
if (bars[i].close > bars[i - 1].close) acc += vol;
|
if (bars[i].close > bars[i - 1].close) acc += vol;
|
||||||
else 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[];
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +65,9 @@ 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)
|
||||||
|
? mergePlotDefs(undefined, mergePlotDefs(visual?.plots, def.plots))
|
||||||
|
: mergePlotDefs(
|
||||||
raw.plots?.length ? raw.plots : undefined,
|
raw.plots?.length ? raw.plots : undefined,
|
||||||
mergePlotDefs(visual?.plots, def.plots),
|
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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user