diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 6fa0151..d9a3541 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -490,7 +490,9 @@ public class LiveConditionStatusService { String indType = c.path("indicatorType").asText(""); String plotKey = plotKeyFromCondition(c, indType); String condType = c.path("conditionType").asText("GT"); - String condLabel = CONDITION_LABEL.getOrDefault(condType, condType); + String baseLabel = CONDITION_LABEL.getOrDefault(condType, condType); + String rangePrefix = StrategyDslToTa4jAdapter.candleRangeConditionPrefix(c); + String condLabel = rangePrefix.isBlank() ? baseLabel : rangePrefix + baseLabel; Double target = readTargetNumeric(c, visual); return LiveConditionRowDto.builder() .id(p.id()) @@ -535,7 +537,9 @@ public class LiveConditionStatusService { String indType = c.path("indicatorType").asText(""); String plotKey = plotKeyFromCondition(c, indType); String condType = c.path("conditionType").asText("GT"); - String condLabel = CONDITION_LABEL.getOrDefault(condType, condType); + String baseLabel = CONDITION_LABEL.getOrDefault(condType, condType); + String rangePrefix = StrategyDslToTa4jAdapter.candleRangeConditionPrefix(c); + String condLabel = rangePrefix.isBlank() ? baseLabel : rangePrefix + baseLabel; return LiveConditionRowDto.builder() .id(p.id()) diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 3906268..0f14514 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -412,9 +412,12 @@ public class StrategyDslToTa4jAdapter { int slopePeriod = cond.path("slopePeriod").asInt(3); int holdDays = cond.path("holdDays").asInt(3); int lookbackPeriod = cond.path("lookbackPeriod").asInt(20); + int candleRange = cond.path("candleRange").asInt(1); + String candleRangeMode = resolveCandleRangeMode(cond, candleRange); if (needsCrossTimeframeComposite(cond, ctx)) { - return buildCrossTimeframeCompositeRule(cond, ctx); + Rule composite = buildCrossTimeframeCompositeRule(cond, ctx); + return applyCandleRangeWindow(composite, candleRangeMode, candleRange); } PriceExtremeNorm px = normalizePriceExtremeCondition(cond); @@ -496,10 +499,11 @@ public class StrategyDslToTa4jAdapter { yield new BooleanRule(false); } }; + Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange); if (px != null) { - return nanSafeCompareRule(core, left, right); + return nanSafeCompareRule(ranged, left, right); } - return core; + return ranged; } catch (Exception e) { log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage()); return new BooleanRule(false); @@ -1041,6 +1045,71 @@ public class StrategyDslToTa4jAdapter { }; } + /** live-conditions·알림 UI — 캔들 범위 접두어 */ + public static String candleRangeConditionPrefix(JsonNode cond) { + if (cond == null || cond.isNull()) return ""; + int candleRange = cond.path("candleRange").asInt(1); + String mode = cond.path("candleRangeMode").asText(""); + if (mode.isBlank()) mode = candleRange <= 1 ? "CURRENT" : "EXISTS_IN"; + if ("CURRENT".equals(mode)) return ""; + int n = Math.max(2, candleRange); + return switch (mode) { + case "EXISTS_IN" -> "최근 " + n + "봉 내 "; + case "NOT_EXISTS_IN" -> "최근 " + n + "봉 내 없음 · "; + default -> ""; + }; + } + + /** candleRangeMode 미설정 시 candleRange>1 → EXISTS_IN (구버전 호환) */ + private String resolveCandleRangeMode(JsonNode cond, int candleRange) { + String mode = cond.path("candleRangeMode").asText(""); + if (!mode.isBlank()) return mode; + return candleRange <= 1 ? "CURRENT" : "EXISTS_IN"; + } + + /** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */ + private Rule applyCandleRangeWindow(Rule core, String mode, int candleRange) { + if ("CURRENT".equals(mode)) return core; + int n = Math.max(2, candleRange); + return switch (mode) { + case "EXISTS_IN" -> buildExistsInWindowRule(core, n); + case "NOT_EXISTS_IN" -> buildNotExistsInWindowRule(core, n); + default -> core; + }; + } + + /** 최근 N봉 중 어느 한 봉이라도 inner 가 true */ + private Rule buildExistsInWindowRule(Rule inner, int n) { + if (n <= 1) return inner; + return new Rule() { + @Override + public boolean isSatisfied(int index, TradingRecord record) { + int start = index - n + 1; + for (int i = start; i <= index; i++) { + if (i < 0) continue; + if (inner.isSatisfied(i, record)) return true; + } + return false; + } + }; + } + + /** 최근 N봉 중 inner 가 true 인 봉이 하나도 없음 */ + private Rule buildNotExistsInWindowRule(Rule inner, int n) { + if (n <= 1) return new NotRule(inner); + return new Rule() { + @Override + public boolean isSatisfied(int index, TradingRecord record) { + int start = index - n + 1; + for (int i = start; i <= index; i++) { + if (i < 0) continue; + if (inner.isSatisfied(i, record)) return false; + } + return true; + } + }; + } + // ── TRIX 헬퍼 ───────────────────────────────────────────────────────────── private Indicator resolvePriceSource(BarSeries s, Map p) { diff --git a/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java b/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java new file mode 100644 index 0000000..6abb829 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java @@ -0,0 +1,157 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.Rule; +import org.ta4j.core.TradingRecord; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * candleRange / candleRangeMode — 최근 N봉 윈도우 존재·미존재 평가. + */ +class CandleRangeWindowRuleTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private StrategyDslToTa4jAdapter adapter; + private BarSeries series; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + series = buildSyntheticSeries(40); + } + + @Test + void existsIn_trueWhenInnerTrueInWindow() throws Exception { + Rule rule = toGtRule(""" + "candleRangeMode": "EXISTS_IN", + "candleRange": 4 + """); + int idx = series.getEndIndex(); + assertTrue(rule.isSatisfied(idx, null), + "close > 0 은 모든 봉에서 true → EXISTS_IN(4) 도 true"); + } + + @Test + void notExistsIn_falseWhenInnerTrueInWindow() throws Exception { + Rule rule = toGtRule(""" + "candleRangeMode": "NOT_EXISTS_IN", + "candleRange": 4 + """); + int idx = series.getEndIndex(); + assertFalse(rule.isSatisfied(idx, null), + "close > 0 이 윈도우 내 항상 true → NOT_EXISTS_IN 은 false"); + } + + @Test + void legacyCandleRangeFour_treatedAsExistsIn() throws Exception { + Rule rule = toGtRule(""" + "candleRange": 4 + """); + int idx = series.getEndIndex(); + assertTrue(rule.isSatisfied(idx, null), + "candleRangeMode 미설정 + candleRange=4 → EXISTS_IN 호환"); + } + + @Test + void currentMode_sameAsInnerWithoutWindow() throws Exception { + Rule current = toGtRule(""" + "candleRangeMode": "CURRENT", + "candleRange": 1 + """); + Rule plain = toGtRule(""); + int idx = series.getEndIndex(); + assertEquals(plain.isSatisfied(idx, null), current.isSatisfied(idx, null)); + } + + @Test + void notExistsIn_trueWhenInnerNeverTrueInWindow() throws Exception { + Rule rule = toGtRule(""" + "candleRangeMode": "NOT_EXISTS_IN", + "candleRange": 3, + "rightField": "K_999999999999" + """); + int idx = series.getEndIndex(); + assertTrue(rule.isSatisfied(idx, null), + "close > 매우 큰 값 은 윈도우 내 항상 false → NOT_EXISTS_IN true"); + } + + @Test + void obvCrossUp_existsInWindow_compilesAndEvaluates() throws Exception { + JsonNode cond = MAPPER.readTree(""" + { + "id": "c-obv-exists", + "type": "CONDITION", + "condition": { + "indicatorType": "OBV", + "conditionType": "CROSS_UP", + "leftField": "OBV_LINE", + "rightField": "OBV_SIGNAL", + "candleRangeMode": "EXISTS_IN", + "candleRange": 4 + } + } + """); + Rule rule = adapter.toRule(cond, series, java.util.Map.of()); + assertNotNull(rule); + assertDoesNotThrow(() -> rule.isSatisfied(series.getEndIndex(), null)); + } + + private Rule toGtRule(String extraFields) throws Exception { + String extra = extraFields == null || extraFields.isBlank() + ? "" + : ",\n " + extraFields.strip().replaceAll(",\\s*$", ""); + String json = """ + { + "id": "c-range", + "type": "CONDITION", + "condition": { + "indicatorType": "MA", + "conditionType": "GT", + "leftField": "CLOSE_PRICE", + "rightField": "K_0"%s + } + } + """.formatted(extra); + JsonNode node = MAPPER.readTree(json); + Rule rule = adapter.toRule(node, series, java.util.Map.of()); + assertNotNull(rule); + return rule; + } + + private static BarSeries buildSyntheticSeries(int barCount) { + BarSeries s = new BaseBarSeriesBuilder() + .withName("test-1m") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-06-01T00:00:00Z"); + Duration period = Duration.ofMinutes(1); + for (int i = 0; i < barCount; i++) { + double close = 50_000_000 + i * 120; + Instant end = base.plus(period.multipliedBy(i + 1L)); + factory.createBarBuilder(s) + .timePeriod(period) + .endTime(end) + .openPrice(close - 500) + .highPrice(close + 800) + .lowPrice(close - 800) + .closePrice(close) + .volume(10 + i) + .add(); + } + return s; + } +} diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx index 3a8411d..f9dd6b0 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -18,6 +18,12 @@ import { simpleTemplateToNode, type StrategyTemplateDef, } from '../utils/strategyPresets'; +import type { CandleRangeMode } from '../utils/strategyTypes'; +import { + resolveCandleRangeMode, + candleRangeWindowBars, + formatCandleRangeClause, +} from '../utils/strategyTypes'; // ─── 타입 ───────────────────────────────────────────────────────────────────── type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME'; @@ -33,6 +39,7 @@ interface ConditionDSL { compareValue?: number; slopePeriod?: number; holdDays?: number; + candleRangeMode?: CandleRangeMode; candleRange?: number; } @@ -572,8 +579,9 @@ const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => { 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); - if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`; - return `${c.indicatorType} - ${L} ${C}`; + const rangeClause = formatCandleRangeClause(c); + if (R && R !== '선택안함') return `${c.indicatorType} - ${rangeClause}${L} ${C} ${R}`; + return `${c.indicatorType} - ${rangeClause}${L} ${C}`; } if (node.type === 'AND') { const parts = (node.children ?? []).map(c => nodeToText(c, DEF)); @@ -712,14 +720,36 @@ const CondEditor: React.FC = ({ cond, signalType, onChange, def {/* 캔들 범위 */}
- { + const mode = e.target.value as CandleRangeMode; + if (mode === 'CURRENT') { + onChange({ ...cond, candleRangeMode: mode, candleRange: 1 }); + } else { + const n = candleRangeWindowBars(cond); + onChange({ ...cond, candleRangeMode: mode, candleRange: n >= 2 ? n : 4 }); + } + }} + > + + + + {resolveCandleRangeMode(cond) !== 'CURRENT' && ( + onChange({ + ...cond, + candleRange: Math.max(2, parseInt(e.target.value, 10) || 4), + })} + /> + )}
{/* 조건대상1 */}
diff --git a/frontend/src/components/tradeNotification/StrategyConditionView.tsx b/frontend/src/components/tradeNotification/StrategyConditionView.tsx index 03c188b..26eecdc 100644 --- a/frontend/src/components/tradeNotification/StrategyConditionView.tsx +++ b/frontend/src/components/tradeNotification/StrategyConditionView.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; import type { LogicNode } from '../../utils/strategyTypes'; -import { CONDITION_LABEL } from '../../utils/strategyTypes'; +import { CONDITION_LABEL, resolveCandleRangeMode, candleRangeWindowBars } from '../../utils/strategyTypes'; import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage'; import { getIndicatorPaletteCategory } from '../strategyEditor/paletteCategories'; import { nodeToText } from '../../utils/strategyEditorShared'; @@ -56,9 +56,13 @@ const ConditionCard: React.FC<{ node: LogicNode }> = ({ node }) => { const descText = fullText.startsWith(prefix) ? fullText.slice(prefix.length) : fullText; const { left, mid: condStr, right } = splitDescByCondType(descText); - const candleRange = cond.candleRange != null && cond.candleRange > 0 - ? `${cond.candleRange}캔들 전` - : '현재 캔들'; + const rangeMode = resolveCandleRangeMode(cond); + const rangeN = candleRangeWindowBars(cond); + const candleRangeLabel = rangeMode === 'CURRENT' + ? '현재 캔들' + : rangeMode === 'EXISTS_IN' + ? `최근 ${rangeN}봉 내 존재` + : `최근 ${rangeN}봉 내 미존재`; const condLabelDisplay = CONDITION_LABEL[cond.conditionType] ?? (condStr || cond.conditionType); @@ -71,7 +75,7 @@ const ConditionCard: React.FC<{ node: LogicNode }> = ({ node }) => {
캔들범위 - {candleRange} + {candleRangeLabel}
{left && (
diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index 1fe4071..6a76dbc 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -2,7 +2,11 @@ * 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명 */ import type { LogicNode } from './strategyTypes'; -import { CONDITION_LABEL } from './strategyTypes'; +import { + resolveCandleRangeMode, + candleRangeWindowBars, + formatCandleRangeClause, +} from './strategyTypes'; import { collectEditorBranches, normalizeStartCombineOp, @@ -163,7 +167,8 @@ function describeCondition( } if (right && right !== '선택안함') { - return describeConditionType(ct, left, right, cond); + const prefix = formatCandleRangeClause(cond); + return prefix + describeConditionType(ct, left, right, cond); } const label = CONDITION_LABEL[ct] ?? ct; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 2062e1a..708d4e8 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -2,8 +2,14 @@ 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 type { LogicNode, LogicNodeType, ConditionDSL, CandleRangeMode } from '../utils/strategyTypes'; +import { + CONDITION_LABEL, + INDICATOR_CONDITIONS, + resolveCandleRangeMode, + candleRangeWindowBars, + formatCandleRangeClause, +} from '../utils/strategyTypes'; import { COMPOSITE_INDICATOR_ITEMS, compositeDisplayName, @@ -791,8 +797,9 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥'; return `${indName} - 최근 ${n}봉 ${L} 최저 ${op} ${R || '침체선'}`; } - if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`; - return `${indName} - ${L} ${C}`; + const rangeClause = formatCandleRangeClause(c); + if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`; + return `${indName} - ${rangeClause}${L} ${C}`; } if (node.type === 'AND') { if (isStableStrategyPairRoot(node) && node.stableStrategyPair?.mode === 'buy') { @@ -1142,14 +1149,38 @@ export const CondEditor: React.FC = ({ ) : ( <> - { + const mode = e.target.value as CandleRangeMode; + if (mode === 'CURRENT') { + onChange({ ...normalized, candleRangeMode: mode, candleRange: 1 }); + } else { + const n = candleRangeWindowBars(normalized); + onChange({ ...normalized, candleRangeMode: mode, candleRange: n >= 2 ? n : 4 }); + } + }} + > + + + + {resolveCandleRangeMode(normalized) !== 'CURRENT' && ( + onChange({ + ...normalized, + candleRange: Math.max(2, parseInt(e.target.value, 10) || 4), + })} + /> + )} )}
diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index d919fe9..87c761e 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -2,6 +2,9 @@ export type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME'; +/** CURRENT=현재 봉만, EXISTS_IN=최근 N봉 내 1회 이상, NOT_EXISTS_IN=최근 N봉 내 없음 */ +export type CandleRangeMode = 'CURRENT' | 'EXISTS_IN' | 'NOT_EXISTS_IN'; + export interface ConditionDSL { indicatorType: string; conditionType: string; @@ -21,6 +24,9 @@ export interface ConditionDSL { holdDays?: number; /** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */ lookbackPeriod?: number; + /** 캔들 범위 모드 — 미설정 시 candleRange>1 이면 EXISTS_IN 으로 해석 */ + candleRangeMode?: CandleRangeMode; + /** EXISTS_IN/NOT_EXISTS_IN 일 때 윈도우 크기(N). CURRENT 일 때는 1 */ candleRange?: number; /** false=보조지표 설정 기본값 상속, true=전략 조건 전용 기간 */ valuePeriodOverride?: boolean; @@ -365,3 +371,30 @@ export function loadStrategiesFromStorage(): StrategyDSLDto[] { export function saveStrategiesToStorage(strategies: StrategyDSLDto[]): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(strategies)); } + +/** candleRangeMode 미설정 시 candleRange>1 → EXISTS_IN (구버전 호환) */ +export function resolveCandleRangeMode( + cond: Pick, +): CandleRangeMode { + if (cond.candleRangeMode) return cond.candleRangeMode; + const n = cond.candleRange ?? 1; + return n <= 1 ? 'CURRENT' : 'EXISTS_IN'; +} + +export function candleRangeWindowBars( + cond: Pick, +): number { + const mode = resolveCandleRangeMode(cond); + if (mode === 'CURRENT') return 1; + return Math.max(2, cond.candleRange ?? 4); +} + +export function formatCandleRangeClause( + cond: Pick, +): string { + const mode = resolveCandleRangeMode(cond); + const n = candleRangeWindowBars(cond); + if (mode === 'EXISTS_IN') return `최근 ${n}봉 내 `; + if (mode === 'NOT_EXISTS_IN') return `최근 ${n}봉 내 미존재 · `; + return ''; +}