/** 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, getChikouDisplacement, } 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, ichSenkouDisp: 26, 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, }; /** * 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, 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 = { 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 = {}; 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), ichSenkouDisp: typeof ich.senkouDisplacement === 'number' ? (ich.senkouDisplacement as number) : num(ich, 'displacement', DEF_DEFAULTS.ichSenkouDisp), 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, ) => Record, 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); 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) => Record, ): 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), 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(); 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(${DEF.ichSenkouDisp}일)` }, { 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; } case 'ICHIMOKU_BB': { const chikouN = getChikouDisplacement(cond ?? { indicatorType: 'ICHIMOKU_BB' } as ConditionDSL); if (slot === 'left') { return [{ value: 'LAGGING_SPAN', label: `${chikouN}봉 전 후행스팬(종가)` }]; } return [ { value: 'UPPER_BAND', label: `${chikouN}봉 전 상한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, { value: 'MIDDLE_BAND', label: `${chikouN}봉 전 중앙값(${DEF.bbPeriod}일)` }, { value: 'LOWER_BAND', label: `${chikouN}봉 전 하한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, ]; } 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 = { 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' }, ICHIMOKU_BB: signalType === 'buy' ? { l: 'LAGGING_SPAN', r: 'UPPER_BAND' } : { l: 'LAGGING_SPAN', r: 'LOWER_BAND' }, }; 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 = { 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 = ({ 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 = { 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 (
복합지표 · {compositeDisplayName(normalized.indicatorType)}
handleCompositeField('left', f)} onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))} />
handleCompositeField('right', f)} onCustomNumberChange={p => onChange(setConditionRightPeriod(normalized, p))} />
); } return (
{/* 4컬럼 row */}
{/* 캔들 범위 / Stoch×보조 복합 보조지표 */}
{stochPairEdit ? ( stochPairEdit.stochLocked ? ( <> Stochastic %K ) : ( <> ) ) : isLookbackType ? ( <> onChange({ ...cond, lookbackPeriod: parseInt(e.target.value) || 20 })} /> ) : ( <> {resolveCandleRangeMode(normalized) !== 'CURRENT' && ( onChange({ ...normalized, candleRange: Math.max(2, parseInt(e.target.value, 10) || 4), })} /> )} )}
{/* 조건대상1 */}
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))} />
{/* 조건대상2 */}
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 }), ), )} />
{/* 조건 */}
{/* 추가 필드 */} {isDiffType && (
onChange({ ...cond, compareValue: parseFloat(e.target.value) || 0 })} />
)} {isSlopeType && (
onChange({ ...cond, slopePeriod: parseInt(e.target.value) || 3 })} />
)} {isHoldType && (
onChange({ ...cond, holdDays: parseInt(e.target.value) || 3 })} />
)}
); }; // ─── 노드 생성 헬퍼 ─────────────────────────────────────────────────────────── 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, }; };