1590 lines
67 KiB
TypeScript
1590 lines
67 KiB
TypeScript
/** 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 {
|
||
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 {
|
||
getCompositeFieldOpts,
|
||
getCompositePeriodPresetOptions,
|
||
getConditionRightPeriod,
|
||
getConditionThreshold,
|
||
getChartReferenceThreshold,
|
||
getConditionValuePeriod,
|
||
getDefaultIndicatorPeriod,
|
||
getPeriodPresetOptions,
|
||
getThresholdBounds,
|
||
getThresholdPresetOptions,
|
||
initConditionPeriodsInherit,
|
||
isThresholdOverridden,
|
||
isValuePeriodFieldStored,
|
||
parseThresholdField,
|
||
resolveFieldCustomKind,
|
||
resolveRightFieldCustomKind,
|
||
getCompositeLeftCandleType,
|
||
getCompositeRightCandleType,
|
||
setCompositeLeftCandleType,
|
||
setCompositeRightCandleType,
|
||
setConditionThreshold,
|
||
setConditionRightPeriod,
|
||
setConditionValuePeriod,
|
||
} from '../utils/conditionPeriods';
|
||
import {
|
||
THRESHOLD_HL_MID,
|
||
THRESHOLD_HL_OVER,
|
||
THRESHOLD_HL_UNDER,
|
||
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봉전 구름' },
|
||
];
|
||
|
||
// ─── 지표별 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 });
|
||
if (slot === 'left') {
|
||
return [
|
||
{ value: 'MACD_LINE', label: 'MACD 선' },
|
||
{ value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` },
|
||
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
||
];
|
||
}
|
||
return [
|
||
{ value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` },
|
||
NONE,
|
||
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
||
];
|
||
}
|
||
case 'RSI': {
|
||
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
|
||
const thresholds = buildThresholdOpts(th({ over: 70, mid: 50, under: 30 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [
|
||
{ value: 'RSI_VALUE', label: `RSI 기간 ${rsiP}` },
|
||
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
|
||
];
|
||
}
|
||
return [
|
||
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
|
||
NONE,
|
||
...thresholds,
|
||
];
|
||
}
|
||
case 'STOCHASTIC': {
|
||
const 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 thresholds = buildThresholdOpts(th({ over: 100, mid: 0, under: -100 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [
|
||
{ value: 'CCI_VALUE', label: `CCI 기간 ${cciP}` },
|
||
{ value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` },
|
||
];
|
||
}
|
||
return [
|
||
{ value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` },
|
||
NONE,
|
||
...thresholds,
|
||
];
|
||
}
|
||
case 'ADX': {
|
||
const adxP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'ADX' }, DEF) : DEF.adxPeriod;
|
||
const thresholds = buildThresholdOpts(th({ over: 40, mid: 25, under: 20 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [{ value: 'ADX_VALUE', label: `ADX 기간 ${adxP}` }];
|
||
}
|
||
return [NONE, ...thresholds];
|
||
}
|
||
case 'TRIX': {
|
||
const trixP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'TRIX' }, DEF) : DEF.trixPeriod;
|
||
const { mid } = th({ mid: 0 });
|
||
if (slot === 'left') {
|
||
return [
|
||
{ value: 'TRIX_VALUE', label: `TRIX ${trixP}` },
|
||
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
|
||
];
|
||
}
|
||
return [
|
||
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
|
||
NONE,
|
||
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
|
||
];
|
||
}
|
||
case 'DMI': {
|
||
const thresholds = buildThresholdOpts(th({ over: 30, mid: 20, under: 10 }), '과열', '침체');
|
||
const plusLabel = `+DI 기간${DEF.dmiPeriod}일`;
|
||
const minusLabel = `-DI 기간${DEF.dmiPeriod}일`;
|
||
if (slot === 'left') {
|
||
return [
|
||
{ value: 'PDI', label: plusLabel },
|
||
{ value: 'MDI', label: minusLabel },
|
||
];
|
||
}
|
||
return [
|
||
{ value: 'MDI', label: minusLabel },
|
||
{ value: 'PDI', label: plusLabel },
|
||
NONE,
|
||
...thresholds,
|
||
];
|
||
}
|
||
case 'OBV':
|
||
if (slot === 'left') {
|
||
return [
|
||
{ value: 'OBV_LINE', label: 'OBV선' },
|
||
{ value: 'OBV_SIGNAL', label: `신호선 ${DEF.obvSignal}일` },
|
||
];
|
||
}
|
||
return [
|
||
{ value: 'OBV_SIGNAL', label: `신호선 ${DEF.obvSignal}일` },
|
||
NONE,
|
||
];
|
||
case 'WILLIAMS_R': {
|
||
const wrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'WILLIAMS_R' }, DEF) : DEF.williamsR;
|
||
const thresholds = buildThresholdOpts(th({ over: -20, mid: -50, under: -80 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [{ value: 'WILLIAMS_R_VALUE', label: `williams%R 기간${wrP}` }];
|
||
}
|
||
return [NONE, ...thresholds];
|
||
}
|
||
case 'VOLUME':
|
||
return slot === 'left'
|
||
? [
|
||
{ value: 'VOLUME_VALUE', label: '거래량' },
|
||
{ value: 'VOLUME_MA', label: '거래량 이동평균' },
|
||
]
|
||
: [NONE, { value: 'VOLUME_MA', label: '거래량 이동평균' }];
|
||
case 'MA': {
|
||
let opts = [
|
||
...(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 psyP = cond
|
||
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
|
||
: DEF.psyPeriod;
|
||
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [{ value: 'PSY_VALUE', label: `심리도 기간 ${psyP}` }];
|
||
}
|
||
return [NONE, ...thresholds];
|
||
}
|
||
case 'NEW_PSYCHOLOGICAL': {
|
||
const nPsyP = cond
|
||
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
|
||
: DEF.newPsy;
|
||
const thresholds = buildThresholdOpts(th({ over: 50, mid: 0, under: -50 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [{ value: 'NEW_PSY_VALUE', label: `신심리도 기간 ${nPsyP}` }];
|
||
}
|
||
return [NONE, ...thresholds];
|
||
}
|
||
case 'INVEST_PSYCHOLOGICAL': {
|
||
const ipsyP = cond
|
||
? getConditionValuePeriod({ ...cond, indicatorType: 'INVEST_PSYCHOLOGICAL' }, DEF)
|
||
: DEF.investPsy;
|
||
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [{ value: 'INVEST_PSY_VALUE', label: `투자심리도 기간 ${ipsyP}` }];
|
||
}
|
||
return [NONE, ...thresholds];
|
||
}
|
||
case 'BWI': {
|
||
const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [{ value: 'BWI_VALUE', label: `BWI 기간 ${DEF.bwiPeriod}` }];
|
||
}
|
||
return [NONE, ...thresholds];
|
||
}
|
||
case 'VR': {
|
||
const vrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'VR' }, DEF) : DEF.vrPeriod;
|
||
const thresholds = buildThresholdOpts(th({ over: 200, mid: 100, under: 50 }), overTerm, underTerm);
|
||
if (slot === 'left') {
|
||
return [{ value: 'VR_VALUE', label: `VR 기간 ${vrP}` }];
|
||
}
|
||
return [NONE, ...thresholds];
|
||
}
|
||
case 'VOLUME_OSC': {
|
||
const { mid } = th({ mid: 0 });
|
||
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 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 (!isValid(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';
|
||
}
|
||
}
|
||
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 = 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 lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
|
||
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 === lv)?.label ?? 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 (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 (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 v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
|
||
return isValid(v) ? v! : defaults.l;
|
||
};
|
||
const getRightValue = () => {
|
||
const raw = normalized.rightField;
|
||
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({ ...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(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' : (normalized.leftField ?? getLeftValue())}
|
||
isPresetField={!isHoldType && (
|
||
leftFieldOpts.some(o => o.value === normalized.leftField)
|
||
|| (leftFieldOpts.some(o => o.value === getLeftValue())
|
||
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
|
||
)}
|
||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
|
||
customNumber={getConditionValuePeriod(normalized, def)}
|
||
numberPresets={getPeriodPresetOptions(normalized.indicatorType, def)}
|
||
min={1}
|
||
max={500}
|
||
disabled={isHoldType}
|
||
onFieldChange={v => {
|
||
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[normalized.indicatorType];
|
||
if (base && v === base) {
|
||
const p = getConditionValuePeriod(normalized, def);
|
||
onChange({ ...normalized, leftField: `${base}_${p}`, period: p, valuePeriodOverride: true });
|
||
} 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)
|
||
?? 0}
|
||
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
||
min={getThresholdBounds(normalized.indicatorType).min}
|
||
max={getThresholdBounds(normalized.indicatorType).max}
|
||
allowDecimal={normalized.indicatorType === 'CCI'}
|
||
disabled={isSlopeType || isHoldType}
|
||
onFieldChange={handleRight}
|
||
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v, def))}
|
||
/>
|
||
</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;
|
||
/** 추천 안정형 전략 복합 */
|
||
stableStrategy?: boolean;
|
||
};
|
||
|
||
/** 팔레트 드래그 payload → makeNode 옵션 */
|
||
export function makeNodeOptionsFromPalette(data: {
|
||
composite?: boolean;
|
||
stochPair?: boolean;
|
||
priceExtremeBreakout?: boolean;
|
||
inflection33?: 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.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?.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,
|
||
};
|
||
};
|