From 207374a08ebc22c89a9c317d82f7ebf1397cb5de Mon Sep 17 00:00:00 2001 From: Macbook Date: Sat, 13 Jun 2026 01:25:56 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8F=89=EA=B0=80=20MA=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LiveConditionStatusService.java | 12 +- .../service/StrategyDslToTa4jAdapter.java | 83 ++++++++++-- .../service/MaDslFieldAdapterTest.java | 59 +++++++++ .../src/components/StrategyEvaluationPage.tsx | 1 + frontend/src/components/StrategyPage.tsx | 47 ++++--- .../StrategyEvaluationSignalPanel.tsx | 9 +- frontend/src/styles/strategyEvaluation.css | 124 ++++++++++++++---- frontend/src/utils/maDslField.ts | 116 ++++++++++++++++ frontend/src/utils/strategyEditorShared.tsx | 58 ++++++-- .../src/utils/strategyEvaluationInterpret.ts | 19 +-- 10 files changed, 451 insertions(+), 77 deletions(-) create mode 100644 backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java create mode 100644 frontend/src/utils/maDslField.ts diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index d9a3541..190bc9e 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -500,7 +500,7 @@ public class LiveConditionStatusService { .displayName(indType) .conditionType(condType) .conditionLabel(condLabel) - .thresholdLabel(formatThresholdStatic(c, target)) + .thresholdLabel(formatThresholdStatic(c, target, visual)) .currentValue(null) .targetValue(target) .satisfied(null) @@ -510,7 +510,8 @@ public class LiveConditionStatusService { .build(); } - private String formatThresholdStatic(JsonNode cond, Double target) { + private String formatThresholdStatic(JsonNode cond, Double target, + Map> params) { if (target != null && !target.isNaN()) { String ct = cond.path("conditionType").asText("GT"); String v = formatNum(target); @@ -525,7 +526,7 @@ public class LiveConditionStatusService { } String rf = cond.path("rightField").asText(""); if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) { - return rf.replace('_', ' '); + return adapter.formatMovingAverageFieldLabel(rf, params); } return "—"; } @@ -576,16 +577,17 @@ public class LiveConditionStatusService { Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false); if (rightLive != null && !rightLive.isNaN()) { String v = formatNum(rightLive); + String fieldLabel = adapter.formatMovingAverageFieldLabel(rf, params); return switch (ct) { case "LT", "CROSS_DOWN" -> "< " + v; case "GT", "CROSS_UP" -> "> " + v; case "GTE" -> "≥ " + v; case "LTE" -> "≤ " + v; case "EQ" -> "= " + v; - default -> rf.replace('_', ' ') + " (" + v + ")"; + default -> fieldLabel + " (" + v + ")"; }; } - return rf.replace('_', ' '); + return adapter.formatMovingAverageFieldLabel(rf, params); } return "—"; } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 3c13336..cafc507 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -138,6 +138,13 @@ public class StrategyDslToTa4jAdapter { market, storage, useConfirmedOnly, seriesOverrides); } + /** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */ + public Map smaParams() { + if (indicatorParams == null) return Map.of(); + Map sma = indicatorParams.get("SMA"); + return sma != null ? sma : Map.of(); + } + /** 사용 중인 시리즈 오버라이드가 있으면 백테스트 모드 */ public boolean isBacktest() { return seriesOverrides != null && !seriesOverrides.isEmpty(); @@ -233,7 +240,9 @@ public class StrategyDslToTa4jAdapter { : Map.of(); try { - Indicator ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod); + RuleBuildContext ctx = new RuleBuildContext(series, params != null ? params : Map.of(), + Map.of(), null, null, false, Map.of()); + Indicator ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod, cond, ctx); Num v = ind.getValue(index); return v != null ? v.doubleValue() : null; } catch (Exception e) { @@ -722,7 +731,7 @@ public class StrategyDslToTa4jAdapter { case "VOLUME_VALUE" -> new VolumeIndicator(s, 1); default -> { Double hlineVal = IndicatorHlineResolver.resolveThresholdField( - cond, field, indType, ctx.indicatorVisual()); + cond, field, indType, ctx != null ? ctx.indicatorVisual() : Map.of()); if (hlineVal != null) { yield new ConstantIndicator<>(s, s.numFactory().numOf(hlineVal)); } @@ -730,7 +739,8 @@ public class StrategyDslToTa4jAdapter { if (!Double.isNaN(constant)) { yield new ConstantIndicator<>(s, s.numFactory().numOf(constant)); } - yield resolveIndicatorField(field, indType, p, s, condPeriod, sidePeriod); + Map sma = ctx != null ? ctx.smaParams() : Map.of(); + yield resolveIndicatorField(field, indType, p, sma, s, condPeriod, sidePeriod); } }; } @@ -764,7 +774,9 @@ public class StrategyDslToTa4jAdapter { } private Indicator resolveIndicatorField(String field, String indType, - Map p, BarSeries s, + Map p, + Map smaParams, + BarSeries s, int condPeriod, int sidePeriod) { ClosePriceIndicator close = new ClosePriceIndicator(s); int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1); @@ -943,12 +955,12 @@ public class StrategyDslToTa4jAdapter { return new LowestValueIndicator(close, period); } if (field.startsWith("MA") && !field.startsWith("MACD")) { - try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); } - catch (NumberFormatException ignored) { /* fall */ } + int period = resolveMovingAveragePeriod(field, "MA", smaParams); + if (period > 0) return new SMAIndicator(close, period); } if (field.startsWith("EMA")) { - try { return new EMAIndicator(close, Integer.parseInt(field.substring(3))); } - catch (NumberFormatException ignored) { /* fall */ } + int period = resolveMovingAveragePeriod(field, "EMA", smaParams); + if (period > 0) return new EMAIndicator(close, period); } // 일목균형표 if (field.equals("CONVERSION_LINE")) return ichimokuTenkan(s, p); @@ -1304,6 +1316,61 @@ public class StrategyDslToTa4jAdapter { return new PreviousValueIndicator(lowest, 1); } + // ── MA/EMA DSL (MA1~11 ↔ SMA period1~11) ───────────────────────────────── + + /** SMA 차트 MA1~MA11 기본 기간 — IndicatorService 와 동일 */ + private static final int[] SMA_DEFAULT_PERIODS = + { 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 }; + private static final int MA_DSL_SLOT_MAX = 11; + + /** + * MA/EMA DSL 필드 → SMA/EMA 계산 기간. + * MA3·MA5(1~11): SMA 설정 period1~11. MA20·MA60(>11): 레거시 기간 인코딩. + */ + public int resolveMovingAveragePeriod(String field, String prefix, Map smaParams) { + if (field == null || !field.startsWith(prefix)) return -1; + if ("MA".equals(prefix) && field.startsWith("MACD")) return -1; + try { + int n = Integer.parseInt(field.substring(prefix.length())); + if (n >= 1 && n <= MA_DSL_SLOT_MAX) { + String periodKey = "period" + n; + int def = SMA_DEFAULT_PERIODS[n - 1]; + return Math.max(1, intP(smaParams, periodKey, def)); + } + if (n > MA_DSL_SLOT_MAX) return n; + } catch (NumberFormatException ignored) { /* fall */ } + return -1; + } + + /** live-conditions·해석 UI — MA5(60일) 형식 */ + 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; + } + } + 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 field; + } + // ── 파라미터 헬퍼 ───────────────────────────────────────────────────────── private int intP(Map p, String k, int def) { diff --git a/backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java b/backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java new file mode 100644 index 0000000..03a5f52 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/MaDslFieldAdapterTest.java @@ -0,0 +1,59 @@ +package com.goldenchart.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MA1~MA11 슬롯 ↔ SMA period1~11 매핑 및 레거시 MA20·MA60(기간 인코딩) 호환. + */ +class MaDslFieldAdapterTest { + + private StrategyDslToTa4jAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + } + + @Test + void resolveMovingAveragePeriod_slotUsesSmaParams() { + Map sma = Map.of("period3", 20, "period5", 60); + assertEquals(20, adapter.resolveMovingAveragePeriod("MA3", "MA", sma)); + assertEquals(60, adapter.resolveMovingAveragePeriod("MA5", "MA", sma)); + } + + @Test + void resolveMovingAveragePeriod_legacyPeriodEncoding() { + Map sma = Map.of("period3", 20, "period5", 60); + assertEquals(20, adapter.resolveMovingAveragePeriod("MA20", "MA", sma)); + assertEquals(60, adapter.resolveMovingAveragePeriod("MA60", "MA", sma)); + } + + @Test + void resolveMovingAveragePeriod_oldSlotBugWouldUseLiteralDays() { + Map sma = Map.of("period3", 20, "period5", 60); + // MA3 must NOT resolve to 3-day SMA when period3=20 + assertEquals(20, adapter.resolveMovingAveragePeriod("MA3", "MA", sma)); + assertEquals(60, adapter.resolveMovingAveragePeriod("MA5", "MA", sma)); + } + + @Test + 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)); + } + + @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)); + } +} diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 9be27f4..af622c7 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -463,6 +463,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { barSignalHighlight={barSignalHighlight} hasStrategy={selectedStrategyId != null} className="seval-signal-panel" + getParams={getEvalParams} /> diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx index f9dd6b0..75049e4 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -24,6 +24,11 @@ import { candleRangeWindowBars, formatCandleRangeClause, } from '../utils/strategyTypes'; +import { + appendLegacyMaFieldOption, + buildEmaFieldOptions, + buildMaFieldOptions, +} from '../utils/maDslField'; // ─── 타입 ───────────────────────────────────────────────────────────────────── type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME'; @@ -298,7 +303,7 @@ const ICHIMOKU_CONDS = [ type Opt = { value: string; label: string }; const NONE: Opt = { value: 'NONE', label: '선택안함' }; -const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => { +const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType, cond?: ConditionDSL): Opt[] => { const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈'; const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입'; const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds]; @@ -377,16 +382,26 @@ const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[ { value:'VOLUME_VALUE', label:'거래량' }, { value:'VOLUME_MA', label:'거래량 이동평균' }, ]); - case 'MA': return [ - NONE, - ...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })), - { value:'CLOSE_PRICE', label:'종가' }, - ]; - case 'EMA': return [ - NONE, - ...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })), - { value:'CLOSE_PRICE', label:'종가' }, - ]; + case 'MA': { + let opts = [ + NONE, + ...buildMaFieldOptions(DEF.maLines), + { value: 'CLOSE_PRICE', label: '종가' }, + ]; + if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField); + if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField); + return opts; + } + case 'EMA': { + let opts = [ + NONE, + ...buildEmaFieldOptions(DEF.maLines, 4), + { value: 'CLOSE_PRICE', label: '종가' }, + ]; + if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField); + if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField); + return opts; + } case 'DISPARITY': return condOpts([ { value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` }, { value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` }, @@ -515,8 +530,8 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): DMI: { l:'PDI', r:'MDI' }, OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' }, VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' }, - MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` }, - EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` }, + MA: { l: 'MA1', r: 'MA2' }, + EMA: { l: 'EMA1', r: 'EMA2' }, DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) }, PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) }, NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: over(50) }, @@ -538,7 +553,7 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): // conditionType 변경 시 자동 필드 조정 const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => { const updated = { ...cond, conditionType: newCondType }; - const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF); + const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF, cond); const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v); const def = getDefaultFields(cond.indicatorType, 'buy', DEF); @@ -575,7 +590,7 @@ const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => { if (!node) return ''; if (node.type === 'CONDITION' && node.condition) { const c = node.condition; - const opts = getFieldOpts(c.indicatorType, 'buy', DEF); + const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c); const L = opts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType; const R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? ''; const C = condLabel(c.conditionType); @@ -680,7 +695,7 @@ interface CondEditorProps { } const CondEditor: React.FC = ({ cond, signalType, onChange, def }) => { - const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def); + const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def, cond); const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS; const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v); diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx index e424051..2ab64c3 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx @@ -23,6 +23,8 @@ interface Props { loadingMessage?: string; hasStrategy?: boolean; className?: string; + /** 보조지표 SMA period1~11 — MA 슬롯 라벨·해석용 */ + getParams?: (type: string, defaults?: Record) => Record; } interface SideCardProps { @@ -32,6 +34,7 @@ interface SideCardProps { signalActive?: boolean; strategy?: StrategyDto | null; barTimeSec?: number | null; + getParams?: Props['getParams']; } const SideCard: React.FC = ({ @@ -41,6 +44,7 @@ const SideCard: React.FC = ({ signalActive = false, strategy, barTimeSec, + getParams, }) => { const [interpretOpen, setInterpretOpen] = useState(false); const sideLabel = side === 'buy' ? '매수' : '매도'; @@ -48,7 +52,7 @@ const SideCard: React.FC = ({ const overallLabel = overallMet === true ? '충족' : overallMet === false ? '미충족' : null; - const contextMap = useMemo(() => buildConditionContextMap(strategy), [strategy]); + const contextMap = useMemo(() => buildConditionContextMap(strategy, getParams), [strategy, getParams]); const interpretation = useMemo( () => buildSideInterpretation(side, metrics, overallMet, contextMap), [side, metrics, overallMet, contextMap], @@ -114,6 +118,7 @@ const StrategyEvaluationSignalPanel: React.FC = ({ loadingMessage, hasStrategy = true, className = '', + getParams, }) => { const rows = snapshot?.rows ?? []; const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); @@ -143,6 +148,7 @@ const StrategyEvaluationSignalPanel: React.FC = ({ signalActive={barSignalHighlight?.sell ?? false} strategy={strategy} barTimeSec={barTimeSec} + getParams={getParams} /> = ({ signalActive={barSignalHighlight?.buy ?? false} strategy={strategy} barTimeSec={barTimeSec} + getParams={getParams} /> ); diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index c129306..d9c01a1 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -1149,9 +1149,9 @@ height: 26px; padding: 0 10px; border-radius: 6px; - border: 1px solid color-mix(in srgb, var(--se-border) 85%, transparent); - background: color-mix(in srgb, var(--se-input-bg, #121826) 92%, transparent); - color: var(--se-text-muted); + border: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 85%, transparent); + background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 92%, transparent); + color: var(--se-text-muted, var(--text2)); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.02em; @@ -1190,30 +1190,30 @@ .seval-interpret-content { font-size: 0.84rem; line-height: 1.65; - color: var(--se-text); + color: var(--se-text, var(--text)); } .seval-interpret-intro { margin: 0 0 12px; font-size: 0.78rem; - color: var(--se-text-muted); + color: var(--se-text-muted, var(--text2)); } .seval-interpret-overall { margin-bottom: 14px; padding: 10px 12px; border-radius: 10px; - border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); - background: color-mix(in srgb, var(--se-input-bg, #121826) 88%, transparent); + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 88%, transparent); } .seval-interpret-overall--met { - border-color: color-mix(in srgb, #3f7ef5 35%, transparent); - background: color-mix(in srgb, #3f7ef5 8%, transparent); + border-color: color-mix(in srgb, var(--se-accent, #1976d2) 35%, transparent); + background: color-mix(in srgb, var(--se-accent, #1976d2) 8%, var(--bg2, #fff)); } .seval-interpret-overall--unmet { - border-color: color-mix(in srgb, var(--se-text-muted) 25%, transparent); + border-color: color-mix(in srgb, var(--se-text-muted, var(--text2)) 25%, transparent); } .seval-interpret-overall-badge { @@ -1227,13 +1227,13 @@ } .seval-interpret-overall--met .seval-interpret-overall-badge { - color: #7eb6ff; - background: color-mix(in srgb, #3f7ef5 18%, transparent); + color: var(--se-accent, #7eb6ff); + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 18%, transparent); } .seval-interpret-overall--unmet .seval-interpret-overall-badge { - color: var(--se-text-muted); - background: color-mix(in srgb, var(--se-text-muted) 12%, transparent); + color: var(--se-text-muted, var(--text2)); + background: color-mix(in srgb, var(--se-text-muted, var(--text2)) 12%, transparent); } .seval-interpret-overall--unknown .seval-interpret-overall-badge { @@ -1248,7 +1248,7 @@ .seval-interpret-empty { margin: 0; - color: var(--se-text-muted); + color: var(--se-text-muted, var(--text2)); } .seval-interpret-list { @@ -1263,16 +1263,16 @@ .seval-interpret-item { padding: 10px 12px; border-radius: 10px; - border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); - background: color-mix(in srgb, var(--se-input-bg, #121826) 90%, transparent); + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 90%, transparent); } .seval-interpret-item--match { - border-left: 3px solid #3f7ef5; + border-left: 3px solid var(--se-accent, #3f7ef5); } .seval-interpret-item--nomatch { - border-left: 3px solid color-mix(in srgb, var(--se-text-muted) 55%, #ef5350); + border-left: 3px solid color-mix(in srgb, var(--se-text-muted, var(--text2)) 55%, var(--se-danger, #ef5350)); } .seval-interpret-item--unknown { @@ -1296,13 +1296,13 @@ } .seval-interpret-status--match { - color: #7eb6ff; - background: color-mix(in srgb, #3f7ef5 16%, transparent); + color: var(--se-accent, #7eb6ff); + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 16%, transparent); } .seval-interpret-status--nomatch { - color: #ff8a80; - background: color-mix(in srgb, #ef5350 14%, transparent); + color: var(--se-danger, #ff8a80); + background: color-mix(in srgb, var(--se-danger, #ef5350) 14%, transparent); } .seval-interpret-status--unknown { @@ -1316,21 +1316,93 @@ font-weight: 700; line-height: 1.4; word-break: break-word; + color: var(--se-text, var(--text)); } .seval-interpret-reason { margin: 0; font-size: 0.78rem; - color: var(--se-text-muted); + color: var(--se-text-muted, var(--text2)); line-height: 1.55; } .seval-interpret-footnote { margin: 14px 0 0; padding-top: 10px; - border-top: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); + border-top: 1px solid var(--se-border, var(--border)); font-size: 0.72rem; - color: var(--se-text-muted); + color: var(--se-text-muted, var(--text2)); +} + +/* body 포털 모달 — 라이트 테마 (html.theme-light · .app.light) */ +html.theme-light .seval-interpret-overall, +.app.light .seval-interpret-overall, +html.theme-light .seval-interpret-item, +.app.light .seval-interpret-item { + background: var(--bg2, #ffffff); + border-color: rgba(0, 0, 0, 0.1); +} + +html.theme-light .seval-interpret-overall--met, +.app.light .seval-interpret-overall--met { + border-color: rgba(25, 118, 210, 0.35); + background: rgba(25, 118, 210, 0.06); +} + +html.theme-light .seval-interpret-overall--met .seval-interpret-overall-badge, +.app.light .seval-interpret-overall--met .seval-interpret-overall-badge { + color: #1565c0; + background: rgba(25, 118, 210, 0.12); +} + +html.theme-light .seval-interpret-overall--unmet .seval-interpret-overall-badge, +.app.light .seval-interpret-overall--unmet .seval-interpret-overall-badge { + color: #546e7a; + background: rgba(0, 0, 0, 0.06); +} + +html.theme-light .seval-interpret-status--match, +.app.light .seval-interpret-status--match { + color: #1565c0; + background: rgba(25, 118, 210, 0.12); +} + +html.theme-light .seval-interpret-status--nomatch, +.app.light .seval-interpret-status--nomatch { + color: #c62828; + background: rgba(198, 40, 40, 0.1); +} + +html.theme-light .seval-interpret-status--unknown, +.app.light .seval-interpret-status--unknown, +html.theme-light .seval-interpret-overall--unknown .seval-interpret-overall-badge, +.app.light .seval-interpret-overall--unknown .seval-interpret-overall-badge { + color: #e65100; + background: rgba(245, 127, 23, 0.12); +} + +html.theme-light .seval-interpret-item--match, +.app.light .seval-interpret-item--match { + border-left-color: #1976d2; +} + +html.theme-light .seval-interpret-item--nomatch, +.app.light .seval-interpret-item--nomatch { + border-left-color: #c62828; +} + +html.theme-light .seval-analyze-btn, +.app.light .seval-analyze-btn { + background: var(--bg2, #ffffff); + border-color: rgba(0, 0, 0, 0.12); + color: var(--text2, #757575); +} + +html.theme-light .seval-analyze-btn:hover:not(:disabled), +.app.light .seval-analyze-btn:hover:not(:disabled) { + color: #1565c0; + border-color: rgba(25, 118, 210, 0.45); + background: rgba(25, 118, 210, 0.08); } @media (max-width: 960px) { diff --git a/frontend/src/utils/maDslField.ts b/frontend/src/utils/maDslField.ts new file mode 100644 index 0000000..2eb4632 --- /dev/null +++ b/frontend/src/utils/maDslField.ts @@ -0,0 +1,116 @@ +/** + * MA/EMA DSL 필드 — MA1~MA11 슬롯 ↔ SMA period1~11 (보조지표 설정과 동일) + * + * - MA3 → period3 (예: 20일) + * - MA20 → 레거시: 숫자>11 이면 기간 20일 그대로 + */ +import { SMA_DEFAULT_PERIODS, SMA_MA_COUNT } from './smaConfig'; + +export const MA_DSL_SLOT_MAX = SMA_MA_COUNT; + +export function buildMaLinesFromSmaParams( + params?: Record, +): number[] { + const lines: number[] = []; + for (let slot = 1; slot <= SMA_MA_COUNT; slot++) { + const raw = params?.[`period${slot}`]; + const p = typeof raw === 'number' && raw > 0 + ? Math.floor(raw) + : SMA_DEFAULT_PERIODS[slot - 1]; + lines.push(p); + } + return lines; +} + +/** DSL MA/EMA 필드 → 실제 SMA/EMA 계산 기간 */ +export function resolveMaDslFieldPeriod( + field: string | undefined, + smaParams?: Record, +): number | null { + if (!field) return null; + const ma = /^MA(\d+)$/.exec(field); + if (ma && !field.startsWith('MACD')) { + const n = parseInt(ma[1], 10); + if (!Number.isFinite(n) || n <= 0) return null; + if (n <= MA_DSL_SLOT_MAX) return buildMaLinesFromSmaParams(smaParams)[n - 1]; + return n; + } + const ema = /^EMA(\d+)$/.exec(field); + if (ema) { + const n = parseInt(ema[1], 10); + if (!Number.isFinite(n) || n <= 0) return null; + if (n <= MA_DSL_SLOT_MAX) return buildMaLinesFromSmaParams(smaParams)[n - 1]; + return n; + } + return null; +} + +/** UI·해석용 라벨 — MA5(60일) */ +export function formatMaDslFieldLabel( + field: string | undefined, + smaParams?: Record, +): string { + 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}일)`; + } + return field; +} + +export function buildMaFieldOptions( + maLines: number[], +): { value: string; label: string }[] { + 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}일)` }; + }); +} + +export function buildEmaFieldOptions( + maLines: number[], + maxSlots = 4, +): { value: string; label: string }[] { + const n = Math.min(maxSlots, SMA_MA_COUNT); + 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}일)` }; + }); +} + +/** 레거시 MA20·MA60(기간 인코딩) 옵션 유지 */ +export function appendLegacyMaFieldOption( + opts: { value: string; label: string }[], + field: string | undefined, +): { value: string; label: string }[] { + if (!field || opts.some(o => o.value === field)) return opts; + const period = resolveMaDslFieldPeriod(field); + if (period == null) return opts; + const ma = /^MA(\d+)$/.exec(field); + if (ma && !field.startsWith('MACD')) { + const n = parseInt(ma[1], 10); + if (n > MA_DSL_SLOT_MAX) { + return [...opts, { value: field, label: `MA(${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; +} diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 708d4e8..0b24a9d 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -10,6 +10,11 @@ import { candleRangeWindowBars, formatCandleRangeClause, } from '../utils/strategyTypes'; +import { + appendLegacyMaFieldOption, + buildEmaFieldOptions, + buildMaFieldOptions, +} from './maDslField'; import { COMPOSITE_INDICATOR_ITEMS, compositeDisplayName, @@ -136,7 +141,7 @@ export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ }; interface HLThresh { over?: number; mid?: number; under?: number; } /** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */ -const DEF_DEFAULTS = { +export const DEF_DEFAULTS = { rsiPeriod: 9, macdFast: 12, macdSlow: 26, macdSignal: 9, cciPeriod: 13, @@ -347,6 +352,23 @@ export function buildStrategyEditorDefFromSettings( } /** @deprecated buildStrategyEditorDefFromSettings 사용 — 하드코딩 폴백 */ +export function buildDefFromGetParams( + getParams: (type: string, defaults?: Record) => Record, +): DefType { + const types = [ + 'RSI', 'MACD', 'CCI', 'Stochastic', 'ADX', 'TRIX', 'DMI', 'BollingerBands', + 'IchimokuCloud', 'WilliamsPercentRange', 'VolumeOscillator', 'VR', 'Disparity', + 'Psychological', 'NewPsychological', 'InvestPsychological', 'OBV', 'SMA', 'EMA', + ] as const; + const configs: IndicatorConfig[] = types.map(type => ({ + id: `eval-def-${type}`, + type, + params: getParams(type, getIndicatorDef(type)?.defaultParams as Record), + plots: getIndicatorDef(type)?.plots ?? [], + })); + return buildDef(configs); +} + export function buildStrategyEditorDef(): DefType { return buildDef([]); } @@ -497,16 +519,26 @@ export const getFieldOpts = ( { value:'VOLUME_VALUE', label:'거래량' }, { value:'VOLUME_MA', label:'거래량 이동평균' }, ]); - case 'MA': return [ - NONE, - ...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })), - { value:'CLOSE_PRICE', label:'종가' }, - ]; - case 'EMA': return [ - NONE, - ...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })), - { value:'CLOSE_PRICE', label:'종가' }, - ]; + case 'MA': { + let opts = [ + NONE, + ...buildMaFieldOptions(DEF.maLines), + { value: 'CLOSE_PRICE', label: '종가' }, + ]; + if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField); + if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField); + return opts; + } + case 'EMA': { + let opts = [ + NONE, + ...buildEmaFieldOptions(DEF.maLines, 4), + { value: 'CLOSE_PRICE', label: '종가' }, + ]; + if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField); + if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField); + return opts; + } case 'DISPARITY': { const { mid } = th({ mid: 100 }); return condOpts([ @@ -667,8 +699,8 @@ const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: D DMI: { l:'PDI', r:'MDI' }, OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' }, VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' }, - MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` }, - EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` }, + MA: { l: 'MA1', r: 'MA2' }, + EMA: { l: 'EMA1', r: 'EMA2' }, DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID }, PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER }, NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: THRESHOLD_HL_OVER }, diff --git a/frontend/src/utils/strategyEvaluationInterpret.ts b/frontend/src/utils/strategyEvaluationInterpret.ts index 65c01e1..20e4f61 100644 --- a/frontend/src/utils/strategyEvaluationInterpret.ts +++ b/frontend/src/utils/strategyEvaluationInterpret.ts @@ -18,7 +18,7 @@ import { formatStrategyThreshold, type ConditionMetric, } from './virtualSignalMetrics'; -import { nodeToText } from './strategyEditorShared'; +import { nodeToText, buildDefFromGetParams, type DefType, DEF_DEFAULTS } from './strategyEditorShared'; export interface ConditionInterpretContext { condition: ConditionDSL; @@ -49,23 +49,24 @@ function walkContext( side: 'buy' | 'sell', out: Map, seen: Set, + def: DefType, ): void { if (!node) return; if (node.type === 'TIMEFRAME') { const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe); - node.children?.forEach(c => walkContext(c, tf, side, out, seen)); + node.children?.forEach(c => walkContext(c, tf, side, out, seen, def)); return; } if (node.type === 'NOT') { const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child; - if (child) walkContext(child, timeframe, side, out, seen); + if (child) walkContext(child, timeframe, side, out, seen, def); return; } if (node.type === 'AND' || node.type === 'OR') { - node.children?.forEach(c => walkContext(c, timeframe, side, out, seen)); + node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def)); return; } @@ -77,22 +78,24 @@ function walkContext( seen.add(rowId); out.set(rowId, { condition: c, - summary: nodeToText(node), + summary: nodeToText(node, def), timeframe: rowTf, }); return; } - node.children?.forEach(c => walkContext(c, timeframe, side, out, seen)); + node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def)); } export function buildConditionContextMap( strategy: StrategyDto | null | undefined, + getParams?: (type: string, defaults?: Record) => Record, ): Map { + const def = getParams ? buildDefFromGetParams(getParams) : DEF_DEFAULTS; const out = new Map(); const seen = new Set(); - walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen); - walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen); + walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen, def); + walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen, def); return out; }