수정
This commit is contained in:
@@ -203,6 +203,42 @@ public class LiveConditionStatusService {
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
/** scan 0건 시 DSL 임계값 요약 로그 */
|
||||
private static String summarizeBuyCondition(JsonNode buyDsl) {
|
||||
if (buyDsl == null || buyDsl.isNull()) return "null";
|
||||
JsonNode cond = findFirstCondition(buyDsl);
|
||||
if (cond == null) return "no-condition";
|
||||
String rf = cond.path("rightField").asText("");
|
||||
boolean override = cond.path("thresholdOverride").asBoolean(false);
|
||||
double target = cond.path("targetValue").asDouble(Double.NaN);
|
||||
Double resolved = IndicatorHlineResolver.resolveThresholdField(cond, rf,
|
||||
cond.path("indicatorType").asText(""), Map.of());
|
||||
return String.format("right=%s override=%s target=%s resolved=%s type=%s left=%s",
|
||||
rf, override, Double.isNaN(target) ? "-" : target,
|
||||
resolved != null ? resolved : "-",
|
||||
cond.path("conditionType").asText(""),
|
||||
cond.path("leftField").asText(""));
|
||||
}
|
||||
|
||||
private static JsonNode findFirstCondition(JsonNode node) {
|
||||
if (node == null || node.isNull()) return null;
|
||||
if (node.has("condition")) {
|
||||
JsonNode c = node.get("condition");
|
||||
if (c != null && c.has("indicatorType")) return c;
|
||||
}
|
||||
if (node.has("indicatorType")) return node;
|
||||
JsonNode children = node.get("children");
|
||||
if (children != null && children.isArray()) {
|
||||
for (JsonNode ch : children) {
|
||||
JsonNode found = findFirstCondition(ch);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
JsonNode child = node.get("child");
|
||||
if (child != null) return findFirstCondition(child);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ta4j Bar → 차트용 봉 시작 시각(초) — BacktestingService.barStartEpoch 와 동일 */
|
||||
private static long barOpenEpochSec(BarSeries series, int index) {
|
||||
var bar = series.getBar(index);
|
||||
@@ -781,9 +817,9 @@ public class LiveConditionStatusService {
|
||||
}
|
||||
}
|
||||
if (signals.isEmpty()) {
|
||||
log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{}",
|
||||
log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{} cond={}",
|
||||
market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx,
|
||||
primarySeries.getEndIndex());
|
||||
primarySeries.getEndIndex(), summarizeBuyCondition(buyDsl));
|
||||
} else {
|
||||
log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}",
|
||||
market, strategyId, primaryTf, buyHits, sellHits);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.dto.OhlcvBar;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Rule;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* scanSignalsOnChartBars 와 동일 경로 — RSI K_55 직접입력 임계값 평가.
|
||||
*/
|
||||
class RsiChartScanIntegrationTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private StrategyDslToTa4jAdapter adapter;
|
||||
private StrategyDslTimeframeNormalizer normalizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new StrategyDslToTa4jAdapter();
|
||||
normalizer = new StrategyDslTimeframeNormalizer(MAPPER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chartScan_rsiCrossUpK55_firesSignals() throws Exception {
|
||||
String chartTf = "3m";
|
||||
List<OhlcvBar> bars = buildOscillatingOhlcvBars(200, chartTf);
|
||||
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf);
|
||||
|
||||
JsonNode buyDslRaw = MAPPER.readTree("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleTypes": ["3m"],
|
||||
"candleType": "3m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "RSI_VALUE_9",
|
||||
"rightField": "K_55",
|
||||
"period": 9,
|
||||
"valuePeriodOverride": true,
|
||||
"thresholdOverride": true,
|
||||
"targetValue": 55,
|
||||
"candleRange": 1
|
||||
}
|
||||
}]
|
||||
}
|
||||
""");
|
||||
|
||||
JsonNode normalized = StrategyConditionThresholdNormalizer.normalizeTree(buyDslRaw);
|
||||
JsonNode buyDsl = normalizer.remapForChartTimeframe(normalized, chartTf);
|
||||
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
|
||||
primarySeries, chartTf, buyDsl, null);
|
||||
|
||||
Map<String, Map<String, Object>> params = Map.of(
|
||||
"RSI", Map.of("length", 9, "maLength", 5, "maType", "SMA", "src", "close"));
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, Map.of(), "KRW-BTC", null, false, seriesOverrides);
|
||||
Rule rule = adapter.toRule(buyDsl, ctx);
|
||||
|
||||
int hits55 = countHits(rule, primarySeries);
|
||||
assertFalse(hits55 == 0, "K_55 chart scan should produce CROSS_UP signals, got " + hits55);
|
||||
|
||||
JsonNode buyDsl50 = buyDsl.deepCopy();
|
||||
JsonNode cond = buyDsl50.path("children").get(0).path("condition");
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) cond).put("rightField", "K_50");
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) cond).put("targetValue", 50);
|
||||
Rule rule50 = adapter.toRule(buyDsl50, ctx);
|
||||
int hits50 = countHits(rule50, primarySeries);
|
||||
assertTrue(hits55 <= hits50,
|
||||
"K_55 should have <= signals than K_50, 55=" + hits55 + " 50=" + hits50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chartScan_k55LegacyWithoutOverride_usesFieldNumberNotHlMid() throws Exception {
|
||||
String chartTf = "3m";
|
||||
List<OhlcvBar> bars = buildOscillatingOhlcvBars(200, chartTf);
|
||||
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf);
|
||||
|
||||
JsonNode buyDslRaw = MAPPER.readTree("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "3m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "RSI_VALUE_9",
|
||||
"rightField": "K_55",
|
||||
"period": 9,
|
||||
"valuePeriodOverride": true,
|
||||
"thresholdOverride": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
""");
|
||||
|
||||
JsonNode buyDsl = normalizer.remapForChartTimeframe(
|
||||
StrategyConditionThresholdNormalizer.normalizeTree(buyDslRaw), chartTf);
|
||||
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
|
||||
primarySeries, chartTf, buyDsl, null);
|
||||
Map<String, Map<String, Object>> params = Map.of("RSI", Map.of("length", 9));
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, Map.of(), null, null, false, seriesOverrides);
|
||||
|
||||
JsonNode cond = buyDsl.path("children").get(0).path("condition");
|
||||
Double resolved = IndicatorHlineResolver.resolveThresholdField(
|
||||
cond, "K_55", "RSI", Map.of(
|
||||
"RSI", Map.of("hlines", List.of(
|
||||
Map.of("label", "중앙선", "price", 50)))));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(55.0, resolved, 0.0001);
|
||||
|
||||
Rule rule = adapter.toRule(buyDsl, ctx);
|
||||
assertFalse(countHits(rule, primarySeries) == 0, "legacy K_55 should evaluate at 55");
|
||||
}
|
||||
|
||||
private static int countHits(Rule rule, BarSeries series) {
|
||||
int hits = 0;
|
||||
for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) {
|
||||
if (rule.isSatisfied(i, null)) hits++;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
private static List<OhlcvBar> buildOscillatingOhlcvBars(int count, String tf) {
|
||||
long periodSec = switch (tf) {
|
||||
case "3m" -> 180;
|
||||
case "5m" -> 300;
|
||||
default -> 60;
|
||||
};
|
||||
List<OhlcvBar> bars = new ArrayList<>();
|
||||
long t = Instant.parse("2024-01-01T00:00:00Z").getEpochSecond();
|
||||
double price = 100;
|
||||
for (int i = 0; i < count; i++) {
|
||||
double swing = Math.sin(i * 0.15) * 8 + Math.sin(i * 0.04) * 4;
|
||||
price = 100 + swing + (i * 0.02);
|
||||
bars.add(OhlcvBar.builder()
|
||||
.time(t)
|
||||
.open(price - 0.5)
|
||||
.high(price + 1.5)
|
||||
.low(price - 1.5)
|
||||
.close(price)
|
||||
.volume(1000)
|
||||
.build());
|
||||
t += periodSec;
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
}
|
||||
@@ -65,4 +65,22 @@ assertOk('directInput 55 => K_55', direct55.rightField === 'K_55' && direct55.ta
|
||||
const filled = ensureDirectThresholdSnapshot({ indicatorType: 'RSI', rightField: 'K_55', thresholdOverride: false });
|
||||
assertOk('K_55 fills targetValue', filled.targetValue === 55 && filled.thresholdOverride === true);
|
||||
|
||||
// 레거시 마이그레이션 — K_55 를 HL_MID(50) 로 접지 않음
|
||||
function migrateK(cond) {
|
||||
if (cond.thresholdOverride === true) return cond;
|
||||
const field = cond.rightField;
|
||||
if (!field?.startsWith('K_')) return cond;
|
||||
const val = parseFloat(field.slice(2));
|
||||
if (!Number.isFinite(val)) return cond;
|
||||
const DEFAULT = { RSI: { over: 70, mid: 50, under: 30 } };
|
||||
const th = DEFAULT.RSI;
|
||||
const eps = 0.0001;
|
||||
if (th.over != null && Math.abs(th.over - val) < eps) return { ...cond, rightField: 'HL_OVER', thresholdOverride: false };
|
||||
if (th.mid != null && Math.abs(th.mid - val) < eps) return { ...cond, rightField: 'HL_MID', thresholdOverride: false };
|
||||
if (th.under != null && Math.abs(th.under - val) < eps) return { ...cond, rightField: 'HL_UNDER', thresholdOverride: false };
|
||||
return { ...cond, rightField: field, targetValue: val, thresholdOverride: true };
|
||||
}
|
||||
const migrated = migrateK({ indicatorType: 'RSI', rightField: 'K_55', thresholdOverride: false });
|
||||
assertOk('migrate K_55 stays K_55', migrated.rightField === 'K_55' && migrated.targetValue === 55);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export const COMBO_FIELD_CUSTOM = '__field_custom__';
|
||||
|
||||
@@ -117,6 +117,22 @@ export default function ComboFieldSelect({
|
||||
setDraft(String(clamped));
|
||||
}, [draft, allowDecimal, min, max, onCustomNumberChange, customNumber]);
|
||||
|
||||
const commitRef = useRef(commitCustom);
|
||||
commitRef.current = commitCustom;
|
||||
|
||||
// 직접입력 blur 전 탭 전환·저장 시 값 유실 방지
|
||||
useEffect(() => () => { commitRef.current(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused || mode !== 'custom') return;
|
||||
const parsed = parseDraft(draft, allowDecimal);
|
||||
if (parsed == null) return;
|
||||
const clamped = clampValue(parsed, min, max, allowDecimal);
|
||||
if (clamped === customNumber) return;
|
||||
const t = window.setTimeout(() => onCustomNumberChange(clamped), 400);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [draft, focused, mode, allowDecimal, min, max, customNumber, onCustomNumberChange]);
|
||||
|
||||
return (
|
||||
<div className={`se-combo-field${mode === 'custom' ? ' se-combo-field--custom-only' : ''}`}>
|
||||
<select
|
||||
|
||||
@@ -382,7 +382,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
}
|
||||
void runSignalScanForBars(bars);
|
||||
return () => { ++backtestGenRef.current; };
|
||||
}, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runSignalScanForBars]);
|
||||
}, [strategy?.id, strategy?.buyCondition, market, timeframe, paramsRevision, bars, loading, runSignalScanForBars]);
|
||||
|
||||
useEffect(() => {
|
||||
applyMarkers();
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
} from './conditionPeriods';
|
||||
import {
|
||||
defaultInheritedThresholdField,
|
||||
inferThresholdSymbolFromLegacy,
|
||||
inferThresholdSymbolFromLegacyExact,
|
||||
isThresholdFieldOrSymbol,
|
||||
isThresholdSymbol,
|
||||
} from './thresholdSymbols';
|
||||
import { parseThresholdField, setDirectThreshold } from './conditionPeriods';
|
||||
import { repairPriceExtremePairForest } from './priceExtremeBreakoutPair';
|
||||
|
||||
function isConditionCarrier(node: LogicNode): node is LogicNode & { condition: ConditionDSL } {
|
||||
@@ -34,8 +34,16 @@ function migrateConditionThreshold(
|
||||
|
||||
let rightField = cond.rightField;
|
||||
if (rightField?.startsWith('K_')) {
|
||||
rightField = inferThresholdSymbolFromLegacyExact(rightField, cond.indicatorType, {})
|
||||
?? inferThresholdSymbolFromLegacy(rightField, cond.indicatorType, {});
|
||||
const exact = inferThresholdSymbolFromLegacyExact(rightField, cond.indicatorType, def.hlThresh);
|
||||
if (exact) {
|
||||
rightField = exact;
|
||||
} else {
|
||||
// K_55 등 비표준값 — 가장 가까운 HL_* 로 접지 않고 숫자 스냅샷 유지
|
||||
const val = parseThresholdField(rightField);
|
||||
if (val != null) {
|
||||
return migrateConditionPeriod(setDirectThreshold(cond, 'right', val));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!rightField || (!isThresholdSymbol(rightField) && !rightField.startsWith('K_'))) {
|
||||
const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def);
|
||||
|
||||
@@ -1135,6 +1135,9 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
||||
};
|
||||
const getRightValue = () => {
|
||||
const raw = normalized.rightField;
|
||||
if (isThresholdOverridden(normalized) && raw?.startsWith('K_')) {
|
||||
return raw;
|
||||
}
|
||||
const v = isThresholdOverridden(normalized)
|
||||
? resolveFieldOptionValue(normalized.indicatorType, raw)
|
||||
: (isThresholdFieldOrSymbol(raw)
|
||||
|
||||
Reference in New Issue
Block a user