Files
goldenChart/frontend/src/utils/strategyEditorShared.tsx
T
2026-06-23 20:42:04 +09:00

1663 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Shared strategy editor utilities — extracted from StrategyPage */
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, CandleRangeMode } from '../utils/strategyTypes';
import {
CONDITION_LABEL,
INDICATOR_CONDITIONS,
resolveCandleRangeMode,
candleRangeWindowBars,
formatCandleRangeClause,
} from '../utils/strategyTypes';
import {
appendLegacyMaFieldOption,
buildEmaFieldOptions,
buildMaFieldOptions,
} from './maDslField';
import {
COMPOSITE_INDICATOR_ITEMS,
compositeDisplayName,
compositeElementLabel,
compositeFieldLabel,
makeCompositeCondition,
normalizeCompositeCondition,
parseDonchianPeriod,
parsePeriodFromCompositeField,
switchCompositeIndicatorType,
syncCompositeFields,
} from '../utils/compositeIndicators';
import {
isPriceExtremeIndicator,
nhPriorField,
nlPriorField,
parsePriorExtremePeriod,
syncPriceExtremeSimpleFields,
} from '../utils/priceExtremeIndicators';
import { getStrategyIndicatorDisplayName } from '../utils/strategyPaletteStorage';
import {
buildInflection33Tree,
isInflection33PairRoot,
isInflection33PaletteValue,
inflection33DisplayName,
inflection33SummaryText,
inferInflection33FilterLevel,
inflection33FilterLevelLabel,
} from '../utils/inflection33Strategy';
import {
buildIchimokuBbTree,
isIchimokuBbPairRoot,
isIchimokuBbPaletteValue,
ichimokuBbDisplayName,
ichimokuBbSummaryText,
inferIchimokuBbConfig,
} from '../utils/ichimokuBbStrategy';
import {
buildStableStrategyTree,
isStableStrategyPairRoot,
isStableStrategyPaletteValue,
stableStrategyDisplayName,
stableStrategySummaryText,
inferStableStrategyFilterLevel,
stableStrategyFilterLevelLabel,
type StableStrategyId,
} from '../utils/stableStrategyPairs';
import {
buildPriceExtremeBreakoutTree,
isPriceExtremeBreakoutPairPaletteValue,
isPriceExtremeBreakoutPairRoot,
priceExtremePairDisplayName,
priceExtremePairSummaryText,
inferPriceExtremeFilterLevel,
filterLevelLabel,
} from '../utils/priceExtremeBreakoutPair';
import {
buildStochOverboughtPairTree,
isStochOverboughtPairPaletteValue,
isStochOverboughtPairRoot,
STOCH_PAIR_SECONDARY_OPTIONS,
stochPairDisplayName,
stochPairSummaryText,
} from '../utils/stochOverboughtPair';
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
import {
activateDirectThresholdInput,
buildHwpValueFieldOpt,
buildSingleIndicatorPeriodOpts,
ensureDirectThresholdSnapshot,
VALUE_FIELD_PREFIX,
getCompositeFieldOpts,
getCompositePeriodPresetOptions,
getConditionRightPeriod,
getConditionThreshold,
getChartReferenceThreshold,
getConditionValuePeriod,
getDefaultIndicatorPeriod,
getPeriodPresetOptions,
getThresholdBounds,
getThresholdPresetOptions,
initConditionPeriodsInherit,
isThresholdOverridden,
isValuePeriodFieldStored,
parseThresholdField,
resolveFieldCustomKind,
resolveRightFieldCustomKind,
resolveStoredValueField,
singleIndicatorLineLabel,
singleIndicatorValuePeriodLabel,
getCompositeLeftCandleType,
getCompositeRightCandleType,
setCompositeLeftCandleType,
setCompositeRightCandleType,
setConditionThreshold,
setConditionRightPeriod,
setConditionValuePeriod,
} from '../utils/conditionPeriods';
import {
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
THRESHOLD_HL_UNDER,
defaultThresholdForRole,
isThresholdFieldOrSymbol,
isThresholdSymbol,
resolveInheritedThresholdField,
} from '../utils/thresholdSymbols';
import { STRATEGY_CANDLE_TYPE_OPTIONS, formatStrategyCandleLabel } from '../utils/strategyStartNodes';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
import { genId } from './strategyNodeIds';
export { genId };
export interface StrategyDto {
id: number;
name: string;
description?: string;
buyCondition?: LogicNode | null;
sellCondition?: LogicNode | null;
flowLayout?: StrategyFlowLayoutStore;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface ValidationResult {
isValid: boolean;
errors: { message: string }[];
warnings: { message: string }[];
}
export type DefType = typeof DEF_DEFAULTS;
/** @deprecated DB(gc_strategy)만 사용 */
export const loadStratsLocal = (): StrategyDto[] => [];
/** @deprecated */
export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ };
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
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,
cciSignal: 10,
stochK: 12, stochSmooth: 3, stochD: 5,
adxPeriod: 14,
trixPeriod: 12, trixSignal: 9,
dmiPeriod: 14,
obvPeriod: 9, obvSignal: 9,
bbPeriod: 20, bbStdDev: 2,
ichTenkan: 9, ichKijun: 26, ichSenkouB: 52,
newPsy: 10, psyPeriod: 12, investPsy: 10,
williamsR: 14,
bwiPeriod: 20,
vrPeriod: 10,
volOscShort: 5, volOscLong: 10,
dispUltra: 5, dispShort: 10, dispMid: 20, dispLong: 60,
maLines: [5, 10, 20, 60, 120] as number[],
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
hlThresh: {
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 },
} as Record<string, HLThresh>,
};
/**
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
* (레거시 투자전략 화면용 — 차트 활성 지표와 동기화)
*/
export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
// params 객체 참조 공유 방지
const p = (registryType: string) => {
const raw = activeIndicators.find(i => i.type === registryType)?.params;
return raw ? { ...raw } : {};
};
const num = (params: Record<string, number | string | boolean>, key: string, fallback: number) =>
typeof params[key] === 'number' ? (params[key] as number) : fallback;
// ── 각 지표별 실제 레지스트리 type명 + params 키 ──────────────────────────
// RSI → type:'RSI', params.length
const rsi = p('RSI');
// MACD → type:'MACD', params.fastLength / slowLength / signalLength
const macd = p('MACD');
// CCI → type:'CCI', params.length
const cci = p('CCI');
// Stochastic → type:'Stochastic', params.kLength / dSmoothing
const stoch = p('Stochastic');
// ADX → type:'ADX', params.adxSmoothing / diLength
const adx = p('ADX');
// TRIX → type:'TRIX', params.length / signalLength
const trix = p('TRIX');
// DMI → type:'DMI', params.diLength / adxSmoothing
const dmi = p('DMI');
// BollingerBands→ type:'BollingerBands', params.length / mult
const bb = p('BollingerBands');
// IchimokuCloud → type:'IchimokuCloud', params.conversionPeriods / basePeriods / laggingSpan2Periods
const ich = p('IchimokuCloud');
// Williams %R → type:'WilliamsPercentRange', params.length
const wr = p('WilliamsPercentRange');
const vo = p('VolumeOscillator');
const vrInd = p('VR');
const disp = p('Disparity');
const psy = p('Psychological');
const nPsy = p('NewPsychological');
const iPsy = p('InvestPsychological');
const obvP = p('OBV');
// SMA (MA lines)→ type:'SMA', params keys depend on smaConfig
const sma = p('SMA');
// MA 기간 배열: SMA 설정에서 period1~period11 추출 (smaConfig 구조)
let maLines = DEF_DEFAULTS.maLines;
const smaPeriods = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
.map(i => sma[`period${i}`])
.filter(v => typeof v === 'number' && (v as number) > 0) as number[];
if (smaPeriods.length >= 2) maLines = smaPeriods;
// ── hline 임계값 추출 ───────────────────────────────────────────────────────
// DSL 지표타입 → 레지스트리 type명 매핑
const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI',
STOCHASTIC: 'Stochastic',
CCI: 'CCI',
WILLIAMS_R: 'WilliamsPercentRange',
MACD: 'MACD',
DMI: 'DMI',
ADX: 'ADX',
TRIX: 'TRIX',
VOLUME_OSC: 'VolumeOscillator',
VR: 'VR',
DISPARITY: 'Disparity',
PSYCHOLOGICAL: 'Psychological',
NEW_PSYCHOLOGICAL: 'NewPsychological',
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
OBV: 'OBV',
BOLLINGER: 'BollingerBands',
DONCHIAN: 'DonchianChannels',
NEW_HIGH: 'PriceExtreme',
NEW_LOW: 'PriceExtreme',
};
const extractThresh = (dslType: string): HLThresh => {
const regType = DSL_TO_REGISTRY[dslType];
if (!regType) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
const config = activeIndicators.find(i => i.type === regType);
const defHl = getIndicatorDef(regType)?.hlines ?? [];
const hlines = config?.hlines ?? defHl;
if (hlines.length === 0) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
const prices = hlines.map(h => h.price);
const find = (lbl: string) => hlines.find(h =>
(h.label ?? getHLineLabel(h.price, prices)) === lbl
);
const result: HLThresh = {};
const ov = find('과열선'); if (ov) result.over = ov.price;
const md = find('중앙선') ?? find('기준선'); if (md) result.mid = md.price;
const un = find('침체선'); if (un) result.under = un.price;
// 값이 하나도 없으면 기본값 사용
if (result.over === undefined && result.mid === undefined && result.under === undefined)
return DEF_DEFAULTS.hlThresh[dslType] ?? {};
return {
...DEF_DEFAULTS.hlThresh[dslType],
...result,
};
};
const hlThresh: Record<string, HLThresh> = {};
for (const dslType of Object.keys(DEF_DEFAULTS.hlThresh)) {
hlThresh[dslType] = extractThresh(dslType);
}
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),
trixSignal: num(trix, 'signalLength', DEF_DEFAULTS.trixSignal),
dmiPeriod: num(dmi, 'diLength', num(dmi, 'length', DEF_DEFAULTS.dmiPeriod)),
obvPeriod: num(obvP, 'maLength', DEF_DEFAULTS.obvPeriod),
obvSignal: num(obvP, 'maLength', DEF_DEFAULTS.obvSignal),
bbPeriod: num(bb, 'length', DEF_DEFAULTS.bbPeriod),
bbStdDev: num(bb, 'mult', DEF_DEFAULTS.bbStdDev),
ichTenkan: num(ich, 'conversionPeriods', DEF_DEFAULTS.ichTenkan),
ichKijun: num(ich, 'basePeriods', DEF_DEFAULTS.ichKijun),
ichSenkouB: num(ich, 'laggingSpan2Periods', DEF_DEFAULTS.ichSenkouB),
psyPeriod: num(psy, 'length', DEF_DEFAULTS.psyPeriod),
newPsy: num(nPsy, 'length', DEF_DEFAULTS.newPsy),
investPsy: num(iPsy, 'length', DEF_DEFAULTS.investPsy),
williamsR: num(wr, 'length', DEF_DEFAULTS.williamsR),
bwiPeriod: DEF_DEFAULTS.bwiPeriod, // BWI는 레지스트리 미등록
vrPeriod: num(vrInd, 'length', DEF_DEFAULTS.vrPeriod),
volOscShort: num(vo, 'shortLength', DEF_DEFAULTS.volOscShort),
volOscLong: num(vo, 'longLength', DEF_DEFAULTS.volOscLong),
dispUltra: num(disp, 'ultraLength', DEF_DEFAULTS.dispUltra),
dispShort: num(disp, 'shortLength', DEF_DEFAULTS.dispShort),
dispMid: num(disp, 'midLength', DEF_DEFAULTS.dispMid),
dispLong: num(disp, 'longLength', DEF_DEFAULTS.dispLong),
maLines,
hlThresh,
};
}
/**
* 전략편집기 DEF — DB 보조지표 설정(파라미터·hline)에서 구성.
* 조건 노드 기본값은 valuePeriodOverride/thresholdOverride=false 일 때 여기서 상속.
*/
export function buildStrategyEditorDefFromSettings(
getParams: (
type: string,
defaults?: Record<string, number | string | boolean>,
) => Record<string, number | string | boolean>,
getVisualConfig: (
type: string,
defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: unknown },
): DefType {
const configs: IndicatorConfig[] = [];
for (const regDef of INDICATOR_REGISTRY) {
const type = regDef.type;
const params = getParams(type, regDef.defaultParams as Record<string, number | string | boolean>);
const { plots, hlines, cloudColors } = getVisualConfig(type, regDef.plots, regDef.hlines);
configs.push({
id: `se-def-${type}`,
type,
params: { ...params },
plots: plots.map(p => ({ ...p })),
hlines: hlines.map(h => ({ ...h })),
...(cloudColors ? { cloudColors } : {}),
} as IndicatorConfig);
}
return buildDef(configs);
}
/** @deprecated buildStrategyEditorDefFromSettings 사용 — 하드코딩 폴백 */
export function buildDefFromGetParams(
getParams: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
): DefType {
const types = [
'RSI', 'MACD', 'CCI', 'Stochastic', 'ADX', 'TRIX', 'DMI', 'BollingerBands',
'IchimokuCloud', 'WilliamsPercentRange', 'VolumeOscillator', 'VR', 'Disparity',
'Psychological', 'NewPsychological', 'InvestPsychological', 'OBV', 'SMA', 'EMA',
] as const;
const configs: IndicatorConfig[] = types.map(type => ({
id: `eval-def-${type}`,
type,
params: getParams(type, getIndicatorDef(type)?.defaultParams as Record<string, number | string | boolean>),
plots: getIndicatorDef(type)?.plots ?? [],
}));
return buildDef(configs);
}
export function buildStrategyEditorDef(): DefType {
return buildDef([]);
}
// ─── 공통 조건 옵션 ───────────────────────────────────────────────────────────
const COND_OPTIONS = [
{ v: 'GT', l: '초과 (>)' },
{ v: 'LT', l: '미만 (<)' },
{ v: 'GTE', l: '이상 (>=)' },
{ v: 'LTE', l: '이하 (<=)' },
{ v: 'EQ', l: '같음 (==)' },
{ v: 'NEQ', l: '다름 (!=)' },
{ v: 'CROSS_UP', l: '상향 돌파' },
{ v: 'CROSS_DOWN', l: '하향 돌파' },
{ v: 'SLOPE_UP', l: '상승 기울기' },
{ v: 'SLOPE_DOWN', l: '하락 기울기' },
{ 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 = [
...COND_OPTIONS,
{ v: 'ABOVE_CLOUD', l: '구름 위' },
{ v: 'BELOW_CLOUD', l: '구름 아래' },
{ v: 'IN_CLOUD', l: '구름 안' },
{ v: 'CLOUD_BREAK_UP', l: '구름 상향 돌파' },
{ v: 'CLOUD_BREAK_DOWN', l: '구름 하향 돌파' },
{ v: 'SPAN1_GT_SPAN2', l: '선행스팬1 > 선행스팬2' },
{ v: 'SPAN1_LT_SPAN2', l: '선행스팬1 < 선행스팬2' },
{ v: 'SPAN1_CROSS_UP_SPAN2', l: '선행스팬1 상향돌파 선행스팬2' },
{ v: 'SPAN1_CROSS_DOWN_SPAN2', l: '선행스팬1 하향돌파 선행스팬2' },
{ v: 'LAGGING_GT_PRICE', l: '후행스팬 > 26봉전 종가' },
{ v: 'LAGGING_LT_PRICE', l: '후행스팬 < 26봉전 종가' },
{ v: 'LAGGING_ABOVE_PRIOR_CLOUD', l: '후행스팬 > 26봉전 구름' },
{ v: 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', l: '후행스팬 26봉전 구름 상향돌파' },
];
// ─── 지표별 fieldOptions 빌더 ────────────────────────────────────────────────
type Opt = { value: string; label: string };
const NONE: Opt = { value: 'NONE', label: '선택안함' };
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',
DEF: DefType,
cond: ConditionDSL | undefined,
slot: FieldOptSlot,
): Opt[] {
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
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,
mid: saved.mid ?? defaults.mid ?? 0,
under: saved.under ?? defaults.under ?? -100,
};
};
switch (ind) {
case 'MACD': {
const { mid } = th({ mid: 0 });
const signalLabel = `신호선 ${DEF.macdSignal}`;
if (slot === 'left') {
return [
{ value: 'MACD_LINE', label: 'MACD선' },
{ value: 'SIGNAL_LINE', label: signalLabel },
];
}
return [
{ value: 'SIGNAL_LINE', label: signalLabel },
NONE,
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
}
case 'RSI': {
const valueOpt = buildHwpValueFieldOpt('RSI', DEF, cond)!;
const signalLabel = `신호선 ${DEF.rsiSignal}`;
const thresholds = buildThresholdOpts(th({ over: 70, mid: 50, under: 30 }), overTerm, underTerm);
if (slot === 'left') {
return [valueOpt, { value: 'RSI_SIGNAL', label: signalLabel }];
}
return [
{ value: 'RSI_SIGNAL', label: signalLabel },
NONE,
...thresholds,
];
}
case 'STOCHASTIC': {
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 cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
const signalLabel = `신호선 ${DEF.cciSignal}`;
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: signalLabel },
];
}
return [
{ value: 'CCI_SIGNAL', label: signalLabel },
NONE,
...thresholds,
];
}
case 'ADX': {
const valueOpt = buildHwpValueFieldOpt('ADX', DEF, cond)!;
const thresholds = buildThresholdOpts(th({ over: 40, mid: 25, under: 20 }), overTerm, underTerm);
if (slot === 'left') {
return [valueOpt];
}
return [NONE, ...thresholds];
}
case 'TRIX': {
const valueOpt = buildHwpValueFieldOpt('TRIX', DEF, cond)!;
const trmaLabel = `TRMA ${DEF.trixSignal}`;
const { mid } = th({ mid: 0 });
if (slot === 'left') {
return [
valueOpt,
{ value: 'TRIX_SIGNAL', label: trmaLabel },
];
}
return [
{ value: 'TRIX_SIGNAL', label: trmaLabel },
NONE,
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
}
case 'DMI': {
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: 'MDI', label: minusLabel },
{ value: 'PDI', label: plusLabel },
];
}
return [
NONE,
{ value: 'MDI', label: minusLabel },
{ value: 'PDI', label: plusLabel },
...thresholds,
];
}
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 valueOpt = buildHwpValueFieldOpt('WILLIAMS_R', DEF, cond)!;
const thresholds = buildThresholdOpts(th({ over: -20, mid: -50, under: -80 }), overTerm, underTerm);
if (slot === 'left') {
return [valueOpt];
}
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 = [
...(slot === 'right' ? [NONE] : []),
...buildMaFieldOptions(DEF.maLines),
{ value: 'CLOSE_PRICE', label: '종가' },
];
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
return opts;
}
case 'EMA': {
let opts = [
...(slot === 'right' ? [NONE] : []),
...buildEmaFieldOptions(DEF.maLines, 4),
{ value: 'CLOSE_PRICE', label: '종가' },
];
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
return opts;
}
case 'DISPARITY': {
const { mid } = th({ mid: 100 });
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 periodOpts = buildSingleIndicatorPeriodOpts('PSYCHOLOGICAL', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
if (slot === 'left') {
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'NEW_PSYCHOLOGICAL': {
const periodOpts = buildSingleIndicatorPeriodOpts('NEW_PSYCHOLOGICAL', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 50, mid: 0, under: -50 }), overTerm, underTerm);
if (slot === 'left') {
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'INVEST_PSYCHOLOGICAL': {
const periodOpts = buildSingleIndicatorPeriodOpts('INVEST_PSYCHOLOGICAL', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
if (slot === 'left') {
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'BWI': {
const periodOpts = buildSingleIndicatorPeriodOpts('BWI', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm);
if (slot === 'left') {
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'VR': {
const periodOpts = buildSingleIndicatorPeriodOpts('VR', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 200, mid: 100, under: 50 }), overTerm, underTerm);
if (slot === 'left') {
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'VOLUME_OSC': {
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 '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);
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);
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': {
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:'CCI_SIGNAL' },
ADX: { l:'ADX_VALUE', r: THRESHOLD_HL_MID },
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
DMI: { l:'PDI', r:'MDI' },
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
MA: { l: 'MA1', r: 'MA2' },
EMA: { l: 'EMA1', r: 'EMA2' },
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID },
PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: THRESHOLD_HL_OVER },
INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: THRESHOLD_HL_OVER },
WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: THRESHOLD_HL_OVER },
BWI: { l:'BWI_VALUE', r: THRESHOLD_HL_OVER },
VR: { l:'VR_VALUE', r: THRESHOLD_HL_OVER },
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: THRESHOLD_HL_MID },
MACD: { l:'MACD_LINE', r:'SIGNAL_LINE' },
BOLLINGER: { l:'CLOSE_PRICE', r:'UPPER_BAND' },
DONCHIAN: signalType === 'buy'
? { l:'CLOSE_PRICE', r:'DC_UPPER_20' }
: { l:'CLOSE_PRICE', r:'DC_LOWER_20' },
NEW_HIGH: { l:'CLOSE_PRICE', r: nhPriorField(9) },
NEW_LOW: { l:'CLOSE_PRICE', r: nlPriorField(9) },
ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' },
};
return map[ind] ?? { l:'NONE', r:'NONE' };
};
export { getDefaultConditionFields };
// conditionType 변경 시 자동 필드 조정
export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
const updated = { ...cond, conditionType: newCondType };
if (cond.composite) {
return syncCompositeFields({
...updated,
composite: true,
leftPeriod: cond.leftPeriod,
rightPeriod: cond.rightPeriod,
});
}
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 (!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;
updated.rightField = 'NONE';
updated.slopePeriod = updated.slopePeriod ?? 3;
} 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 특수처리
if (cond.indicatorType === 'ICHIMOKU') {
if (['ABOVE_CLOUD','BELOW_CLOUD','IN_CLOUD','CLOUD_BREAK_UP','CLOUD_BREAK_DOWN'].includes(newCondType)) {
updated.leftField = 'NONE'; updated.rightField = 'NONE';
} else if (['SPAN1_GT_SPAN2','SPAN1_LT_SPAN2','SPAN1_CROSS_UP_SPAN2','SPAN1_CROSS_DOWN_SPAN2'].includes(newCondType)) {
updated.leftField = 'LEADING_SPAN1'; updated.rightField = 'LEADING_SPAN2';
} else if (['LAGGING_GT_PRICE','LAGGING_LT_PRICE'].includes(newCondType)) {
updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE';
} else if (newCondType === 'LAGGING_ABOVE_PRIOR_CLOUD') {
updated.leftField = 'NONE'; updated.rightField = 'NONE';
} else if (newCondType === 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD') {
updated.leftField = 'NONE'; updated.rightField = 'NONE';
}
}
if (cond.indicatorType === 'VOLUME' && newCondType === 'VOLUME_GT_MA_RATIO') {
updated.leftField = 'VOLUME_VALUE';
updated.rightField = 'VOLUME_MA';
updated.compareValue = updated.compareValue ?? 1.5;
updated.params = { length: 5, ...(updated.params ?? {}) };
}
if (isPriceExtremeIndicator(updated.indicatorType)) {
return syncPriceExtremeSimpleFields(updated);
}
return updated;
};
// ─── 자연어 변환 ──────────────────────────────────────────────────────────────
const condLabel = (v: string) => COND_OPTIONS.concat(ICHIMOKU_CONDS as any).find(o => o.v === v)?.l ?? v;
/** RSI_VALUE_9 → RSI_VALUE (옵션 조회용) */
export function resolveFieldOptionValue(indicatorType: string, field?: string): string | undefined {
if (!field) return field;
const bases: Record<string, string> = {
RSI: 'RSI_VALUE',
CCI: 'CCI_VALUE',
ADX: 'ADX_VALUE',
WILLIAMS_R: 'WILLIAMS_R_VALUE',
TRIX: 'TRIX_VALUE',
VR: 'VR_VALUE',
PSYCHOLOGICAL: 'PSY_VALUE',
NEW_PSYCHOLOGICAL: 'NEW_PSY_VALUE',
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
};
const base = bases[indicatorType];
if (base && (field === base || field.startsWith(`${base}_`))) return base;
return field;
}
export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
if (!node) return '';
if (node.type === 'CONDITION' && node.condition) {
const c = ensureDirectThresholdSnapshot(normalizeCompositeCondition(node.condition));
const indName = getStrategyIndicatorDisplayName(c.indicatorType);
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
const C = condLabel(c.conditionType);
if (c.composite && c.leftPeriod && c.rightPeriod) {
const leftOpts = getCompositeFieldOpts(c.indicatorType, 1, DEF, c);
const rightOpts = getCompositeFieldOpts(c.indicatorType, 2, DEF, c);
const L = leftOpts.find(o => o.value === c.leftField)?.label
?? compositeFieldLabel(c.indicatorType, 1, c.leftPeriod);
const R = rightOpts.find(o => o.value === c.rightField)?.label
?? compositeFieldLabel(c.indicatorType, 2, c.rightPeriod);
const lCt = getCompositeLeftCandleType(c);
const rCt = getCompositeRightCandleType(c);
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
return `${indName} - ${L} ${C} ${R}${tf}`;
}
const defaults = getDefaultConditionFields(c.indicatorType, 'buy', DEF);
const leftStored = resolveStoredValueField(c.indicatorType, c.leftField, DEF, c);
const lv = resolveFieldOptionValue(c.indicatorType, leftStored);
const rvRaw = c.rightField;
const rv = isThresholdOverridden(c)
? resolveFieldOptionValue(c.indicatorType, rvRaw)
: (resolveInheritedThresholdField(rvRaw, c.indicatorType, DEF.hlThresh)
?? resolveFieldOptionValue(c.indicatorType, rvRaw));
const L = opts.find(o => o.value === leftStored)?.label
?? opts.find(o => o.value === lv)?.label
?? (() => {
const m = leftStored?.match(/_(\d+)$/);
if (m) return singleIndicatorValuePeriodLabel(c.indicatorType, parseInt(m[1], 10));
const base = VALUE_FIELD_PREFIX[c.indicatorType];
if (base && (leftStored === base || lv === base)) {
return singleIndicatorValuePeriodLabel(c.indicatorType, getConditionValuePeriod(c, DEF));
}
return null;
})()
?? c.leftField ?? indName;
const chartRef = getChartReferenceThreshold(c, 'right', defaults.r, DEF);
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 || '침체선'}`;
}
const rangeClause = formatCandleRangeClause(c);
if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`;
return `${indName} - ${rangeClause}${L} ${C}`;
}
if (node.type === 'AND') {
if (isStableStrategyPairRoot(node) && node.stableStrategyPair?.mode === 'buy') {
const { strategyId, mode } = node.stableStrategyPair;
const level = inferStableStrategyFilterLevel(node);
return `${stableStrategyDisplayName(strategyId as StableStrategyId, mode)} [필터:${stableStrategyFilterLevelLabel(level)}]\n${stableStrategySummaryText(strategyId as StableStrategyId, mode, level, DEF)}`;
}
if (isInflection33PairRoot(node) && node.inflection33Pair?.mode === 'buy') {
const { mode, period } = node.inflection33Pair;
const level = inferInflection33FilterLevel(node);
return `${inflection33DisplayName(mode, period)} [필터:${inflection33FilterLevelLabel(level)}]\n${inflection33SummaryText(mode, period, level)}`;
}
if (isIchimokuBbPairRoot(node) && node.ichimokuBbPair?.mode === 'buy') {
const cfg = inferIchimokuBbConfig(node);
return `${ichimokuBbDisplayName(cfg.mode, cfg.chikouDisplacement)}\n${ichimokuBbSummaryText(cfg)}`;
}
if (isPriceExtremeBreakoutPairRoot(node) && node.priceExtremePair) {
const { mode, shortPeriod, longPeriod } = node.priceExtremePair;
const level = inferPriceExtremeFilterLevel(node);
return `${priceExtremePairDisplayName(mode, shortPeriod, longPeriod)} [필터:${filterLevelLabel(level)}]\n${priceExtremePairSummaryText(mode, shortPeriod, longPeriod, level)}`;
}
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
}
if (node.type === 'OR') {
if (isStableStrategyPairRoot(node) && node.stableStrategyPair?.mode === 'sell') {
const { strategyId, mode } = node.stableStrategyPair;
const level = inferStableStrategyFilterLevel(node);
return `${stableStrategyDisplayName(strategyId as StableStrategyId, mode)} [필터:${stableStrategyFilterLevelLabel(level)}]\n${stableStrategySummaryText(strategyId as StableStrategyId, mode, level, DEF)}`;
}
if (isInflection33PairRoot(node) && node.inflection33Pair?.mode === 'sell') {
const { mode, period } = node.inflection33Pair;
const level = inferInflection33FilterLevel(node);
return `${inflection33DisplayName(mode, period)} [필터:${inflection33FilterLevelLabel(level)}]\n${inflection33SummaryText(mode, period, level)}`;
}
if (isIchimokuBbPairRoot(node) && node.ichimokuBbPair?.mode === 'sell') {
const cfg = inferIchimokuBbConfig(node);
return `${ichimokuBbDisplayName(cfg.mode, cfg.chikouDisplacement)}\n${ichimokuBbSummaryText(cfg)}`;
}
if (isStochOverboughtPairRoot(node)) {
const sec = node.stochPair!.secondaryIndicatorType;
return `${stochPairDisplayName(sec)}\n${stochPairSummaryText(sec)}`;
}
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
return parts.length === 0 ? '(OR 그룹)' : parts.join('\nOR ');
}
if (node.type === 'NOT') {
const c = node.children?.[0];
return c ? `NOT (${nodeToText(c, DEF)})` : '(NOT 그룹)';
}
if (node.type === 'TIMEFRAME') {
const inner = node.children?.[0];
const types = node.candleTypes?.length
? node.candleTypes
: [node.candleType ?? '1m'];
const tfLabel = types.map(formatStrategyCandleLabel).join(' · ');
if (!inner) return `[${tfLabel}] (빈 조건)`;
const innerText = nodeToText(inner, DEF);
return `[${tfLabel}] ${innerText}`;
}
return '';
};
function formulaParts(nodes: LogicNode[], DEF: DefType): string[] {
return nodes.map(c => nodeToFormula(c, DEF)).filter(p => p.length > 0);
}
export const nodeToFormula = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
if (!node) return '';
if (node.type === 'CONDITION') return `(${nodeToText(node, DEF)})`;
if (node.type === 'AND') {
const parts = formulaParts(node.children ?? [], DEF);
return parts.length === 0 ? '(빈 AND)' : `(${parts.join(' AND ')})`;
}
if (node.type === 'OR') {
const parts = formulaParts(node.children ?? [], DEF);
return parts.length === 0 ? '(빈 OR)' : `(${parts.join(' OR ')})`;
}
if (node.type === 'NOT') {
const c = node.children?.[0];
return c ? `NOT ${nodeToFormula(c, DEF)}` : '(빈 NOT)';
}
if (node.type === 'TIMEFRAME') {
const inner = node.children?.[0];
const types = node.candleTypes?.length
? node.candleTypes
: [node.candleType ?? '1m'];
const tfLabel = types.map(formatStrategyCandleLabel).join('·');
if (!inner) return `[${tfLabel}]`;
const innerFormula = nodeToFormula(inner, DEF);
return innerFormula ? `[${tfLabel}] ${innerFormula}` : `[${tfLabel}]`;
}
return '';
};
// ─── 트리 조작 ────────────────────────────────────────────────────────────────
export const updateNode = (root: LogicNode, id: string, fn: (n: LogicNode) => LogicNode): LogicNode => {
if (root.id === id) return fn(root);
if (!root.children) return root;
return { ...root, children: root.children.map(c => updateNode(c, id, fn)) };
};
export const deleteNode = (root: LogicNode, id: string): LogicNode | null => {
if (root.id === id) return null;
if (!root.children) return root;
const children = root.children.map(c => deleteNode(c, id)).filter(Boolean) as LogicNode[];
return { ...root, children };
};
export const addChild = (root: LogicNode, parentId: string, child: LogicNode): LogicNode => {
if (root.id === parentId) {
if (root.type === 'NOT' && (root.children?.length ?? 0) >= 1) { alert('NOT는 자식 1개만 가능합니다.'); return root; }
return { ...root, children: [...(root.children ?? []), child] };
}
if (!root.children) return root;
return { ...root, children: root.children.map(c => addChild(c, parentId, child)) };
};
/** 팔레트·터치로 루트에 추가할 때 — OR/AND 그룹이면 자식으로 붙이고, 단일 조건만 AND로 묶음 */
export const mergeAtRoot = (root: LogicNode | null, newNode: LogicNode, isOperator: boolean): LogicNode => {
if (!root) return newNode;
if (isOperator) return { ...newNode, children: [root] };
if (root.type === 'AND' || root.type === 'OR') {
return { ...root, children: [...(root.children ?? []), newNode] };
}
return { id: genId(), type: 'AND', children: [root, newNode] };
};
// ─── 검증 ─────────────────────────────────────────────────────────────────────
export const validateTree = (node: LogicNode): ValidationResult => {
const errors: { message: string }[] = [];
const warnings: { message: string }[] = [];
const check = (n: LogicNode) => {
if (n.type === 'AND' || n.type === 'OR') {
if (!n.children || n.children.length === 0) errors.push({ message: `${n.type} 노드에 자식 조건이 없습니다.` });
else if (n.children.length === 1) warnings.push({ message: `${n.type} 노드에 자식이 1개뿐입니다.` });
n.children?.forEach(check);
} else if (n.type === 'NOT') {
if (!n.children || n.children.length === 0) errors.push({ message: 'NOT 노드에 자식 조건이 없습니다.' });
else if (n.children.length > 1) errors.push({ message: 'NOT 노드는 자식이 1개여야 합니다.' });
n.children?.forEach(check);
} else if (n.type === 'CONDITION') {
if (!n.condition) errors.push({ message: '조건 내용이 비어있습니다.' });
}
};
check(node);
return { isValid: errors.length === 0, errors, warnings };
};
// ─── 조건 편집 UI (인라인 4컬럼) ─────────────────────────────────────────────
interface CondEditorProps {
cond: ConditionDSL;
signalType: 'buy'|'sell';
onChange: (c: ConditionDSL) => void;
def: DefType;
/** Stoch 과열×보조 복합 — 캔들범위 대신 보조지표 선택 */
stochPairEdit?: {
secondaryIndicator: string;
onSecondaryChange: (indicator: string) => void;
stochLocked?: boolean;
};
}
export const CondEditor: React.FC<CondEditorProps> = ({
cond, signalType, onChange, def, stochPairEdit,
}) => {
const normalized = normalizeCompositeCondition(cond);
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);
const defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def);
const inheritedThresholdField = defaults.r;
const getLeftValue = () => {
const raw = normalized.leftField;
const resolved = resolveStoredValueField(normalized.indicatorType, raw, def, normalized);
if (resolved && leftFieldOpts.some(o => o.value === resolved)) return resolved;
if (raw && leftFieldOpts.some(o => o.value === raw)) return raw;
const v = resolveFieldOptionValue(normalized.indicatorType, raw);
return isValid(v) ? v! : defaults.l;
};
const getRightValue = () => {
const raw = normalized.rightField;
if (isThresholdOverridden(normalized) && raw?.startsWith('K_')) {
return raw;
}
const v = isThresholdOverridden(normalized)
? resolveFieldOptionValue(normalized.indicatorType, raw)
: (isThresholdFieldOrSymbol(raw)
? (resolveInheritedThresholdField(raw, normalized.indicatorType, def.hlThresh)
?? resolveFieldOptionValue(normalized.indicatorType, raw))
: resolveFieldOptionValue(normalized.indicatorType, raw));
return isValid(v) ? v! : defaults.r;
};
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();
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
const handleRight = (v: string) => {
if (isThresholdSymbol(v)) {
const { targetValue: _t, ...rest } = normalized;
onChange(ensureDirectThresholdSnapshot({ ...rest, rightField: v, thresholdOverride: false }));
return;
}
const thresholds: Record<string,number> = {
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
OVERBOUGHT_80: 80, OVERSOLD_20: 20,
OVERBOUGHT_100: 100, NEUTRAL_0: 0, OVERSOLD_NEG100: -100,
ADX_25: 25, ADX_50: 50,
OVERBOUGHT_75: 75, OVERSOLD_25: 25,
OVERBOUGHT_NEG20: -20, NEUTRAL_NEG50: -50, OVERSOLD_NEG80: -80,
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
ZERO_LINE: 0,
};
let upd: ConditionDSL = { ...normalized, rightField: v };
if (thresholds[v] !== undefined) {
upd.targetValue = thresholds[v];
upd.thresholdOverride = true;
}
const priorP = parsePriorExtremePeriod(v);
if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) {
upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP });
}
onChange(ensureDirectThresholdSnapshot(upd));
};
if (normalized.composite) {
const leftOpts = getCompositeFieldOpts(normalized.indicatorType, 1, def, normalized);
const rightOpts = getCompositeFieldOpts(normalized.indicatorType, 2, def, normalized);
const leftField = normalized.leftField ?? leftOpts[0]?.value ?? 'NONE';
const rightField = normalized.rightField ?? rightOpts[0]?.value ?? 'NONE';
const leftIsPreset = leftOpts.some(o => o.value === leftField);
const rightIsPreset = rightOpts.some(o => o.value === rightField);
const leftPeriod = getConditionValuePeriod(normalized, def);
const rightPeriod = getConditionRightPeriod(normalized, def);
const periodPresetsLeft = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left');
const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right');
const handleCompositeField = (side: 'left' | 'right', field: string) => {
const p = parseDonchianPeriod(field)
?? parsePeriodFromCompositeField(normalized.indicatorType, field);
if (p == null) return;
if (side === 'left') {
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field, valuePeriodOverride: true }));
} else {
onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field, rightPeriodOverride: true }));
}
};
const leftCt = getCompositeLeftCandleType(normalized);
const rightCt = getCompositeRightCandleType(normalized);
return (
<div className="sp-cond-editor sp-cond-editor--composite">
<div className="sp-cond-composite-badge">복합지표 · {compositeDisplayName(normalized.indicatorType)}</div>
<div className="sp-cond-row-composite-tf">
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} 시간봉</label>
<select
className="sp-cond-sel"
value={leftCt}
onChange={e => onChange(setCompositeLeftCandleType(normalized, e.target.value))}
>
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} 시간봉</label>
<select
className="sp-cond-sel"
value={rightCt}
onChange={e => onChange(setCompositeRightCandleType(normalized, e.target.value))}
>
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
<div className="sp-cond-row4">
<div className="sp-cond-field">
<label className="sp-cond-lbl">지표</label>
<select
className="sp-cond-sel"
value={normalized.indicatorType}
onChange={e => onChange(switchCompositeIndicatorType(normalized, e.target.value, def))}
>
{COMPOSITE_INDICATOR_ITEMS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">조건대상1</label>
<ComboFieldSelect
options={leftOpts}
fieldValue={leftField}
isPresetField={leftIsPreset}
customKind="period"
customNumber={leftPeriod}
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 1, leftPeriod)}
numberPresets={periodPresetsLeft}
min={1}
max={500}
onFieldChange={f => handleCompositeField('left', f)}
onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
/>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">조건대상2</label>
<ComboFieldSelect
options={rightOpts}
fieldValue={rightField}
isPresetField={rightIsPreset}
customKind="period"
customNumber={rightPeriod}
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 2, rightPeriod)}
numberPresets={periodPresetsRight}
min={1}
max={500}
onFieldChange={f => handleCompositeField('right', f)}
onCustomNumberChange={p => onChange(setConditionRightPeriod(normalized, p))}
/>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">조건</label>
<select className="sp-cond-sel"
value={normalized.conditionType}
onChange={e => handleCondType(e.target.value)}>
{condOpts.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
</select>
</div>
</div>
</div>
);
}
return (
<div className="sp-cond-editor">
{/* 4컬럼 row */}
<div className="sp-cond-row4">
{/* 캔들 범위 / Stoch×보조 복합 보조지표 */}
<div className="sp-cond-field">
{stochPairEdit ? (
stochPairEdit.stochLocked ? (
<>
<label className="sp-cond-lbl">고정 지표</label>
<output className="sp-cond-readout">Stochastic %K</output>
</>
) : (
<>
<label className="sp-cond-lbl">보조지표</label>
<select
className="sp-cond-sel"
value={stochPairEdit.secondaryIndicator}
onChange={e => stochPairEdit.onSecondaryChange(e.target.value)}
>
{STOCH_PAIR_SECONDARY_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</>
)
) : isLookbackType ? (
<>
<label className="sp-cond-lbl">lookback ()</label>
<input
type="number"
className="sp-cond-num sp-cond-num--inline"
min={2}
max={500}
value={cond.lookbackPeriod ?? ''}
placeholder="예: 20"
onChange={e => onChange({ ...cond, lookbackPeriod: parseInt(e.target.value) || 20 })}
/>
</>
) : (
<>
<label className="sp-cond-lbl">캔들 범위</label>
<select
className="sp-cond-sel"
value={resolveCandleRangeMode(normalized)}
onChange={e => {
const mode = e.target.value as CandleRangeMode;
if (mode === 'CURRENT') {
onChange({ ...normalized, candleRangeMode: mode, candleRange: 1 });
} else {
const n = candleRangeWindowBars(normalized);
onChange({ ...normalized, candleRangeMode: mode, candleRange: n >= 2 ? n : 4 });
}
}}
>
<option value="CURRENT">현재 캔들</option>
<option value="EXISTS_IN">N봉 존재</option>
<option value="NOT_EXISTS_IN">N봉 미존재</option>
</select>
{resolveCandleRangeMode(normalized) !== 'CURRENT' && (
<input
type="number"
className="sp-cond-num sp-cond-num--inline"
min={2}
max={500}
value={candleRangeWindowBars(normalized)}
placeholder="N"
title="윈도우 봉 수"
onChange={e => onChange({
...normalized,
candleRange: Math.max(2, parseInt(e.target.value, 10) || 4),
})}
/>
)}
</>
)}
</div>
{/* 조건대상1 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl">조건대상1</label>
<ComboFieldSelect
options={leftFieldOpts}
fieldValue={isHoldType ? 'NONE' : (resolveStoredValueField(normalized.indicatorType, normalized.leftField, def, normalized) ?? getLeftValue())}
isPresetField={!isHoldType && (
leftFieldOpts.some(o => o.value === normalized.leftField)
|| (leftFieldOpts.some(o => o.value === resolveFieldOptionValue(normalized.indicatorType, normalized.leftField))
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
|| leftFieldOpts.some(o => o.value === resolveStoredValueField(normalized.indicatorType, normalized.leftField, def, normalized))
)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
customNumber={getConditionValuePeriod(normalized, def)}
numberPresets={getPeriodPresetOptions(normalized.indicatorType, def)}
min={1}
max={500}
disabled={isHoldType}
customOptionLabel={singleIndicatorValuePeriodLabel(
normalized.indicatorType,
getConditionValuePeriod(normalized, def),
)}
onFieldChange={v => {
const valuePrefix = VALUE_FIELD_PREFIX[normalized.indicatorType];
if (valuePrefix && v === valuePrefix) {
onChange(initConditionPeriodsInherit(
normalized.indicatorType,
{ ...normalized, leftField: v },
def,
));
return;
}
const pMatch = v.match(/_(\d+)$/);
if (valuePrefix && v.startsWith(`${valuePrefix}_`) && pMatch) {
onChange(setConditionValuePeriod(normalized, parseInt(pMatch[1], 10), def));
} else {
onChange({ ...normalized, leftField: v });
}
}}
onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
/>
</div>
{/* 조건대상2 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl">조건대상2</label>
<ComboFieldSelect
options={rightFieldOpts}
fieldValue={rightValue}
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)
?? 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}
allowDecimal={normalized.indicatorType === 'CCI'}
disabled={isSlopeType || isHoldType}
onFieldChange={handleRight}
onDirectInputActivate={() => {
onChange(activateDirectThresholdInput(
normalized,
'right',
inheritedThresholdField,
def,
));
}}
onCustomNumberChange={v => onChange(
ensureDirectThresholdSnapshot(
setConditionThreshold(normalized, 'right', v, def, { directInput: true }),
),
)}
/>
</div>
{/* 조건 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl">조건</label>
<select className="sp-cond-sel"
value={cond.conditionType}
onChange={e => handleCondType(e.target.value)}>
{condOpts.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
</select>
</div>
</div>
{/* 추가 필드 */}
{isDiffType && (
<div className="sp-cond-extra">
<label className="sp-cond-lbl">비교값</label>
<input type="number" className="sp-cond-num"
value={cond.compareValue ?? ''} placeholder="예: 0.5"
onChange={e => onChange({ ...cond, compareValue: parseFloat(e.target.value) || 0 })} />
</div>
)}
{isSlopeType && (
<div className="sp-cond-extra">
<label className="sp-cond-lbl">기울기 기간 ()</label>
<input type="number" className="sp-cond-num" min={1} max={100}
value={cond.slopePeriod ?? ''} placeholder="예: 5"
onChange={e => onChange({ ...cond, slopePeriod: parseInt(e.target.value) || 3 })} />
</div>
)}
{isHoldType && (
<div className="sp-cond-extra">
<label className="sp-cond-lbl">유지 일수 ()</label>
<input type="number" className="sp-cond-num" min={1} max={30}
value={cond.holdDays ?? ''} placeholder="예: 3"
onChange={e => onChange({ ...cond, holdDays: parseInt(e.target.value) || 3 })} />
</div>
)}
</div>
);
};
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
export type MakeNodeOptions = {
composite?: boolean;
/** 단일 보조지표 기간 오버라이드 */
period?: number;
/** 복합지표 단기·장기 기간 오버라이드 */
leftPeriod?: number;
rightPeriod?: number;
leftCandleType?: string;
rightCandleType?: string;
/** Stoch 과열×보조 복합 */
stochPair?: boolean;
secondaryIndicator?: string;
/** 9·20일 신고가/신저가 복합 */
priceExtremeBreakout?: boolean;
/** 33변곡 EMA+채널 복합 */
inflection33?: boolean;
/** 일목 후행×볼린저 복합 */
ichimokuBb?: boolean;
/** 추천 안정형 전략 복합 */
stableStrategy?: boolean;
};
/** 팔레트 드래그 payload → makeNode 옵션 */
export function makeNodeOptionsFromPalette(data: {
composite?: boolean;
stochPair?: boolean;
priceExtremeBreakout?: boolean;
inflection33?: boolean;
ichimokuBb?: boolean;
stableStrategy?: boolean;
secondaryIndicator?: string;
period?: number;
shortPeriod?: number;
longPeriod?: number;
leftCandleType?: string;
rightCandleType?: string;
value?: string;
}): MakeNodeOptions | undefined {
if (data.stochPair || (data.value && isStochOverboughtPairPaletteValue(data.value))) {
return {
stochPair: true,
secondaryIndicator: data.secondaryIndicator ?? 'CCI',
};
}
if (data.priceExtremeBreakout || (data.value && isPriceExtremeBreakoutPairPaletteValue(data.value))) {
return { priceExtremeBreakout: true };
}
if (data.inflection33 || (data.value && isInflection33PaletteValue(data.value))) {
return { inflection33: true };
}
if (data.ichimokuBb || (data.value && isIchimokuBbPaletteValue(data.value))) {
return { ichimokuBb: true };
}
if (data.stableStrategy || (data.value && isStableStrategyPaletteValue(data.value))) {
return { stableStrategy: true };
}
if (data.composite) {
return {
composite: true,
leftCandleType: data.leftCandleType,
rightCandleType: data.rightCandleType,
};
}
return undefined;
}
export const makeNode = (
type: string,
value: string,
signalType: 'buy'|'sell',
DEF: DefType,
options?: MakeNodeOptions,
): LogicNode => {
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
if (options?.stochPair || isStochOverboughtPairPaletteValue(value)) {
return buildStochOverboughtPairTree(options?.secondaryIndicator ?? 'CCI', DEF);
}
if (options?.priceExtremeBreakout || isPriceExtremeBreakoutPairPaletteValue(value)) {
const tree = buildPriceExtremeBreakoutTree(value, DEF);
if (tree) return tree;
}
if (options?.inflection33 || isInflection33PaletteValue(value)) {
const tree = buildInflection33Tree(value, DEF);
if (tree) return tree;
}
if (options?.ichimokuBb || isIchimokuBbPaletteValue(value)) {
const tree = buildIchimokuBbTree(value, DEF);
if (tree) return tree;
}
if (options?.stableStrategy || isStableStrategyPaletteValue(value)) {
const tree = buildStableStrategyTree(value, DEF);
if (tree) return tree;
}
if (options?.composite) {
let condition = makeCompositeCondition(value, signalType, DEF);
condition = {
...condition,
valuePeriodOverride: false,
rightPeriodOverride: false,
thresholdOverride: false,
};
if (options.leftCandleType != null || options.rightCandleType != null) {
condition = syncCompositeFields({
...condition,
composite: true,
leftCandleType: options.leftCandleType ?? condition.leftCandleType,
rightCandleType: options.rightCandleType ?? condition.rightCandleType,
});
}
return { id: genId(), type: 'CONDITION', condition };
}
const def = getDefaultConditionFields(value, signalType, DEF);
let conditionType = signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN';
if (value === 'NEW_HIGH' && signalType === 'buy') conditionType = 'CROSS_UP';
if (value === 'NEW_LOW' && signalType === 'sell') conditionType = 'CROSS_DOWN';
const baseCondition: ConditionDSL = {
indicatorType: value,
conditionType,
leftField: def.l,
rightField: def.r,
candleRange: 1,
valuePeriodOverride: false,
thresholdOverride: false,
rightPeriodOverride: false,
};
const condition = initConditionPeriodsInherit(value, baseCondition, DEF);
return {
id: genId(), type: 'CONDITION',
condition: isPriceExtremeIndicator(value)
? syncPriceExtremeSimpleFields(condition)
: condition,
};
};