n봉 존재 조건 수정
This commit is contained in:
@@ -490,7 +490,9 @@ public class LiveConditionStatusService {
|
|||||||
String indType = c.path("indicatorType").asText("");
|
String indType = c.path("indicatorType").asText("");
|
||||||
String plotKey = plotKeyFromCondition(c, indType);
|
String plotKey = plotKeyFromCondition(c, indType);
|
||||||
String condType = c.path("conditionType").asText("GT");
|
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);
|
Double target = readTargetNumeric(c, visual);
|
||||||
return LiveConditionRowDto.builder()
|
return LiveConditionRowDto.builder()
|
||||||
.id(p.id())
|
.id(p.id())
|
||||||
@@ -535,7 +537,9 @@ public class LiveConditionStatusService {
|
|||||||
String indType = c.path("indicatorType").asText("");
|
String indType = c.path("indicatorType").asText("");
|
||||||
String plotKey = plotKeyFromCondition(c, indType);
|
String plotKey = plotKeyFromCondition(c, indType);
|
||||||
String condType = c.path("conditionType").asText("GT");
|
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()
|
return LiveConditionRowDto.builder()
|
||||||
.id(p.id())
|
.id(p.id())
|
||||||
|
|||||||
@@ -412,9 +412,12 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
int slopePeriod = cond.path("slopePeriod").asInt(3);
|
int slopePeriod = cond.path("slopePeriod").asInt(3);
|
||||||
int holdDays = cond.path("holdDays").asInt(3);
|
int holdDays = cond.path("holdDays").asInt(3);
|
||||||
int lookbackPeriod = cond.path("lookbackPeriod").asInt(20);
|
int lookbackPeriod = cond.path("lookbackPeriod").asInt(20);
|
||||||
|
int candleRange = cond.path("candleRange").asInt(1);
|
||||||
|
String candleRangeMode = resolveCandleRangeMode(cond, candleRange);
|
||||||
|
|
||||||
if (needsCrossTimeframeComposite(cond, ctx)) {
|
if (needsCrossTimeframeComposite(cond, ctx)) {
|
||||||
return buildCrossTimeframeCompositeRule(cond, ctx);
|
Rule composite = buildCrossTimeframeCompositeRule(cond, ctx);
|
||||||
|
return applyCandleRangeWindow(composite, candleRangeMode, candleRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
PriceExtremeNorm px = normalizePriceExtremeCondition(cond);
|
PriceExtremeNorm px = normalizePriceExtremeCondition(cond);
|
||||||
@@ -496,10 +499,11 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
yield new BooleanRule(false);
|
yield new BooleanRule(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange);
|
||||||
if (px != null) {
|
if (px != null) {
|
||||||
return nanSafeCompareRule(core, left, right);
|
return nanSafeCompareRule(ranged, left, right);
|
||||||
}
|
}
|
||||||
return core;
|
return ranged;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage());
|
log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage());
|
||||||
return new BooleanRule(false);
|
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 헬퍼 ─────────────────────────────────────────────────────────────
|
// ── TRIX 헬퍼 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Indicator<Num> resolvePriceSource(BarSeries s, Map<String, Object> p) {
|
private Indicator<Num> resolvePriceSource(BarSeries s, Map<String, Object> p) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
simpleTemplateToNode,
|
simpleTemplateToNode,
|
||||||
type StrategyTemplateDef,
|
type StrategyTemplateDef,
|
||||||
} from '../utils/strategyPresets';
|
} from '../utils/strategyPresets';
|
||||||
|
import type { CandleRangeMode } from '../utils/strategyTypes';
|
||||||
|
import {
|
||||||
|
resolveCandleRangeMode,
|
||||||
|
candleRangeWindowBars,
|
||||||
|
formatCandleRangeClause,
|
||||||
|
} from '../utils/strategyTypes';
|
||||||
|
|
||||||
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
||||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||||
@@ -33,6 +39,7 @@ interface ConditionDSL {
|
|||||||
compareValue?: number;
|
compareValue?: number;
|
||||||
slopePeriod?: number;
|
slopePeriod?: number;
|
||||||
holdDays?: number;
|
holdDays?: number;
|
||||||
|
candleRangeMode?: CandleRangeMode;
|
||||||
candleRange?: number;
|
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 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 R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
|
||||||
const C = condLabel(c.conditionType);
|
const C = condLabel(c.conditionType);
|
||||||
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
|
const rangeClause = formatCandleRangeClause(c);
|
||||||
return `${c.indicatorType} - ${L} ${C}`;
|
if (R && R !== '선택안함') return `${c.indicatorType} - ${rangeClause}${L} ${C} ${R}`;
|
||||||
|
return `${c.indicatorType} - ${rangeClause}${L} ${C}`;
|
||||||
}
|
}
|
||||||
if (node.type === 'AND') {
|
if (node.type === 'AND') {
|
||||||
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
||||||
@@ -712,14 +720,36 @@ const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def
|
|||||||
{/* 캔들 범위 */}
|
{/* 캔들 범위 */}
|
||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
<label className="sp-cond-lbl">캔들 범위</label>
|
<label className="sp-cond-lbl">캔들 범위</label>
|
||||||
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
|
<select
|
||||||
onChange={e => onChange({ ...cond, candleRange: Number(e.target.value) })}>
|
className="sp-cond-sel"
|
||||||
<option value={1}>현재 캔들</option>
|
value={resolveCandleRangeMode(cond)}
|
||||||
<option value={2}>2개 캔들</option>
|
onChange={e => {
|
||||||
<option value={3}>3개 캔들</option>
|
const mode = e.target.value as CandleRangeMode;
|
||||||
<option value={4}>4개 캔들</option>
|
if (mode === 'CURRENT') {
|
||||||
<option value={5}>5개 캔들</option>
|
onChange({ ...cond, candleRangeMode: mode, candleRange: 1 });
|
||||||
|
} else {
|
||||||
|
const n = candleRangeWindowBars(cond);
|
||||||
|
onChange({ ...cond, candleRangeMode: mode, candleRange: n >= 2 ? n : 4 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="CURRENT">현재 캔들</option>
|
||||||
|
<option value="EXISTS_IN">N봉 내 존재</option>
|
||||||
|
<option value="NOT_EXISTS_IN">N봉 내 미존재</option>
|
||||||
</select>
|
</select>
|
||||||
|
{resolveCandleRangeMode(cond) !== 'CURRENT' && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="sp-cond-num sp-cond-num--inline"
|
||||||
|
min={2}
|
||||||
|
max={500}
|
||||||
|
value={candleRangeWindowBars(cond)}
|
||||||
|
onChange={e => onChange({
|
||||||
|
...cond,
|
||||||
|
candleRange: Math.max(2, parseInt(e.target.value, 10) || 4),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 조건대상1 */}
|
{/* 조건대상1 */}
|
||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { LogicNode } from '../../utils/strategyTypes';
|
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 { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
import { getIndicatorPaletteCategory } from '../strategyEditor/paletteCategories';
|
import { getIndicatorPaletteCategory } from '../strategyEditor/paletteCategories';
|
||||||
import { nodeToText } from '../../utils/strategyEditorShared';
|
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 descText = fullText.startsWith(prefix) ? fullText.slice(prefix.length) : fullText;
|
||||||
const { left, mid: condStr, right } = splitDescByCondType(descText);
|
const { left, mid: condStr, right } = splitDescByCondType(descText);
|
||||||
|
|
||||||
const candleRange = cond.candleRange != null && cond.candleRange > 0
|
const rangeMode = resolveCandleRangeMode(cond);
|
||||||
? `${cond.candleRange}캔들 전`
|
const rangeN = candleRangeWindowBars(cond);
|
||||||
: '현재 캔들';
|
const candleRangeLabel = rangeMode === 'CURRENT'
|
||||||
|
? '현재 캔들'
|
||||||
|
: rangeMode === 'EXISTS_IN'
|
||||||
|
? `최근 ${rangeN}봉 내 존재`
|
||||||
|
: `최근 ${rangeN}봉 내 미존재`;
|
||||||
|
|
||||||
const condLabelDisplay = CONDITION_LABEL[cond.conditionType] ?? (condStr || cond.conditionType);
|
const condLabelDisplay = CONDITION_LABEL[cond.conditionType] ?? (condStr || cond.conditionType);
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ const ConditionCard: React.FC<{ node: LogicNode }> = ({ node }) => {
|
|||||||
<div className="scv-cond-fields">
|
<div className="scv-cond-fields">
|
||||||
<div className="scv-cond-field">
|
<div className="scv-cond-field">
|
||||||
<span className="scv-cond-field-lbl">캔들범위</span>
|
<span className="scv-cond-field-lbl">캔들범위</span>
|
||||||
<span className="scv-cond-field-val">{candleRange}</span>
|
<span className="scv-cond-field-val">{candleRangeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{left && (
|
{left && (
|
||||||
<div className="scv-cond-field">
|
<div className="scv-cond-field">
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
|
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
|
||||||
*/
|
*/
|
||||||
import type { LogicNode } from './strategyTypes';
|
import type { LogicNode } from './strategyTypes';
|
||||||
import { CONDITION_LABEL } from './strategyTypes';
|
import {
|
||||||
|
resolveCandleRangeMode,
|
||||||
|
candleRangeWindowBars,
|
||||||
|
formatCandleRangeClause,
|
||||||
|
} from './strategyTypes';
|
||||||
import {
|
import {
|
||||||
collectEditorBranches,
|
collectEditorBranches,
|
||||||
normalizeStartCombineOp,
|
normalizeStartCombineOp,
|
||||||
@@ -163,7 +167,8 @@ function describeCondition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (right && right !== '선택안함') {
|
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;
|
const label = CONDITION_LABEL[ct] ?? ct;
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { IndicatorConfig } from '../types/index';
|
import type { IndicatorConfig } from '../types/index';
|
||||||
import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
|
import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
|
||||||
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
import type { LogicNode, LogicNodeType, ConditionDSL, CandleRangeMode } from '../utils/strategyTypes';
|
||||||
import { CONDITION_LABEL, INDICATOR_CONDITIONS } from '../utils/strategyTypes';
|
import {
|
||||||
|
CONDITION_LABEL,
|
||||||
|
INDICATOR_CONDITIONS,
|
||||||
|
resolveCandleRangeMode,
|
||||||
|
candleRangeWindowBars,
|
||||||
|
formatCandleRangeClause,
|
||||||
|
} from '../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
COMPOSITE_INDICATOR_ITEMS,
|
COMPOSITE_INDICATOR_ITEMS,
|
||||||
compositeDisplayName,
|
compositeDisplayName,
|
||||||
@@ -791,8 +797,9 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
|||||||
const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥';
|
const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥';
|
||||||
return `${indName} - 최근 ${n}봉 ${L} 최저 ${op} ${R || '침체선'}`;
|
return `${indName} - 최근 ${n}봉 ${L} 최저 ${op} ${R || '침체선'}`;
|
||||||
}
|
}
|
||||||
if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`;
|
const rangeClause = formatCandleRangeClause(c);
|
||||||
return `${indName} - ${L} ${C}`;
|
if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`;
|
||||||
|
return `${indName} - ${rangeClause}${L} ${C}`;
|
||||||
}
|
}
|
||||||
if (node.type === 'AND') {
|
if (node.type === 'AND') {
|
||||||
if (isStableStrategyPairRoot(node) && node.stableStrategyPair?.mode === 'buy') {
|
if (isStableStrategyPairRoot(node) && node.stableStrategyPair?.mode === 'buy') {
|
||||||
@@ -1142,14 +1149,38 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="sp-cond-lbl">캔들 범위</label>
|
<label className="sp-cond-lbl">캔들 범위</label>
|
||||||
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
|
<select
|
||||||
onChange={e => onChange({ ...cond, candleRange: Number(e.target.value) })}>
|
className="sp-cond-sel"
|
||||||
<option value={1}>현재 캔들</option>
|
value={resolveCandleRangeMode(normalized)}
|
||||||
<option value={2}>2개 캔들</option>
|
onChange={e => {
|
||||||
<option value={3}>3개 캔들</option>
|
const mode = e.target.value as CandleRangeMode;
|
||||||
<option value={4}>4개 캔들</option>
|
if (mode === 'CURRENT') {
|
||||||
<option value={5}>5개 캔들</option>
|
onChange({ ...normalized, candleRangeMode: mode, candleRange: 1 });
|
||||||
|
} else {
|
||||||
|
const n = candleRangeWindowBars(normalized);
|
||||||
|
onChange({ ...normalized, candleRangeMode: mode, candleRange: n >= 2 ? n : 4 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="CURRENT">현재 캔들</option>
|
||||||
|
<option value="EXISTS_IN">N봉 내 존재</option>
|
||||||
|
<option value="NOT_EXISTS_IN">N봉 내 미존재</option>
|
||||||
</select>
|
</select>
|
||||||
|
{resolveCandleRangeMode(normalized) !== 'CURRENT' && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="sp-cond-num sp-cond-num--inline"
|
||||||
|
min={2}
|
||||||
|
max={500}
|
||||||
|
value={candleRangeWindowBars(normalized)}
|
||||||
|
placeholder="N"
|
||||||
|
title="윈도우 봉 수"
|
||||||
|
onChange={e => onChange({
|
||||||
|
...normalized,
|
||||||
|
candleRange: Math.max(2, parseInt(e.target.value, 10) || 4),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
export type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
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 {
|
export interface ConditionDSL {
|
||||||
indicatorType: string;
|
indicatorType: string;
|
||||||
conditionType: string;
|
conditionType: string;
|
||||||
@@ -21,6 +24,9 @@ export interface ConditionDSL {
|
|||||||
holdDays?: number;
|
holdDays?: number;
|
||||||
/** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */
|
/** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */
|
||||||
lookbackPeriod?: number;
|
lookbackPeriod?: number;
|
||||||
|
/** 캔들 범위 모드 — 미설정 시 candleRange>1 이면 EXISTS_IN 으로 해석 */
|
||||||
|
candleRangeMode?: CandleRangeMode;
|
||||||
|
/** EXISTS_IN/NOT_EXISTS_IN 일 때 윈도우 크기(N). CURRENT 일 때는 1 */
|
||||||
candleRange?: number;
|
candleRange?: number;
|
||||||
/** false=보조지표 설정 기본값 상속, true=전략 조건 전용 기간 */
|
/** false=보조지표 설정 기본값 상속, true=전략 조건 전용 기간 */
|
||||||
valuePeriodOverride?: boolean;
|
valuePeriodOverride?: boolean;
|
||||||
@@ -365,3 +371,30 @@ export function loadStrategiesFromStorage(): StrategyDSLDto[] {
|
|||||||
export function saveStrategiesToStorage(strategies: StrategyDSLDto[]): void {
|
export function saveStrategiesToStorage(strategies: StrategyDSLDto[]): void {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(strategies));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(strategies));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** candleRangeMode 미설정 시 candleRange>1 → EXISTS_IN (구버전 호환) */
|
||||||
|
export function resolveCandleRangeMode(
|
||||||
|
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
||||||
|
): CandleRangeMode {
|
||||||
|
if (cond.candleRangeMode) return cond.candleRangeMode;
|
||||||
|
const n = cond.candleRange ?? 1;
|
||||||
|
return n <= 1 ? 'CURRENT' : 'EXISTS_IN';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function candleRangeWindowBars(
|
||||||
|
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
||||||
|
): number {
|
||||||
|
const mode = resolveCandleRangeMode(cond);
|
||||||
|
if (mode === 'CURRENT') return 1;
|
||||||
|
return Math.max(2, cond.candleRange ?? 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCandleRangeClause(
|
||||||
|
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
||||||
|
): string {
|
||||||
|
const mode = resolveCandleRangeMode(cond);
|
||||||
|
const n = candleRangeWindowBars(cond);
|
||||||
|
if (mode === 'EXISTS_IN') return `최근 ${n}봉 내 `;
|
||||||
|
if (mode === 'NOT_EXISTS_IN') return `최근 ${n}봉 내 미존재 · `;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user