n봉 침체 터치 조건 추가
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 '가격이 일목균형표 구름 안에 있는 경우';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user