N봉 기울기 판단로직 수정
This commit is contained in:
@@ -426,7 +426,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
|
|
||||||
if (needsCrossTimeframeComposite(cond, ctx)) {
|
if (needsCrossTimeframeComposite(cond, ctx)) {
|
||||||
Rule composite = buildCrossTimeframeCompositeRule(cond, ctx);
|
Rule composite = buildCrossTimeframeCompositeRule(cond, ctx);
|
||||||
return applyCandleRangeWindow(composite, candleRangeMode, candleRange);
|
return applyCandleRangeWindow(composite, candleRangeMode, candleRange, condType);
|
||||||
}
|
}
|
||||||
|
|
||||||
PriceExtremeNorm px = normalizePriceExtremeCondition(cond);
|
PriceExtremeNorm px = normalizePriceExtremeCondition(cond);
|
||||||
@@ -466,8 +466,8 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
// CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음
|
// CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음
|
||||||
case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right);
|
case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right);
|
||||||
case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right);
|
case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right);
|
||||||
case "SLOPE_UP" -> new IsRisingRule(left, slopePeriod);
|
case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
|
||||||
case "SLOPE_DOWN" -> new IsFallingRule(left, slopePeriod);
|
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
|
||||||
case "DIFF_GT" -> {
|
case "DIFF_GT" -> {
|
||||||
double v = cond.path("compareValue").asDouble(0);
|
double v = cond.path("compareValue").asDouble(0);
|
||||||
yield new OverIndicatorRule(
|
yield new OverIndicatorRule(
|
||||||
@@ -508,7 +508,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
yield new BooleanRule(false);
|
yield new BooleanRule(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange);
|
Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange, condType);
|
||||||
if (px != null) {
|
if (px != null) {
|
||||||
return nanSafeCompareRule(ranged, left, right);
|
return nanSafeCompareRule(ranged, left, right);
|
||||||
}
|
}
|
||||||
@@ -789,6 +789,18 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
if (field.equals("CCI_VALUE") || indType.equals("CCI"))
|
if (field.equals("CCI_VALUE") || indType.equals("CCI"))
|
||||||
return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)),
|
return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)),
|
||||||
periodOverride > 0 ? periodOverride : intP(p, "length", 13));
|
periodOverride > 0 ? periodOverride : intP(p, "length", 13));
|
||||||
|
if (field.equals("CCI_SIGNAL")) {
|
||||||
|
Map<String, Object> norm = CciOnSourceIndicator.normalizeParams(p);
|
||||||
|
CciOnSourceIndicator cci = new CciOnSourceIndicator(resolvePriceSource(s, norm), intP(p, "length", 13));
|
||||||
|
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
||||||
|
int maLen = intP(p, "maLength", 10);
|
||||||
|
if ("None".equals(maType)) return cci;
|
||||||
|
return switch (maType) {
|
||||||
|
case "EMA" -> new EMAIndicator(cci, maLen);
|
||||||
|
case "WMA" -> new WMAIndicator(cci, maLen);
|
||||||
|
default -> new SMAIndicator(cci, maLen);
|
||||||
|
};
|
||||||
|
}
|
||||||
// RSI — 기간 접미사(RSI_VALUE_9) 또는 기본 RSI_VALUE
|
// RSI — 기간 접미사(RSI_VALUE_9) 또는 기본 RSI_VALUE
|
||||||
if (field.startsWith("RSI_VALUE_")) {
|
if (field.startsWith("RSI_VALUE_")) {
|
||||||
int period = parseTrailingInt(field, "RSI_VALUE_", intP(p, "length", 14));
|
int period = parseTrailingInt(field, "RSI_VALUE_", intP(p, "length", 14));
|
||||||
@@ -796,6 +808,17 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
}
|
}
|
||||||
if (field.equals("RSI_VALUE"))
|
if (field.equals("RSI_VALUE"))
|
||||||
return new RSIIndicator(close, periodOverride > 0 ? periodOverride : intP(p, "length", 14));
|
return new RSIIndicator(close, periodOverride > 0 ? periodOverride : intP(p, "length", 14));
|
||||||
|
if (field.equals("RSI_SIGNAL")) {
|
||||||
|
RSIIndicator rsi = new RSIIndicator(close, intP(p, "length", 14));
|
||||||
|
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
||||||
|
int maLen = intP(p, "maLength", 14);
|
||||||
|
if ("None".equals(maType)) return rsi;
|
||||||
|
return switch (maType) {
|
||||||
|
case "EMA" -> new EMAIndicator(rsi, maLen);
|
||||||
|
case "WMA" -> new WMAIndicator(rsi, maLen);
|
||||||
|
default -> new SMAIndicator(rsi, maLen);
|
||||||
|
};
|
||||||
|
}
|
||||||
// MACD
|
// MACD
|
||||||
if (field.equals("MACD_LINE")) {
|
if (field.equals("MACD_LINE")) {
|
||||||
return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
|
return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
|
||||||
@@ -1062,8 +1085,16 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
if (cond == null || cond.isNull()) return "";
|
if (cond == null || cond.isNull()) return "";
|
||||||
int candleRange = cond.path("candleRange").asInt(1);
|
int candleRange = cond.path("candleRange").asInt(1);
|
||||||
String mode = cond.path("candleRangeMode").asText("");
|
String mode = cond.path("candleRangeMode").asText("");
|
||||||
|
String condType = cond.path("conditionType").asText("");
|
||||||
if (mode.isBlank()) return "";
|
if (mode.isBlank()) return "";
|
||||||
int n = Math.max(2, candleRange);
|
int n = Math.max(2, candleRange);
|
||||||
|
if (isSlopeConditionType(condType) && ("EXISTS_IN".equals(mode) || "NOT_EXISTS_IN".equals(mode))) {
|
||||||
|
return switch (mode) {
|
||||||
|
case "EXISTS_IN" -> "최근 " + n + "봉 구간 ";
|
||||||
|
case "NOT_EXISTS_IN" -> "최근 " + n + "봉 구간 비추세 · ";
|
||||||
|
default -> "";
|
||||||
|
};
|
||||||
|
}
|
||||||
return switch (mode) {
|
return switch (mode) {
|
||||||
case "EXISTS_IN" -> "최근 " + n + "봉 내 ";
|
case "EXISTS_IN" -> "최근 " + n + "봉 내 ";
|
||||||
case "NOT_EXISTS_IN" -> "최근 " + n + "봉 내 없음 · ";
|
case "NOT_EXISTS_IN" -> "최근 " + n + "봉 내 없음 · ";
|
||||||
@@ -1079,8 +1110,10 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */
|
/** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */
|
||||||
private Rule applyCandleRangeWindow(Rule core, String mode, int candleRange) {
|
private Rule applyCandleRangeWindow(Rule core, String mode, int candleRange, String condType) {
|
||||||
if ("CURRENT".equals(mode)) return core;
|
if ("CURRENT".equals(mode)) return core;
|
||||||
|
// 기울기 + N봉 모드: buildSlopeTrendRule 에서 구간 추세로 이미 평가
|
||||||
|
if (isSlopeConditionType(condType)) return core;
|
||||||
int n = Math.max(2, candleRange);
|
int n = Math.max(2, candleRange);
|
||||||
return switch (mode) {
|
return switch (mode) {
|
||||||
case "EXISTS_IN" -> buildExistsInWindowRule(core, n);
|
case "EXISTS_IN" -> buildExistsInWindowRule(core, n);
|
||||||
@@ -1089,6 +1122,35 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기울기 조건.
|
||||||
|
* CURRENT: slopePeriod 구간 추세(현재 봉 기준).
|
||||||
|
* EXISTS_IN / NOT_EXISTS_IN: candleRange(N) 구간 전체 그래프가 상승/하락 상태인지 현재 봉에서 판정
|
||||||
|
* (N봉 내 임의 시점의 짧은 기울기 존재 여부가 아님).
|
||||||
|
*/
|
||||||
|
private Rule buildSlopeTrendRule(
|
||||||
|
Indicator<Num> left,
|
||||||
|
boolean up,
|
||||||
|
String candleRangeMode,
|
||||||
|
int candleRange,
|
||||||
|
int slopePeriod) {
|
||||||
|
int bars = slopePeriod;
|
||||||
|
if ("EXISTS_IN".equals(candleRangeMode) || "NOT_EXISTS_IN".equals(candleRangeMode)) {
|
||||||
|
bars = Math.max(2, candleRange);
|
||||||
|
} else {
|
||||||
|
bars = Math.max(1, slopePeriod);
|
||||||
|
}
|
||||||
|
Rule trend = up ? new IsRisingRule(left, bars) : new IsFallingRule(left, bars);
|
||||||
|
if ("NOT_EXISTS_IN".equals(candleRangeMode)) {
|
||||||
|
return new NotRule(trend);
|
||||||
|
}
|
||||||
|
return trend;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSlopeConditionType(String condType) {
|
||||||
|
return "SLOPE_UP".equals(condType) || "SLOPE_DOWN".equals(condType);
|
||||||
|
}
|
||||||
|
|
||||||
/** 최근 N봉 중 어느 한 봉이라도 inner 가 true */
|
/** 최근 N봉 중 어느 한 봉이라도 inner 가 true */
|
||||||
private Rule buildExistsInWindowRule(Rule inner, int n) {
|
private Rule buildExistsInWindowRule(Rule inner, int n) {
|
||||||
if (n <= 1) return inner;
|
if (n <= 1) return inner;
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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.bars.TimeBarBuilderFactory;
|
||||||
|
import org.ta4j.core.num.DoubleNumFactory;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기울기 + N봉 내 존재 — N봉 구간 전체 추세 판정 (임의 시점 짧은 기울기 존재 여부 아님).
|
||||||
|
*/
|
||||||
|
class SlopeCandleRangeRuleTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private StrategyDslToTa4jAdapter adapter;
|
||||||
|
private BarSeries series;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new StrategyDslToTa4jAdapter();
|
||||||
|
// 중간 3봉만 상승, 전체 10봉은 횡보·하락 혼재 → 짧은 기울기만 존재
|
||||||
|
series = buildOscillatingSeries(new double[] {
|
||||||
|
100, 101, 100, 101, 100, 101, 102, 103, 102, 101
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void slopeUp_existsIn_usesFullWindowTrend_notAnyShortRiseInWindow() throws Exception {
|
||||||
|
Rule rule = toSlopeRule("""
|
||||||
|
"candleRangeMode": "EXISTS_IN",
|
||||||
|
"candleRange": 10,
|
||||||
|
"slopePeriod": 3
|
||||||
|
""", "SLOPE_UP");
|
||||||
|
int idx = series.getEndIndex();
|
||||||
|
assertFalse(rule.isSatisfied(idx, null),
|
||||||
|
"10봉 구간 전체가 상승 추세가 아니면 false (중간 3봉 상승만으로는 true 아님)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void slopeUp_current_usesSlopePeriod() throws Exception {
|
||||||
|
BarSeries rising = buildOscillatingSeries(new double[] {
|
||||||
|
100, 101, 102, 103, 104
|
||||||
|
});
|
||||||
|
Rule rule = toSlopeRule("""
|
||||||
|
"candleRangeMode": "CURRENT",
|
||||||
|
"slopePeriod": 3
|
||||||
|
""", "SLOPE_UP", rising);
|
||||||
|
assertTrue(rule.isSatisfied(rising.getEndIndex(), null),
|
||||||
|
"현재 봉 기준 slopePeriod 구간 상승이면 true");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void slopeUp_existsIn_monotonicWindow_true() throws Exception {
|
||||||
|
BarSeries rising = buildOscillatingSeries(new double[] {
|
||||||
|
100, 101, 102, 103, 104, 105, 106, 107, 108, 109
|
||||||
|
});
|
||||||
|
Rule rule = toSlopeRule("""
|
||||||
|
"candleRangeMode": "EXISTS_IN",
|
||||||
|
"candleRange": 10,
|
||||||
|
"slopePeriod": 3
|
||||||
|
""", "SLOPE_UP", rising);
|
||||||
|
assertTrue(rule.isSatisfied(rising.getEndIndex(), null),
|
||||||
|
"10봉 구간 전체 상승 추세이면 true");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void slopeDown_notExistsIn_trueWhenWindowNotFalling() throws Exception {
|
||||||
|
Rule rule = toSlopeRule("""
|
||||||
|
"candleRangeMode": "NOT_EXISTS_IN",
|
||||||
|
"candleRange": 10,
|
||||||
|
"slopePeriod": 3
|
||||||
|
""", "SLOPE_DOWN");
|
||||||
|
assertTrue(rule.isSatisfied(series.getEndIndex(), null),
|
||||||
|
"10봉 구간이 하락 추세가 아니면 NOT_EXISTS_IN(SLOPE_DOWN) true");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void candleRangePrefix_slopeExistsIn_usesPeriodWording() throws Exception {
|
||||||
|
JsonNode cond = MAPPER.readTree("""
|
||||||
|
{
|
||||||
|
"conditionType": "SLOPE_UP",
|
||||||
|
"candleRangeMode": "EXISTS_IN",
|
||||||
|
"candleRange": 8
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
assertEquals("최근 8봉 구간 ", StrategyDslToTa4jAdapter.candleRangeConditionPrefix(cond));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rule toSlopeRule(String extraFields, String condType) throws Exception {
|
||||||
|
return toSlopeRule(extraFields, condType, series);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rule toSlopeRule(String extraFields, String condType, BarSeries s) throws Exception {
|
||||||
|
String extra = extraFields == null || extraFields.isBlank()
|
||||||
|
? ""
|
||||||
|
: ",\n " + extraFields.strip().replaceAll(",\\s*$", "");
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"id": "c-slope",
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "MA",
|
||||||
|
"conditionType": "%s",
|
||||||
|
"leftField": "CLOSE_PRICE",
|
||||||
|
"rightField": "NONE"%s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(condType, extra);
|
||||||
|
JsonNode node = MAPPER.readTree(json);
|
||||||
|
Rule rule = adapter.toRule(node, s, java.util.Map.of());
|
||||||
|
assertNotNull(rule);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BarSeries buildOscillatingSeries(double[] closes) {
|
||||||
|
BarSeries s = new BaseBarSeriesBuilder()
|
||||||
|
.withName("test-slope")
|
||||||
|
.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 < closes.length; i++) {
|
||||||
|
double close = closes[i];
|
||||||
|
Instant end = base.plus(period.multipliedBy(i + 1L));
|
||||||
|
factory.createBarBuilder(s)
|
||||||
|
.timePeriod(period)
|
||||||
|
.endTime(end)
|
||||||
|
.openPrice(close - 1)
|
||||||
|
.highPrice(close + 1)
|
||||||
|
.lowPrice(close - 1)
|
||||||
|
.closePrice(close)
|
||||||
|
.volume(10 + i)
|
||||||
|
.add();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -253,6 +253,15 @@ html.desktop-client .tmb-logo-version {
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* 버튼 ↔ 드롭다운 간격(6px)에서 hover 끊김 방지 */
|
||||||
|
.tmb-nav-group--open::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: max(100%, 168px);
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
.tmb-nav-group-btn {
|
.tmb-nav-group-btn {
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
loadStrategies, saveStrategy, deleteStrategy,
|
loadStrategies, saveStrategy, deleteStrategy,
|
||||||
type StrategyDto as ApiStrategyDto,
|
type StrategyDto as ApiStrategyDto,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
|
import { getStrategyTemplates,
|
||||||
import {
|
|
||||||
getStrategyTemplates,
|
|
||||||
simpleTemplateToNode,
|
simpleTemplateToNode,
|
||||||
type StrategyTemplateDef,
|
type StrategyTemplateDef,
|
||||||
} from '../utils/strategyPresets';
|
} from '../utils/strategyPresets';
|
||||||
@@ -29,6 +27,11 @@ import {
|
|||||||
buildEmaFieldOptions,
|
buildEmaFieldOptions,
|
||||||
buildMaFieldOptions,
|
buildMaFieldOptions,
|
||||||
} from '../utils/maDslField';
|
} from '../utils/maDslField';
|
||||||
|
import {
|
||||||
|
buildDef,
|
||||||
|
DEF_DEFAULTS,
|
||||||
|
type DefType,
|
||||||
|
} from '../utils/strategyEditorShared';
|
||||||
|
|
||||||
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
||||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||||
@@ -81,185 +84,10 @@ const genId = () => `n_${++_cnt}_${Date.now()}`;
|
|||||||
const loadStratsLocal = (): StrategyDto[] => [];
|
const loadStratsLocal = (): StrategyDto[] => [];
|
||||||
const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ };
|
const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ };
|
||||||
|
|
||||||
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
|
// ─── 기본 파라미터 — strategyEditorShared.buildDef 사용 ─────────────────────
|
||||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||||
interface HLThresh { over?: number; mid?: number; under?: number; }
|
interface HLThresh { over?: number; mid?: number; under?: number; }
|
||||||
|
|
||||||
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
|
|
||||||
const DEF_DEFAULTS = {
|
|
||||||
rsiPeriod: 9,
|
|
||||||
macdFast: 12, macdSlow: 26, macdSignal: 9,
|
|
||||||
cciPeriod: 13,
|
|
||||||
stochK: 12, stochD: 5,
|
|
||||||
adxPeriod: 14,
|
|
||||||
trixPeriod: 12, trixSignal: 9,
|
|
||||||
dmiPeriod: 14,
|
|
||||||
obvPeriod: 9, obvSignal: 9,
|
|
||||||
bbPeriod: 20, bbStdDev: 2,
|
|
||||||
ichTenkan: 9, ichKijun: 26, ichSenkouB: 52,
|
|
||||||
newPsy: 10, psyPeriod: 12, investPsy: 10,
|
|
||||||
williamsR: 14,
|
|
||||||
bwiPeriod: 20,
|
|
||||||
vrPeriod: 10,
|
|
||||||
volOscShort: 5, volOscLong: 10,
|
|
||||||
dispUltra: 5, dispShort: 10, dispMid: 20, dispLong: 60,
|
|
||||||
maLines: [5, 10, 20, 60, 120] as number[],
|
|
||||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
|
||||||
hlThresh: {
|
|
||||||
RSI: { over: 70, mid: 50, under: 30 },
|
|
||||||
STOCHASTIC: { over: 80, mid: 50, under: 20 },
|
|
||||||
CCI: { over: 100, mid: 0, under: -100 },
|
|
||||||
WILLIAMS_R: { over: -20, mid: -50, under: -80 },
|
|
||||||
BWI: { over: 80, mid: 50, under: 20 },
|
|
||||||
VR: { over: 200, mid: 100, under: 50 },
|
|
||||||
PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
|
|
||||||
NEW_PSYCHOLOGICAL: { over: 50, mid: 0, under: -50 },
|
|
||||||
INVEST_PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
|
|
||||||
MACD: { mid: 0 },
|
|
||||||
ADX: { over: 40, mid: 25, under: 20 },
|
|
||||||
DMI: { over: 30, mid: 20, under: 10 },
|
|
||||||
TRIX: { mid: 0 },
|
|
||||||
VOLUME_OSC: { mid: 0 },
|
|
||||||
} as Record<string, HLThresh>,
|
|
||||||
};
|
|
||||||
type DefType = typeof DEF_DEFAULTS;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
|
|
||||||
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
|
|
||||||
*/
|
|
||||||
function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
|
||||||
// 레지스트리 type명으로 조회
|
|
||||||
const p = (registryType: string) =>
|
|
||||||
activeIndicators.find(i => i.type === registryType)?.params ?? {};
|
|
||||||
const num = (params: Record<string, number | string | boolean>, key: string, fallback: number) =>
|
|
||||||
typeof params[key] === 'number' ? (params[key] as number) : fallback;
|
|
||||||
|
|
||||||
// ── 각 지표별 실제 레지스트리 type명 + params 키 ──────────────────────────
|
|
||||||
// RSI → type:'RSI', params.length
|
|
||||||
const rsi = p('RSI');
|
|
||||||
// MACD → type:'MACD', params.fastLength / slowLength / signalLength
|
|
||||||
const macd = p('MACD');
|
|
||||||
// CCI → type:'CCI', params.length
|
|
||||||
const cci = p('CCI');
|
|
||||||
// Stochastic → type:'Stochastic', params.kLength / dSmoothing
|
|
||||||
const stoch = p('Stochastic');
|
|
||||||
// ADX → type:'ADX', params.adxSmoothing / diLength
|
|
||||||
const adx = p('ADX');
|
|
||||||
// TRIX → type:'TRIX', params.length / signalLength
|
|
||||||
const trix = p('TRIX');
|
|
||||||
// DMI → type:'DMI', params.diLength / adxSmoothing
|
|
||||||
const dmi = p('DMI');
|
|
||||||
// BollingerBands→ type:'BollingerBands', params.length / mult
|
|
||||||
const bb = p('BollingerBands');
|
|
||||||
// IchimokuCloud → type:'IchimokuCloud', params.conversionPeriods / basePeriods / laggingSpan2Periods
|
|
||||||
const ich = p('IchimokuCloud');
|
|
||||||
// Williams %R → type:'WilliamsPercentRange', params.length
|
|
||||||
const wr = p('WilliamsPercentRange');
|
|
||||||
const vo = p('VolumeOscillator');
|
|
||||||
const vrInd = p('VR');
|
|
||||||
const disp = p('Disparity');
|
|
||||||
const psy = p('Psychological');
|
|
||||||
const nPsy = p('NewPsychological');
|
|
||||||
const iPsy = p('InvestPsychological');
|
|
||||||
const obvP = p('OBV');
|
|
||||||
// SMA (MA lines)→ type:'SMA', params keys depend on smaConfig
|
|
||||||
const sma = p('SMA');
|
|
||||||
|
|
||||||
// MA 기간 배열: SMA 설정에서 period1~period11 추출 (smaConfig 구조)
|
|
||||||
let maLines = DEF_DEFAULTS.maLines;
|
|
||||||
const smaPeriods = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
|
||||||
.map(i => sma[`period${i}`])
|
|
||||||
.filter(v => typeof v === 'number' && (v as number) > 0) as number[];
|
|
||||||
if (smaPeriods.length >= 2) maLines = smaPeriods;
|
|
||||||
|
|
||||||
// ── hline 임계값 추출 ───────────────────────────────────────────────────────
|
|
||||||
// DSL 지표타입 → 레지스트리 type명 매핑
|
|
||||||
const DSL_TO_REGISTRY: Record<string, string> = {
|
|
||||||
RSI: 'RSI',
|
|
||||||
STOCHASTIC: 'Stochastic',
|
|
||||||
CCI: 'CCI',
|
|
||||||
WILLIAMS_R: 'WilliamsPercentRange',
|
|
||||||
MACD: 'MACD',
|
|
||||||
DMI: 'DMI',
|
|
||||||
ADX: 'ADX',
|
|
||||||
TRIX: 'TRIX',
|
|
||||||
VOLUME_OSC: 'VolumeOscillator',
|
|
||||||
VR: 'VR',
|
|
||||||
DISPARITY: 'Disparity',
|
|
||||||
PSYCHOLOGICAL: 'Psychological',
|
|
||||||
NEW_PSYCHOLOGICAL: 'NewPsychological',
|
|
||||||
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
|
|
||||||
OBV: 'OBV',
|
|
||||||
BOLLINGER: 'BollingerBands',
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractThresh = (dslType: string): HLThresh => {
|
|
||||||
const regType = DSL_TO_REGISTRY[dslType];
|
|
||||||
if (!regType) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
|
|
||||||
const config = activeIndicators.find(i => i.type === regType);
|
|
||||||
const defHl = getIndicatorDef(regType)?.hlines ?? [];
|
|
||||||
const hlines = config?.hlines ?? defHl;
|
|
||||||
if (hlines.length === 0) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
|
|
||||||
|
|
||||||
const prices = hlines.map(h => h.price);
|
|
||||||
const find = (lbl: string) => hlines.find(h =>
|
|
||||||
(h.label ?? getHLineLabel(h.price, prices)) === lbl
|
|
||||||
);
|
|
||||||
const result: HLThresh = {};
|
|
||||||
const ov = find('과열선'); if (ov) result.over = ov.price;
|
|
||||||
const md = find('중앙선') ?? find('기준선'); if (md) result.mid = md.price;
|
|
||||||
const un = find('침체선'); if (un) result.under = un.price;
|
|
||||||
// 값이 하나도 없으면 기본값 사용
|
|
||||||
if (result.over === undefined && result.mid === undefined && result.under === undefined)
|
|
||||||
return DEF_DEFAULTS.hlThresh[dslType] ?? {};
|
|
||||||
return {
|
|
||||||
...DEF_DEFAULTS.hlThresh[dslType],
|
|
||||||
...result,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const hlThresh: Record<string, HLThresh> = {};
|
|
||||||
for (const dslType of Object.keys(DEF_DEFAULTS.hlThresh)) {
|
|
||||||
hlThresh[dslType] = extractThresh(dslType);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rsiPeriod: num(rsi, 'length', DEF_DEFAULTS.rsiPeriod),
|
|
||||||
macdFast: num(macd, 'fastLength', DEF_DEFAULTS.macdFast),
|
|
||||||
macdSlow: num(macd, 'slowLength', DEF_DEFAULTS.macdSlow),
|
|
||||||
macdSignal: num(macd, 'signalLength', DEF_DEFAULTS.macdSignal),
|
|
||||||
cciPeriod: num(cci, 'length', DEF_DEFAULTS.cciPeriod),
|
|
||||||
stochK: num(stoch, 'kLength', DEF_DEFAULTS.stochK),
|
|
||||||
stochD: num(stoch, 'dSmoothing', DEF_DEFAULTS.stochD),
|
|
||||||
adxPeriod: num(adx, 'adxSmoothing', DEF_DEFAULTS.adxPeriod),
|
|
||||||
trixPeriod: num(trix, 'length', DEF_DEFAULTS.trixPeriod),
|
|
||||||
trixSignal: num(trix, 'signalLength', DEF_DEFAULTS.trixSignal),
|
|
||||||
dmiPeriod: num(dmi, 'diLength', num(dmi, 'length', DEF_DEFAULTS.dmiPeriod)),
|
|
||||||
obvPeriod: num(obvP, 'maLength', DEF_DEFAULTS.obvPeriod),
|
|
||||||
obvSignal: num(obvP, 'maLength', DEF_DEFAULTS.obvSignal),
|
|
||||||
bbPeriod: num(bb, 'length', DEF_DEFAULTS.bbPeriod),
|
|
||||||
bbStdDev: num(bb, 'mult', DEF_DEFAULTS.bbStdDev),
|
|
||||||
ichTenkan: num(ich, 'conversionPeriods', DEF_DEFAULTS.ichTenkan),
|
|
||||||
ichKijun: num(ich, 'basePeriods', DEF_DEFAULTS.ichKijun),
|
|
||||||
ichSenkouB: num(ich, 'laggingSpan2Periods', DEF_DEFAULTS.ichSenkouB),
|
|
||||||
psyPeriod: num(psy, 'length', DEF_DEFAULTS.psyPeriod),
|
|
||||||
newPsy: num(nPsy, 'length', DEF_DEFAULTS.newPsy),
|
|
||||||
investPsy: num(iPsy, 'length', DEF_DEFAULTS.investPsy),
|
|
||||||
williamsR: num(wr, 'length', DEF_DEFAULTS.williamsR),
|
|
||||||
bwiPeriod: DEF_DEFAULTS.bwiPeriod, // BWI는 레지스트리 미등록
|
|
||||||
vrPeriod: num(vrInd, 'length', DEF_DEFAULTS.vrPeriod),
|
|
||||||
volOscShort: num(vo, 'shortLength', DEF_DEFAULTS.volOscShort),
|
|
||||||
volOscLong: num(vo, 'longLength', DEF_DEFAULTS.volOscLong),
|
|
||||||
dispUltra: num(disp, 'ultraLength', DEF_DEFAULTS.dispUltra),
|
|
||||||
dispShort: num(disp, 'shortLength', DEF_DEFAULTS.dispShort),
|
|
||||||
dispMid: num(disp, 'midLength', DEF_DEFAULTS.dispMid),
|
|
||||||
dispLong: num(disp, 'longLength', DEF_DEFAULTS.dispLong),
|
|
||||||
maLines,
|
|
||||||
hlThresh,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
|
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
|
||||||
* 예: 70 → "K_70", -100 → "K_-100"
|
* 예: 70 → "K_70", -100 → "K_-100"
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||||||
const navRef = useRef<HTMLElement>(null);
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const hoverCapableRef = useRef(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
&& window.matchMedia('(hover: hover) and (pointer: fine)').matches,
|
||||||
|
);
|
||||||
|
|
||||||
const itemByPage = useMemo(() => {
|
const itemByPage = useMemo(() => {
|
||||||
const map = new Map<MenuPage, MenuItemDef>();
|
const map = new Map<MenuPage, MenuItemDef>();
|
||||||
@@ -54,6 +59,28 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({
|
|||||||
|
|
||||||
const closeDropdown = useCallback(() => setOpenGroupId(null), []);
|
const closeDropdown = useCallback(() => setOpenGroupId(null), []);
|
||||||
|
|
||||||
|
const cancelScheduledClose = useCallback(() => {
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openGroup = useCallback((groupId: string) => {
|
||||||
|
cancelScheduledClose();
|
||||||
|
setOpenGroupId(groupId);
|
||||||
|
}, [cancelScheduledClose]);
|
||||||
|
|
||||||
|
const scheduleClose = useCallback(() => {
|
||||||
|
cancelScheduledClose();
|
||||||
|
closeTimerRef.current = setTimeout(() => {
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
setOpenGroupId(null);
|
||||||
|
}, 120);
|
||||||
|
}, [cancelScheduledClose]);
|
||||||
|
|
||||||
|
useEffect(() => () => cancelScheduledClose(), [cancelScheduledClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openGroupId) return;
|
if (!openGroupId) return;
|
||||||
const onDoc = (e: MouseEvent) => {
|
const onDoc = (e: MouseEvent) => {
|
||||||
@@ -96,13 +123,22 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={group.id}>
|
<React.Fragment key={group.id}>
|
||||||
{index > 0 && <span className="tmb-divider tmb-divider--nav" aria-hidden="true" />}
|
{index > 0 && <span className="tmb-divider tmb-divider--nav" aria-hidden="true" />}
|
||||||
<div className={`tmb-nav-group${isOpen ? ' tmb-nav-group--open' : ''}`}>
|
<div
|
||||||
|
className={`tmb-nav-group${isOpen ? ' tmb-nav-group--open' : ''}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (hoverCapableRef.current) openGroup(group.id);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (hoverCapableRef.current) scheduleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tmb-nav-btn tmb-nav-group-btn${isActive ? ' tmb-nav-btn--active' : ''}${isOpen ? ' tmb-nav-group-btn--open' : ''}`}
|
className={`tmb-nav-btn tmb-nav-group-btn${isActive ? ' tmb-nav-btn--active' : ''}${isOpen ? ' tmb-nav-group-btn--open' : ''}`}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
onPointerDown={e => {
|
onPointerDown={e => {
|
||||||
|
if (hoverCapableRef.current) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenGroupId(prev => (prev === group.id ? null : group.id));
|
setOpenGroupId(prev => (prev === group.id ? null : group.id));
|
||||||
|
|||||||
@@ -150,6 +150,25 @@ export function resolveFieldCustomKind(
|
|||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INDICATORS_WITH_RIGHT_THRESHOLD_INPUT = new Set([
|
||||||
|
'RSI', 'STOCHASTIC', 'CCI', 'ADX', 'DMI', 'WILLIAMS_R', 'TRIX',
|
||||||
|
'PSYCHOLOGICAL', 'NEW_PSYCHOLOGICAL', 'INVEST_PSYCHOLOGICAL', 'BWI', 'VR',
|
||||||
|
'VOLUME_OSC', 'DISPARITY',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 조건대상2 — 임계값 직접입력 노출 여부 */
|
||||||
|
export function resolveRightFieldCustomKind(
|
||||||
|
indicatorType: string,
|
||||||
|
field: string | undefined,
|
||||||
|
): 'period' | 'threshold' | 'none' {
|
||||||
|
const base = resolveFieldCustomKind(indicatorType, field);
|
||||||
|
if (base !== 'none') return base;
|
||||||
|
if (parseThresholdField(field) != null) return 'threshold';
|
||||||
|
if (isThresholdFieldOrSymbol(field)) return 'threshold';
|
||||||
|
if (INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(indicatorType)) return 'threshold';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
export function isValuePeriodFieldStored(indicatorType: string, field?: string): boolean {
|
export function isValuePeriodFieldStored(indicatorType: string, field?: string): boolean {
|
||||||
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||||
if (!prefix || !field) return false;
|
if (!prefix || !field) return false;
|
||||||
@@ -244,7 +263,17 @@ export function setConditionThreshold(
|
|||||||
): ConditionDSL {
|
): ConditionDSL {
|
||||||
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
|
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
|
||||||
const current = cond[fieldKey];
|
const current = cond[fieldKey];
|
||||||
if (!isThresholdFieldOrSymbol(current)) return cond;
|
if (!isThresholdFieldOrSymbol(current)) {
|
||||||
|
if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) {
|
||||||
|
return {
|
||||||
|
...cond,
|
||||||
|
[fieldKey]: thresholdField(value),
|
||||||
|
targetValue: value,
|
||||||
|
thresholdOverride: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return cond;
|
||||||
|
}
|
||||||
|
|
||||||
const inheritedField = side === 'right'
|
const inheritedField = side === 'right'
|
||||||
? (current?.startsWith('K_') || isThresholdSymbol(current) ? current : THRESHOLD_HL_OVER)
|
? (current?.startsWith('K_') || isThresholdSymbol(current) ? current : THRESHOLD_HL_OVER)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
|
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
|
||||||
*/
|
*/
|
||||||
import type { LogicNode } from './strategyTypes';
|
import type { CandleRangeMode, LogicNode } from './strategyTypes';
|
||||||
import { CONDITION_LABEL } from './strategyTypes';
|
import { CONDITION_LABEL } from './strategyTypes';
|
||||||
import {
|
import {
|
||||||
resolveCandleRangeMode,
|
resolveCandleRangeMode,
|
||||||
@@ -94,10 +94,19 @@ function describeConditionType(
|
|||||||
conditionType: string,
|
conditionType: string,
|
||||||
left: string,
|
left: string,
|
||||||
right: string,
|
right: string,
|
||||||
extras: { compareValue?: number; slopePeriod?: number; holdDays?: number; lookbackPeriod?: number },
|
extras: {
|
||||||
|
compareValue?: number;
|
||||||
|
slopePeriod?: number;
|
||||||
|
holdDays?: number;
|
||||||
|
lookbackPeriod?: number;
|
||||||
|
candleRangeMode?: CandleRangeMode;
|
||||||
|
candleRange?: number;
|
||||||
|
},
|
||||||
): string {
|
): string {
|
||||||
const cv = extras.compareValue;
|
const cv = extras.compareValue;
|
||||||
const sp = extras.slopePeriod ?? 3;
|
const sp = (extras.candleRangeMode === 'EXISTS_IN' || extras.candleRangeMode === 'NOT_EXISTS_IN')
|
||||||
|
? Math.max(2, extras.candleRange ?? 4)
|
||||||
|
: (extras.slopePeriod ?? 3);
|
||||||
const hd = extras.holdDays ?? 3;
|
const hd = extras.holdDays ?? 3;
|
||||||
const lb = extras.lookbackPeriod ?? 20;
|
const lb = extras.lookbackPeriod ?? 20;
|
||||||
|
|
||||||
@@ -156,6 +165,8 @@ function describeCondition(
|
|||||||
compareValue: cond.compareValue,
|
compareValue: cond.compareValue,
|
||||||
slopePeriod: cond.slopePeriod,
|
slopePeriod: cond.slopePeriod,
|
||||||
holdDays: cond.holdDays,
|
holdDays: cond.holdDays,
|
||||||
|
candleRangeMode: cond.candleRangeMode,
|
||||||
|
candleRange: cond.candleRange,
|
||||||
});
|
});
|
||||||
return base + tfNote;
|
return base + tfNote;
|
||||||
}
|
}
|
||||||
@@ -172,6 +183,11 @@ function describeCondition(
|
|||||||
return prefix + describeConditionType(ct, left, right, cond);
|
return prefix + describeConditionType(ct, left, right, cond);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (['SLOPE_UP', 'SLOPE_DOWN'].includes(ct)) {
|
||||||
|
const prefix = formatCandleRangeClause(cond);
|
||||||
|
return prefix + describeConditionType(ct, left, right, cond);
|
||||||
|
}
|
||||||
|
|
||||||
const label = CONDITION_LABEL[ct] ?? ct;
|
const label = CONDITION_LABEL[ct] ?? ct;
|
||||||
return `${left}에 대해 「${label}」 조건이 성립하는 경우`;
|
return `${left}에 대해 「${label}」 조건이 성립하는 경우`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import {
|
|||||||
isValuePeriodFieldStored,
|
isValuePeriodFieldStored,
|
||||||
parseThresholdField,
|
parseThresholdField,
|
||||||
resolveFieldCustomKind,
|
resolveFieldCustomKind,
|
||||||
|
resolveRightFieldCustomKind,
|
||||||
getCompositeLeftCandleType,
|
getCompositeLeftCandleType,
|
||||||
getCompositeRightCandleType,
|
getCompositeRightCandleType,
|
||||||
setCompositeLeftCandleType,
|
setCompositeLeftCandleType,
|
||||||
@@ -143,9 +144,11 @@ interface HLThresh { over?: number; mid?: number; under?: number; }
|
|||||||
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
|
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
|
||||||
export const DEF_DEFAULTS = {
|
export const DEF_DEFAULTS = {
|
||||||
rsiPeriod: 9,
|
rsiPeriod: 9,
|
||||||
|
rsiSignal: 14,
|
||||||
macdFast: 12, macdSlow: 26, macdSignal: 9,
|
macdFast: 12, macdSlow: 26, macdSignal: 9,
|
||||||
cciPeriod: 13,
|
cciPeriod: 13,
|
||||||
stochK: 12, stochD: 5,
|
cciSignal: 10,
|
||||||
|
stochK: 12, stochSmooth: 3, stochD: 5,
|
||||||
adxPeriod: 14,
|
adxPeriod: 14,
|
||||||
trixPeriod: 12, trixSignal: 9,
|
trixPeriod: 12, trixSignal: 9,
|
||||||
dmiPeriod: 14,
|
dmiPeriod: 14,
|
||||||
@@ -285,11 +288,14 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
rsiPeriod: num(rsi, 'length', DEF_DEFAULTS.rsiPeriod),
|
rsiPeriod: num(rsi, 'length', DEF_DEFAULTS.rsiPeriod),
|
||||||
|
rsiSignal: num(rsi, 'maLength', DEF_DEFAULTS.rsiSignal),
|
||||||
macdFast: num(macd, 'fastLength', DEF_DEFAULTS.macdFast),
|
macdFast: num(macd, 'fastLength', DEF_DEFAULTS.macdFast),
|
||||||
macdSlow: num(macd, 'slowLength', DEF_DEFAULTS.macdSlow),
|
macdSlow: num(macd, 'slowLength', DEF_DEFAULTS.macdSlow),
|
||||||
macdSignal: num(macd, 'signalLength', DEF_DEFAULTS.macdSignal),
|
macdSignal: num(macd, 'signalLength', DEF_DEFAULTS.macdSignal),
|
||||||
cciPeriod: num(cci, 'length', DEF_DEFAULTS.cciPeriod),
|
cciPeriod: num(cci, 'length', DEF_DEFAULTS.cciPeriod),
|
||||||
|
cciSignal: num(cci, 'maLength', DEF_DEFAULTS.cciSignal),
|
||||||
stochK: num(stoch, 'kLength', DEF_DEFAULTS.stochK),
|
stochK: num(stoch, 'kLength', DEF_DEFAULTS.stochK),
|
||||||
|
stochSmooth: num(stoch, 'smooth', DEF_DEFAULTS.stochSmooth),
|
||||||
stochD: num(stoch, 'dSmoothing', DEF_DEFAULTS.stochD),
|
stochD: num(stoch, 'dSmoothing', DEF_DEFAULTS.stochD),
|
||||||
adxPeriod: num(adx, 'adxSmoothing', DEF_DEFAULTS.adxPeriod),
|
adxPeriod: num(adx, 'adxSmoothing', DEF_DEFAULTS.adxPeriod),
|
||||||
trixPeriod: num(trix, 'length', DEF_DEFAULTS.trixPeriod),
|
trixPeriod: num(trix, 'length', DEF_DEFAULTS.trixPeriod),
|
||||||
@@ -431,18 +437,44 @@ const ICHIMOKU_CONDS = [
|
|||||||
type Opt = { value: string; label: string };
|
type Opt = { value: string; label: string };
|
||||||
const NONE: Opt = { value: 'NONE', label: '선택안함' };
|
const NONE: Opt = { value: 'NONE', label: '선택안함' };
|
||||||
|
|
||||||
export const getFieldOpts = (
|
export type FieldOptSlot = 'left' | 'right';
|
||||||
|
|
||||||
|
function mergeFieldOpts(...lists: Opt[][]): Opt[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Opt[] = [];
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const o of list) {
|
||||||
|
if (seen.has(o.value)) continue;
|
||||||
|
seen.add(o.value);
|
||||||
|
out.push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThresholdOpts(
|
||||||
|
th: { over: number; mid: number; under: number },
|
||||||
|
overTerm: string,
|
||||||
|
underTerm: string,
|
||||||
|
includeMid = true,
|
||||||
|
): Opt[] {
|
||||||
|
const opts: Opt[] = [{ value: THRESHOLD_HL_OVER, label: `${overTerm}(${th.over})` }];
|
||||||
|
if (includeMid) opts.push({ value: THRESHOLD_HL_MID, label: `중앙선(${th.mid})` });
|
||||||
|
opts.push({ value: THRESHOLD_HL_UNDER, label: `${underTerm}(${th.under})` });
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldOptsForSlot(
|
||||||
ind: string,
|
ind: string,
|
||||||
signalType: 'buy' | 'sell',
|
signalType: 'buy' | 'sell',
|
||||||
DEF: DefType,
|
DEF: DefType,
|
||||||
cond?: ConditionDSL,
|
cond: ConditionDSL | undefined,
|
||||||
): Opt[] => {
|
slot: FieldOptSlot,
|
||||||
|
): Opt[] {
|
||||||
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
|
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
|
||||||
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
||||||
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
|
|
||||||
|
|
||||||
/** 해당 지표의 hline 임계값 — 없으면 기본값 */
|
const th = (defaults: { over?: number; mid?: number; under?: number }): { over: number; mid: number; under: number } => {
|
||||||
const th = (defaults: HLThresh): Required<HLThresh> => {
|
|
||||||
const saved = DEF.hlThresh[ind] ?? {};
|
const saved = DEF.hlThresh[ind] ?? {};
|
||||||
return {
|
return {
|
||||||
over: saved.over ?? defaults.over ?? 100,
|
over: saved.over ?? defaults.over ?? 100,
|
||||||
@@ -452,76 +484,136 @@ export const getFieldOpts = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
switch (ind) {
|
switch (ind) {
|
||||||
case 'RSI': {
|
case 'MACD': {
|
||||||
const { over, mid, under } = th({ over:70, mid:50, under:30 });
|
const { mid } = th({ mid: 0 });
|
||||||
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
|
if (slot === 'left') {
|
||||||
return condOpts([
|
return [
|
||||||
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
|
{ value: 'MACD_LINE', label: 'MACD 선' },
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
{ value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` },
|
||||||
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
];
|
||||||
]);
|
}
|
||||||
|
return [
|
||||||
|
{ value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` },
|
||||||
|
NONE,
|
||||||
|
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case 'RSI': {
|
||||||
|
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
|
||||||
|
const thresholds = buildThresholdOpts(th({ over: 70, mid: 50, under: 30 }), overTerm, underTerm);
|
||||||
|
if (slot === 'left') {
|
||||||
|
return [
|
||||||
|
{ value: 'RSI_VALUE', label: `RSI 기간 ${rsiP}` },
|
||||||
|
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
|
||||||
|
NONE,
|
||||||
|
...thresholds,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'STOCHASTIC': {
|
case 'STOCHASTIC': {
|
||||||
const { over, mid, under } = th({ over:80, mid:50, under:20 });
|
const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm);
|
||||||
return condOpts([
|
const kLabel = `Stochastic 기간${DEF.stochK}일 %K${DEF.stochSmooth}일`;
|
||||||
{ value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` },
|
const dLabel = `Stochastic %D${DEF.stochD}`;
|
||||||
{ value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
{ value: 'STOCH_K', label: kLabel },
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
{ value: 'STOCH_D', label: dLabel },
|
||||||
]);
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: 'STOCH_D', label: `${dLabel}일` },
|
||||||
|
NONE,
|
||||||
|
...thresholds,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'CCI': {
|
case 'CCI': {
|
||||||
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
|
|
||||||
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
|
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
|
||||||
return condOpts([
|
const thresholds = buildThresholdOpts(th({ over: 100, mid: 0, under: -100 }), overTerm, underTerm);
|
||||||
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
{ value: 'CCI_VALUE', label: `CCI 기간 ${cciP}` },
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
{ value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` },
|
||||||
]);
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` },
|
||||||
|
NONE,
|
||||||
|
...thresholds,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'ADX': {
|
case 'ADX': {
|
||||||
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
|
|
||||||
const adxP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'ADX' }, DEF) : DEF.adxPeriod;
|
const adxP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'ADX' }, DEF) : DEF.adxPeriod;
|
||||||
return condOpts([
|
const thresholds = buildThresholdOpts(th({ over: 40, mid: 25, under: 20 }), overTerm, underTerm);
|
||||||
{ value:'ADX_VALUE', label:`ADX 라인(${adxP}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [{ value: 'ADX_VALUE', label: `ADX 기간 ${adxP}` }];
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
}
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
return [NONE, ...thresholds];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'TRIX': {
|
case 'TRIX': {
|
||||||
const { mid } = th({ mid:0 });
|
|
||||||
const trixP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'TRIX' }, DEF) : DEF.trixPeriod;
|
const trixP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'TRIX' }, DEF) : DEF.trixPeriod;
|
||||||
return condOpts([
|
const { mid } = th({ mid: 0 });
|
||||||
{ value:'TRIX_VALUE', label:`TRIX 라인(${trixP}일)` },
|
if (slot === 'left') {
|
||||||
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
|
return [
|
||||||
|
{ value: 'TRIX_VALUE', label: `TRIX ${trixP}` },
|
||||||
|
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
|
||||||
|
NONE,
|
||||||
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
case 'DMI': {
|
case 'DMI': {
|
||||||
const { over, mid, under } = th({ over:30, mid:20, under:10 });
|
const thresholds = buildThresholdOpts(th({ over: 30, mid: 20, under: 10 }), '과열', '침체');
|
||||||
return condOpts([
|
const plusLabel = `+DI 기간${DEF.dmiPeriod}일`;
|
||||||
{ value:'PDI', label:`DI+(${DEF.dmiPeriod}일)` },
|
const minusLabel = `-DI 기간${DEF.dmiPeriod}일`;
|
||||||
{ value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`과열(${over})` },
|
return [
|
||||||
{ value:THRESHOLD_HL_MID, label:`중간값(${mid})` },
|
{ value: 'PDI', label: plusLabel },
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`침체(${under})` },
|
{ value: 'MDI', label: minusLabel },
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
case 'OBV': return condOpts([
|
return [
|
||||||
{ value:'OBV_LINE', label:`OBV선(${DEF.obvPeriod}일)` },
|
{ value: 'MDI', label: minusLabel },
|
||||||
{ value:'OBV_SIGNAL', label:`신호선(${DEF.obvSignal}일)` },
|
{ value: 'PDI', label: plusLabel },
|
||||||
]);
|
NONE,
|
||||||
case 'VOLUME': return condOpts([
|
...thresholds,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case 'OBV':
|
||||||
|
if (slot === 'left') {
|
||||||
|
return [
|
||||||
|
{ value: 'OBV_LINE', label: 'OBV선' },
|
||||||
|
{ value: 'OBV_SIGNAL', label: `신호선 ${DEF.obvSignal}일` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: 'OBV_SIGNAL', label: `신호선 ${DEF.obvSignal}일` },
|
||||||
|
NONE,
|
||||||
|
];
|
||||||
|
case 'WILLIAMS_R': {
|
||||||
|
const wrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'WILLIAMS_R' }, DEF) : DEF.williamsR;
|
||||||
|
const thresholds = buildThresholdOpts(th({ over: -20, mid: -50, under: -80 }), overTerm, underTerm);
|
||||||
|
if (slot === 'left') {
|
||||||
|
return [{ value: 'WILLIAMS_R_VALUE', label: `williams%R 기간${wrP}` }];
|
||||||
|
}
|
||||||
|
return [NONE, ...thresholds];
|
||||||
|
}
|
||||||
|
case 'VOLUME':
|
||||||
|
return slot === 'left'
|
||||||
|
? [
|
||||||
{ value: 'VOLUME_VALUE', label: '거래량' },
|
{ value: 'VOLUME_VALUE', label: '거래량' },
|
||||||
{ value: 'VOLUME_MA', label: '거래량 이동평균' },
|
{ value: 'VOLUME_MA', label: '거래량 이동평균' },
|
||||||
]);
|
]
|
||||||
|
: [NONE, { value: 'VOLUME_MA', label: '거래량 이동평균' }];
|
||||||
case 'MA': {
|
case 'MA': {
|
||||||
let opts = [
|
let opts = [
|
||||||
NONE,
|
...(slot === 'right' ? [NONE] : []),
|
||||||
...buildMaFieldOptions(DEF.maLines),
|
...buildMaFieldOptions(DEF.maLines),
|
||||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
];
|
];
|
||||||
@@ -531,7 +623,7 @@ export const getFieldOpts = (
|
|||||||
}
|
}
|
||||||
case 'EMA': {
|
case 'EMA': {
|
||||||
let opts = [
|
let opts = [
|
||||||
NONE,
|
...(slot === 'right' ? [NONE] : []),
|
||||||
...buildEmaFieldOptions(DEF.maLines, 4),
|
...buildEmaFieldOptions(DEF.maLines, 4),
|
||||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
];
|
];
|
||||||
@@ -541,97 +633,71 @@ export const getFieldOpts = (
|
|||||||
}
|
}
|
||||||
case 'DISPARITY': {
|
case 'DISPARITY': {
|
||||||
const { mid } = th({ mid: 100 });
|
const { mid } = th({ mid: 100 });
|
||||||
return condOpts([
|
const lines = [
|
||||||
{ value: `DISPARITY${DEF.dispUltra}`, label: `초단기 이격도(${DEF.dispUltra}일)` },
|
{ value: `DISPARITY${DEF.dispUltra}`, label: `초단기 이격도(${DEF.dispUltra}일)` },
|
||||||
{ value: `DISPARITY${DEF.dispShort}`, label: `단기 이격도(${DEF.dispShort}일)` },
|
{ value: `DISPARITY${DEF.dispShort}`, label: `단기 이격도(${DEF.dispShort}일)` },
|
||||||
{ value: `DISPARITY${DEF.dispMid}`, label: `중기 이격도(${DEF.dispMid}일)` },
|
{ value: `DISPARITY${DEF.dispMid}`, label: `중기 이격도(${DEF.dispMid}일)` },
|
||||||
{ value: `DISPARITY${DEF.dispLong}`, label: `장기 이격도(${DEF.dispLong}일)` },
|
{ value: `DISPARITY${DEF.dispLong}`, label: `장기 이격도(${DEF.dispLong}일)` },
|
||||||
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
||||||
]);
|
];
|
||||||
|
return slot === 'right' ? [NONE, ...lines] : lines;
|
||||||
}
|
}
|
||||||
case 'PSYCHOLOGICAL': {
|
case 'PSYCHOLOGICAL': {
|
||||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
|
||||||
const psyP = cond
|
const psyP = cond
|
||||||
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
|
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
|
||||||
: DEF.psyPeriod;
|
: DEF.psyPeriod;
|
||||||
return condOpts([
|
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
|
||||||
{ value:'PSY_VALUE', label:`심리도 라인(${psyP}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [{ value: 'PSY_VALUE', label: `심리도 기간 ${psyP}` }];
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
}
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
return [NONE, ...thresholds];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'NEW_PSYCHOLOGICAL': {
|
case 'NEW_PSYCHOLOGICAL': {
|
||||||
const { over, mid, under } = th({ over:50, mid:0, under:-50 });
|
|
||||||
const nPsyP = cond
|
const nPsyP = cond
|
||||||
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
|
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
|
||||||
: DEF.newPsy;
|
: DEF.newPsy;
|
||||||
return condOpts([
|
const thresholds = buildThresholdOpts(th({ over: 50, mid: 0, under: -50 }), overTerm, underTerm);
|
||||||
{ value:'NEW_PSY_VALUE', label:`신심리도 라인(${nPsyP}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [{ value: 'NEW_PSY_VALUE', label: `신심리도 기간 ${nPsyP}` }];
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
}
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
return [NONE, ...thresholds];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'INVEST_PSYCHOLOGICAL': {
|
case 'INVEST_PSYCHOLOGICAL': {
|
||||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
|
||||||
const ipsyP = cond
|
const ipsyP = cond
|
||||||
? getConditionValuePeriod({ ...cond, indicatorType: 'INVEST_PSYCHOLOGICAL' }, DEF)
|
? getConditionValuePeriod({ ...cond, indicatorType: 'INVEST_PSYCHOLOGICAL' }, DEF)
|
||||||
: DEF.investPsy;
|
: DEF.investPsy;
|
||||||
return condOpts([
|
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
|
||||||
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${ipsyP}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [{ value: 'INVEST_PSY_VALUE', label: `투자심리도 기간 ${ipsyP}` }];
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'WILLIAMS_R': {
|
return [NONE, ...thresholds];
|
||||||
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
|
|
||||||
const wrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'WILLIAMS_R' }, DEF) : DEF.williamsR;
|
|
||||||
return condOpts([
|
|
||||||
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${wrP}일)` },
|
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'BWI': {
|
case 'BWI': {
|
||||||
const { over, mid, under } = th({ over:80, mid:50, under:20 });
|
const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm);
|
||||||
const bwiP = DEF.bwiPeriod;
|
if (slot === 'left') {
|
||||||
return condOpts([
|
return [{ value: 'BWI_VALUE', label: `BWI 기간 ${DEF.bwiPeriod}` }];
|
||||||
{ value:'BWI_VALUE', label:`BWI 라인(${bwiP}일)` },
|
}
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [NONE, ...thresholds];
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'VR': {
|
case 'VR': {
|
||||||
const { over, mid, under } = th({ over:200, mid:100, under:50 });
|
|
||||||
const vrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'VR' }, DEF) : DEF.vrPeriod;
|
const vrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'VR' }, DEF) : DEF.vrPeriod;
|
||||||
return condOpts([
|
const thresholds = buildThresholdOpts(th({ over: 200, mid: 100, under: 50 }), overTerm, underTerm);
|
||||||
{ value:'VR_VALUE', label:`VR 라인(${vrP}일)` },
|
if (slot === 'left') {
|
||||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
return [{ value: 'VR_VALUE', label: `VR 기간 ${vrP}` }];
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
}
|
||||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
return [NONE, ...thresholds];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'VOLUME_OSC': {
|
case 'VOLUME_OSC': {
|
||||||
const { mid } = th({ mid: 0 });
|
const { mid } = th({ mid: 0 });
|
||||||
return condOpts([
|
if (slot === 'left') {
|
||||||
{ value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터 값(${DEF.volOscShort}일/${DEF.volOscLong}일)` },
|
return [{ value: 'VOLUME_OSC_VALUE', label: `거래량 오실레이터(${DEF.volOscShort}일/${DEF.volOscLong}일)` }];
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'MACD': {
|
return [NONE, { value: THRESHOLD_HL_MID, label: `중앙선(${mid})` }];
|
||||||
const { mid } = th({ mid:0 });
|
|
||||||
return condOpts([
|
|
||||||
{ value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` },
|
|
||||||
{ value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` },
|
|
||||||
{ value:'HISTOGRAM', label:'히스토그램' },
|
|
||||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
case 'BOLLINGER': return condOpts([
|
case 'BOLLINGER':
|
||||||
|
return slot === 'right'
|
||||||
|
? [
|
||||||
|
NONE,
|
||||||
{ value: 'UPPER_BAND', label: `상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
{ value: 'UPPER_BAND', label: `상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
||||||
{ value: 'MIDDLE_BAND', label: `중심선(${DEF.bbPeriod}일)` },
|
{ value: 'MIDDLE_BAND', label: `중심선(${DEF.bbPeriod}일)` },
|
||||||
{ value: 'LOWER_BAND', label: `하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
{ value: 'LOWER_BAND', label: `하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
||||||
@@ -639,28 +705,40 @@ export const getFieldOpts = (
|
|||||||
{ value: 'OPEN_PRICE', label: '시가' },
|
{ value: 'OPEN_PRICE', label: '시가' },
|
||||||
{ value: 'HIGH_PRICE', label: '고가' },
|
{ value: 'HIGH_PRICE', label: '고가' },
|
||||||
{ value: 'LOW_PRICE', label: '저가' },
|
{ value: 'LOW_PRICE', label: '저가' },
|
||||||
]);
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'UPPER_BAND', label: `상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
||||||
|
{ value: 'MIDDLE_BAND', label: `중심선(${DEF.bbPeriod}일)` },
|
||||||
|
{ value: 'LOWER_BAND', label: `하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
||||||
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
|
{ value: 'OPEN_PRICE', label: '시가' },
|
||||||
|
{ value: 'HIGH_PRICE', label: '고가' },
|
||||||
|
{ value: 'LOW_PRICE', label: '저가' },
|
||||||
|
];
|
||||||
case 'NEW_HIGH': {
|
case 'NEW_HIGH': {
|
||||||
const p = cond?.period ?? 9;
|
const p = cond?.period ?? 9;
|
||||||
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
||||||
return condOpts([
|
const lines = [
|
||||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
{ value: 'HIGH_PRICE', label: '고가' },
|
{ value: 'HIGH_PRICE', label: '고가' },
|
||||||
{ value: 'OPEN_PRICE', label: '시가' },
|
{ value: 'OPEN_PRICE', label: '시가' },
|
||||||
...periods.map(n => ({ value: nhPriorField(n), label: `${n}일 신고가선(직전 N봉)` })),
|
...periods.map(n => ({ value: nhPriorField(n), label: `${n}일 신고가선(직전 N봉)` })),
|
||||||
]);
|
];
|
||||||
|
return slot === 'right' ? [NONE, ...lines] : lines;
|
||||||
}
|
}
|
||||||
case 'NEW_LOW': {
|
case 'NEW_LOW': {
|
||||||
const p = cond?.period ?? 9;
|
const p = cond?.period ?? 9;
|
||||||
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
||||||
return condOpts([
|
const lines = [
|
||||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
{ value: 'LOW_PRICE', label: '저가' },
|
{ value: 'LOW_PRICE', label: '저가' },
|
||||||
{ value: 'OPEN_PRICE', label: '시가' },
|
{ value: 'OPEN_PRICE', label: '시가' },
|
||||||
...periods.map(n => ({ value: nlPriorField(n), label: `${n}일 신저가선(직전 N봉)` })),
|
...periods.map(n => ({ value: nlPriorField(n), label: `${n}일 신저가선(직전 N봉)` })),
|
||||||
]);
|
];
|
||||||
|
return slot === 'right' ? [NONE, ...lines] : lines;
|
||||||
}
|
}
|
||||||
case 'DONCHIAN': return condOpts([
|
case 'DONCHIAN': {
|
||||||
|
const lines = [
|
||||||
{ value: 'DC_UPPER_9', label: '상단(9일 최고가·신고가)' },
|
{ value: 'DC_UPPER_9', label: '상단(9일 최고가·신고가)' },
|
||||||
{ value: 'DC_UPPER_10', label: '상단(10일 최고가)' },
|
{ value: 'DC_UPPER_10', label: '상단(10일 최고가)' },
|
||||||
{ value: 'DC_UPPER_20', label: '상단(20일 최고가·신고가)' },
|
{ value: 'DC_UPPER_20', label: '상단(20일 최고가·신고가)' },
|
||||||
@@ -673,8 +751,11 @@ export const getFieldOpts = (
|
|||||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
{ value: 'LOW_PRICE', label: '저가' },
|
{ value: 'LOW_PRICE', label: '저가' },
|
||||||
{ value: 'HIGH_PRICE', label: '고가' },
|
{ value: 'HIGH_PRICE', label: '고가' },
|
||||||
]);
|
];
|
||||||
case 'ICHIMOKU': return condOpts([
|
return slot === 'right' ? [NONE, ...lines] : lines;
|
||||||
|
}
|
||||||
|
case 'ICHIMOKU': {
|
||||||
|
const lines = [
|
||||||
{ value: 'CONVERSION_LINE', label: `전환선(${DEF.ichTenkan}일)` },
|
{ value: 'CONVERSION_LINE', label: `전환선(${DEF.ichTenkan}일)` },
|
||||||
{ value: 'BASE_LINE', label: `기준선(${DEF.ichKijun}일)` },
|
{ value: 'BASE_LINE', label: `기준선(${DEF.ichKijun}일)` },
|
||||||
{ value: 'LEADING_SPAN1', label: '선행스팬1' },
|
{ value: 'LEADING_SPAN1', label: '선행스팬1' },
|
||||||
@@ -684,16 +765,43 @@ export const getFieldOpts = (
|
|||||||
{ value: 'OPEN_PRICE', label: '시가' },
|
{ value: 'OPEN_PRICE', label: '시가' },
|
||||||
{ value: 'HIGH_PRICE', label: '고가' },
|
{ value: 'HIGH_PRICE', label: '고가' },
|
||||||
{ value: 'LOW_PRICE', label: '저가' },
|
{ value: 'LOW_PRICE', label: '저가' },
|
||||||
]);
|
];
|
||||||
default: return [NONE];
|
return slot === 'right' ? [NONE, ...lines] : lines;
|
||||||
}
|
}
|
||||||
};
|
default:
|
||||||
|
return slot === 'right' ? [NONE] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLeftFieldOpts = (
|
||||||
|
ind: string,
|
||||||
|
signalType: 'buy' | 'sell',
|
||||||
|
DEF: DefType,
|
||||||
|
cond?: ConditionDSL,
|
||||||
|
): Opt[] => buildFieldOptsForSlot(ind, signalType, DEF, cond, 'left');
|
||||||
|
|
||||||
|
export const getRightFieldOpts = (
|
||||||
|
ind: string,
|
||||||
|
signalType: 'buy' | 'sell',
|
||||||
|
DEF: DefType,
|
||||||
|
cond?: ConditionDSL,
|
||||||
|
): Opt[] => buildFieldOptsForSlot(ind, signalType, DEF, cond, 'right');
|
||||||
|
|
||||||
|
export const getFieldOpts = (
|
||||||
|
ind: string,
|
||||||
|
signalType: 'buy'|'sell',
|
||||||
|
DEF: DefType,
|
||||||
|
cond?: ConditionDSL,
|
||||||
|
): Opt[] => mergeFieldOpts(
|
||||||
|
getLeftFieldOpts(ind, signalType, DEF, cond),
|
||||||
|
getRightFieldOpts(ind, signalType, DEF, cond),
|
||||||
|
);
|
||||||
|
|
||||||
const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
|
const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
|
||||||
const map: Record<string, {l:string;r:string}> = {
|
const map: Record<string, {l:string;r:string}> = {
|
||||||
RSI: { l:'RSI_VALUE', r: THRESHOLD_HL_OVER },
|
RSI: { l:'RSI_VALUE', r: THRESHOLD_HL_OVER },
|
||||||
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
|
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
|
||||||
CCI: { l:'CCI_VALUE', r: THRESHOLD_HL_OVER },
|
CCI: { l:'CCI_VALUE', r:'CCI_SIGNAL' },
|
||||||
ADX: { l:'ADX_VALUE', r: THRESHOLD_HL_MID },
|
ADX: { l:'ADX_VALUE', r: THRESHOLD_HL_MID },
|
||||||
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
|
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
|
||||||
DMI: { l:'PDI', r:'MDI' },
|
DMI: { l:'PDI', r:'MDI' },
|
||||||
@@ -968,7 +1076,9 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
|||||||
cond, signalType, onChange, def, stochPairEdit,
|
cond, signalType, onChange, def, stochPairEdit,
|
||||||
}) => {
|
}) => {
|
||||||
const normalized = normalizeCompositeCondition(cond);
|
const normalized = normalizeCompositeCondition(cond);
|
||||||
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
|
const leftFieldOpts = getLeftFieldOpts(normalized.indicatorType, signalType, def, normalized);
|
||||||
|
const rightFieldOpts = getRightFieldOpts(normalized.indicatorType, signalType, def, normalized);
|
||||||
|
const fieldOpts = mergeFieldOpts(leftFieldOpts, rightFieldOpts);
|
||||||
const condOpts = getCondOptionsForIndicator(normalized.indicatorType);
|
const condOpts = getCondOptionsForIndicator(normalized.indicatorType);
|
||||||
|
|
||||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||||
@@ -1220,11 +1330,11 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
|||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
<label className="sp-cond-lbl">조건대상1</label>
|
<label className="sp-cond-lbl">조건대상1</label>
|
||||||
<ComboFieldSelect
|
<ComboFieldSelect
|
||||||
options={fieldOpts}
|
options={leftFieldOpts}
|
||||||
fieldValue={isHoldType ? 'NONE' : (normalized.leftField ?? getLeftValue())}
|
fieldValue={isHoldType ? 'NONE' : (normalized.leftField ?? getLeftValue())}
|
||||||
isPresetField={!isHoldType && (
|
isPresetField={!isHoldType && (
|
||||||
fieldOpts.some(o => o.value === normalized.leftField)
|
leftFieldOpts.some(o => o.value === normalized.leftField)
|
||||||
|| (fieldOpts.some(o => o.value === getLeftValue())
|
|| (leftFieldOpts.some(o => o.value === getLeftValue())
|
||||||
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
|
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
|
||||||
)}
|
)}
|
||||||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
|
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
|
||||||
@@ -1260,10 +1370,14 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
|||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
<label className="sp-cond-lbl">조건대상2</label>
|
<label className="sp-cond-lbl">조건대상2</label>
|
||||||
<ComboFieldSelect
|
<ComboFieldSelect
|
||||||
options={fieldOpts}
|
options={rightFieldOpts}
|
||||||
fieldValue={rightValue}
|
fieldValue={rightValue}
|
||||||
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
|
isPresetField={
|
||||||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
|
!isThresholdOverridden(normalized)
|
||||||
|
&& parseThresholdField(normalized.rightField) == null
|
||||||
|
&& rightFieldOpts.some(o => o.value === getRightValue())
|
||||||
|
}
|
||||||
|
customKind={resolveRightFieldCustomKind(normalized.indicatorType, normalized.rightField)}
|
||||||
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def)
|
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def)
|
||||||
?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def)
|
?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def)
|
||||||
?? parseThresholdField(normalized.rightField)
|
?? parseThresholdField(normalized.rightField)
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ function rangeNote(cond?: ConditionDSL): string {
|
|||||||
const mode = resolveCandleRangeMode(cond);
|
const mode = resolveCandleRangeMode(cond);
|
||||||
if (mode === 'CURRENT') return '';
|
if (mode === 'CURRENT') return '';
|
||||||
const n = candleRangeWindowBars(cond);
|
const n = candleRangeWindowBars(cond);
|
||||||
|
if (cond.conditionType === 'SLOPE_UP' || cond.conditionType === 'SLOPE_DOWN') {
|
||||||
|
if (mode === 'EXISTS_IN') {
|
||||||
|
return `최근 ${n}봉(현재 봉 포함) 구간에서 그래프선이 ${cond.conditionType === 'SLOPE_UP' ? '상승' : '하락'} 추세인지 현재 봉 기준으로 판정합니다. `;
|
||||||
|
}
|
||||||
|
if (mode === 'NOT_EXISTS_IN') {
|
||||||
|
return `최근 ${n}봉(현재 봉 포함) 구간에서 그래프선이 ${cond.conditionType === 'SLOPE_UP' ? '상승' : '하락'} 추세가 아닌지 현재 봉 기준으로 판정합니다. `;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (mode === 'EXISTS_IN') return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번이라도 성립하면 충족으로 판정합니다. `;
|
if (mode === 'EXISTS_IN') return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번이라도 성립하면 충족으로 판정합니다. `;
|
||||||
return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번도 없어야 충족으로 판정합니다. `;
|
return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번도 없어야 충족으로 판정합니다. `;
|
||||||
}
|
}
|
||||||
@@ -201,6 +209,18 @@ function buildReason(
|
|||||||
|
|
||||||
if (row.conditionType === 'SLOPE_UP' || row.conditionType === 'SLOPE_DOWN') {
|
if (row.conditionType === 'SLOPE_UP' || row.conditionType === 'SLOPE_DOWN') {
|
||||||
const dir = row.conditionType === 'SLOPE_UP' ? '상승' : '하락';
|
const dir = row.conditionType === 'SLOPE_UP' ? '상승' : '하락';
|
||||||
|
const mode = cond ? resolveCandleRangeMode(cond) : 'CURRENT';
|
||||||
|
if (mode === 'EXISTS_IN' || mode === 'NOT_EXISTS_IN') {
|
||||||
|
const n = cond ? candleRangeWindowBars(cond) : 0;
|
||||||
|
const neg = mode === 'NOT_EXISTS_IN';
|
||||||
|
return prefix + (status === 'match'
|
||||||
|
? (neg
|
||||||
|
? `최근 ${n}봉 구간 그래프선이 ${dir} 추세가 아니어서 충족합니다.`
|
||||||
|
: `최근 ${n}봉 구간 그래프선이 ${dir} 추세로 판정되어 충족합니다.`)
|
||||||
|
: (neg
|
||||||
|
? `최근 ${n}봉 구간 그래프선이 ${dir} 추세가 아님 — 미충족입니다.`
|
||||||
|
: `최근 ${n}봉 구간 그래프선이 ${dir} 추세가 아니어서 미충족입니다.`));
|
||||||
|
}
|
||||||
return prefix + (status === 'match'
|
return prefix + (status === 'match'
|
||||||
? `최근 기울기가 ${dir} 추세로 판정되어 충족합니다.`
|
? `최근 기울기가 ${dir} 추세로 판정되어 충족합니다.`
|
||||||
: `최근 기울기가 ${dir} 추세가 아니어서 미충족입니다.`);
|
: `최근 기울기가 ${dir} 추세가 아니어서 미충족입니다.`);
|
||||||
|
|||||||
@@ -380,6 +380,14 @@ export function resolveCandleRangeMode(
|
|||||||
return 'CURRENT';
|
return 'CURRENT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 기울기 + N봉 모드: N봉 구간 전체 추세로 판정 (N봉 내 임의 시점 기울기 존재 여부 아님) */
|
||||||
|
export function isSlopeWindowTrendMode(
|
||||||
|
cond: Pick<ConditionDSL, 'conditionType' | 'candleRangeMode'>,
|
||||||
|
): boolean {
|
||||||
|
return (cond.conditionType === 'SLOPE_UP' || cond.conditionType === 'SLOPE_DOWN')
|
||||||
|
&& (cond.candleRangeMode === 'EXISTS_IN' || cond.candleRangeMode === 'NOT_EXISTS_IN');
|
||||||
|
}
|
||||||
|
|
||||||
export function candleRangeWindowBars(
|
export function candleRangeWindowBars(
|
||||||
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
||||||
): number {
|
): number {
|
||||||
@@ -389,10 +397,14 @@ export function candleRangeWindowBars(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatCandleRangeClause(
|
export function formatCandleRangeClause(
|
||||||
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange' | 'conditionType'>,
|
||||||
): string {
|
): string {
|
||||||
const mode = resolveCandleRangeMode(cond);
|
const mode = resolveCandleRangeMode(cond);
|
||||||
const n = candleRangeWindowBars(cond);
|
const n = candleRangeWindowBars(cond);
|
||||||
|
if (isSlopeWindowTrendMode(cond)) {
|
||||||
|
if (mode === 'EXISTS_IN') return `최근 ${n}봉 구간 `;
|
||||||
|
if (mode === 'NOT_EXISTS_IN') return `최근 ${n}봉 구간 비추세 · `;
|
||||||
|
}
|
||||||
if (mode === 'EXISTS_IN') return `최근 ${n}봉 내 `;
|
if (mode === 'EXISTS_IN') return `최근 ${n}봉 내 `;
|
||||||
if (mode === 'NOT_EXISTS_IN') return `최근 ${n}봉 내 미존재 · `;
|
if (mode === 'NOT_EXISTS_IN') return `최근 ${n}봉 내 미존재 · `;
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
Reference in New Issue
Block a user