n봉 침체 터치 조건 추가

This commit is contained in:
Macbook
2026-06-12 09:29:59 +09:00
parent d741d3fec6
commit 94934d8ce5
7 changed files with 109 additions and 6 deletions
@@ -36,6 +36,8 @@ public class LiveConditionStatusService {
Map.entry("CROSS_UP", "상향 돌파"),
Map.entry("CROSS_DOWN", "하향 돌파"),
Map.entry("SLOPE_UP", "상승 중"),
Map.entry("LOWEST_LTE", "N봉 최저 ≤"),
Map.entry("LOWEST_GTE", "N봉 최저 ≥"),
Map.entry("SLOPE_DOWN", "하락 중")
);
@@ -411,6 +411,7 @@ public class StrategyDslToTa4jAdapter {
int rightPeriod = cond.path("rightPeriod").asInt(-1);
int slopePeriod = cond.path("slopePeriod").asInt(3);
int holdDays = cond.path("holdDays").asInt(3);
int lookbackPeriod = cond.path("lookbackPeriod").asInt(20);
if (needsCrossTimeframeComposite(cond, ctx)) {
return buildCrossTimeframeCompositeRule(cond, ctx);
@@ -468,6 +469,16 @@ public class StrategyDslToTa4jAdapter {
new ConstantIndicator<>(series, series.numFactory().numOf(v)));
}
case "HOLD_N_DAYS" -> buildHoldRule(new OverIndicatorRule(left, right), holdDays);
case "LOWEST_LTE" -> {
Indicator<Num> rollingMin = new LowestValueIndicator(left, lookbackPeriod);
yield new OrRule(new UnderIndicatorRule(rollingMin, right),
buildEqRule(rollingMin, right, series));
}
case "LOWEST_GTE" -> {
Indicator<Num> rollingMin = new LowestValueIndicator(left, lookbackPeriod);
yield new OrRule(new OverIndicatorRule(rollingMin, right),
buildEqRule(rollingMin, right, series));
}
// ── 일목균형표 전용 ────────────────────────────────────────────
case "ABOVE_CLOUD" -> buildCloudAboveRule(series, indParams);
case "BELOW_CLOUD" -> buildCloudBelowRule(series, indParams);
@@ -288,6 +288,28 @@ class ComplexStrategyDslAdapterTest {
assertDoesNotThrow(() -> rule.isSatisfied(primary1m.getEndIndex(), null));
}
@Test
void stochLowestLte_compilesAndEvaluates() throws Exception {
JsonNode cond = MAPPER.readTree("""
{
"id": "c-stoch-lowest",
"type": "CONDITION",
"condition": {
"indicatorType": "STOCHASTIC",
"conditionType": "LOWEST_LTE",
"leftField": "STOCH_K",
"rightField": "HL_UNDER",
"lookbackPeriod": 20,
"candleRange": 1
}
}
""");
Rule rule = adapter.toRule(cond, primary1m, Map.of());
assertNotNull(rule);
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
}
// ── helpers ────────────────────────────────────────────────────────────────
private static void evaluateRange(Rule rule, int endIndex) {
+20 -1
View File
@@ -16,6 +16,7 @@ export type SidewaysFilterCategory =
| 'ichimoku'
| 'donchian'
| 'oscillator'
| 'cycle'
| 'dmi'
| 'volume'
| 'combo';
@@ -37,6 +38,7 @@ export const SIDEWAYS_FILTER_CATEGORY_LABEL: Record<SidewaysFilterCategory, stri
ichimoku: '일목균형표',
donchian: 'Donchian · 채널',
oscillator: '오실레이터 중립',
cycle: 'Stoch·룩백 사이클',
dmi: 'DMI · 방향성',
volume: '거래량 · VR',
combo: '복합 횡보 필터',
@@ -107,6 +109,13 @@ function stochBand(low: number, high: number): LogicNode {
);
}
/** 최근 N봉 내 Stoch %K 최저 ≤ 침체선 — 횡보 50→80 재매수 차단용 */
function stochOversoldTouchLookback(lookback: number): LogicNode {
return condition('STOCHASTIC', 'LOWEST_LTE', 'STOCH_K', THRESHOLD_HL_UNDER, {
lookbackPeriod: lookback,
});
}
function cciBand(low: number, high: number): LogicNode {
return andNode(
condition('CCI', 'GTE', 'CCI_VALUE', `K_${low}`),
@@ -241,6 +250,16 @@ export const DEFAULT_SIDEWAYS_FILTER_ITEMS: SidewaysFilterItem[] = [
item('disp-near-100', '이격도 ≈ 100', '20일 이격도 기준선 근처', 'oscillator', 'include',
diffCond('DISPARITY', 'DISPARITY20', THRESHOLD_HL_MID, 'DIFF_LT', 3)),
// ── Stoch·룩백 사이클 (침체 터치 후 재과열만 허용) ──
item('stoch-lowest-20', '%K 20봉 침체', '최근 20봉 내 Stoch %K 최저 ≤ 침체선 — 매수 AND용', 'cycle', 'include',
stochOversoldTouchLookback(20)),
item('stoch-lowest-30', '%K 30봉 침체', '최근 30봉 내 침체(≤20) 터치 경험', 'cycle', 'include',
stochOversoldTouchLookback(30)),
item('stoch-lowest-40', '%K 40봉 침체', '최근 40봉 내 침체 터치 — 넓은 lookback', 'cycle', 'include',
stochOversoldTouchLookback(40)),
item('combo-stoch-cycle-adx', '침체30+ADX<25', '침체 터치 + 비추세 횡보', 'cycle', 'include',
andNode(stochOversoldTouchLookback(30), adxWeak())),
// ── DMI ──
item('dmi-no-direction', '+DI ≈ -DI', '방향성 우열 없음', 'dmi', 'include',
diffCond('DMI', 'PDI', 'MDI', 'DIFF_LT', 5)),
@@ -316,7 +335,7 @@ export function groupSidewaysFiltersByCategory(
items: SidewaysFilterItem[],
): { category: SidewaysFilterCategory; label: string; items: SidewaysFilterItem[] }[] {
const order: SidewaysFilterCategory[] = [
'adx', 'bollinger', 'ma', 'ichimoku', 'donchian', 'oscillator', 'dmi', 'volume', 'combo',
'adx', 'bollinger', 'ma', 'ichimoku', 'donchian', 'oscillator', 'cycle', 'dmi', 'volume', 'combo',
];
const grouped = new Map<SidewaysFilterCategory, SidewaysFilterItem[]>();
for (const i of items) {
@@ -89,11 +89,12 @@ function describeConditionType(
conditionType: string,
left: string,
right: string,
extras: { compareValue?: number; slopePeriod?: number; holdDays?: number },
extras: { compareValue?: number; slopePeriod?: number; holdDays?: number; lookbackPeriod?: number },
): string {
const cv = extras.compareValue;
const sp = extras.slopePeriod ?? 3;
const hd = extras.holdDays ?? 3;
const lb = extras.lookbackPeriod ?? 20;
switch (conditionType) {
case 'GT': return `${left}이(가) ${right}보다 큰 경우`;
@@ -109,6 +110,8 @@ function describeConditionType(
case 'DIFF_GT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 큰 경우`;
case 'DIFF_LT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 작은 경우`;
case 'HOLD_N_DAYS': return `조건이 ${hd}봉 연속 유지되는 경우`;
case 'LOWEST_LTE': return `최근 ${lb}봉 동안 ${left}의 최저값이 ${right} 이하인 경우`;
case 'LOWEST_GTE': return `최근 ${lb}봉 동안 ${left}의 최저값이 ${right} 이상인 경우`;
case 'ABOVE_CLOUD': return '가격이 일목균형표 구름대 위에 있는 경우';
case 'BELOW_CLOUD': return '가격이 일목균형표 구름대 아래에 있는 경우';
case 'IN_CLOUD': return '가격이 일목균형표 구름 안에 있는 경우';
+42 -1
View File
@@ -3,6 +3,7 @@ import React from 'react';
import type { IndicatorConfig } from '../types/index';
import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
import { CONDITION_LABEL, INDICATOR_CONDITIONS } from '../utils/strategyTypes';
import {
COMPOSITE_INDICATOR_ITEMS,
compositeDisplayName,
@@ -331,6 +332,28 @@ const COND_OPTIONS = [
{ v: 'DIFF_GT', l: '차이 > 값' },
{ v: 'DIFF_LT', l: '차이 < 값' },
{ v: 'HOLD_N_DAYS', l: 'N일 연속 유지' },
{ v: 'LOWEST_LTE', l: 'N봉 최저 ≤ 값' },
{ v: 'LOWEST_GTE', l: 'N봉 최저 ≥ 값' },
];
const condLabelFromRegistry = (ind: string, v: string): string => {
const fromCond = COND_OPTIONS.concat(ICHIMOKU_CONDS).find(o => o.v === v)?.l;
if (fromCond) return fromCond;
return CONDITION_LABEL[v] ?? v;
};
type CondTypeOpt = { v: string; l: string };
/** 지표별 조건 타입 드롭다운 — 오실레이터는 룩백 조건 포함 */
export const getCondOptionsForIndicator = (ind: string): CondTypeOpt[] => {
if (ind === 'ICHIMOKU') return ICHIMOKU_CONDS;
const keys = INDICATOR_CONDITIONS[ind] ?? COMMON_COND_KEYS;
return keys.map(v => ({ v, l: condLabelFromRegistry(ind, v) }));
};
const COMMON_COND_KEYS = [
'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN',
'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS',
];
const ICHIMOKU_CONDS = [
@@ -660,6 +683,10 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def
} else if (newCondType === 'HOLD_N_DAYS') {
updated.leftField = 'NONE'; updated.rightField = 'NONE';
updated.holdDays = updated.holdDays ?? 3;
} else if (['LOWEST_LTE', 'LOWEST_GTE'].includes(newCondType)) {
if (!isValid(updated.leftField)) updated.leftField = def.l;
if (!isValid(updated.rightField)) updated.rightField = THRESHOLD_HL_UNDER;
updated.lookbackPeriod = updated.lookbackPeriod ?? 20;
}
// Ichimoku 특수처리
@@ -731,6 +758,11 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
const R = opts.find(o => o.value === rv)?.label
?? (chartRef != null && !isThresholdOverridden(c) ? `기준(${chartRef})` : null)
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
if (['LOWEST_LTE', 'LOWEST_GTE'].includes(c.conditionType)) {
const n = c.lookbackPeriod ?? 20;
const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥';
return `${indName} - 최근 ${n}${L} 최저 ${op} ${R || '침체선'}`;
}
if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`;
return `${indName} - ${L} ${C}`;
}
@@ -845,7 +877,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
}) => {
const normalized = normalizeCompositeCondition(cond);
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
const condOpts = getCondOptionsForIndicator(normalized.indicatorType);
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
const defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def);
@@ -868,6 +900,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType);
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
@@ -1150,6 +1183,14 @@ export const CondEditor: React.FC<CondEditorProps> = ({
onChange={e => onChange({ ...cond, holdDays: parseInt(e.target.value) || 3 })} />
</div>
)}
{isLookbackType && (
<div className="sp-cond-extra">
<label className="sp-cond-lbl"> ()</label>
<input type="number" className="sp-cond-num" min={2} max={500}
value={cond.lookbackPeriod ?? ''} placeholder="예: 20"
onChange={e => onChange({ ...cond, lookbackPeriod: parseInt(e.target.value) || 20 })} />
</div>
)}
</div>
);
};
+8 -3
View File
@@ -19,6 +19,8 @@ export interface ConditionDSL {
compareValue?: number;
slopePeriod?: number;
holdDays?: number;
/** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */
lookbackPeriod?: number;
candleRange?: number;
/** false=보조지표 설정 기본값 상속, true=전략 조건 전용 기간 */
valuePeriodOverride?: boolean;
@@ -64,6 +66,7 @@ export const CONDITION_LABEL: Record<string, string> = {
SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중',
DIFF_GT: '차이>값', DIFF_LT: '차이<값',
HOLD_N_DAYS: 'N일 유지',
LOWEST_LTE: 'N봉 최저 ≤', LOWEST_GTE: 'N봉 최저 ≥',
ABOVE_CLOUD: '구름 위', BELOW_CLOUD: '구름 아래', IN_CLOUD: '구름 안',
CLOUD_BREAK_UP: '구름 상향 돌파', CLOUD_BREAK_DOWN: '구름 하향 돌파',
SPAN1_GT_SPAN2: '선행1>선행2', SPAN1_LT_SPAN2: '선행1<선행2',
@@ -72,11 +75,13 @@ export const CONDITION_LABEL: Record<string, string> = {
};
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE'];
const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS];
export const INDICATOR_CONDITIONS: Record<string, string[]> = {
RSI: COMMON_CONDITIONS, MACD: COMMON_CONDITIONS, CCI: COMMON_CONDITIONS,
ADX: COMMON_CONDITIONS, BWI: COMMON_CONDITIONS, DMI: COMMON_CONDITIONS,
OBV: COMMON_CONDITIONS, STOCHASTIC: COMMON_CONDITIONS, WILLIAMS_R: COMMON_CONDITIONS,
RSI: OSCILLATOR_CONDITIONS, MACD: COMMON_CONDITIONS, CCI: OSCILLATOR_CONDITIONS,
ADX: COMMON_CONDITIONS, BWI: OSCILLATOR_CONDITIONS, DMI: COMMON_CONDITIONS,
OBV: COMMON_CONDITIONS, STOCHASTIC: OSCILLATOR_CONDITIONS, WILLIAMS_R: OSCILLATOR_CONDITIONS,
TRIX: COMMON_CONDITIONS, VOLUME_OSC: COMMON_CONDITIONS, VR: COMMON_CONDITIONS,
DISPARITY: COMMON_CONDITIONS, PSYCHOLOGICAL: COMMON_CONDITIONS,
NEW_PSYCHOLOGICAL: COMMON_CONDITIONS, INVEST_PSYCHOLOGICAL: COMMON_CONDITIONS,