From 94934d8ce554e9acb2f8ec26bc4d4116ab5786f2 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 12 Jun 2026 09:29:59 +0900 Subject: [PATCH] =?UTF-8?q?n=EB=B4=89=20=EC=B9=A8=EC=B2=B4=20=ED=84=B0?= =?UTF-8?q?=EC=B9=98=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LiveConditionStatusService.java | 2 + .../service/StrategyDslToTa4jAdapter.java | 11 +++++ .../ComplexStrategyDslAdapterTest.java | 22 ++++++++++ frontend/src/utils/sidewaysFilterPalette.ts | 21 ++++++++- .../src/utils/strategyDescriptionNarrative.ts | 5 ++- frontend/src/utils/strategyEditorShared.tsx | 43 ++++++++++++++++++- frontend/src/utils/strategyTypes.ts | 11 +++-- 7 files changed, 109 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 3cf9eda..f9f5f9c 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -36,6 +36,8 @@ public class LiveConditionStatusService { Map.entry("CROSS_UP", "상향 돌파"), Map.entry("CROSS_DOWN", "하향 돌파"), Map.entry("SLOPE_UP", "상승 중"), + Map.entry("LOWEST_LTE", "N봉 최저 ≤"), + Map.entry("LOWEST_GTE", "N봉 최저 ≥"), Map.entry("SLOPE_DOWN", "하락 중") ); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 3fc91a2..9ab7879 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -411,6 +411,7 @@ public class StrategyDslToTa4jAdapter { int rightPeriod = cond.path("rightPeriod").asInt(-1); int slopePeriod = cond.path("slopePeriod").asInt(3); int holdDays = cond.path("holdDays").asInt(3); + int lookbackPeriod = cond.path("lookbackPeriod").asInt(20); if (needsCrossTimeframeComposite(cond, ctx)) { return buildCrossTimeframeCompositeRule(cond, ctx); @@ -468,6 +469,16 @@ public class StrategyDslToTa4jAdapter { new ConstantIndicator<>(series, series.numFactory().numOf(v))); } case "HOLD_N_DAYS" -> buildHoldRule(new OverIndicatorRule(left, right), holdDays); + case "LOWEST_LTE" -> { + Indicator rollingMin = new LowestValueIndicator(left, lookbackPeriod); + yield new OrRule(new UnderIndicatorRule(rollingMin, right), + buildEqRule(rollingMin, right, series)); + } + case "LOWEST_GTE" -> { + Indicator rollingMin = new LowestValueIndicator(left, lookbackPeriod); + yield new OrRule(new OverIndicatorRule(rollingMin, right), + buildEqRule(rollingMin, right, series)); + } // ── 일목균형표 전용 ──────────────────────────────────────────── case "ABOVE_CLOUD" -> buildCloudAboveRule(series, indParams); case "BELOW_CLOUD" -> buildCloudBelowRule(series, indParams); diff --git a/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java b/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java index 15c3bef..0610bc1 100644 --- a/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java +++ b/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java @@ -288,6 +288,28 @@ class ComplexStrategyDslAdapterTest { assertDoesNotThrow(() -> rule.isSatisfied(primary1m.getEndIndex(), null)); } + @Test + void stochLowestLte_compilesAndEvaluates() throws Exception { + JsonNode cond = MAPPER.readTree(""" + { + "id": "c-stoch-lowest", + "type": "CONDITION", + "condition": { + "indicatorType": "STOCHASTIC", + "conditionType": "LOWEST_LTE", + "leftField": "STOCH_K", + "rightField": "HL_UNDER", + "lookbackPeriod": 20, + "candleRange": 1 + } + } + """); + + Rule rule = adapter.toRule(cond, primary1m, Map.of()); + assertNotNull(rule); + assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex())); + } + // ── helpers ──────────────────────────────────────────────────────────────── private static void evaluateRange(Rule rule, int endIndex) { diff --git a/frontend/src/utils/sidewaysFilterPalette.ts b/frontend/src/utils/sidewaysFilterPalette.ts index 42563ee..04ca3d5 100644 --- a/frontend/src/utils/sidewaysFilterPalette.ts +++ b/frontend/src/utils/sidewaysFilterPalette.ts @@ -16,6 +16,7 @@ export type SidewaysFilterCategory = | 'ichimoku' | 'donchian' | 'oscillator' + | 'cycle' | 'dmi' | 'volume' | 'combo'; @@ -37,6 +38,7 @@ export const SIDEWAYS_FILTER_CATEGORY_LABEL: Record(); for (const i of items) { diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index aa15708..1fe4071 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -89,11 +89,12 @@ function describeConditionType( conditionType: string, left: string, right: string, - extras: { compareValue?: number; slopePeriod?: number; holdDays?: number }, + extras: { compareValue?: number; slopePeriod?: number; holdDays?: number; lookbackPeriod?: number }, ): string { const cv = extras.compareValue; const sp = extras.slopePeriod ?? 3; const hd = extras.holdDays ?? 3; + const lb = extras.lookbackPeriod ?? 20; switch (conditionType) { case 'GT': return `${left}이(가) ${right}보다 큰 경우`; @@ -109,6 +110,8 @@ function describeConditionType( case 'DIFF_GT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 큰 경우`; case 'DIFF_LT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 작은 경우`; case 'HOLD_N_DAYS': return `조건이 ${hd}봉 연속 유지되는 경우`; + case 'LOWEST_LTE': return `최근 ${lb}봉 동안 ${left}의 최저값이 ${right} 이하인 경우`; + case 'LOWEST_GTE': return `최근 ${lb}봉 동안 ${left}의 최저값이 ${right} 이상인 경우`; case 'ABOVE_CLOUD': return '가격이 일목균형표 구름대 위에 있는 경우'; case 'BELOW_CLOUD': return '가격이 일목균형표 구름대 아래에 있는 경우'; case 'IN_CLOUD': return '가격이 일목균형표 구름 안에 있는 경우'; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 26561e4..6163c40 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { IndicatorConfig } from '../types/index'; import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry'; import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes'; +import { CONDITION_LABEL, INDICATOR_CONDITIONS } from '../utils/strategyTypes'; import { COMPOSITE_INDICATOR_ITEMS, compositeDisplayName, @@ -331,6 +332,28 @@ const COND_OPTIONS = [ { v: 'DIFF_GT', l: '차이 > 값' }, { v: 'DIFF_LT', l: '차이 < 값' }, { v: 'HOLD_N_DAYS', l: 'N일 연속 유지' }, + { v: 'LOWEST_LTE', l: 'N봉 최저 ≤ 값' }, + { v: 'LOWEST_GTE', l: 'N봉 최저 ≥ 값' }, +]; + +const condLabelFromRegistry = (ind: string, v: string): string => { + const fromCond = COND_OPTIONS.concat(ICHIMOKU_CONDS).find(o => o.v === v)?.l; + if (fromCond) return fromCond; + return CONDITION_LABEL[v] ?? v; +}; + +type CondTypeOpt = { v: string; l: string }; + +/** 지표별 조건 타입 드롭다운 — 오실레이터는 룩백 조건 포함 */ +export const getCondOptionsForIndicator = (ind: string): CondTypeOpt[] => { + if (ind === 'ICHIMOKU') return ICHIMOKU_CONDS; + const keys = INDICATOR_CONDITIONS[ind] ?? COMMON_COND_KEYS; + return keys.map(v => ({ v, l: condLabelFromRegistry(ind, v) })); +}; + +const COMMON_COND_KEYS = [ + 'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', + 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS', ]; const ICHIMOKU_CONDS = [ @@ -660,6 +683,10 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def } else if (newCondType === 'HOLD_N_DAYS') { updated.leftField = 'NONE'; updated.rightField = 'NONE'; updated.holdDays = updated.holdDays ?? 3; + } else if (['LOWEST_LTE', 'LOWEST_GTE'].includes(newCondType)) { + if (!isValid(updated.leftField)) updated.leftField = def.l; + if (!isValid(updated.rightField)) updated.rightField = THRESHOLD_HL_UNDER; + updated.lookbackPeriod = updated.lookbackPeriod ?? 20; } // Ichimoku 특수처리 @@ -731,6 +758,11 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string const R = opts.find(o => o.value === rv)?.label ?? (chartRef != null && !isThresholdOverridden(c) ? `기준(${chartRef})` : null) ?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? ''); + if (['LOWEST_LTE', 'LOWEST_GTE'].includes(c.conditionType)) { + const n = c.lookbackPeriod ?? 20; + const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥'; + return `${indName} - 최근 ${n}봉 ${L} 최저 ${op} ${R || '침체선'}`; + } if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`; return `${indName} - ${L} ${C}`; } @@ -845,7 +877,7 @@ export const CondEditor: React.FC = ({ }) => { const normalized = normalizeCompositeCondition(cond); const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized); - const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS; + const condOpts = getCondOptionsForIndicator(normalized.indicatorType); const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v); const defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def); @@ -868,6 +900,7 @@ export const CondEditor: React.FC = ({ const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType); const isHoldType = normalized.conditionType === 'HOLD_N_DAYS'; + const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType); const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType); const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue(); @@ -1150,6 +1183,14 @@ export const CondEditor: React.FC = ({ onChange={e => onChange({ ...cond, holdDays: parseInt(e.target.value) || 3 })} /> )} + {isLookbackType && ( +
+ + onChange({ ...cond, lookbackPeriod: parseInt(e.target.value) || 20 })} /> +
+ )} ); }; diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index ff795a3..3250724 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -19,6 +19,8 @@ export interface ConditionDSL { compareValue?: number; slopePeriod?: number; holdDays?: number; + /** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */ + lookbackPeriod?: number; candleRange?: number; /** false=보조지표 설정 기본값 상속, true=전략 조건 전용 기간 */ valuePeriodOverride?: boolean; @@ -64,6 +66,7 @@ export const CONDITION_LABEL: Record = { SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중', DIFF_GT: '차이>값', DIFF_LT: '차이<값', HOLD_N_DAYS: 'N일 유지', + LOWEST_LTE: 'N봉 최저 ≤', LOWEST_GTE: 'N봉 최저 ≥', ABOVE_CLOUD: '구름 위', BELOW_CLOUD: '구름 아래', IN_CLOUD: '구름 안', CLOUD_BREAK_UP: '구름 상향 돌파', CLOUD_BREAK_DOWN: '구름 하향 돌파', SPAN1_GT_SPAN2: '선행1>선행2', SPAN1_LT_SPAN2: '선행1<선행2', @@ -72,11 +75,13 @@ export const CONDITION_LABEL: Record = { }; const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS']; +const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE']; +const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS]; export const INDICATOR_CONDITIONS: Record = { - RSI: COMMON_CONDITIONS, MACD: COMMON_CONDITIONS, CCI: COMMON_CONDITIONS, - ADX: COMMON_CONDITIONS, BWI: COMMON_CONDITIONS, DMI: COMMON_CONDITIONS, - OBV: COMMON_CONDITIONS, STOCHASTIC: COMMON_CONDITIONS, WILLIAMS_R: COMMON_CONDITIONS, + RSI: OSCILLATOR_CONDITIONS, MACD: COMMON_CONDITIONS, CCI: OSCILLATOR_CONDITIONS, + ADX: COMMON_CONDITIONS, BWI: OSCILLATOR_CONDITIONS, DMI: COMMON_CONDITIONS, + OBV: COMMON_CONDITIONS, STOCHASTIC: OSCILLATOR_CONDITIONS, WILLIAMS_R: OSCILLATOR_CONDITIONS, TRIX: COMMON_CONDITIONS, VOLUME_OSC: COMMON_CONDITIONS, VR: COMMON_CONDITIONS, DISPARITY: COMMON_CONDITIONS, PSYCHOLOGICAL: COMMON_CONDITIONS, NEW_PSYCHOLOGICAL: COMMON_CONDITIONS, INVEST_PSYCHOLOGICAL: COMMON_CONDITIONS,