/** * 일목균형표(IchimokuCloud) 설정 */ import type { IndicatorConfig } from '../types'; import type { PlotDef } from './indicatorRegistry'; /** 설정 화면 한 줄 = 플롯 1개 + (선택) 기간 파라미터 */ export interface IchimokuLineRowDef { plotIndex: number; plotId: string; /** 플롯 행 기간 입력에 쓰는 params 키 */ paramKey?: string; } /** registry plot ID → 차트·설정 화면 한글 라벨 */ export const ICHIMOKU_PLOT_TITLES: Record = { plot0: '전환선', plot1: '기준선', plot2: '후행스팬', plot3: '선행스팬1', plot4: '선행스팬2', }; export function getIchimokuPlotTitle(plotId: string): string { return ICHIMOKU_PLOT_TITLES[plotId] ?? plotId; } export const ICHIMOKU_LINE_ROWS: IchimokuLineRowDef[] = [ { plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' }, { plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' }, { plotIndex: 2, plotId: 'plot2', paramKey: 'chikouDisplacement' }, { plotIndex: 3, plotId: 'plot3', paramKey: 'senkouDisplacement' }, { plotIndex: 4, plotId: 'plot4', paramKey: 'laggingSpan2Periods' }, ]; export const ICHIMOKU_DEFAULT_PARAMS: Record = { conversionPeriods: 9, basePeriods: 26, laggingSpan2Periods: 52, /** @deprecated senkouDisplacement 우선 — API·구버전 호환 */ displacement: 26, chikouDisplacement: 26, senkouDisplacement: 26, }; /** 후행·선행 이동 기간 (구 displacement 단일 키 마이그레이션) */ export function resolveIchimokuDisplacements( params: Record | undefined, ): { chikou: number; senkou: number } { const legacy = typeof params?.displacement === 'number' && params.displacement >= 1 ? params.displacement : 26; const chikou = typeof params?.chikouDisplacement === 'number' && params.chikouDisplacement >= 1 ? params.chikouDisplacement : legacy; const senkou = typeof params?.senkouDisplacement === 'number' && params.senkouDisplacement >= 1 ? params.senkouDisplacement : legacy; return { chikou, senkou }; } export function createDefaultIchimokuParams(): Record { return { ...ICHIMOKU_DEFAULT_PARAMS }; } export function createDefaultIchimokuPlotVisibility(): Record { const vis: Record = {}; for (const row of ICHIMOKU_LINE_ROWS) vis[row.plotId] = true; return vis; } export interface IchimokuCloudColors { /** 선행스팬1 > 선행스팬2 (상승 구름) */ bullishColor: string; /** 선행스팬1 < 선행스팬2 (하락 구름) */ bearishColor: string; /** 상승 구름 영역 표시 */ bullishVisible?: boolean; /** 하락 구름 영역 표시 */ bearishVisible?: boolean; } /** #RRGGBBAA — 상승 구름 빨강 20%, 하락 구름 청록 20% */ export const DEFAULT_ICHIMOKU_CLOUD_COLORS: IchimokuCloudColors = { bullishColor: '#EF535033', bearishColor: '#26A69A33', bullishVisible: true, bearishVisible: true, }; /** registry plot ID → lightweight-charts-indicators plot ID */ export const ICHIMOKU_REGISTRY_TO_LIB: Record = { plot0: 'plot0', plot1: 'plot1', plot2: 'plot3', plot3: 'plot4', plot4: 'plot2', }; export const ICHIMOKU_LIB_TO_REGISTRY: Record = { plot0: 'plot0', plot1: 'plot1', plot2: 'plot4', plot3: 'plot2', plot4: 'plot3', }; /** 선행스팬1·2 라인이 켜져 있어 구름 계산이 가능한지 */ export function areIchimokuSpanLinesVisible(config: IndicatorConfig): boolean { return ( config.plotVisibility?.['plot3'] !== false && config.plotVisibility?.['plot4'] !== false ); } /** 구름 영역을 그릴 수 있는지 (지표 표시 on + 구름 색상 설정) * * 선행스팬 라인의 개별 표시 여부(plotVisibility)와 구름 표시는 독립적으로 동작. * 서버 DB에 저장된 구버전 설정에서 plot3·plot4 가 false 여도 구름은 표시해야 하므로 * areIchimokuSpanLinesVisible 검사를 생략한다. * * bullishVisible/bearishVisible 이 둘 다 false 이면 DB 잘못된 저장값으로 간주하고 * 구름을 표시한다 (하나만 false 인 경우는 사용자 의도이므로 유지). */ export function isIchimokuCloudVisible(config: IndicatorConfig): boolean { if (config.hidden === true) return false; const colors = resolveIchimokuCloudColors(config.cloudColors); const bullishVis = colors.bullishVisible !== false; const bearishVis = colors.bearishVisible !== false; // 둘 다 false → DB 잘못된 저장값, 기본값으로 복원 간주 → 표시 if (!bullishVis && !bearishVis) return true; return bullishVis || bearishVis; } export function resolveIchimokuCloudColors( colors?: Partial, ): IchimokuCloudColors { return { bullishColor: colors?.bullishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bullishColor, bearishColor: colors?.bearishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bearishColor, bullishVisible: colors?.bullishVisible !== false, bearishVisible: colors?.bearishVisible !== false, }; } export function normalizeIchimokuConfig(config: IndicatorConfig): IndicatorConfig { if (config.type !== 'IchimokuCloud') return config; const params: Record = { ...createDefaultIchimokuParams(), ...config.params, }; for (const key of Object.keys(ICHIMOKU_DEFAULT_PARAMS)) { const v = params[key]; if (typeof v !== 'number' || v < 1) { params[key] = ICHIMOKU_DEFAULT_PARAMS[key]; } } const { chikou, senkou } = resolveIchimokuDisplacements(params); params.chikouDisplacement = chikou; params.senkouDisplacement = senkou; params.displacement = senkou; const plotVisibility: Record = { ...createDefaultIchimokuPlotVisibility(), ...config.plotVisibility, }; return { ...config, params, plotVisibility, cloudColors: resolveIchimokuCloudColors(config.cloudColors), }; } /** registry 기본 플롯과 병합된 일목 플롯 목록 */ export function mergeIchimokuPlots( saved: PlotDef[] | undefined, defaults: PlotDef[], ): PlotDef[] { const savedMap = new Map((saved ?? []).map(p => [p.id, p])); return defaults.map(def => { const s = savedMap.get(def.id); const title = getIchimokuPlotTitle(def.id); return s ? { ...def, ...s, id: def.id, title, type: 'line' as const } : { ...def, title }; }); } /** #RRGGBB 또는 #RRGGBBAA → rgba(r,g,b,a) 표시 문자열 */ export function colorToRgbaString(color: string): string { if (color.startsWith('rgba(')) return color; const hex = color.startsWith('#') ? color.slice(1) : color; if (hex.length < 6) return color; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); const a = hex.length >= 8 ? Math.round(parseInt(hex.slice(6, 8), 16) / 255 * 100) / 100 : 1; return `rgba(${r}, ${g}, ${b}, ${a})`; } export function cloudColorOpacityPercent(color: string): number { if (color.startsWith('rgba(')) { const m = color.match(/,\s*([\d.]+)\s*\)$/); return m ? Math.round(parseFloat(m[1]) * 100) : 100; } if (color.length >= 9) { return Math.round(parseInt(color.slice(7, 9), 16) / 255 * 100); } return 100; }