diff --git a/backend/src/test/java/com/goldenchart/service/RsiThresholdCrossSignalTest.java b/backend/src/test/java/com/goldenchart/service/RsiThresholdCrossSignalTest.java new file mode 100644 index 0000000..9f721f6 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/RsiThresholdCrossSignalTest.java @@ -0,0 +1,154 @@ +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.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * RSI CROSS_UP — HL_MID(중앙선) vs K_50(직접입력) 동일 임계값 시그널 일치. + */ +class RsiThresholdCrossSignalTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private StrategyDslToTa4jAdapter adapter; + private BarSeries series; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + series = buildOscillatingSeries(200); + } + + @Test + void rsiCrossUp_hlMid_equalsK50Override() throws Exception { + assertSignalHitsEqual( + """ + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "HL_MID", + "period": 9, + "valuePeriodOverride": true, + "thresholdOverride": false, + "candleRange": 1 + } + """, + """ + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "K_50", + "period": 9, + "valuePeriodOverride": true, + "thresholdOverride": true, + "targetValue": 50, + "candleRange": 1 + } + """); + } + + @Test + void rsiCrossUp_hlMid_equalsK50WithoutOverride() throws Exception { + assertSignalHitsEqual( + """ + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "HL_MID", + "period": 9, + "valuePeriodOverride": true, + "candleRange": 1 + } + """, + """ + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "K_50", + "period": 9, + "valuePeriodOverride": true, + "candleRange": 1 + } + """); + } + + private void assertSignalHitsEqual(String condA, String condB) throws Exception { + Map> indicatorParams = Map.of( + "RSI", Map.of("length", 9, "maLength", 3, "maType", "SMA", "src", "close")); + Map> visual = Map.of( + "RSI", Map.of("hlines", List.of( + Map.of("label", "과열선", "price", 70), + Map.of("label", "중앙선", "price", 50), + Map.of("label", "침체선", "price", 30)))); + + List hitsA = scanHits(condA, indicatorParams, visual); + List hitsB = scanHits(condB, indicatorParams, visual); + assertEquals(hitsA, hitsB, + "HL_MID and K_50 should produce identical CROSS_UP bars, A=" + hitsA.size() + " B=" + hitsB.size()); + } + + private List scanHits(String condJson, + Map> indicatorParams, + Map> visual) throws Exception { + JsonNode buyDsl = MAPPER.readTree(""" + { + "type": "TIMEFRAME", + "candleTypes": ["3m"], + "candleType": "3m", + "children": [{ + "type": "CONDITION", + "condition": %s + }] + } + """.formatted(condJson.trim())); + + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + series, indicatorParams, visual, null, null, false, Map.of()); + Rule rule = adapter.toRule(buyDsl, ctx); + + java.util.ArrayList hits = new java.util.ArrayList<>(); + for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) { + if (rule.isSatisfied(i, null)) hits.add(i); + } + return hits; + } + + private static BarSeries buildOscillatingSeries(int count) { + BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder() + .withName("test") + .withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(3))) + .withNumFactory(DoubleNumFactory.getInstance()); + BarSeries s = builder.build(); + Instant t = Instant.parse("2024-01-01T00:00:00Z"); + 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); + double o = price - 0.5; + double h = price + 1.5; + double l = price - 1.5; + double c = price; + s.addBar(t, o, h, l, c, 1000); + t = t.plus(Duration.ofMinutes(3)); + } + return s; + } +} diff --git a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx index 18716ba..ff3bd92 100644 --- a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx +++ b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx @@ -94,7 +94,6 @@ export default function ComboFieldSelect({ if (raw === COMBO_FIELD_CUSTOM) { if (canCustom) { setMode('custom'); - onCustomNumberChange(customNumber); setDraft(String(customNumber)); } return; diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index 2763e37..1f410f2 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -21,6 +21,7 @@ import { import { isThresholdFieldOrSymbol, isThresholdSymbol, + inferThresholdRoleForValue, resolveInheritedThresholdField, resolveThresholdFromDef, THRESHOLD_HL_MID, @@ -282,6 +283,14 @@ export function setConditionThreshold( ? getChartReferenceThreshold(cond, side, inheritedField, def) : null; + const matchedRole = def + ? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh) + : null; + if (matchedRole != null) { + const { targetValue: _t, ...rest } = cond; + return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; + } + if (chartRef != null && Math.abs(chartRef - value) < 0.0001) { const role = isThresholdSymbol(inheritedField) ? inheritedField diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts index 836d42b..f413e22 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -11,6 +11,7 @@ import { } from './conditionPeriods'; import { defaultInheritedThresholdField, + inferThresholdRoleForValue, inferThresholdSymbolFromLegacy, isThresholdFieldOrSymbol, isThresholdSymbol, @@ -95,13 +96,33 @@ export function migrateLogicRootForEditor( return repairPriceExtremePairForest(migrated.root, migrated.orphans); } -/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 */ +/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거, 직접입력은 hline 역할로 등가 정규화 */ export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL { let next: ConditionDSL = { ...cond }; + if (isThresholdOverridden(next)) { + if (next.targetValue != null && Number.isFinite(next.targetValue)) { + const role = inferThresholdRoleForValue(next.targetValue, next.indicatorType, {}); + if (role != null) { + const { targetValue: _t, ...rest } = next; + next = { ...rest, rightField: role, thresholdOverride: false }; + } + } else if (next.rightField?.startsWith('K_')) { + const role = inferThresholdSymbolFromLegacy(next.rightField, next.indicatorType, {}); + if (role && isThresholdSymbol(role)) { + const { targetValue: _t, ...rest } = next; + next = { ...rest, rightField: role, thresholdOverride: false }; + } + } + } + if (!isThresholdOverridden(next)) { if (next.rightField?.startsWith('K_')) { - next.rightField = defaultInheritedThresholdField(next.indicatorType); + next.rightField = inferThresholdSymbolFromLegacy( + next.rightField, + next.indicatorType, + {}, + ) ?? defaultInheritedThresholdField(next.indicatorType); } if (!isThresholdSymbol(next.rightField)) { const role = next.indicatorType === 'DISPARITY' || next.indicatorType === 'VOLUME_OSC' diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index dcd1d5f..4d9bde2 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -101,6 +101,7 @@ import { THRESHOLD_HL_MID, THRESHOLD_HL_OVER, THRESHOLD_HL_UNDER, + defaultThresholdForRole, isThresholdFieldOrSymbol, isThresholdSymbol, resolveInheritedThresholdField, @@ -846,11 +847,16 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D } const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF); const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v); + const isValidRight = (v: string|undefined) => { + if (!v) return false; + if (isThresholdOverridden(cond) && (v.startsWith('K_') || isThresholdSymbol(v))) return true; + return isValid(v); + }; const def = getDefaultConditionFields(cond.indicatorType, 'buy', DEF); if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) { if (!isValid(updated.leftField)) updated.leftField = def.l; - if (!isValid(updated.rightField)) updated.rightField = def.r; + if (!isValidRight(updated.rightField)) updated.rightField = def.r; if (['DIFF_GT','DIFF_LT'].includes(newCondType)) updated.compareValue = updated.compareValue ?? 0; } else if (['SLOPE_UP','SLOPE_DOWN'].includes(newCondType)) { if (!isValid(updated.leftField)) updated.leftField = def.l; @@ -1417,7 +1423,9 @@ export const CondEditor: React.FC = ({ customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def) ?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def) ?? parseThresholdField(normalized.rightField) - ?? 0} + ?? defaultThresholdForRole(normalized.indicatorType, THRESHOLD_HL_MID, def.hlThresh) + ?? defaultThresholdForRole(normalized.indicatorType, THRESHOLD_HL_OVER, def.hlThresh) + ?? 50} numberPresets={getThresholdPresetOptions(normalized.indicatorType)} min={getThresholdBounds(normalized.indicatorType).min} max={getThresholdBounds(normalized.indicatorType).max} diff --git a/frontend/src/utils/thresholdSymbols.ts b/frontend/src/utils/thresholdSymbols.ts index 4b3d0e9..c9234d2 100644 --- a/frontend/src/utils/thresholdSymbols.ts +++ b/frontend/src/utils/thresholdSymbols.ts @@ -15,16 +15,53 @@ export function isThresholdFieldOrSymbol(field?: string): boolean { type HlThresh = { over?: number; mid?: number; under?: number }; +/** Ta4j IndicatorHlineResolver.defaultForRole 와 동일 — hlThresh 미설정 시 폴백 */ +export const DEFAULT_HL_THRESH: Record = { + 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 }, + DISPARITY: { mid: 100 }, +}; + +function mergedHlThresh( + indicatorType: string, + hlThresh: Record, +): HlThresh { + return { ...DEFAULT_HL_THRESH[indicatorType], ...hlThresh[indicatorType] }; +} + +export function defaultThresholdForRole( + indicatorType: string, + role: ThresholdHlineRole, + hlThresh: Record = {}, +): number | null { + const th = mergedHlThresh(indicatorType, hlThresh); + if (role === THRESHOLD_HL_OVER) return th.over ?? null; + if (role === THRESHOLD_HL_MID) return th.mid ?? null; + if (role === THRESHOLD_HL_UNDER) return th.under ?? null; + return null; +} + export function resolveThresholdFromDef( field: string | undefined, indicatorType: string, hlThresh: Record, ): number | null { if (!field) return null; - const th = hlThresh[indicatorType] ?? {}; - if (field === THRESHOLD_HL_OVER) return th.over ?? null; - if (field === THRESHOLD_HL_MID) return th.mid ?? null; - if (field === THRESHOLD_HL_UNDER) return th.under ?? null; + if (field === THRESHOLD_HL_OVER) return defaultThresholdForRole(indicatorType, THRESHOLD_HL_OVER, hlThresh); + if (field === THRESHOLD_HL_MID) return defaultThresholdForRole(indicatorType, THRESHOLD_HL_MID, hlThresh); + if (field === THRESHOLD_HL_UNDER) return defaultThresholdForRole(indicatorType, THRESHOLD_HL_UNDER, hlThresh); if (field.startsWith('K_')) { const n = parseFloat(field.slice(2)); return Number.isFinite(n) ? n : null; @@ -41,7 +78,7 @@ export function inferThresholdSymbolFromLegacy( if (!field?.startsWith('K_')) return field; const val = parseFloat(field.slice(2)); if (!Number.isFinite(val)) return field; - const th = hlThresh[indicatorType] ?? {}; + const th = mergedHlThresh(indicatorType, hlThresh); const eps = 0.0001; if (th.over != null && Math.abs(th.over - val) < eps) return THRESHOLD_HL_OVER; if (th.mid != null && Math.abs(th.mid - val) < eps) return THRESHOLD_HL_MID; @@ -80,6 +117,21 @@ export function resolveInheritedThresholdField( return field; } +/** K_ 숫자값이 hline 역할과 일치하면 역할 심볼 반환 (직접입력 ↔ 중앙선 등가) */ +export function inferThresholdRoleForValue( + value: number, + indicatorType: string, + hlThresh: Record = {}, +): ThresholdHlineRole | null { + if (!Number.isFinite(value)) return null; + const th = mergedHlThresh(indicatorType, hlThresh); + const eps = 0.0001; + if (th.over != null && Math.abs(th.over - value) < eps) return THRESHOLD_HL_OVER; + if (th.mid != null && Math.abs(th.mid - value) < eps) return THRESHOLD_HL_MID; + if (th.under != null && Math.abs(th.under - value) < eps) return THRESHOLD_HL_UNDER; + return null; +} + /** 상속 모드 기본 rightField — 과열선 역할 */ export function defaultInheritedThresholdField(indicatorType: string): string { switch (indicatorType) {