/** * 볼린저밴드 (업비트 BB 설정) * - 길이(length) 기본 20, 곱(mult) 기본 2, 오프셋(offset) 기본 0 * - 기준선: 종가 SMA, 표준편차는 모집단(N) 기준 */ import type { OHLCVBar, Timeframe } from '../types'; import type { PlotData } from './indicatorRegistry'; import { fetchUpbitCandles, isUpbitMarket } from './upbitApi'; export const BB_DEFAULT_LENGTH = 20; export const BB_DEFAULT_MULT = 2; export const BB_DEFAULT_OFFSET = 0; /** 업비트 BB 백그라운드 그리기 기본 (반투명 파랑) */ export const DEFAULT_BB_BAND_BACKGROUND = { visible: true, color: '#42A5F528', } as const; const BB_PLOT_TITLES = ['중앙값', '어퍼', '로우어'] as const; const BB_PLOT_IDS = ['plot0', 'plot1', 'plot2'] as const; const BB_DEFAULT_COLORS = ['#FF9800', '#42A5F5', '#42A5F5'] as const; export type BbSymbolMode = 'main' | 'other'; export type BbBandBackground = { visible: boolean; color: string }; export function resolveBbBandBackground( bg?: Partial | null, ): BbBandBackground { return { visible: bg?.visible !== false, color: bg?.color ?? DEFAULT_BB_BAND_BACKGROUND.color, }; } /** 업비트 플롯 정의 병합 (중앙값·어퍼·로우어, 기본 색) */ export function mergeBbPlots( saved: import('./indicatorRegistry').PlotDef[] | undefined, defaults: import('./indicatorRegistry').PlotDef[], ): import('./indicatorRegistry').PlotDef[] { const byId = new Map((saved ?? []).map(p => [p.id, p])); return BB_PLOT_IDS.map((id, i) => { const def = defaults.find(p => p.id === id) ?? defaults[i]; const prev = byId.get(id); return { ...def, ...prev, id, title: BB_PLOT_TITLES[i], color: prev?.color ?? BB_DEFAULT_COLORS[i], }; }); } /** 차트 레전드: BB 20 2 0 SMA */ export function formatBbLegendLabel( params: Record, ): string { const p = normalizeBollingerParams(params); const ma = String(p.maType ?? 'SMA').replace(/\s*\(.*\)\s*/g, '').trim(); return `BB ${p.length} ${p.mult} ${p.offset} ${ma}`; } export function normalizeBollingerParams( params: Record, ): Record { const length = Math.max(1, Math.round(Number(params.length ?? BB_DEFAULT_LENGTH) || BB_DEFAULT_LENGTH)); const mult = Math.max(0.001, Number(params.mult ?? BB_DEFAULT_MULT) || BB_DEFAULT_MULT); const offset = Math.round(Number(params.offset ?? BB_DEFAULT_OFFSET) || 0); const rawMode = String(params.symbolMode ?? 'main'); const symbolMode: BbSymbolMode = rawMode === 'other' ? 'other' : 'main'; const refSymbol = String(params.refSymbol ?? '').trim().toUpperCase(); return { ...params, length, mult, offset, symbolMode, refSymbol, src: 'close', maType: 'SMA', }; } /** TradingView/업비트 오프셋: 양수면 플롯을 오른쪽(미래)으로 이동 */ export function applyPlotOffset(data: PlotData, offset: number): PlotData { if (!offset) return data; const n = data.length; return data.map((point, i) => { const srcIdx = i - offset; if (srcIdx < 0 || srcIdx >= n) { return { time: point.time, value: NaN }; } return { time: point.time, value: data[srcIdx].value }; }); } /** 다른 심볼 캔들을 메인 차트 시간축에 맞춤 (같은 time 키) */ export function alignBarsToMainTimeline(mainBars: OHLCVBar[], refBars: OHLCVBar[]): OHLCVBar[] { const byTime = new Map(refBars.map(b => [b.time, b])); return mainBars.map(mb => { const ref = byTime.get(mb.time); if (!ref) { return { ...mb, open: NaN, high: NaN, low: NaN, close: NaN, volume: 0 }; } return ref; }); } export async function resolveBollingerBars( mainBars: OHLCVBar[], params: Record, timeframe: Timeframe, ): Promise { const p = normalizeBollingerParams(params); if (p.symbolMode !== 'other') return mainBars; const ref = String(p.refSymbol ?? ''); if (!ref || !isUpbitMarket(ref)) return mainBars; try { const refBars = await fetchUpbitCandles(ref, timeframe, mainBars.length); return alignBarsToMainTimeline(mainBars, refBars); } catch (e) { console.warn('[BB] ref symbol fetch failed:', ref, e); return mainBars; } }