전략평가 로직 오류 수정
This commit is contained in:
@@ -463,9 +463,9 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
buildEqRule(left, right, series));
|
buildEqRule(left, right, series));
|
||||||
case "EQ" -> buildEqRule(left, right, series);
|
case "EQ" -> buildEqRule(left, right, series);
|
||||||
case "NEQ" -> new NotRule(buildEqRule(left, right, series));
|
case "NEQ" -> new NotRule(buildEqRule(left, right, series));
|
||||||
// CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음
|
// 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외)
|
||||||
case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right);
|
case "CROSS_UP" -> buildCrossUpRule(left, right);
|
||||||
case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right);
|
case "CROSS_DOWN" -> buildCrossDownRule(left, right);
|
||||||
case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
|
case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
|
||||||
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
|
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
|
||||||
case "DIFF_GT" -> {
|
case "DIFF_GT" -> {
|
||||||
@@ -750,7 +750,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
|
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
|
||||||
}
|
}
|
||||||
Map<String, Object> sma = ctx != null ? ctx.smaParams() : Map.of();
|
Map<String, Object> sma = ctx != null ? ctx.smaParams() : Map.of();
|
||||||
yield resolveIndicatorField(field, indType, p, sma, s, condPeriod, sidePeriod);
|
yield resolveIndicatorField(field, indType, p, sma, s, condPeriod, sidePeriod, cond);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -787,7 +787,8 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
Map<String, Object> p,
|
Map<String, Object> p,
|
||||||
Map<String, Object> smaParams,
|
Map<String, Object> smaParams,
|
||||||
BarSeries s,
|
BarSeries s,
|
||||||
int condPeriod, int sidePeriod) {
|
int condPeriod, int sidePeriod,
|
||||||
|
JsonNode cond) {
|
||||||
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
||||||
int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1);
|
int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1);
|
||||||
|
|
||||||
@@ -798,13 +799,13 @@ 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));
|
effectivePeriod(periodOverride, p, "length", 13));
|
||||||
if (field.equals("CCI_SIGNAL")) {
|
if (field.equals("CCI_SIGNAL")) {
|
||||||
Map<String, Object> norm = CciOnSourceIndicator.normalizeParams(p);
|
Map<String, Object> norm = CciOnSourceIndicator.normalizeParams(p);
|
||||||
int cciLen = periodOverride > 0 ? periodOverride : intP(p, "length", 13);
|
int cciLen = resolveLinkedBasePeriod(cond, p, "CCI_VALUE", periodOverride, "length", 13);
|
||||||
CciOnSourceIndicator cci = new CciOnSourceIndicator(resolvePriceSource(s, norm), cciLen);
|
CciOnSourceIndicator cci = new CciOnSourceIndicator(resolvePriceSource(s, norm), cciLen);
|
||||||
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
||||||
int maLen = intP(p, "maLength", 10);
|
int maLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 10);
|
||||||
if ("None".equals(maType)) return cci;
|
if ("None".equals(maType)) return cci;
|
||||||
return switch (maType) {
|
return switch (maType) {
|
||||||
case "EMA" -> new EMAIndicator(cci, maLen);
|
case "EMA" -> new EMAIndicator(cci, maLen);
|
||||||
@@ -818,12 +819,12 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return new RSIIndicator(close, period);
|
return new RSIIndicator(close, period);
|
||||||
}
|
}
|
||||||
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, effectivePeriod(periodOverride, p, "length", 14));
|
||||||
if (field.equals("RSI_SIGNAL")) {
|
if (field.equals("RSI_SIGNAL")) {
|
||||||
int rsiLen = periodOverride > 0 ? periodOverride : intP(p, "length", 14);
|
int rsiLen = resolveLinkedBasePeriod(cond, p, "RSI_VALUE", periodOverride, "length", 14);
|
||||||
RSIIndicator rsi = new RSIIndicator(close, rsiLen);
|
RSIIndicator rsi = new RSIIndicator(close, rsiLen);
|
||||||
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
||||||
int maLen = intP(p, "maLength", 14);
|
int maLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 14);
|
||||||
if ("None".equals(maType)) return rsi;
|
if ("None".equals(maType)) return rsi;
|
||||||
return switch (maType) {
|
return switch (maType) {
|
||||||
case "EMA" -> new EMAIndicator(rsi, maLen);
|
case "EMA" -> new EMAIndicator(rsi, maLen);
|
||||||
@@ -831,17 +832,16 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
default -> new SMAIndicator(rsi, maLen);
|
default -> new SMAIndicator(rsi, maLen);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// MACD
|
// MACD — cond.period → fastLength (프론트 대표 기간)
|
||||||
if (field.equals("MACD_LINE")) {
|
if (field.equals("MACD_LINE")) {
|
||||||
return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
|
return buildMacd(close, p, periodOverride);
|
||||||
}
|
}
|
||||||
if (field.equals("SIGNAL_LINE")) {
|
if (field.equals("SIGNAL_LINE")) {
|
||||||
return new EMAIndicator(
|
MACDIndicator macd = buildMacd(close, p, periodOverride);
|
||||||
new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)),
|
return new EMAIndicator(macd, intP(p, "signalLength", 9));
|
||||||
intP(p, "signalLength", 9));
|
|
||||||
}
|
}
|
||||||
if (field.equals("HISTOGRAM")) {
|
if (field.equals("HISTOGRAM")) {
|
||||||
MACDIndicator macd = new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
|
MACDIndicator macd = buildMacd(close, p, periodOverride);
|
||||||
return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9)));
|
return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9)));
|
||||||
}
|
}
|
||||||
// ADX / DMI
|
// ADX / DMI
|
||||||
@@ -850,13 +850,14 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return new ADXIndicator(s, intP(p, "diLength", 14), period);
|
return new ADXIndicator(s, intP(p, "diLength", 14), period);
|
||||||
}
|
}
|
||||||
if (field.equals("ADX_VALUE"))
|
if (field.equals("ADX_VALUE"))
|
||||||
return new ADXIndicator(s, intP(p, "diLength", 14), intP(p, "adxSmoothing", 14));
|
return new ADXIndicator(s, intP(p, "diLength", 14),
|
||||||
|
effectivePeriod(periodOverride, p, "adxSmoothing", 14));
|
||||||
if (field.equals("PDI") || field.equals("PLUS_DI")) {
|
if (field.equals("PDI") || field.equals("PLUS_DI")) {
|
||||||
int diLen = intP(p, "diLength", intP(p, "length", 14));
|
int diLen = effectivePeriod(periodOverride, p, "diLength", intP(p, "length", 14));
|
||||||
return new PlusDIIndicator(s, diLen);
|
return new PlusDIIndicator(s, diLen);
|
||||||
}
|
}
|
||||||
if (field.equals("MDI") || field.equals("MINUS_DI")) {
|
if (field.equals("MDI") || field.equals("MINUS_DI")) {
|
||||||
int diLen = intP(p, "diLength", intP(p, "length", 14));
|
int diLen = effectivePeriod(periodOverride, p, "diLength", intP(p, "length", 14));
|
||||||
return new MinusDIIndicator(s, diLen);
|
return new MinusDIIndicator(s, diLen);
|
||||||
}
|
}
|
||||||
// Stochastic Slow — IndicatorService.calcStochastic 과 동일한 로직:
|
// Stochastic Slow — IndicatorService.calcStochastic 과 동일한 로직:
|
||||||
@@ -864,12 +865,12 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
// slow %K = SMA(raw %K, smooth)
|
// slow %K = SMA(raw %K, smooth)
|
||||||
// %D = SMA(slow %K, dSmoothing)
|
// %D = SMA(slow %K, dSmoothing)
|
||||||
if (field.equals("STOCH_K")) {
|
if (field.equals("STOCH_K")) {
|
||||||
int kLen = intP(p, "kLength", 14);
|
int kLen = effectivePeriod(periodOverride, p, "kLength", 14);
|
||||||
int smooth = intP(p, "smooth", 3);
|
int smooth = intP(p, "smooth", 3);
|
||||||
return new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth);
|
return new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth);
|
||||||
}
|
}
|
||||||
if (field.equals("STOCH_D")) {
|
if (field.equals("STOCH_D")) {
|
||||||
int kLen = intP(p, "kLength", 14);
|
int kLen = effectivePeriod(periodOverride, p, "kLength", 14);
|
||||||
int smooth = intP(p, "smooth", 3);
|
int smooth = intP(p, "smooth", 3);
|
||||||
int dSmooth = intP(p, "dSmoothing", 3);
|
int dSmooth = intP(p, "dSmoothing", 3);
|
||||||
SMAIndicator slowK = new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth);
|
SMAIndicator slowK = new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth);
|
||||||
@@ -878,29 +879,23 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
// TRIX — 종가 3×EMA ROC% (업비트·키움)
|
// TRIX — 종가 3×EMA ROC% (업비트·키움)
|
||||||
if (field.startsWith("TRIX_VALUE_")) {
|
if (field.startsWith("TRIX_VALUE_")) {
|
||||||
int len = parseTrailingInt(field, "TRIX_VALUE_", intP(p, "length", 12));
|
int len = parseTrailingInt(field, "TRIX_VALUE_", intP(p, "length", 12));
|
||||||
EMAIndicator e3 = tripleEma(close, len);
|
return buildTrixLine(close, len);
|
||||||
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
|
|
||||||
return NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100);
|
|
||||||
}
|
}
|
||||||
if (field.equals("TRIX_VALUE")) {
|
if (field.equals("TRIX_VALUE") || indType.equals("TRIX")) {
|
||||||
int len = intP(p, "length", 12);
|
return buildTrixLine(close, effectivePeriod(periodOverride, p, "length", 12));
|
||||||
EMAIndicator e3 = tripleEma(close, len);
|
|
||||||
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
|
|
||||||
return NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100);
|
|
||||||
}
|
}
|
||||||
if (field.equals("TRIX_SIGNAL")) {
|
if (field.equals("TRIX_SIGNAL")) {
|
||||||
int len = intP(p, "length", 12);
|
int trixLen = resolveLinkedBasePeriod(cond, p, "TRIX_VALUE", periodOverride, "length", 12);
|
||||||
EMAIndicator e3 = tripleEma(close, len);
|
NumericIndicator trix = buildTrixLine(close, trixLen);
|
||||||
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
|
int sigLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "signalLength", 9);
|
||||||
NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100);
|
return new EMAIndicator(trix, sigLen);
|
||||||
return new EMAIndicator(trix, intP(p, "signalLength", 9));
|
|
||||||
}
|
}
|
||||||
// OBV
|
// OBV
|
||||||
if (field.equals("OBV_LINE")) return new OnBalanceVolumeIndicator(s);
|
if (field.equals("OBV_LINE")) return new OnBalanceVolumeIndicator(s);
|
||||||
if (field.equals("OBV_SIGNAL")) {
|
if (field.equals("OBV_SIGNAL")) {
|
||||||
OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s);
|
OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s);
|
||||||
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
||||||
int maLen = intP(p, "maLength", 9);
|
int maLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 9);
|
||||||
return switch (maType) {
|
return switch (maType) {
|
||||||
case "EMA" -> new EMAIndicator(obv, maLen);
|
case "EMA" -> new EMAIndicator(obv, maLen);
|
||||||
case "WMA" -> new WMAIndicator(obv, maLen);
|
case "WMA" -> new WMAIndicator(obv, maLen);
|
||||||
@@ -913,7 +908,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return vrIndicator(s, period);
|
return vrIndicator(s, period);
|
||||||
}
|
}
|
||||||
if (field.equals("VR_VALUE") || indType.equals("VR"))
|
if (field.equals("VR_VALUE") || indType.equals("VR"))
|
||||||
return vrIndicator(s, intP(p, "length", 10));
|
return vrIndicator(s, effectivePeriod(periodOverride, p, "length", 10));
|
||||||
// 이격도 DISPARITY5 → period 5
|
// 이격도 DISPARITY5 → period 5
|
||||||
if (field.startsWith("DISPARITY")) {
|
if (field.startsWith("DISPARITY")) {
|
||||||
int period = parseTrailingInt(field, "DISPARITY", 20);
|
int period = parseTrailingInt(field, "DISPARITY", 20);
|
||||||
@@ -924,25 +919,33 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return psychIndicator(s, period, false);
|
return psychIndicator(s, period, false);
|
||||||
}
|
}
|
||||||
if (field.equals("PSY_VALUE") || indType.equals("PSYCHOLOGICAL"))
|
if (field.equals("PSY_VALUE") || indType.equals("PSYCHOLOGICAL"))
|
||||||
return psychIndicator(s, intP(p, "length", 12), false);
|
return psychIndicator(s, effectivePeriod(periodOverride, p, "length", 12), false);
|
||||||
if (field.startsWith("NEW_PSY_VALUE_")) {
|
if (field.startsWith("NEW_PSY_VALUE_")) {
|
||||||
int period = parseTrailingInt(field, "NEW_PSY_VALUE_", intP(p, "length", 10));
|
int period = parseTrailingInt(field, "NEW_PSY_VALUE_", intP(p, "length", 10));
|
||||||
return newSentPsyIndicator(s, period);
|
return newSentPsyIndicator(s, period);
|
||||||
}
|
}
|
||||||
if (field.equals("NEW_PSY_VALUE") || indType.equals("NEW_PSYCHOLOGICAL"))
|
if (field.equals("NEW_PSY_VALUE") || indType.equals("NEW_PSYCHOLOGICAL"))
|
||||||
return newSentPsyIndicator(s, intP(p, "length", 10));
|
return newSentPsyIndicator(s, effectivePeriod(periodOverride, p, "length", 10));
|
||||||
if (field.startsWith("INVEST_PSY_VALUE_")) {
|
if (field.startsWith("INVEST_PSY_VALUE_")) {
|
||||||
int period = parseTrailingInt(field, "INVEST_PSY_VALUE_", intP(p, "length", 10));
|
int period = parseTrailingInt(field, "INVEST_PSY_VALUE_", intP(p, "length", 10));
|
||||||
return psychIndicator(s, period, false);
|
return psychIndicator(s, period, false);
|
||||||
}
|
}
|
||||||
if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL"))
|
if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL"))
|
||||||
return psychIndicator(s, intP(p, "length", 10), false);
|
return psychIndicator(s, effectivePeriod(periodOverride, p, "length", 10), false);
|
||||||
|
// BWI (Bollinger Band Width)
|
||||||
|
if (field.startsWith("BWI_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "BWI_VALUE_", intP(p, "length", 20));
|
||||||
|
return bollingerBandWidth(s, p, period);
|
||||||
|
}
|
||||||
|
if (field.equals("BWI_VALUE") || indType.equals("BWI"))
|
||||||
|
return bollingerBandWidth(s, p, effectivePeriod(periodOverride, p, "length", 20));
|
||||||
// Williams %R
|
// Williams %R
|
||||||
if (field.startsWith("WILLIAMS_R_VALUE_")) {
|
if (field.startsWith("WILLIAMS_R_VALUE_")) {
|
||||||
int period = parseTrailingInt(field, "WILLIAMS_R_VALUE_", intP(p, "length", 14));
|
int period = parseTrailingInt(field, "WILLIAMS_R_VALUE_", intP(p, "length", 14));
|
||||||
return new WilliamsRIndicator(s, period);
|
return new WilliamsRIndicator(s, period);
|
||||||
}
|
}
|
||||||
if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14));
|
if (field.equals("WILLIAMS_R_VALUE") || indType.equals("WILLIAMS_R"))
|
||||||
|
return new WilliamsRIndicator(s, effectivePeriod(periodOverride, p, "length", 14));
|
||||||
// 신고가·신저가 — 직전 N봉 최고/최저 (당일 봉 제외, PreviousValueIndicator)
|
// 신고가·신저가 — 직전 N봉 최고/최저 (당일 봉 제외, PreviousValueIndicator)
|
||||||
if (field.startsWith("NH_PRIOR_")) {
|
if (field.startsWith("NH_PRIOR_")) {
|
||||||
int period = parseTrailingInt(field, "NH_PRIOR_", intP(p, "length", 9));
|
int period = parseTrailingInt(field, "NH_PRIOR_", intP(p, "length", 9));
|
||||||
@@ -967,7 +970,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
}
|
}
|
||||||
// Bollinger Bands
|
// Bollinger Bands
|
||||||
if (field.equals("UPPER_BAND") || field.equals("LOWER_BAND") || field.equals("MIDDLE_BAND")) {
|
if (field.equals("UPPER_BAND") || field.equals("LOWER_BAND") || field.equals("MIDDLE_BAND")) {
|
||||||
int len = intP(p, "length", 20);
|
int len = effectivePeriod(periodOverride, p, "length", 20);
|
||||||
double mult = dblP(p, "mult", 2.0);
|
double mult = dblP(p, "mult", 2.0);
|
||||||
String maType = p.getOrDefault("maType", "SMA").toString();
|
String maType = p.getOrDefault("maType", "SMA").toString();
|
||||||
Indicator<Num> basis = maOfSource(close, s, maType, len);
|
Indicator<Num> basis = maOfSource(close, s, maType, len);
|
||||||
@@ -1004,7 +1007,13 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
if (field.equals("LEADING_SPAN2")) return ichimokuSpanB(s, p);
|
if (field.equals("LEADING_SPAN2")) return ichimokuSpanB(s, p);
|
||||||
if (field.equals("LAGGING_SPAN")) return ichimokuLagging(s, p);
|
if (field.equals("LAGGING_SPAN")) return ichimokuLagging(s, p);
|
||||||
// 거래량 MA
|
// 거래량 MA
|
||||||
if (field.equals("VOLUME_MA")) return new SMAIndicator(new VolumeIndicator(s, 1), intP(p, "length", 20));
|
if (field.equals("VOLUME_VALUE") || indType.equals("VOLUME"))
|
||||||
|
return new VolumeIndicator(s, 1);
|
||||||
|
if (field.equals("VOLUME_MA"))
|
||||||
|
return new SMAIndicator(new VolumeIndicator(s, 1), effectivePeriod(periodOverride, p, "length", 20));
|
||||||
|
// 거래량 오실레이터 — cond.period → shortLength (프론트 대표 기간)
|
||||||
|
if (field.equals("VOLUME_OSC_VALUE") || indType.equals("VOLUME_OSC"))
|
||||||
|
return volumeOscillator(s, p, periodOverride);
|
||||||
|
|
||||||
log.warn("[Adapter] 미지원 필드: {} (indicatorType={})", field, indType);
|
log.warn("[Adapter] 미지원 필드: {} (indicatorType={})", field, indType);
|
||||||
return new ClosePriceIndicator(s);
|
return new ClosePriceIndicator(s);
|
||||||
@@ -1240,6 +1249,41 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return new EMAIndicator(new EMAIndicator(new EMAIndicator(close, len), len), len);
|
return new EMAIndicator(new EMAIndicator(new EMAIndicator(close, len), len), len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TRIX 본선 — 종가 3×EMA ROC% */
|
||||||
|
private NumericIndicator buildTrixLine(ClosePriceIndicator close, int len) {
|
||||||
|
EMAIndicator e3 = tripleEma(close, len);
|
||||||
|
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
|
||||||
|
return NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MACDIndicator buildMacd(ClosePriceIndicator close, Map<String, Object> p, int periodOverride) {
|
||||||
|
int fast = effectivePeriod(periodOverride, p, "fastLength", 12);
|
||||||
|
return new MACDIndicator(close, fast, intP(p, "slowLength", 26));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BWI — IndicatorService.calcBBBandWidth 와 동일 */
|
||||||
|
private BollingerBandWidthIndicator bollingerBandWidth(BarSeries s, Map<String, Object> p, int len) {
|
||||||
|
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
||||||
|
double mult = dblP(p, "mult", 2.0);
|
||||||
|
SMAIndicator sma = new SMAIndicator(close, len);
|
||||||
|
StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(close, len);
|
||||||
|
BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma);
|
||||||
|
Num k = s.numFactory().numOf(mult);
|
||||||
|
BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k);
|
||||||
|
BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k);
|
||||||
|
return new BollingerBandWidthIndicator(upper, mid, lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VolumeOscillator — IndicatorService.calcVolumeOscillator 와 동일 */
|
||||||
|
private NumericIndicator volumeOscillator(BarSeries s, Map<String, Object> p, int periodOverride) {
|
||||||
|
int shortLen = effectivePeriod(periodOverride, p, "shortLength", 5);
|
||||||
|
int longLen = intP(p, "longLength", 10);
|
||||||
|
VolumeIndicator vol = new VolumeIndicator(s, 1);
|
||||||
|
EMAIndicator shortEma = new EMAIndicator(vol, shortLen);
|
||||||
|
EMAIndicator longEma = new EMAIndicator(vol, longLen);
|
||||||
|
return NumericIndicator.of(shortEma).minus(longEma).dividedBy(longEma).multipliedBy(100);
|
||||||
|
}
|
||||||
|
|
||||||
private Indicator<Num> maOfSource(Indicator<Num> source, BarSeries s, String maType, int len) {
|
private Indicator<Num> maOfSource(Indicator<Num> source, BarSeries s, String maType, int len) {
|
||||||
return switch (maType) {
|
return switch (maType) {
|
||||||
case "EMA" -> new EMAIndicator(source, len);
|
case "EMA" -> new EMAIndicator(source, len);
|
||||||
@@ -1463,6 +1507,76 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
|
|
||||||
// ── 파라미터 헬퍼 ─────────────────────────────────────────────────────────
|
// ── 파라미터 헬퍼 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 직전봉 left≤right → 현재봉 left>right (차트 교차·CrossTimeframeCompositeRule 과 동일) */
|
||||||
|
private Rule buildCrossUpRule(Indicator<Num> left, Indicator<Num> right) {
|
||||||
|
return new Rule() {
|
||||||
|
@Override
|
||||||
|
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||||
|
if (index < 1) return false;
|
||||||
|
Num l0 = left.getValue(index - 1);
|
||||||
|
Num l1 = left.getValue(index);
|
||||||
|
Num r0 = right.getValue(index - 1);
|
||||||
|
Num r1 = right.getValue(index);
|
||||||
|
if (l0 == null || l1 == null || r0 == null || r1 == null) return false;
|
||||||
|
if (l0.isNaN() || l1.isNaN() || r0.isNaN() || r1.isNaN()) return false;
|
||||||
|
return l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rule buildCrossDownRule(Indicator<Num> left, Indicator<Num> right) {
|
||||||
|
return new Rule() {
|
||||||
|
@Override
|
||||||
|
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||||
|
if (index < 1) return false;
|
||||||
|
Num l0 = left.getValue(index - 1);
|
||||||
|
Num l1 = left.getValue(index);
|
||||||
|
Num r0 = right.getValue(index - 1);
|
||||||
|
Num r1 = right.getValue(index);
|
||||||
|
if (l0 == null || l1 == null || r0 == null || r1 == null) return false;
|
||||||
|
if (l0.isNaN() || l1.isNaN() || r0.isNaN() || r1.isNaN()) return false;
|
||||||
|
return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다.
|
||||||
|
* sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다.
|
||||||
|
*/
|
||||||
|
private int resolveLinkedBasePeriod(JsonNode cond, Map<String, Object> p, String valuePrefix,
|
||||||
|
int periodOverride, String lengthKey, int defaultLen) {
|
||||||
|
if (cond != null && !cond.isNull()) {
|
||||||
|
String leftField = cond.path("leftField").asText("");
|
||||||
|
if (leftField.startsWith(valuePrefix + "_")) {
|
||||||
|
int fromField = parseTrailingInt(leftField, valuePrefix + "_", defaultLen);
|
||||||
|
if (fromField > 0) return fromField;
|
||||||
|
}
|
||||||
|
int leftPeriod = cond.path("leftPeriod").asInt(-1);
|
||||||
|
if (leftPeriod > 0) return leftPeriod;
|
||||||
|
int condPeriod = cond.path("period").asInt(-1);
|
||||||
|
if (condPeriod > 0) return condPeriod;
|
||||||
|
}
|
||||||
|
return effectivePeriod(periodOverride, p, lengthKey, defaultLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 신호선 SMA/EMA 길이 — rightPeriod·sidePeriod 우선, 없으면 지표 설정 maLength */
|
||||||
|
private int resolveSignalSmoothingPeriod(JsonNode cond, int sidePeriod,
|
||||||
|
Map<String, Object> p, String maKey, int defaultMa) {
|
||||||
|
if (sidePeriod > 0) return sidePeriod;
|
||||||
|
if (cond != null && !cond.isNull()) {
|
||||||
|
int rightPeriod = cond.path("rightPeriod").asInt(-1);
|
||||||
|
if (rightPeriod > 0) return rightPeriod;
|
||||||
|
}
|
||||||
|
return intP(p, maKey, defaultMa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조건 period / left·right sidePeriod 가 지표 length 계열 파라미터보다 우선 */
|
||||||
|
private int effectivePeriod(int periodOverride, Map<String, Object> p, String paramKey, int defaultVal) {
|
||||||
|
if (periodOverride > 0) return periodOverride;
|
||||||
|
return intP(p, paramKey, defaultVal);
|
||||||
|
}
|
||||||
|
|
||||||
private int intP(Map<String, Object> p, String k, int def) {
|
private int intP(Map<String, Object> p, String k, int def) {
|
||||||
if (p == null) return def;
|
if (p == null) return def;
|
||||||
Object v = p.get(k);
|
Object v = p.get(k);
|
||||||
@@ -1512,4 +1626,19 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
merged.putAll(overrides);
|
merged.putAll(overrides);
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 패키지 단위 테스트 — condPeriod/sidePeriod 가 지표에 반영되는지 검증용 */
|
||||||
|
Indicator<Num> resolveIndicatorFieldForTest(String field, String indType,
|
||||||
|
Map<String, Object> p, BarSeries s,
|
||||||
|
int condPeriod, int sidePeriod) {
|
||||||
|
return resolveIndicatorField(field, indType, p, Map.of(), s, condPeriod, sidePeriod, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 패키지 단위 테스트 — cond JSON 포함 CCI 본선/신호선 연동 검증용 */
|
||||||
|
Indicator<Num> resolveIndicatorFieldForTest(String field, String indType,
|
||||||
|
Map<String, Object> p, BarSeries s,
|
||||||
|
int condPeriod, int sidePeriod,
|
||||||
|
JsonNode cond) {
|
||||||
|
return resolveIndicatorField(field, indType, p, Map.of(), s, condPeriod, sidePeriod, cond);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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 java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CCI 본선(CCI_VALUE_20) ↔ 신호선(CCI_SIGNAL) 교차 — 차트와 동일 Rule 평가.
|
||||||
|
*/
|
||||||
|
class CciCrossSignalScanTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private StrategyDslToTa4jAdapter adapter;
|
||||||
|
private BarSeries series;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new StrategyDslToTa4jAdapter();
|
||||||
|
series = buildOscillatingSeries(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cciSignal_usesLeftFieldPeriod_notGlobalLength() throws Exception {
|
||||||
|
JsonNode cond = MAPPER.readTree("""
|
||||||
|
{
|
||||||
|
"indicatorType": "CCI",
|
||||||
|
"conditionType": "CROSS_UP",
|
||||||
|
"leftField": "CCI_VALUE_20",
|
||||||
|
"rightField": "CCI_SIGNAL",
|
||||||
|
"period": 20,
|
||||||
|
"valuePeriodOverride": true
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Map<String, Object> params = Map.of("length", 13, "maLength", 10, "maType", "SMA", "src", "hlc3");
|
||||||
|
|
||||||
|
var value20 = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"CCI_VALUE_20", "CCI", params, series, 20, -1, cond);
|
||||||
|
var signalLinked = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"CCI_SIGNAL", "CCI", params, series, 20, -1, cond);
|
||||||
|
var signalUnlinked = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"CCI_SIGNAL", "CCI", params, series, -1, -1, null);
|
||||||
|
|
||||||
|
int idx = 80;
|
||||||
|
assertNotEquals(signalLinked.getValue(idx).doubleValue(), signalUnlinked.getValue(idx).doubleValue(), 1e-9,
|
||||||
|
"CCI_SIGNAL must use CCI(20) from leftField, not global length=13");
|
||||||
|
assertNotEquals(value20.getValue(idx).doubleValue(), signalLinked.getValue(idx).doubleValue(), 1e-9,
|
||||||
|
"signal line is SMA(CCI), not raw CCI");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cciCrossUp_firesOnAtLeastOneBar() throws Exception {
|
||||||
|
JsonNode buyDsl = MAPPER.readTree("""
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "5m",
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "CCI",
|
||||||
|
"conditionType": "CROSS_UP",
|
||||||
|
"leftField": "CCI_VALUE_20",
|
||||||
|
"rightField": "CCI_SIGNAL",
|
||||||
|
"period": 20,
|
||||||
|
"valuePeriodOverride": true,
|
||||||
|
"candleRange": 1
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Map<String, Map<String, Object>> indicatorParams = Map.of(
|
||||||
|
"CCI", Map.of("length", 20, "maLength", 10, "maType", "SMA", "src", "hlc3"));
|
||||||
|
|
||||||
|
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||||
|
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||||
|
series, indicatorParams, Map.of(), null, null, false, Map.of());
|
||||||
|
Rule rule = adapter.toRule(buyDsl, ctx);
|
||||||
|
|
||||||
|
int hits = 0;
|
||||||
|
for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) {
|
||||||
|
if (rule.isSatisfied(i, null)) hits++;
|
||||||
|
}
|
||||||
|
assertTrue(hits > 0, "CCI(20) cross signal(10) should fire at least once on oscillating series, got " + hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BarSeries buildOscillatingSeries(int count) {
|
||||||
|
BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder()
|
||||||
|
.withName("cci-cross-test")
|
||||||
|
.withNumFactory(DoubleNumFactory.getInstance())
|
||||||
|
.withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(5), 1));
|
||||||
|
BarSeries s = builder.build();
|
||||||
|
Instant t = Instant.parse("2024-06-01T00:00:00Z");
|
||||||
|
double price = 100_000_000.0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
double wave = Math.sin(i * 0.22) * 800_000 + Math.sin(i * 0.07) * 400_000;
|
||||||
|
double close = price + wave + i * 5000;
|
||||||
|
double open = close - 20_000;
|
||||||
|
double high = close + 80_000;
|
||||||
|
double low = close - 80_000;
|
||||||
|
s.addBar(t, open, high, low, close, 50 + i);
|
||||||
|
t = t.plus(Duration.ofMinutes(5));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
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.Indicator;
|
||||||
|
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||||
|
import org.ta4j.core.num.DoubleNumFactory;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건 노드 period / leftPeriod / rightPeriod 가 각 지표 필드에 반영되는지 검증.
|
||||||
|
* (CCI·RSI·TRIX 등 본선/신호선 교차 시 동일 기간 사용)
|
||||||
|
*/
|
||||||
|
class OscillatorPeriodOverrideTest {
|
||||||
|
|
||||||
|
private StrategyDslToTa4jAdapter adapter;
|
||||||
|
private BarSeries series;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new StrategyDslToTa4jAdapter();
|
||||||
|
series = buildSeries(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trixSignal_respectsConditionPeriod() {
|
||||||
|
Map<String, Object> p = Map.of("length", 12, "signalLength", 9);
|
||||||
|
Indicator<?> defaultSig = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"TRIX_SIGNAL", "TRIX", p, series, -1, -1);
|
||||||
|
Indicator<?> overrideSig = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"TRIX_SIGNAL", "TRIX", p, series, 20, -1);
|
||||||
|
assertValuesDiffer(defaultSig, overrideSig, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stochK_respectsConditionPeriod() {
|
||||||
|
Map<String, Object> p = Map.of("kLength", 14, "smooth", 3, "dSmoothing", 3);
|
||||||
|
Indicator<?> defaultK = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"STOCH_K", "STOCHASTIC", p, series, -1, -1);
|
||||||
|
Indicator<?> overrideK = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"STOCH_K", "STOCHASTIC", p, series, 21, -1);
|
||||||
|
assertValuesDiffer(defaultK, overrideK, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void williamsR_respectsConditionPeriod() {
|
||||||
|
Map<String, Object> p = Map.of("length", 14);
|
||||||
|
Indicator<?> defaultW = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"WILLIAMS_R_VALUE", "WILLIAMS_R", p, series, -1, -1);
|
||||||
|
Indicator<?> overrideW = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"WILLIAMS_R_VALUE", "WILLIAMS_R", p, series, 20, -1);
|
||||||
|
assertValuesDiffer(defaultW, overrideW, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bwiValue_resolvesInsteadOfCloseFallback() {
|
||||||
|
Map<String, Object> p = Map.of("length", 20, "mult", 2.0);
|
||||||
|
Indicator<?> bwi = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"BWI_VALUE", "BWI", p, series, -1, -1);
|
||||||
|
Indicator<?> close = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"UNKNOWN_FIELD", "BWI", p, series, -1, -1);
|
||||||
|
assertValuesDiffer(bwi, close, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cciSignalAndValue_useSamePeriodWhenCondPeriodSet() {
|
||||||
|
Map<String, Object> p = Map.of("length", 13, "maLength", 10, "maType", "SMA");
|
||||||
|
Indicator<?> value = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"CCI_VALUE", "CCI", p, series, 20, -1);
|
||||||
|
Indicator<?> signal = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"CCI_SIGNAL", "CCI", p, series, 20, -1);
|
||||||
|
// 동일 condPeriod → 신호선은 본선 SMA이므로 값이 항상 같지는 않지만,
|
||||||
|
// 기본(13) 대비 override(20) 시 둘 다 변해야 함
|
||||||
|
Indicator<?> valueDefault = adapter.resolveIndicatorFieldForTest(
|
||||||
|
"CCI_VALUE", "CCI", p, series, -1, -1);
|
||||||
|
assertNotEquals(value.getValue(80).doubleValue(), valueDefault.getValue(80).doubleValue(), 1e-6);
|
||||||
|
assertNotEquals(signal.getValue(80).doubleValue(),
|
||||||
|
adapter.resolveIndicatorFieldForTest("CCI_SIGNAL", "CCI", p, series, -1, -1)
|
||||||
|
.getValue(80).doubleValue(), 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertValuesDiffer(Indicator<?> a, Indicator<?> b, int index) {
|
||||||
|
assertNotEquals(a.getValue(index).doubleValue(), b.getValue(index).doubleValue(), 1e-9,
|
||||||
|
"period override should change indicator values at index " + index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BarSeries buildSeries(int count) {
|
||||||
|
BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder()
|
||||||
|
.withName("test")
|
||||||
|
.withNumFactory(DoubleNumFactory.getInstance())
|
||||||
|
.withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(1), 1));
|
||||||
|
BarSeries s = builder.build();
|
||||||
|
Instant t = Instant.parse("2024-01-01T00:00:00Z");
|
||||||
|
double price = 100.0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
double drift = Math.sin(i * 0.15) * 3 + i * 0.05;
|
||||||
|
double close = price + drift;
|
||||||
|
double open = close - 0.3;
|
||||||
|
double high = close + 1.2;
|
||||||
|
double low = close - 1.0;
|
||||||
|
s.addBar(t, open, high, low, close, 1000 + i * 10);
|
||||||
|
t = t.plus(Duration.ofMinutes(1));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,10 @@ interface MarketSearchPanelProps {
|
|||||||
marketInfos?: HookMarketInfo[];
|
marketInfos?: HookMarketInfo[];
|
||||||
marketLoading?: boolean;
|
marketLoading?: boolean;
|
||||||
refreshAllTickers?: () => Promise<void>;
|
refreshAllTickers?: () => Promise<void>;
|
||||||
|
/** 내부 훅 사용 시 전 종목 REST·폴링 (기본 true). 전략평가 검색은 false 권장 */
|
||||||
|
tickerLoadFull?: boolean;
|
||||||
|
/** 내부 훅 우선 종목 (전략평가 차트 종목 등) */
|
||||||
|
tickerPriorityMarkets?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||||
@@ -94,7 +98,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
variant = 'overlay', query: controlledQuery, onQueryChange,
|
variant = 'overlay', query: controlledQuery, onQueryChange,
|
||||||
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
|
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
|
||||||
tickers: tickersProp, marketInfos: marketInfosProp, marketLoading: marketLoadingProp,
|
tickers: tickersProp, marketInfos: marketInfosProp, marketLoading: marketLoadingProp,
|
||||||
refreshAllTickers: refreshProp,
|
refreshAllTickers: refreshProp, tickerLoadFull = true, tickerPriorityMarkets,
|
||||||
}) => {
|
}) => {
|
||||||
const embedded = variant === 'embedded';
|
const embedded = variant === 'embedded';
|
||||||
const isCompact = useIsCompactDevice();
|
const isCompact = useIsCompactDevice();
|
||||||
@@ -102,7 +106,8 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
|
|
||||||
const internalFeed = useMarketTicker({
|
const internalFeed = useMarketTicker({
|
||||||
enabled: !useExternalFeed,
|
enabled: !useExternalFeed,
|
||||||
loadFull: true,
|
loadFull: tickerLoadFull,
|
||||||
|
priorityMarkets: tickerPriorityMarkets ?? (currentMarket ? [currentMarket] : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tickers = tickersProp ?? internalFeed.tickers;
|
const tickers = tickersProp ?? internalFeed.tickers;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
|
|||||||
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||||
import { getKoreanName } from '../utils/marketNameCache';
|
import { getKoreanName } from '../utils/marketNameCache';
|
||||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||||
|
import { useMarketTicker } from '../hooks/useMarketTicker';
|
||||||
import {
|
import {
|
||||||
mergeEvalGetParams,
|
mergeEvalGetParams,
|
||||||
buildEvalParamsFromStrategy,
|
buildEvalParamsFromStrategy,
|
||||||
@@ -69,6 +70,7 @@ interface Props {
|
|||||||
export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||||
const { getParams: baseGetParams, getVisualConfig } = useIndicatorSettings();
|
const { getParams: baseGetParams, getVisualConfig } = useIndicatorSettings();
|
||||||
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
||||||
|
const [marketSearchOpen, setMarketSearchOpen] = useState(false);
|
||||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
const [strategySearch, setStrategySearch] = useState('');
|
const [strategySearch, setStrategySearch] = useState('');
|
||||||
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
|
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
|
||||||
@@ -100,6 +102,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
[baseGetParams, appliedIndicatorParams],
|
[baseGetParams, appliedIndicatorParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 종목 탭·종목검색 전용 ticker — 차트 OHLCV·scan-signals 와 분리 (upbitWsBroker ticker 채널) */
|
||||||
|
const marketTabActive = leftOpen && leftTab === 'market';
|
||||||
|
const marketTickerOpts = useMemo(() => ({
|
||||||
|
enabled: marketTabActive || marketSearchOpen,
|
||||||
|
loadFull: marketTabActive,
|
||||||
|
priorityMarkets: [market],
|
||||||
|
}), [marketTabActive, marketSearchOpen, market]);
|
||||||
|
const marketFeed = useMarketTicker(marketTickerOpts);
|
||||||
|
|
||||||
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||||||
const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true));
|
const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true));
|
||||||
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
||||||
@@ -543,7 +554,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
<StrategyEvaluationMarketTab
|
<StrategyEvaluationMarketTab
|
||||||
activeMarket={market}
|
activeMarket={market}
|
||||||
onSelectMarket={setMarket}
|
onSelectMarket={setMarket}
|
||||||
enabled={leftOpen && leftTab === 'market'}
|
tickers={marketFeed.tickers}
|
||||||
|
marketInfos={marketFeed.marketInfos}
|
||||||
|
loading={marketFeed.loading}
|
||||||
|
usdRate={marketFeed.usdRate}
|
||||||
|
refreshAllTickers={marketFeed.refreshAllTickers}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StrategyEvaluationSettingsTab
|
<StrategyEvaluationSettingsTab
|
||||||
@@ -602,6 +617,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
onOpenReport={() => { void handleOpenReport(); }}
|
onOpenReport={() => { void handleOpenReport(); }}
|
||||||
reportDisabled={reportDisabled}
|
reportDisabled={reportDisabled}
|
||||||
reportLoading={reportLoading}
|
reportLoading={reportLoading}
|
||||||
|
marketTickers={marketFeed.tickers}
|
||||||
|
marketInfos={marketFeed.marketInfos}
|
||||||
|
marketLoading={marketFeed.loading}
|
||||||
|
refreshMarketTickers={marketFeed.refreshAllTickers}
|
||||||
|
onMarketSearchOpenChange={setMarketSearchOpen}
|
||||||
/>
|
/>
|
||||||
<StrategyEvaluationConditionPanel
|
<StrategyEvaluationConditionPanel
|
||||||
strategy={selectedStrategy}
|
strategy={selectedStrategy}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
|||||||
import { getKoreanName } from '../../utils/marketNameCache';
|
import { getKoreanName } from '../../utils/marketNameCache';
|
||||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||||
import { fetchLiveConditionScanSignals } from '../../utils/backendApi';
|
import { fetchLiveConditionScanSignals } from '../../utils/backendApi';
|
||||||
|
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
|
||||||
import {
|
import {
|
||||||
BACKTEST_DISPLAY_BAR_COUNT,
|
BACKTEST_DISPLAY_BAR_COUNT,
|
||||||
fetchBacktestCandleBundle,
|
fetchBacktestCandleBundle,
|
||||||
@@ -73,6 +74,12 @@ interface Props {
|
|||||||
onOpenReport?: () => void;
|
onOpenReport?: () => void;
|
||||||
reportDisabled?: boolean;
|
reportDisabled?: boolean;
|
||||||
reportLoading?: boolean;
|
reportLoading?: boolean;
|
||||||
|
/** StrategyEvaluationPage 공유 ticker (종목 탭·검색과 단일 useMarketTicker) */
|
||||||
|
marketTickers?: Map<string, TickerData>;
|
||||||
|
marketInfos?: MarketInfo[];
|
||||||
|
marketLoading?: boolean;
|
||||||
|
refreshMarketTickers?: () => Promise<void>;
|
||||||
|
onMarketSearchOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
||||||
@@ -109,6 +116,11 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
onOpenReport,
|
onOpenReport,
|
||||||
reportDisabled = false,
|
reportDisabled = false,
|
||||||
reportLoading = false,
|
reportLoading = false,
|
||||||
|
marketTickers,
|
||||||
|
marketInfos,
|
||||||
|
marketLoading,
|
||||||
|
refreshMarketTickers,
|
||||||
|
onMarketSearchOpenChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings();
|
const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings();
|
||||||
const getParams = getParamsOverride ?? baseGetParams;
|
const getParams = getParamsOverride ?? baseGetParams;
|
||||||
@@ -405,6 +417,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [bars.length, selectedBarIndex, stepBar]);
|
}, [bars.length, selectedBarIndex, stepBar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMarketSearchOpenChange?.(showMarketSearch);
|
||||||
|
}, [showMarketSearch, onMarketSearchOpenChange]);
|
||||||
|
|
||||||
const selectedBar = bars[selectedBarIndex] ?? null;
|
const selectedBar = bars[selectedBarIndex] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -611,6 +627,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
currentMarket={market}
|
currentMarket={market}
|
||||||
query={marketQuery}
|
query={marketQuery}
|
||||||
onQueryChange={setMarketQuery}
|
onQueryChange={setMarketQuery}
|
||||||
|
tickers={marketTickers}
|
||||||
|
marketInfos={marketInfos}
|
||||||
|
marketLoading={marketLoading}
|
||||||
|
refreshAllTickers={refreshMarketTickers}
|
||||||
onSelect={m => {
|
onSelect={m => {
|
||||||
onMarketChange(m);
|
onMarketChange(m);
|
||||||
setShowMarketSearch(false);
|
setShowMarketSearch(false);
|
||||||
|
|||||||
@@ -1,34 +1,28 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import MarketPanel from '../MarketPanel';
|
import MarketPanel from '../MarketPanel';
|
||||||
import { useMarketTicker } from '../../hooks/useMarketTicker';
|
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activeMarket: string;
|
activeMarket: string;
|
||||||
onSelectMarket: (market: string) => void;
|
onSelectMarket: (market: string) => void;
|
||||||
/** 종목 탭 표시 중 — 전 종목 시세 로드 */
|
/** StrategyEvaluationPage 공유 useMarketTicker — 차트·시그널과 분리된 ticker 전용 */
|
||||||
enabled: boolean;
|
tickers: Map<string, TickerData>;
|
||||||
|
marketInfos: MarketInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
usdRate: number;
|
||||||
|
refreshAllTickers?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 전략평가 좌측 — 실시간 차트 MarketPanel 과 동일 종목 목록 */
|
/** 전략평가 좌측 — 실시간 차트 MarketPanel 과 동일 종목 목록 (현재가 WS/REST만, OHLCV·시그널 무관) */
|
||||||
const StrategyEvaluationMarketTab: React.FC<Props> = ({
|
const StrategyEvaluationMarketTab: React.FC<Props> = ({
|
||||||
activeMarket,
|
activeMarket,
|
||||||
onSelectMarket,
|
onSelectMarket,
|
||||||
enabled,
|
|
||||||
}) => {
|
|
||||||
const tickerOpts = useMemo(() => ({
|
|
||||||
enabled,
|
|
||||||
loadFull: enabled,
|
|
||||||
priorityMarkets: [activeMarket],
|
|
||||||
}), [enabled, activeMarket]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
tickers,
|
tickers,
|
||||||
marketInfos,
|
marketInfos,
|
||||||
loading,
|
loading,
|
||||||
usdRate,
|
usdRate,
|
||||||
refreshAllTickers,
|
refreshAllTickers,
|
||||||
} = useMarketTicker(tickerOpts);
|
}) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="seval-market-tab">
|
<div className="seval-market-tab">
|
||||||
<MarketPanel
|
<MarketPanel
|
||||||
|
|||||||
Reference in New Issue
Block a user