diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index cafc507..afc9c3f 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -1342,31 +1342,19 @@ public class StrategyDslToTa4jAdapter { return -1; } - /** live-conditions·해석 UI — MA5(60일) 형식 */ + /** live-conditions·해석 UI — MA20일 형식 (슬롯·기간 설정값 기준) */ public String formatMovingAverageFieldLabel(String field, Map> params) { if (field == null || field.isBlank()) return field; Map sma = params != null ? params.getOrDefault("SMA", Map.of()) : Map.of(); if (field.startsWith("MA") && !field.startsWith("MACD")) { int period = resolveMovingAveragePeriod(field, "MA", sma); if (period <= 0) return field; - try { - int n = Integer.parseInt(field.substring(2)); - if (n >= 1 && n <= MA_DSL_SLOT_MAX) return "MA" + n + "(" + period + "일)"; - return "MA(" + period + "일)"; - } catch (NumberFormatException e) { - return field; - } + return "MA" + period + "일"; } if (field.startsWith("EMA")) { int period = resolveMovingAveragePeriod(field, "EMA", sma); if (period <= 0) return field; - try { - int n = Integer.parseInt(field.substring(3)); - if (n >= 1 && n <= MA_DSL_SLOT_MAX) return "EMA" + n + "(" + period + "일)"; - return "EMA(" + period + "일)"; - } catch (NumberFormatException e) { - return field; - } + return "EMA" + period + "일"; } return field; } diff --git a/backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java b/backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java index 03a5f52..69414a2 100644 --- a/backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java +++ b/backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java @@ -45,15 +45,15 @@ class MaDslFieldAdapterTest { void formatMovingAverageFieldLabel_slotLabels() { Map> params = Map.of( "SMA", Map.of("period3", 20, "period5", 60)); - assertEquals("MA3(20일)", adapter.formatMovingAverageFieldLabel("MA3", params)); - assertEquals("MA5(60일)", adapter.formatMovingAverageFieldLabel("MA5", params)); + assertEquals("MA20일", adapter.formatMovingAverageFieldLabel("MA3", params)); + assertEquals("MA60일", adapter.formatMovingAverageFieldLabel("MA5", params)); } @Test void formatMovingAverageFieldLabel_legacyLabels() { Map> params = Map.of( "SMA", Map.of("period3", 20, "period5", 60)); - assertEquals("MA(20일)", adapter.formatMovingAverageFieldLabel("MA20", params)); - assertEquals("MA(60일)", adapter.formatMovingAverageFieldLabel("MA60", params)); + assertEquals("MA20일", adapter.formatMovingAverageFieldLabel("MA20", params)); + assertEquals("MA60일", adapter.formatMovingAverageFieldLabel("MA60", params)); } } diff --git a/frontend/src/chart/ChartWorkspaceView.tsx b/frontend/src/chart/ChartWorkspaceView.tsx index a050fea..2af46e5 100644 --- a/frontend/src/chart/ChartWorkspaceView.tsx +++ b/frontend/src/chart/ChartWorkspaceView.tsx @@ -826,6 +826,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { selection={customOverlaySelection} onSave={sel => handleSaveCustomOverlaySelection('custom', sel)} onCancel={() => setCustomOverlaySettingsSlot(null)} + smaParams={indicators.find(i => i.type === 'SMA')?.params} /> )} {customOverlaySettingsSlot === 'custom1' && ( @@ -834,6 +835,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { selection={custom1OverlaySelection} onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)} onCancel={() => setCustomOverlaySettingsSlot(null)} + smaParams={indicators.find(i => i.type === 'SMA')?.params} /> )} {isSingleLoadingMore && ( diff --git a/frontend/src/components/ChartCustomOverlaySettingsModal.tsx b/frontend/src/components/ChartCustomOverlaySettingsModal.tsx index 928226c..a1f42c1 100644 --- a/frontend/src/components/ChartCustomOverlaySettingsModal.tsx +++ b/frontend/src/components/ChartCustomOverlaySettingsModal.tsx @@ -17,6 +17,8 @@ interface ChartCustomOverlaySettingsModalProps { selection: ChartCustomOverlaySelection; onSave: (selection: ChartCustomOverlaySelection) => void; onCancel: () => void; + /** SMA 보조지표 params — MA5일 등 동적 라벨 */ + smaParams?: Record; } const SECTION_META: Array<{ @@ -56,6 +58,7 @@ const ChartCustomOverlaySettingsModal: React.FC { const draftRef = useRef(cloneChartCustomOverlaySelection(selection)); const [, bump] = useState(0); @@ -110,7 +113,7 @@ const ChartCustomOverlaySettingsModal: React.FC patch(d => { d.smaPlots[pid] = v; })} /> diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 07a9f16..750de6d 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -1120,6 +1120,7 @@ const ChartSlot = forwardRef(function ChartSlot selection={customOverlaySelection} onSave={sel => handleSaveCustomOverlaySelection('custom', sel)} onCancel={() => setCustomOverlaySettingsSlot(null)} + smaParams={indicators.find(i => i.type === 'SMA')?.params} /> )} {customOverlaySettingsSlot === 'custom1' && !compactMode && ( @@ -1128,6 +1129,7 @@ const ChartSlot = forwardRef(function ChartSlot selection={custom1OverlaySelection} onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)} onCancel={() => setCustomOverlaySettingsSlot(null)} + smaParams={indicators.find(i => i.type === 'SMA')?.params} /> )} diff --git a/frontend/src/components/IndicatorSettingsForm.tsx b/frontend/src/components/IndicatorSettingsForm.tsx index a9e429b..a49304e 100644 --- a/frontend/src/components/IndicatorSettingsForm.tsx +++ b/frontend/src/components/IndicatorSettingsForm.tsx @@ -5,7 +5,6 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import type { IndicatorConfig, Timeframe } from '../types'; import { getIndicatorDef } from '../utils/indicatorRegistry'; import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; -import { getPlotLabel } from '../utils/indicatorLabels'; import { getNumericParamSpec } from '../utils/indicatorParamSpec'; import NumericParamInput from './NumericParamInput'; import SmaPeriodInput from './SmaPeriodInput'; @@ -14,6 +13,7 @@ import { SMA_MA_COUNT, smaPeriodKey, smaPlotId, + formatSmaMaDisplayLabel, } from '../utils/smaConfig'; import PlotLineStylePicker from './PlotLineStylePicker'; import { @@ -188,8 +188,14 @@ const IndicatorSettingsForm: React.FC = ({ }, [plotVis, patch]); const handleSmaPeriod = useCallback((maIndex: number, v: number) => { - patch({ params: { ...params, [smaPeriodKey(maIndex)]: v } }); - }, [params, patch]); + const nextParams = { ...params, [smaPeriodKey(maIndex)]: v }; + const nextPlots = plots.map((pl, i) => + i === maIndex - 1 + ? { ...pl, title: formatSmaMaDisplayLabel(nextParams, maIndex) } + : pl, + ); + patch({ params: nextParams, plots: nextPlots }); + }, [params, plots, patch]); const handleSmaToggle = useCallback((plotIndex: number, enabled: boolean) => { patch({ plotVis: { ...plotVis, [smaPlotId(plotIndex)]: enabled } }); @@ -254,7 +260,9 @@ const IndicatorSettingsForm: React.FC = ({ /> - +
e.stopPropagation()} @@ -268,7 +276,7 @@ const IndicatorSettingsForm: React.FC = ({ {plot && !inputsOnly && ( ; + const vis = config.plotVisibility ?? {}; + const labels: string[] = []; + for (let i = 0; i < SMA_MA_COUNT; i++) { + const pid = smaPlotId(i); + if (vis[pid] === false) continue; + labels.push(formatSmaMaDisplayLabel(params, i + 1)); + } + return labels.length ? labels.join(' · ') : getIndicatorChartTitle('SMA'); + } const name = getIndicatorChartTitle(config.type); const def = getIndicatorDef(config.type); const params = (config.params ?? def?.defaultParams ?? {}) as Record; diff --git a/frontend/src/utils/chartCustomOverlay.ts b/frontend/src/utils/chartCustomOverlay.ts index 0a5718c..31d047b 100644 --- a/frontend/src/utils/chartCustomOverlay.ts +++ b/frontend/src/utils/chartCustomOverlay.ts @@ -1,4 +1,4 @@ -import { SMA_MA_COUNT, smaPlotId } from './smaConfig'; +import { SMA_MA_COUNT, smaPlotId, formatSmaMaDisplayLabel } from './smaConfig'; /** Custom / Custom1 — 동일 기능, 설정만 분리 (세션만, DB 미저장) */ export type ChartCustomOverlaySlotId = 'custom' | 'custom1'; @@ -70,8 +70,11 @@ export function cloneChartCustomOverlaySelection( }; } -export function smaMaLabel(plotIndex: number): string { - return `MA${plotIndex + 1}`; +export function smaMaLabel( + plotIndex: number, + params?: Record, +): string { + return formatSmaMaDisplayLabel(params, plotIndex + 1); } export const ICHIMOKU_CUSTOM_ROWS: Array<{ diff --git a/frontend/src/utils/indicatorLabels.ts b/frontend/src/utils/indicatorLabels.ts index 127603a..8eb0784 100644 --- a/frontend/src/utils/indicatorLabels.ts +++ b/frontend/src/utils/indicatorLabels.ts @@ -231,6 +231,7 @@ export function getParamLabel(key: string, indicatorType?: string): string { } export function getPlotLabel(title: string): string { + if (/^MA\d+일$/.test(title) || /^EMA\d+일$/.test(title)) return title; const pair = PLOT_LABELS[title]; if (pair) return bilingual(pair); return bilingual({ ko: title, en: title }); diff --git a/frontend/src/utils/indicatorMainTab.ts b/frontend/src/utils/indicatorMainTab.ts index 6a3c985..77ee4d4 100644 --- a/frontend/src/utils/indicatorMainTab.ts +++ b/frontend/src/utils/indicatorMainTab.ts @@ -54,7 +54,7 @@ export const MAIN_INDICATOR_LABELS: Record = Psychological: { ko: '심리도', en: 'Psychological Line' }, NewPsychological: { ko: '신심리도', en: 'New Psychological Line' }, InvestPsychological: { ko: '투자심리도', en: 'Invest Psychological Line' }, - SMA: { ko: '단순 이동평균 (MA1~MA11)', en: 'Simple Moving Average' }, + SMA: { ko: '단순 이동평균', en: 'Simple Moving Average' }, }; export function isMainIndicatorType(type: string): boolean { diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index 189ab0e..e3976d5 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -268,7 +268,7 @@ export interface IndicatorDef { export const INDICATOR_REGISTRY: IndicatorDef[] = [ // ── Moving Averages ────────────────────────────────────────────────────── - { type:'SMA', name:'Simple Moving Average', koreanName:'단순이동평균', shortName:'SMA', category:'Moving Averages', overlay:true, defaultParams:createDefaultSmaParams(), plots:createDefaultSmaPlots(), description:'단순 이동평균 (MA1~MA11)' }, + { type:'SMA', name:'Simple Moving Average', koreanName:'단순이동평균', shortName:'SMA', category:'Moving Averages', overlay:true, defaultParams:createDefaultSmaParams(), plots:createDefaultSmaPlots(), description:'단순 이동평균 (MA{기간}일 × 11)' }, { type:'EMA', name:'Exponential Moving Average', koreanName:'지수이동평균', shortName:'EMA', category:'Moving Averages', overlay:true, defaultParams:{length:21, src:'close'}, plots:[{id:'plot0',title:'EMA', color:'#FF6D00',type:'line',lineWidth:2}], description:'지수 이동평균' }, { type:'WMA', name:'Weighted Moving Average', koreanName:'가중이동평균', shortName:'WMA', category:'Moving Averages', overlay:true, defaultParams:{length:20, src:'close'}, plots:[{id:'plot0',title:'WMA', color:'#00BCD4',type:'line',lineWidth:2}], description:'가중 이동평균' }, { type:'HMA', name:'Hull Moving Average', koreanName:'헐이동평균', shortName:'HMA', category:'Moving Averages', overlay:true, defaultParams:{length:16, src:'close'}, plots:[{id:'plot0',title:'HMA', color:'#4CAF50',type:'line',lineWidth:2}], description:'헐 이동평균 (래그 최소)' }, diff --git a/frontend/src/utils/maDslField.ts b/frontend/src/utils/maDslField.ts index 2eb4632..e3cf80c 100644 --- a/frontend/src/utils/maDslField.ts +++ b/frontend/src/utils/maDslField.ts @@ -4,7 +4,7 @@ * - MA3 → period3 (예: 20일) * - MA20 → 레거시: 숫자>11 이면 기간 20일 그대로 */ -import { SMA_DEFAULT_PERIODS, SMA_MA_COUNT } from './smaConfig'; +import { SMA_DEFAULT_PERIODS, SMA_MA_COUNT, formatSmaPeriodDisplayLabel } from './smaConfig'; export const MA_DSL_SLOT_MAX = SMA_MA_COUNT; @@ -45,7 +45,14 @@ export function resolveMaDslFieldPeriod( return null; } -/** UI·해석용 라벨 — MA5(60일) */ +/** EMA 설정 기간 → EMA5일 */ +export function formatEmaPeriodDisplayLabel(period: number | string | boolean | undefined): string { + const raw = typeof period === 'number' ? period : Number(period); + const p = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1; + return `EMA${p}일`; +} + +/** UI·해석용 라벨 — MA20일 */ export function formatMaDslFieldLabel( field: string | undefined, smaParams?: Record, @@ -53,18 +60,8 @@ export function formatMaDslFieldLabel( if (!field) return ''; const period = resolveMaDslFieldPeriod(field, smaParams); if (period == null) return field; - const ma = /^MA(\d+)$/.exec(field); - if (ma && !field.startsWith('MACD')) { - const n = parseInt(ma[1], 10); - if (n >= 1 && n <= MA_DSL_SLOT_MAX) return `MA${n}(${period}일)`; - return `MA(${period}일)`; - } - const ema = /^EMA(\d+)$/.exec(field); - if (ema) { - const n = parseInt(ema[1], 10); - if (n >= 1 && n <= MA_DSL_SLOT_MAX) return `EMA${n}(${period}일)`; - return `EMA(${period}일)`; - } + if (field.startsWith('EMA')) return formatEmaPeriodDisplayLabel(period); + if (field.startsWith('MA') && !field.startsWith('MACD')) return formatSmaPeriodDisplayLabel(period); return field; } @@ -74,7 +71,7 @@ export function buildMaFieldOptions( return Array.from({ length: SMA_MA_COUNT }, (_, i) => { const slot = i + 1; const p = maLines[i] ?? SMA_DEFAULT_PERIODS[i]; - return { value: `MA${slot}`, label: `MA${slot}(${p}일)` }; + return { value: `MA${slot}`, label: formatSmaPeriodDisplayLabel(p) }; }); } @@ -86,7 +83,7 @@ export function buildEmaFieldOptions( return Array.from({ length: n }, (_, i) => { const slot = i + 1; const p = maLines[i] ?? SMA_DEFAULT_PERIODS[i]; - return { value: `EMA${slot}`, label: `EMA${slot}(${p}일)` }; + return { value: `EMA${slot}`, label: formatEmaPeriodDisplayLabel(p) }; }); } @@ -102,14 +99,14 @@ export function appendLegacyMaFieldOption( if (ma && !field.startsWith('MACD')) { const n = parseInt(ma[1], 10); if (n > MA_DSL_SLOT_MAX) { - return [...opts, { value: field, label: `MA(${period}일)` }]; + return [...opts, { value: field, label: formatSmaPeriodDisplayLabel(period) }]; } } const ema = /^EMA(\d+)$/.exec(field); if (ema) { const n = parseInt(ema[1], 10); if (n > MA_DSL_SLOT_MAX) { - return [...opts, { value: field, label: `EMA(${period}일)` }]; + return [...opts, { value: field, label: formatEmaPeriodDisplayLabel(period) }]; } } return opts; diff --git a/frontend/src/utils/smaConfig.ts b/frontend/src/utils/smaConfig.ts index 95e854f..661b3b8 100644 --- a/frontend/src/utils/smaConfig.ts +++ b/frontend/src/utils/smaConfig.ts @@ -40,10 +40,49 @@ export function smaPeriodKey(maIndex: number): string { return `period${maIndex}`; } +/** 설정 기간 → 표시명 MA5일 (보조지표·차트·전략 공통) */ +export function formatSmaPeriodDisplayLabel( + period: number | string | boolean | undefined, + fallback = SMA_DEFAULT_PERIODS[0], +): string { + const raw = typeof period === 'number' ? period : Number(period); + const p = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback; + return `MA${p}일`; +} + +export function resolveSmaMaPeriod( + params: Record | undefined, + maIndex: number, +): number { + const slot = Math.max(1, Math.min(SMA_MA_COUNT, Math.floor(maIndex))); + const raw = params?.[smaPeriodKey(slot)]; + if (typeof raw === 'number' && raw > 0) return Math.floor(raw); + return SMA_DEFAULT_PERIODS[slot - 1]; +} + +/** MA 슬롯(1~11) + params → MA5일 */ +export function formatSmaMaDisplayLabel( + params: Record | undefined, + maIndex: number, +): string { + return formatSmaPeriodDisplayLabel(resolveSmaMaPeriod(params, maIndex)); +} + +/** params 변경 시 plot.title 을 MA{period}일 로 동기화 */ +export function syncSmaPlotTitles( + params: Record, + plots: PlotDef[], +): PlotDef[] { + return plots.map((plot, i) => ({ + ...plot, + title: formatSmaMaDisplayLabel(params, i + 1), + })); +} + export function createDefaultSmaPlots(): PlotDef[] { - return SMA_DEFAULT_PERIODS.map((_, i) => ({ + return SMA_DEFAULT_PERIODS.map((period, i) => ({ id: smaPlotId(i), - title: `MA${i + 1}`, + title: formatSmaPeriodDisplayLabel(period), color: SMA_DEFAULT_COLORS[i], type: 'line', lineWidth: 2, @@ -114,7 +153,7 @@ export function normalizeSmaConfig(config: IndicatorConfig): IndicatorConfig { const plots: PlotDef[] = defaultPlots.map((def, i) => { const saved = savedById.get(def.id) ?? config.plots?.[i]; return saved - ? { ...def, ...saved, id: def.id, title: `MA${i + 1}`, type: 'line' as const } + ? { ...def, ...saved, id: def.id, title: formatSmaMaDisplayLabel(params, i + 1), type: 'line' as const } : def; });