가상투자 종목카드박스 레이아웃 수정
This commit is contained in:
@@ -86,6 +86,8 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map.entry("VR", "VR"),
|
||||
Map.entry("DISPARITY", "Disparity"),
|
||||
Map.entry("DONCHIAN", "DonchianChannels"),
|
||||
Map.entry("NEW_HIGH", "PriceExtreme"),
|
||||
Map.entry("NEW_LOW", "PriceExtreme"),
|
||||
Map.entry("VOLUME", "VOLUME")
|
||||
);
|
||||
|
||||
@@ -281,6 +283,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
return buildCrossTimeframeCompositeRule(cond, ctx);
|
||||
}
|
||||
|
||||
PriceExtremeNorm px = normalizePriceExtremeCondition(cond);
|
||||
if (px != null) {
|
||||
indType = px.indType();
|
||||
condType = px.condType();
|
||||
leftField = px.leftField();
|
||||
rightField = px.rightField();
|
||||
condPeriod = px.period();
|
||||
}
|
||||
|
||||
// DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회
|
||||
String registryKey = toRegistryKey(indType);
|
||||
Map<String, Object> indParams = params != null
|
||||
@@ -291,7 +302,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod);
|
||||
Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod);
|
||||
|
||||
return switch (condType) {
|
||||
Rule core = switch (condType) {
|
||||
case "GT" -> new OverIndicatorRule(left, right);
|
||||
case "LT" -> new UnderIndicatorRule(left, right);
|
||||
// GTE/LTE: OverIndicator OR 동일 (epsilon 비교)
|
||||
@@ -336,12 +347,72 @@ public class StrategyDslToTa4jAdapter {
|
||||
yield new BooleanRule(false);
|
||||
}
|
||||
};
|
||||
if (px != null) {
|
||||
return nanSafeCompareRule(core, left, right);
|
||||
}
|
||||
return core;
|
||||
} catch (Exception e) {
|
||||
log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage());
|
||||
return new BooleanRule(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고가·신저가 DSL 정규화 — 구버전 DC_UPPER/DC_LOWER·복합지표 승격 필드를 NH_PRIOR/NL_PRIOR 로 통일.
|
||||
*/
|
||||
private record PriceExtremeNorm(String indType, String condType, String leftField, String rightField, int period) {}
|
||||
|
||||
private PriceExtremeNorm normalizePriceExtremeCondition(JsonNode cond) {
|
||||
String ind = cond.path("indicatorType").asText("");
|
||||
if (!"NEW_HIGH".equals(ind) && !"NEW_LOW".equals(ind)) return null;
|
||||
|
||||
String ct = cond.path("conditionType").asText("GT");
|
||||
String left = cond.path("leftField").asText("CLOSE_PRICE");
|
||||
String right = cond.path("rightField").asText("");
|
||||
|
||||
int period = cond.path("period").asInt(-1);
|
||||
int leftP = cond.path("leftPeriod").asInt(-1);
|
||||
if (period <= 0) period = leftP > 0 ? leftP : 9;
|
||||
|
||||
if (right.startsWith("DC_UPPER_")) {
|
||||
period = parseTrailingInt(right, "DC_UPPER_", period);
|
||||
right = "NH_PRIOR_" + period;
|
||||
} else if (right.startsWith("DC_LOWER_")) {
|
||||
period = parseTrailingInt(right, "DC_LOWER_", period);
|
||||
right = "NL_PRIOR_" + period;
|
||||
} else if (right.startsWith("NH_PRIOR_")) {
|
||||
period = parseTrailingInt(right, "NH_PRIOR_", period);
|
||||
} else if (right.startsWith("NL_PRIOR_")) {
|
||||
period = parseTrailingInt(right, "NL_PRIOR_", period);
|
||||
} else if (right.isBlank() || "NONE".equals(right)) {
|
||||
right = "NEW_HIGH".equals(ind) ? "NH_PRIOR_" + period : "NL_PRIOR_" + period;
|
||||
}
|
||||
|
||||
if (left.isBlank() || "NONE".equals(left)) left = "CLOSE_PRICE";
|
||||
|
||||
// Donchian 당일봉 포함 DC_* + CROSS 는 거의 성립하지 않음 → PRIOR 필드 + GTE/LTE
|
||||
if ("NEW_HIGH".equals(ind) && "CROSS_UP".equals(ct) && !right.startsWith("NH_PRIOR_")) {
|
||||
ct = "GTE";
|
||||
} else if ("NEW_LOW".equals(ind) && "CROSS_DOWN".equals(ct) && !right.startsWith("NL_PRIOR_")) {
|
||||
ct = "LTE";
|
||||
}
|
||||
|
||||
return new PriceExtremeNorm(ind, ct, left, right, period);
|
||||
}
|
||||
|
||||
/** 직전 N봉 극값이 NaN 인 구간(워밍업)에서는 조건 미충족 */
|
||||
private Rule nanSafeCompareRule(Rule inner, Indicator<Num> left, Indicator<Num> right) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
Num l = left.getValue(index);
|
||||
Num r = right.getValue(index);
|
||||
if (l == null || r == null || l.isNaN() || r.isNaN()) return false;
|
||||
return inner.isSatisfied(index, tradingRecord);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */
|
||||
private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) {
|
||||
if (!cond.path("composite").asBoolean(false)) return false;
|
||||
@@ -611,6 +682,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
return new WilliamsRIndicator(s, period);
|
||||
}
|
||||
if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14));
|
||||
// 신고가·신저가 — 직전 N봉 최고/최저 (당일 봉 제외, PreviousValueIndicator)
|
||||
if (field.startsWith("NH_PRIOR_")) {
|
||||
int period = parseTrailingInt(field, "NH_PRIOR_", intP(p, "length", 9));
|
||||
return priorHighestHigh(s, period);
|
||||
}
|
||||
if (field.startsWith("NL_PRIOR_")) {
|
||||
int period = parseTrailingInt(field, "NL_PRIOR_", intP(p, "length", 9));
|
||||
return priorLowestLow(s, period);
|
||||
}
|
||||
// Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사
|
||||
if (field.startsWith("DC_UPPER_")) {
|
||||
int period = parseTrailingInt(field, "DC_UPPER_", intP(p, "length", 20));
|
||||
@@ -857,6 +937,25 @@ public class StrategyDslToTa4jAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직전 N봉 고가 중 최고값 (현재 봉 제외).
|
||||
* index t 에서 max(high[t-N]..high[t-1]).
|
||||
*/
|
||||
private Indicator<Num> priorHighestHigh(BarSeries s, int period) {
|
||||
HighPriceIndicator high = new HighPriceIndicator(s);
|
||||
HighestValueIndicator highest = new HighestValueIndicator(high, period);
|
||||
return new PreviousValueIndicator(highest, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직전 N봉 저가 중 최저값 (현재 봉 제외).
|
||||
*/
|
||||
private Indicator<Num> priorLowestLow(BarSeries s, int period) {
|
||||
LowPriceIndicator low = new LowPriceIndicator(s);
|
||||
LowestValueIndicator lowest = new LowestValueIndicator(low, period);
|
||||
return new PreviousValueIndicator(lowest, 1);
|
||||
}
|
||||
|
||||
// ── 파라미터 헬퍼 ─────────────────────────────────────────────────────────
|
||||
|
||||
private int intP(Map<String, Object> p, String k, int def) {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.ta4j.core.TradingRecord;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 9일 신고가 매수 / 9일 신저가 매도 — Rule·백테스트 루프 동작 검증.
|
||||
*/
|
||||
class PriceExtremeRuleTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private StrategyDslToTa4jAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new StrategyDslToTa4jAdapter();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nhPrior_buyGte_firesOnBreakout_notOnFirstBars() throws Exception {
|
||||
BarSeries series = buildTrendSeries(120, true);
|
||||
Rule buy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_HIGH",
|
||||
"conditionType": "GTE",
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "NH_PRIOR_9",
|
||||
"period": 9
|
||||
}}
|
||||
""");
|
||||
|
||||
List<Integer> hits = satisfiedIndices(buy, series);
|
||||
assertTrue(hits.stream().noneMatch(i -> i < 9),
|
||||
"9봉 미만에서는 신고가선이 정의되지 않아 매수 조건이 없어야 함");
|
||||
assertFalse(hits.isEmpty(), "상승 구간에서 최소 1회 이상 매수 조건 충족");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nlPrior_sellLte_firesOnBreakdown() throws Exception {
|
||||
BarSeries series = buildTrendSeries(120, false);
|
||||
Rule sell = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_LOW",
|
||||
"conditionType": "LTE",
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "NL_PRIOR_9",
|
||||
"period": 9
|
||||
}}
|
||||
""");
|
||||
|
||||
List<Integer> hits = satisfiedIndices(sell, series);
|
||||
assertFalse(hits.isEmpty(), "하락 구간에서 신저가 이탈 조건이 최소 1회 충족되어야 함");
|
||||
}
|
||||
|
||||
/** LONG_ONLY 백테스트 루프 — 매수 후 하락 시 매도가 나와야 함 */
|
||||
@Test
|
||||
void longOnlyLoop_buyThenSell_completesRoundTrip() throws Exception {
|
||||
BarSeries series = buildCrashThenRecoverSeries(80);
|
||||
Rule buy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
|
||||
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9
|
||||
}}
|
||||
""");
|
||||
Rule sell = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_LOW", "conditionType": "LTE",
|
||||
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9
|
||||
}}
|
||||
""");
|
||||
|
||||
SimResult sim = simulateLongOnly(buy, sell, series);
|
||||
assertTrue(sim.buyBars.size() >= 1, "매수 시그널 최소 1회");
|
||||
assertTrue(sim.sellBars.size() >= 1,
|
||||
"하락 구간에서 신저가 매도가 나와야 함. buys=" + sim.buyBars + " sells=" + sim.sellBars);
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizedLegacySell_inLoop_producesSellAfterAdapterMigrate() throws Exception {
|
||||
BarSeries series = buildCrashThenRecoverSeries(80);
|
||||
Rule buy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
|
||||
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9
|
||||
}}
|
||||
""");
|
||||
Rule sellLegacy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_LOW",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"composite": true,
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "DC_LOWER_9",
|
||||
"leftPeriod": 9,
|
||||
"rightPeriod": 20
|
||||
}}
|
||||
""");
|
||||
|
||||
SimResult sim = simulateLongOnly(buy, sellLegacy, series);
|
||||
assertTrue(sim.buyBars.size() >= 1);
|
||||
assertTrue(sim.sellBars.size() >= 1,
|
||||
"어댑터가 DC_LOWER→NL_PRIOR 로 정규화하면 하락 구간에서 매도가 나와야 함");
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Rule rule(BarSeries series, String json) throws Exception {
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
return adapter.toRule(node, series, Map.of());
|
||||
}
|
||||
|
||||
private List<Integer> satisfiedIndices(Rule rule, BarSeries series) {
|
||||
List<Integer> hits = new ArrayList<>();
|
||||
TradingRecord record = null;
|
||||
for (int i = 0; i < series.getBarCount(); i++) {
|
||||
if (rule.isSatisfied(i, record)) hits.add(i);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
private static BarSeries buildTrendSeries(int barCount, boolean up) {
|
||||
Duration period = Duration.ofDays(1);
|
||||
BarSeries series = new BaseBarSeriesBuilder()
|
||||
.withName("trend")
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||
Instant base = Instant.parse("2024-01-01T00:00:00Z");
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
double delta = up ? 1.5 : -1.5;
|
||||
double close = price + delta;
|
||||
double high = Math.max(price, close) + 0.5;
|
||||
double low = Math.min(price, close) - 0.5;
|
||||
factory.createBarBuilder(series)
|
||||
.timePeriod(period)
|
||||
.endTime(base.plus(period.multipliedBy(i + 1L)))
|
||||
.openPrice(price).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(1000)
|
||||
.add();
|
||||
price = close;
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
/** 초반 횡보 후 급락 → 회복 (차트와 유사) */
|
||||
private static BarSeries buildCrashThenRecoverSeries(int barCount) {
|
||||
Duration period = Duration.ofDays(1);
|
||||
BarSeries series = new BaseBarSeriesBuilder()
|
||||
.withName("crash-recover")
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||
Instant base = Instant.parse("2024-01-01T00:00:00Z");
|
||||
double price = 100_000_000;
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
double chg;
|
||||
if (i < 15) chg = (i % 3 - 1) * 0.002;
|
||||
else if (i < 35) chg = -0.04;
|
||||
else chg = 0.008;
|
||||
double close = price * (1 + chg);
|
||||
double high = Math.max(price, close) * 1.005;
|
||||
double low = Math.min(price, close) * 0.995;
|
||||
factory.createBarBuilder(series)
|
||||
.timePeriod(period)
|
||||
.endTime(base.plus(period.multipliedBy(i + 1L)))
|
||||
.openPrice(price).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(1000)
|
||||
.add();
|
||||
price = close;
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
private SimResult simulateLongOnly(Rule entryRule, Rule exitRule, BarSeries series) {
|
||||
List<Integer> buyBars = new ArrayList<>();
|
||||
List<Integer> sellBars = new ArrayList<>();
|
||||
boolean inPosition = false;
|
||||
org.ta4j.core.BaseTradingRecord record = new org.ta4j.core.BaseTradingRecord();
|
||||
for (int i = 0; i < series.getBarCount(); i++) {
|
||||
if (!inPosition) {
|
||||
if (entryRule.isSatisfied(i, record)) {
|
||||
buyBars.add(i);
|
||||
inPosition = true;
|
||||
}
|
||||
} else if (exitRule.isSatisfied(i, record)) {
|
||||
sellBars.add(i);
|
||||
inPosition = false;
|
||||
}
|
||||
}
|
||||
return new SimResult(buyBars, sellBars);
|
||||
}
|
||||
|
||||
private record SimResult(List<Integer> buyBars, List<Integer> sellBars) {}
|
||||
}
|
||||
Reference in New Issue
Block a user