N봉 기울기 판단로직 수정

This commit is contained in:
Macbook
2026-06-16 09:12:24 +09:00
parent 518b69cdc6
commit f85c0bef20
10 changed files with 642 additions and 367 deletions
+30 -1
View File
@@ -150,6 +150,25 @@ export function resolveFieldCustomKind(
return 'none';
}
const INDICATORS_WITH_RIGHT_THRESHOLD_INPUT = new Set([
'RSI', 'STOCHASTIC', 'CCI', 'ADX', 'DMI', 'WILLIAMS_R', 'TRIX',
'PSYCHOLOGICAL', 'NEW_PSYCHOLOGICAL', 'INVEST_PSYCHOLOGICAL', 'BWI', 'VR',
'VOLUME_OSC', 'DISPARITY',
]);
/** 조건대상2 — 임계값 직접입력 노출 여부 */
export function resolveRightFieldCustomKind(
indicatorType: string,
field: string | undefined,
): 'period' | 'threshold' | 'none' {
const base = resolveFieldCustomKind(indicatorType, field);
if (base !== 'none') return base;
if (parseThresholdField(field) != null) return 'threshold';
if (isThresholdFieldOrSymbol(field)) return 'threshold';
if (INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(indicatorType)) return 'threshold';
return 'none';
}
export function isValuePeriodFieldStored(indicatorType: string, field?: string): boolean {
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix || !field) return false;
@@ -244,7 +263,17 @@ export function setConditionThreshold(
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
const current = cond[fieldKey];
if (!isThresholdFieldOrSymbol(current)) return cond;
if (!isThresholdFieldOrSymbol(current)) {
if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) {
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
}
return cond;
}
const inheritedField = side === 'right'
? (current?.startsWith('K_') || isThresholdSymbol(current) ? current : THRESHOLD_HL_OVER)
@@ -1,7 +1,7 @@
/**
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
*/
import type { LogicNode } from './strategyTypes';
import type { CandleRangeMode, LogicNode } from './strategyTypes';
import { CONDITION_LABEL } from './strategyTypes';
import {
resolveCandleRangeMode,
@@ -94,10 +94,19 @@ function describeConditionType(
conditionType: string,
left: string,
right: string,
extras: { compareValue?: number; slopePeriod?: number; holdDays?: number; lookbackPeriod?: number },
extras: {
compareValue?: number;
slopePeriod?: number;
holdDays?: number;
lookbackPeriod?: number;
candleRangeMode?: CandleRangeMode;
candleRange?: number;
},
): string {
const cv = extras.compareValue;
const sp = extras.slopePeriod ?? 3;
const sp = (extras.candleRangeMode === 'EXISTS_IN' || extras.candleRangeMode === 'NOT_EXISTS_IN')
? Math.max(2, extras.candleRange ?? 4)
: (extras.slopePeriod ?? 3);
const hd = extras.holdDays ?? 3;
const lb = extras.lookbackPeriod ?? 20;
@@ -156,6 +165,8 @@ function describeCondition(
compareValue: cond.compareValue,
slopePeriod: cond.slopePeriod,
holdDays: cond.holdDays,
candleRangeMode: cond.candleRangeMode,
candleRange: cond.candleRange,
});
return base + tfNote;
}
@@ -172,6 +183,11 @@ function describeCondition(
return prefix + describeConditionType(ct, left, right, cond);
}
if (['SLOPE_UP', 'SLOPE_DOWN'].includes(ct)) {
const prefix = formatCandleRangeClause(cond);
return prefix + describeConditionType(ct, left, right, cond);
}
const label = CONDITION_LABEL[ct] ?? ct;
return `${left}에 대해 「${label}」 조건이 성립하는 경우`;
}
+291 -177
View File
@@ -88,6 +88,7 @@ import {
isValuePeriodFieldStored,
parseThresholdField,
resolveFieldCustomKind,
resolveRightFieldCustomKind,
getCompositeLeftCandleType,
getCompositeRightCandleType,
setCompositeLeftCandleType,
@@ -143,9 +144,11 @@ interface HLThresh { over?: number; mid?: number; under?: number; }
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
export const DEF_DEFAULTS = {
rsiPeriod: 9,
rsiSignal: 14,
macdFast: 12, macdSlow: 26, macdSignal: 9,
cciPeriod: 13,
stochK: 12, stochD: 5,
cciSignal: 10,
stochK: 12, stochSmooth: 3, stochD: 5,
adxPeriod: 14,
trixPeriod: 12, trixSignal: 9,
dmiPeriod: 14,
@@ -285,11 +288,14 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
return {
rsiPeriod: num(rsi, 'length', DEF_DEFAULTS.rsiPeriod),
rsiSignal: num(rsi, 'maLength', DEF_DEFAULTS.rsiSignal),
macdFast: num(macd, 'fastLength', DEF_DEFAULTS.macdFast),
macdSlow: num(macd, 'slowLength', DEF_DEFAULTS.macdSlow),
macdSignal: num(macd, 'signalLength', DEF_DEFAULTS.macdSignal),
cciPeriod: num(cci, 'length', DEF_DEFAULTS.cciPeriod),
cciSignal: num(cci, 'maLength', DEF_DEFAULTS.cciSignal),
stochK: num(stoch, 'kLength', DEF_DEFAULTS.stochK),
stochSmooth: num(stoch, 'smooth', DEF_DEFAULTS.stochSmooth),
stochD: num(stoch, 'dSmoothing', DEF_DEFAULTS.stochD),
adxPeriod: num(adx, 'adxSmoothing', DEF_DEFAULTS.adxPeriod),
trixPeriod: num(trix, 'length', DEF_DEFAULTS.trixPeriod),
@@ -431,18 +437,44 @@ const ICHIMOKU_CONDS = [
type Opt = { value: string; label: string };
const NONE: Opt = { value: 'NONE', label: '선택안함' };
export const getFieldOpts = (
export type FieldOptSlot = 'left' | 'right';
function mergeFieldOpts(...lists: Opt[][]): Opt[] {
const seen = new Set<string>();
const out: Opt[] = [];
for (const list of lists) {
for (const o of list) {
if (seen.has(o.value)) continue;
seen.add(o.value);
out.push(o);
}
}
return out;
}
function buildThresholdOpts(
th: { over: number; mid: number; under: number },
overTerm: string,
underTerm: string,
includeMid = true,
): Opt[] {
const opts: Opt[] = [{ value: THRESHOLD_HL_OVER, label: `${overTerm}(${th.over})` }];
if (includeMid) opts.push({ value: THRESHOLD_HL_MID, label: `중앙선(${th.mid})` });
opts.push({ value: THRESHOLD_HL_UNDER, label: `${underTerm}(${th.under})` });
return opts;
}
function buildFieldOptsForSlot(
ind: string,
signalType: 'buy'|'sell',
signalType: 'buy' | 'sell',
DEF: DefType,
cond?: ConditionDSL,
): Opt[] => {
cond: ConditionDSL | undefined,
slot: FieldOptSlot,
): Opt[] {
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
/** 해당 지표의 hline 임계값 — 없으면 기본값 */
const th = (defaults: HLThresh): Required<HLThresh> => {
const th = (defaults: { over?: number; mid?: number; under?: number }): { over: number; mid: number; under: number } => {
const saved = DEF.hlThresh[ind] ?? {};
return {
over: saved.over ?? defaults.over ?? 100,
@@ -451,77 +483,137 @@ export const getFieldOpts = (
};
};
switch(ind) {
switch (ind) {
case 'MACD': {
const { mid } = th({ mid: 0 });
if (slot === 'left') {
return [
{ value: 'MACD_LINE', label: 'MACD 선' },
{ value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` },
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
}
return [
{ value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` },
NONE,
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
}
case 'RSI': {
const { over, mid, under } = th({ over:70, mid:50, under:30 });
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
return condOpts([
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 70, mid: 50, under: 30 }), overTerm, underTerm);
if (slot === 'left') {
return [
{ value: 'RSI_VALUE', label: `RSI 기간 ${rsiP}` },
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
];
}
return [
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
NONE,
...thresholds,
];
}
case 'STOCHASTIC': {
const { over, mid, under } = th({ over:80, mid:50, under:20 });
return condOpts([
{ value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` },
{ value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm);
const kLabel = `Stochastic 기간${DEF.stochK}일 %K${DEF.stochSmooth}`;
const dLabel = `Stochastic %D${DEF.stochD}`;
if (slot === 'left') {
return [
{ value: 'STOCH_K', label: kLabel },
{ value: 'STOCH_D', label: dLabel },
];
}
return [
{ value: 'STOCH_D', label: `${dLabel}` },
NONE,
...thresholds,
];
}
case 'CCI': {
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
return condOpts([
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 100, mid: 0, under: -100 }), overTerm, underTerm);
if (slot === 'left') {
return [
{ value: 'CCI_VALUE', label: `CCI 기간 ${cciP}` },
{ value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` },
];
}
return [
{ value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` },
NONE,
...thresholds,
];
}
case 'ADX': {
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
const adxP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'ADX' }, DEF) : DEF.adxPeriod;
return condOpts([
{ value:'ADX_VALUE', label:`ADX 라인(${adxP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 40, mid: 25, under: 20 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'ADX_VALUE', label: `ADX 기간 ${adxP}` }];
}
return [NONE, ...thresholds];
}
case 'TRIX': {
const { mid } = th({ mid:0 });
const trixP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'TRIX' }, DEF) : DEF.trixPeriod;
return condOpts([
{ value:'TRIX_VALUE', label:`TRIX 라인(${trixP}일)` },
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
const { mid } = th({ mid: 0 });
if (slot === 'left') {
return [
{ value: 'TRIX_VALUE', label: `TRIX ${trixP}` },
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
];
}
return [
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
NONE,
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
}
case 'DMI': {
const { over, mid, under } = th({ over:30, mid:20, under:10 });
return condOpts([
{ value:'PDI', label:`DI+(${DEF.dmiPeriod})` },
{ value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` },
{ value:THRESHOLD_HL_OVER, label:`과열(${over})` },
{ value:THRESHOLD_HL_MID, label:`중간값(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`침체(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 30, mid: 20, under: 10 }), '과열', '침체');
const plusLabel = `+DI 기간${DEF.dmiPeriod}`;
const minusLabel = `-DI 기간${DEF.dmiPeriod}`;
if (slot === 'left') {
return [
{ value: 'PDI', label: plusLabel },
{ value: 'MDI', label: minusLabel },
];
}
return [
{ value: 'MDI', label: minusLabel },
{ value: 'PDI', label: plusLabel },
NONE,
...thresholds,
];
}
case 'OBV': return condOpts([
{ value:'OBV_LINE', label:`OBV선(${DEF.obvPeriod}일)` },
{ value:'OBV_SIGNAL', label:`신호선(${DEF.obvSignal}일)` },
]);
case 'VOLUME': return condOpts([
{ value:'VOLUME_VALUE', label:'거래량' },
{ value:'VOLUME_MA', label:'거래량 이동평균' },
]);
case 'OBV':
if (slot === 'left') {
return [
{ value: 'OBV_LINE', label: 'OBV선' },
{ value: 'OBV_SIGNAL', label: `신호선 ${DEF.obvSignal}` },
];
}
return [
{ value: 'OBV_SIGNAL', label: `신호선 ${DEF.obvSignal}` },
NONE,
];
case 'WILLIAMS_R': {
const wrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'WILLIAMS_R' }, DEF) : DEF.williamsR;
const thresholds = buildThresholdOpts(th({ over: -20, mid: -50, under: -80 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'WILLIAMS_R_VALUE', label: `williams%R 기간${wrP}` }];
}
return [NONE, ...thresholds];
}
case 'VOLUME':
return slot === 'left'
? [
{ value: 'VOLUME_VALUE', label: '거래량' },
{ value: 'VOLUME_MA', label: '거래량 이동평균' },
]
: [NONE, { value: 'VOLUME_MA', label: '거래량 이동평균' }];
case 'MA': {
let opts = [
NONE,
...(slot === 'right' ? [NONE] : []),
...buildMaFieldOptions(DEF.maLines),
{ value: 'CLOSE_PRICE', label: '종가' },
];
@@ -531,7 +623,7 @@ export const getFieldOpts = (
}
case 'EMA': {
let opts = [
NONE,
...(slot === 'right' ? [NONE] : []),
...buildEmaFieldOptions(DEF.maLines, 4),
{ value: 'CLOSE_PRICE', label: '종가' },
];
@@ -541,159 +633,175 @@ export const getFieldOpts = (
}
case 'DISPARITY': {
const { mid } = th({ mid: 100 });
return condOpts([
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
{ value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
{ value:`DISPARITY${DEF.dispMid}`, label:`중기 이격도(${DEF.dispMid}일)` },
{ value:`DISPARITY${DEF.dispLong}`, label:`장기 이격도(${DEF.dispLong}일)` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
const lines = [
{ value: `DISPARITY${DEF.dispUltra}`, label: `초단기 이격도(${DEF.dispUltra}일)` },
{ value: `DISPARITY${DEF.dispShort}`, label: `단기 이격도(${DEF.dispShort}일)` },
{ value: `DISPARITY${DEF.dispMid}`, label: `중기 이격도(${DEF.dispMid}일)` },
{ value: `DISPARITY${DEF.dispLong}`, label: `장기 이격도(${DEF.dispLong}일)` },
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
return slot === 'right' ? [NONE, ...lines] : lines;
}
case 'PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
const psyP = cond
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
: DEF.psyPeriod;
return condOpts([
{ value:'PSY_VALUE', label:`심리도 라인(${psyP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'PSY_VALUE', label: `심리도 기간 ${psyP}` }];
}
return [NONE, ...thresholds];
}
case 'NEW_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:50, mid:0, under:-50 });
const nPsyP = cond
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
: DEF.newPsy;
return condOpts([
{ value:'NEW_PSY_VALUE', label:`신심리도 라인(${nPsyP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 50, mid: 0, under: -50 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'NEW_PSY_VALUE', label: `신심리도 기간 ${nPsyP}` }];
}
return [NONE, ...thresholds];
}
case 'INVEST_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
const ipsyP = cond
? getConditionValuePeriod({ ...cond, indicatorType: 'INVEST_PSYCHOLOGICAL' }, DEF)
: DEF.investPsy;
return condOpts([
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${ipsyP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'WILLIAMS_R': {
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
const wrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'WILLIAMS_R' }, DEF) : DEF.williamsR;
return condOpts([
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${wrP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'INVEST_PSY_VALUE', label: `투자심리도 기간 ${ipsyP}` }];
}
return [NONE, ...thresholds];
}
case 'BWI': {
const { over, mid, under } = th({ over:80, mid:50, under:20 });
const bwiP = DEF.bwiPeriod;
return condOpts([
{ value:'BWI_VALUE', label:`BWI 라인(${bwiP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'BWI_VALUE', label: `BWI 기간 ${DEF.bwiPeriod}` }];
}
return [NONE, ...thresholds];
}
case 'VR': {
const { over, mid, under } = th({ over:200, mid:100, under:50 });
const vrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'VR' }, DEF) : DEF.vrPeriod;
return condOpts([
{ value:'VR_VALUE', label:`VR 라인(${vrP}일)` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
const thresholds = buildThresholdOpts(th({ over: 200, mid: 100, under: 50 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'VR_VALUE', label: `VR 기간 ${vrP}` }];
}
return [NONE, ...thresholds];
}
case 'VOLUME_OSC': {
const { mid } = th({ mid:0 });
return condOpts([
{ value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터(${DEF.volOscShort}일/${DEF.volOscLong}일)` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
const { mid } = th({ mid: 0 });
if (slot === 'left') {
return [{ value: 'VOLUME_OSC_VALUE', label: `거래량 오실레이터(${DEF.volOscShort}일/${DEF.volOscLong}일)` }];
}
return [NONE, { value: THRESHOLD_HL_MID, label: `중앙선(${mid})` }];
}
case 'MACD': {
const { mid } = th({ mid:0 });
return condOpts([
{ value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` },
{ value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` },
{ value:'HISTOGRAM', label:'히스토그램' },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
}
case 'BOLLINGER': return condOpts([
{ value:'UPPER_BAND', label:`상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
{ value:'MIDDLE_BAND', label:`중심선(${DEF.bbPeriod}일)` },
{ value:'LOWER_BAND', label:`하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
{ value:'CLOSE_PRICE', label:'종가' },
{ value:'OPEN_PRICE', label:'시가' },
{ value:'HIGH_PRICE', label:'고가' },
{ value:'LOW_PRICE', label:'가' },
]);
case 'BOLLINGER':
return slot === 'right'
? [
NONE,
{ value: 'UPPER_BAND', label: `상단밴드(${DEF.bbPeriod}, σ${DEF.bbStdDev})` },
{ value: 'MIDDLE_BAND', label: `중심선(${DEF.bbPeriod}일)` },
{ value: 'LOWER_BAND', label: `하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
{ value: 'CLOSE_PRICE', label: '종가' },
{ value: 'OPEN_PRICE', label: '시가' },
{ value: 'HIGH_PRICE', label: '고가' },
{ value: 'LOW_PRICE', label: '저가' },
]
: [
{ value: 'UPPER_BAND', label: `상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
{ value: 'MIDDLE_BAND', label: `중심선(${DEF.bbPeriod}일)` },
{ value: 'LOWER_BAND', label: `하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
{ value: 'CLOSE_PRICE', label: '가' },
{ value: 'OPEN_PRICE', label: '시가' },
{ value: 'HIGH_PRICE', label: '고가' },
{ value: 'LOW_PRICE', label: '저가' },
];
case 'NEW_HIGH': {
const p = cond?.period ?? 9;
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
return condOpts([
const lines = [
{ value: 'CLOSE_PRICE', label: '종가' },
{ value: 'HIGH_PRICE', label: '고가' },
{ value: 'OPEN_PRICE', label: '시가' },
...periods.map(n => ({ value: nhPriorField(n), label: `${n}일 신고가선(직전 N봉)` })),
]);
];
return slot === 'right' ? [NONE, ...lines] : lines;
}
case 'NEW_LOW': {
const p = cond?.period ?? 9;
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
return condOpts([
const lines = [
{ value: 'CLOSE_PRICE', label: '종가' },
{ value: 'LOW_PRICE', label: '저가' },
{ value: 'OPEN_PRICE', label: '시가' },
...periods.map(n => ({ value: nlPriorField(n), label: `${n}일 신저가선(직전 N봉)` })),
]);
];
return slot === 'right' ? [NONE, ...lines] : lines;
}
case 'DONCHIAN': return condOpts([
{ value:'DC_UPPER_9', label:'상단(9일 최고가·신고가)' },
{ value:'DC_UPPER_10', label:'상단(10일 최고가)' },
{ value:'DC_UPPER_20', label:'상단(20일 최고가·신고가)' },
{ value:'DC_UPPER_55', label:'상단(55일 최고가)' },
{ value:'DC_MIDDLE_20', label:'중심(20일)' },
{ value:'DC_LOWER_9', label:'하단(9일 최저가·신저가)' },
{ value:'DC_LOWER_10', label:'하단(10일 최저가)' },
{ value:'DC_LOWER_20', label:'하단(20일 최저가·신저가)' },
{ value:'DC_LOWER_55', label:'하단(55일 최저가)' },
{ value:'CLOSE_PRICE', label:'종가' },
{ value:'LOW_PRICE', label:'가' },
{ value:'HIGH_PRICE', label:'가' },
]);
case 'ICHIMOKU': return condOpts([
{ value:'CONVERSION_LINE', label:`전환선(${DEF.ichTenkan}일)` },
{ value:'BASE_LINE', label:`기준선(${DEF.ichKijun}일)` },
{ value:'LEADING_SPAN1', label:'선행스팬1' },
{ value:'LEADING_SPAN2', label:`선행스팬2(${DEF.ichSenkouB}일)` },
{ value:'LAGGING_SPAN', label:'후행스팬' },
{ value:'CLOSE_PRICE', label:'종가' },
{ value:'OPEN_PRICE', label:'시가' },
{ value:'HIGH_PRICE', label:'고가' },
{ value:'LOW_PRICE', label:'저가' },
]);
default: return [NONE];
case 'DONCHIAN': {
const lines = [
{ value: 'DC_UPPER_9', label: '상단(9일 최고가·신고가)' },
{ value: 'DC_UPPER_10', label: '상단(10일 최고가)' },
{ value: 'DC_UPPER_20', label: '상단(20일 최고가·신고가)' },
{ value: 'DC_UPPER_55', label: '상단(55일 최고가)' },
{ value: 'DC_MIDDLE_20', label: '중심(20일)' },
{ value: 'DC_LOWER_9', label: '하단(9일 최저가·신저가)' },
{ value: 'DC_LOWER_10', label: '하단(10일 최저가)' },
{ value: 'DC_LOWER_20', label: '하단(20일 최저가·신저가)' },
{ value: 'DC_LOWER_55', label: '하단(55일 최저가)' },
{ value: 'CLOSE_PRICE', label: '가' },
{ value: 'LOW_PRICE', label: '가' },
{ value: 'HIGH_PRICE', label: '고가' },
];
return slot === 'right' ? [NONE, ...lines] : lines;
}
case 'ICHIMOKU': {
const lines = [
{ value: 'CONVERSION_LINE', label: `전환선(${DEF.ichTenkan}일)` },
{ value: 'BASE_LINE', label: `기준선(${DEF.ichKijun}일)` },
{ value: 'LEADING_SPAN1', label: '선행스팬1' },
{ value: 'LEADING_SPAN2', label: `선행스팬2(${DEF.ichSenkouB}일)` },
{ value: 'LAGGING_SPAN', label: '후행스팬' },
{ value: 'CLOSE_PRICE', label: '종가' },
{ value: 'OPEN_PRICE', label: '시가' },
{ value: 'HIGH_PRICE', label: '고가' },
{ value: 'LOW_PRICE', label: '저가' },
];
return slot === 'right' ? [NONE, ...lines] : lines;
}
default:
return slot === 'right' ? [NONE] : [];
}
};
}
export const getLeftFieldOpts = (
ind: string,
signalType: 'buy' | 'sell',
DEF: DefType,
cond?: ConditionDSL,
): Opt[] => buildFieldOptsForSlot(ind, signalType, DEF, cond, 'left');
export const getRightFieldOpts = (
ind: string,
signalType: 'buy' | 'sell',
DEF: DefType,
cond?: ConditionDSL,
): Opt[] => buildFieldOptsForSlot(ind, signalType, DEF, cond, 'right');
export const getFieldOpts = (
ind: string,
signalType: 'buy'|'sell',
DEF: DefType,
cond?: ConditionDSL,
): Opt[] => mergeFieldOpts(
getLeftFieldOpts(ind, signalType, DEF, cond),
getRightFieldOpts(ind, signalType, DEF, cond),
);
const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
const map: Record<string, {l:string;r:string}> = {
RSI: { l:'RSI_VALUE', r: THRESHOLD_HL_OVER },
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
CCI: { l:'CCI_VALUE', r: THRESHOLD_HL_OVER },
CCI: { l:'CCI_VALUE', r:'CCI_SIGNAL' },
ADX: { l:'ADX_VALUE', r: THRESHOLD_HL_MID },
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
DMI: { l:'PDI', r:'MDI' },
@@ -968,7 +1076,9 @@ export const CondEditor: React.FC<CondEditorProps> = ({
cond, signalType, onChange, def, stochPairEdit,
}) => {
const normalized = normalizeCompositeCondition(cond);
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
const leftFieldOpts = getLeftFieldOpts(normalized.indicatorType, signalType, def, normalized);
const rightFieldOpts = getRightFieldOpts(normalized.indicatorType, signalType, def, normalized);
const fieldOpts = mergeFieldOpts(leftFieldOpts, rightFieldOpts);
const condOpts = getCondOptionsForIndicator(normalized.indicatorType);
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
@@ -1220,11 +1330,11 @@ export const CondEditor: React.FC<CondEditorProps> = ({
<div className="sp-cond-field">
<label className="sp-cond-lbl">1</label>
<ComboFieldSelect
options={fieldOpts}
options={leftFieldOpts}
fieldValue={isHoldType ? 'NONE' : (normalized.leftField ?? getLeftValue())}
isPresetField={!isHoldType && (
fieldOpts.some(o => o.value === normalized.leftField)
|| (fieldOpts.some(o => o.value === getLeftValue())
leftFieldOpts.some(o => o.value === normalized.leftField)
|| (leftFieldOpts.some(o => o.value === getLeftValue())
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
@@ -1260,10 +1370,14 @@ export const CondEditor: React.FC<CondEditorProps> = ({
<div className="sp-cond-field">
<label className="sp-cond-lbl">2</label>
<ComboFieldSelect
options={fieldOpts}
options={rightFieldOpts}
fieldValue={rightValue}
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
isPresetField={
!isThresholdOverridden(normalized)
&& parseThresholdField(normalized.rightField) == null
&& rightFieldOpts.some(o => o.value === getRightValue())
}
customKind={resolveRightFieldCustomKind(normalized.indicatorType, normalized.rightField)}
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def)
?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def)
?? parseThresholdField(normalized.rightField)
@@ -104,6 +104,14 @@ function rangeNote(cond?: ConditionDSL): string {
const mode = resolveCandleRangeMode(cond);
if (mode === 'CURRENT') return '';
const n = candleRangeWindowBars(cond);
if (cond.conditionType === 'SLOPE_UP' || cond.conditionType === 'SLOPE_DOWN') {
if (mode === 'EXISTS_IN') {
return `최근 ${n}봉(현재 봉 포함) 구간에서 그래프선이 ${cond.conditionType === 'SLOPE_UP' ? '상승' : '하락'} 추세인지 현재 봉 기준으로 판정합니다. `;
}
if (mode === 'NOT_EXISTS_IN') {
return `최근 ${n}봉(현재 봉 포함) 구간에서 그래프선이 ${cond.conditionType === 'SLOPE_UP' ? '상승' : '하락'} 추세가 아닌지 현재 봉 기준으로 판정합니다. `;
}
}
if (mode === 'EXISTS_IN') return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번이라도 성립하면 충족으로 판정합니다. `;
return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번도 없어야 충족으로 판정합니다. `;
}
@@ -201,6 +209,18 @@ function buildReason(
if (row.conditionType === 'SLOPE_UP' || row.conditionType === 'SLOPE_DOWN') {
const dir = row.conditionType === 'SLOPE_UP' ? '상승' : '하락';
const mode = cond ? resolveCandleRangeMode(cond) : 'CURRENT';
if (mode === 'EXISTS_IN' || mode === 'NOT_EXISTS_IN') {
const n = cond ? candleRangeWindowBars(cond) : 0;
const neg = mode === 'NOT_EXISTS_IN';
return prefix + (status === 'match'
? (neg
? `최근 ${n}봉 구간 그래프선이 ${dir} 추세가 아니어서 충족합니다.`
: `최근 ${n}봉 구간 그래프선이 ${dir} 추세로 판정되어 충족합니다.`)
: (neg
? `최근 ${n}봉 구간 그래프선이 ${dir} 추세가 아님 — 미충족입니다.`
: `최근 ${n}봉 구간 그래프선이 ${dir} 추세가 아니어서 미충족입니다.`));
}
return prefix + (status === 'match'
? `최근 기울기가 ${dir} 추세로 판정되어 충족합니다.`
: `최근 기울기가 ${dir} 추세가 아니어서 미충족입니다.`);
+13 -1
View File
@@ -380,6 +380,14 @@ export function resolveCandleRangeMode(
return 'CURRENT';
}
/** 기울기 + N봉 모드: N봉 구간 전체 추세로 판정 (N봉 내 임의 시점 기울기 존재 여부 아님) */
export function isSlopeWindowTrendMode(
cond: Pick<ConditionDSL, 'conditionType' | 'candleRangeMode'>,
): boolean {
return (cond.conditionType === 'SLOPE_UP' || cond.conditionType === 'SLOPE_DOWN')
&& (cond.candleRangeMode === 'EXISTS_IN' || cond.candleRangeMode === 'NOT_EXISTS_IN');
}
export function candleRangeWindowBars(
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
): number {
@@ -389,10 +397,14 @@ export function candleRangeWindowBars(
}
export function formatCandleRangeClause(
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange' | 'conditionType'>,
): string {
const mode = resolveCandleRangeMode(cond);
const n = candleRangeWindowBars(cond);
if (isSlopeWindowTrendMode(cond)) {
if (mode === 'EXISTS_IN') return `최근 ${n}봉 구간 `;
if (mode === 'NOT_EXISTS_IN') return `최근 ${n}봉 구간 비추세 · `;
}
if (mode === 'EXISTS_IN') return `최근 ${n}봉 내 `;
if (mode === 'NOT_EXISTS_IN') return `최근 ${n}봉 내 미존재 · `;
return '';