전략편집기 조건 목록 수정

This commit is contained in:
Macbook
2026-06-18 17:47:49 +09:00
parent 97a1549647
commit 5bbc105326
7 changed files with 335 additions and 99 deletions
+99 -7
View File
@@ -12,6 +12,7 @@ import {
syncCompositeFields,
type CompositePeriodDef,
} from './compositeIndicators';
import { getStrategyIndicatorDisplayName } from './strategyPaletteStorage';
import {
isPriceExtremeIndicator,
migratePriceExtremeCondition,
@@ -55,6 +56,7 @@ export const VALUE_FIELD_PREFIX: Record<string, string> = {
WILLIAMS_R: 'WILLIAMS_R_VALUE',
TRIX: 'TRIX_VALUE',
VR: 'VR_VALUE',
BWI: 'BWI_VALUE',
PSYCHOLOGICAL: 'PSY_VALUE',
NEW_PSYCHOLOGICAL: 'NEW_PSY_VALUE',
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
@@ -447,14 +449,104 @@ export function hasNodeSettings(cond: ConditionDSL): boolean {
|| !!cond.composite;
}
const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120];
export const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120];
/** 지표별 추가 기간 프리셋 — HWP·UI 스펙 (예: CCI 100·200일) */
const INDICATOR_EXTRA_PERIODS: Record<string, number[]> = {
CCI: [100, 200],
};
function mergePeriodPresets(...groups: number[][]): number[] {
return [...new Set(groups.flat())].sort((a, b) => a - b);
}
/** 단일·복합 공통 — 지표별 기간 프리셋 */
export function getIndicatorPeriodPresets(
indicatorType: string,
def: IndicatorPeriodDef,
extra: number[] = [],
): number[] {
const base = getDefaultIndicatorPeriod(indicatorType, def);
const indicatorExtras = INDICATOR_EXTRA_PERIODS[indicatorType] ?? [];
if (isPriceExtremeIndicator(indicatorType)) {
return mergePeriodPresets([base, 5, 9, 10, 20, 55], COMMON_PERIODS, indicatorExtras, extra);
}
return mergePeriodPresets([base], COMMON_PERIODS, indicatorExtras, extra);
}
/** 단일 보조지표 값 라인 라벨 — 예: CCI 1 라인(9일) */
export function singleIndicatorLineLabel(indicatorType: string, period: number): string {
const name = getStrategyIndicatorDisplayName(indicatorType);
return `${name} 1 라인(${period}일)`;
}
/** 단일 보조지표 조건대상1 값·기간 라벨 — 지표별 HWP 표기 */
export function singleIndicatorValuePeriodLabel(indicatorType: string, period: number): string {
switch (indicatorType) {
case 'CCI':
return `CCI 기간 ${period}`;
case 'RSI':
return `RSI 기간 ${period}`;
case 'ADX':
return `ADX 기간 ${period}`;
case 'WILLIAMS_R':
return `williams%R 기간${period}`;
case 'TRIX':
return `TRIX ${period}`;
default:
return singleIndicatorLineLabel(indicatorType, period);
}
}
/** HWP — 조건대상1 기본 값 필드 (차트 설정 기간 + 직접입력) */
export function buildHwpValueFieldOpt(
indicatorType: string,
def: IndicatorPeriodDef,
cond?: ConditionDSL,
): { value: string; label: string } | null {
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix) return null;
const mock = cond ? { ...cond, indicatorType } : { indicatorType } as ConditionDSL;
const period = getConditionValuePeriod(mock, def);
return { value: prefix, label: singleIndicatorValuePeriodLabel(indicatorType, period) };
}
/** 단일 보조지표 조건대상1 — 기간별 값 라인 드롭다운 (직접입력은 ComboFieldSelect) */
export function buildSingleIndicatorPeriodOpts(
indicatorType: string,
def: IndicatorPeriodDef,
cond?: ConditionDSL,
): { value: string; label: string }[] {
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix) return [];
const mock = cond ? { ...cond, indicatorType } : { indicatorType } as ConditionDSL;
const current = getConditionValuePeriod(mock, def);
const periods = [...new Set([...getPeriodPresetOptions(indicatorType, def), current])].sort((a, b) => a - b);
return periods.map(p => ({
value: `${prefix}_${p}`,
label: singleIndicatorLineLabel(indicatorType, p),
}));
}
/** RSI_VALUE → RSI_VALUE_9 — 옵션·라벨 조회용 */
export function resolveStoredValueField(
indicatorType: string,
field: string | undefined,
def: IndicatorPeriodDef,
cond?: ConditionDSL,
): string | undefined {
if (!field) return field;
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (prefix && field === prefix) {
const mock = cond ? { ...cond, indicatorType, leftField: field } : { indicatorType, leftField: field } as ConditionDSL;
const p = getConditionValuePeriod(mock, def);
return `${prefix}_${p}`;
}
return field;
}
export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] {
const base = getDefaultIndicatorPeriod(indicatorType, def);
if (isPriceExtremeIndicator(indicatorType)) {
return [...new Set([base, 5, 9, 10, 20, 55, ...COMMON_PERIODS])].sort((a, b) => a - b);
}
return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b);
return getIndicatorPeriodPresets(indicatorType, def);
}
export function getPeriodSettingsLabel(cond: ConditionDSL): string | null {
@@ -472,7 +564,7 @@ export function getCompositePeriodPresetOptions(
): number[] {
const { short, long } = getCompositeDefaultPeriods(indicatorType, def as CompositePeriodDef);
const base = side === 'left' ? short : long;
return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b);
return getIndicatorPeriodPresets(indicatorType, def, [base, short, long]);
}
/** 복합지표 조건대상1/2 드롭다운 — CCI 1 라인(9일) 형식 */
@@ -24,7 +24,11 @@ import {
getCompositeLeftCandleType,
getCompositeRightCandleType,
ensureDirectThresholdSnapshot,
getConditionValuePeriod,
parseThresholdField,
resolveStoredValueField,
singleIndicatorLineLabel,
singleIndicatorValuePeriodLabel,
} from './conditionPeriods';
import { formatStrategyCandleLabel } from './strategyStartNodes';
import { formatStartCandleLabel } from './strategyStartNodes';
@@ -83,9 +87,23 @@ function fieldLabel(
if (hit) return hit.label;
}
const opts = getFieldOpts(indicatorType, signalType, def, cond);
const resolved = resolveFieldOptionValue(indicatorType, field);
const hit = opts.find(o => o.value === resolved);
const stored = resolveStoredValueField(indicatorType, field, def, cond);
const hit = opts.find(o => o.value === stored) ?? opts.find(o => o.value === field);
if (hit && hit.label !== '선택안함') return hit.label;
const resolved = resolveFieldOptionValue(indicatorType, field);
const hitLegacy = opts.find(o => o.value === resolved);
if (hitLegacy && hitLegacy.label !== '선택안함') return hitLegacy.label;
const m = stored?.match(/_(\d+)$/);
if (m) return singleIndicatorValuePeriodLabel(indicatorType, parseInt(m[1], 10));
if (cond) {
const base = {
RSI: 'RSI_VALUE', CCI: 'CCI_VALUE', ADX: 'ADX_VALUE',
WILLIAMS_R: 'WILLIAMS_R_VALUE', TRIX: 'TRIX_VALUE',
}[indicatorType];
if (base && (stored === base || field === base)) {
return singleIndicatorValuePeriodLabel(indicatorType, getConditionValuePeriod(cond, def));
}
}
const thresh = field ? parseThresholdField(field) : null;
if (thresh != null) return `임계값 ${thresh}`;
return field ?? indicatorType;
+77 -58
View File
@@ -74,7 +74,10 @@ import {
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
import {
activateDirectThresholdInput,
buildHwpValueFieldOpt,
buildSingleIndicatorPeriodOpts,
ensureDirectThresholdSnapshot,
VALUE_FIELD_PREFIX,
getCompositeFieldOpts,
getCompositePeriodPresetOptions,
getConditionRightPeriod,
@@ -91,6 +94,9 @@ import {
parseThresholdField,
resolveFieldCustomKind,
resolveRightFieldCustomKind,
resolveStoredValueField,
singleIndicatorLineLabel,
singleIndicatorValuePeriodLabel,
getCompositeLeftCandleType,
getCompositeRightCandleType,
setCompositeLeftCandleType,
@@ -491,37 +497,35 @@ function buildFieldOptsForSlot(
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: `신호선 ${DEF.macdSignal}` },
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
{ value: 'MACD_LINE', label: 'MACD선' },
{ value: 'SIGNAL_LINE', label: signalLabel },
];
}
return [
{ value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` },
{ value: 'SIGNAL_LINE', label: signalLabel },
NONE,
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
}
case 'RSI': {
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
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 [
{ value: 'RSI_VALUE', label: `RSI 기간 ${rsiP}` },
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
];
return [valueOpt, { value: 'RSI_SIGNAL', label: signalLabel }];
}
return [
{ value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` },
{ 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 kLabel = `Stochastic 기간${DEF.stochK}일 %K${DEF.stochSmooth}`;
const dLabel = `Stochastic %D${DEF.stochD}`;
if (slot === 'left') {
return [
@@ -537,38 +541,40 @@ function buildFieldOptsForSlot(
}
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: `신호선 ${DEF.cciSignal}` },
{ value: 'CCI_SIGNAL', label: signalLabel },
];
}
return [
{ value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` },
{ value: 'CCI_SIGNAL', label: signalLabel },
NONE,
...thresholds,
];
}
case 'ADX': {
const adxP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'ADX' }, DEF) : DEF.adxPeriod;
const valueOpt = buildHwpValueFieldOpt('ADX', DEF, cond)!;
const thresholds = buildThresholdOpts(th({ over: 40, mid: 25, under: 20 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'ADX_VALUE', label: `ADX 기간 ${adxP}` }];
return [valueOpt];
}
return [NONE, ...thresholds];
}
case 'TRIX': {
const trixP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'TRIX' }, DEF) : DEF.trixPeriod;
const valueOpt = buildHwpValueFieldOpt('TRIX', DEF, cond)!;
const trmaLabel = `TRMA ${DEF.trixSignal}`;
const { mid } = th({ mid: 0 });
if (slot === 'left') {
return [
{ value: 'TRIX_VALUE', label: `TRIX ${trixP}` },
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
valueOpt,
{ value: 'TRIX_SIGNAL', label: trmaLabel },
];
}
return [
{ value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` },
{ value: 'TRIX_SIGNAL', label: trmaLabel },
NONE,
{ value: THRESHOLD_HL_MID, label: `중앙선(${mid})` },
];
@@ -579,14 +585,14 @@ function buildFieldOptsForSlot(
const minusLabel = `-DI 기간${DEF.dmiPeriod}`;
if (slot === 'left') {
return [
{ value: 'PDI', label: plusLabel },
{ value: 'MDI', label: minusLabel },
{ value: 'PDI', label: plusLabel },
];
}
return [
NONE,
{ value: 'MDI', label: minusLabel },
{ value: 'PDI', label: plusLabel },
NONE,
...thresholds,
];
}
@@ -602,10 +608,10 @@ function buildFieldOptsForSlot(
NONE,
];
case 'WILLIAMS_R': {
const wrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'WILLIAMS_R' }, DEF) : DEF.williamsR;
const valueOpt = buildHwpValueFieldOpt('WILLIAMS_R', DEF, cond)!;
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 [valueOpt];
}
return [NONE, ...thresholds];
}
@@ -648,47 +654,42 @@ function buildFieldOptsForSlot(
return slot === 'right' ? [NONE, ...lines] : lines;
}
case 'PSYCHOLOGICAL': {
const psyP = cond
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
: DEF.psyPeriod;
const periodOpts = buildSingleIndicatorPeriodOpts('PSYCHOLOGICAL', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'PSY_VALUE', label: `심리도 기간 ${psyP}` }];
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'NEW_PSYCHOLOGICAL': {
const nPsyP = cond
? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF)
: DEF.newPsy;
const periodOpts = buildSingleIndicatorPeriodOpts('NEW_PSYCHOLOGICAL', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 50, mid: 0, under: -50 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'NEW_PSY_VALUE', label: `신심리도 기간 ${nPsyP}` }];
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'INVEST_PSYCHOLOGICAL': {
const ipsyP = cond
? getConditionValuePeriod({ ...cond, indicatorType: 'INVEST_PSYCHOLOGICAL' }, DEF)
: DEF.investPsy;
const periodOpts = buildSingleIndicatorPeriodOpts('INVEST_PSYCHOLOGICAL', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'INVEST_PSY_VALUE', label: `투자심리도 기간 ${ipsyP}` }];
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 [{ value: 'BWI_VALUE', label: `BWI 기간 ${DEF.bwiPeriod}` }];
return periodOpts;
}
return [NONE, ...thresholds];
}
case 'VR': {
const vrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'VR' }, DEF) : DEF.vrPeriod;
const periodOpts = buildSingleIndicatorPeriodOpts('VR', DEF, cond);
const thresholds = buildThresholdOpts(th({ over: 200, mid: 100, under: 50 }), overTerm, underTerm);
if (slot === 'left') {
return [{ value: 'VR_VALUE', label: `VR 기간 ${vrP}` }];
return periodOpts;
}
return [NONE, ...thresholds];
}
@@ -941,13 +942,25 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
return `${indName} - ${L} ${C} ${R}${tf}`;
}
const defaults = getDefaultConditionFields(c.indicatorType, 'buy', DEF);
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
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 === lv)?.label ?? c.leftField ?? indName;
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)
@@ -1130,7 +1143,11 @@ export const CondEditor: React.FC<CondEditorProps> = ({
const inheritedThresholdField = defaults.r;
const getLeftValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
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 = () => {
@@ -1378,11 +1395,12 @@ export const CondEditor: React.FC<CondEditorProps> = ({
<label className="sp-cond-lbl">1</label>
<ComboFieldSelect
options={leftFieldOpts}
fieldValue={isHoldType ? 'NONE' : (normalized.leftField ?? getLeftValue())}
fieldValue={isHoldType ? 'NONE' : (resolveStoredValueField(normalized.indicatorType, normalized.leftField, def, normalized) ?? getLeftValue())}
isPresetField={!isHoldType && (
leftFieldOpts.some(o => o.value === normalized.leftField)
|| (leftFieldOpts.some(o => o.value === getLeftValue())
|| (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)}
@@ -1390,22 +1408,23 @@ export const CondEditor: React.FC<CondEditorProps> = ({
min={1}
max={500}
disabled={isHoldType}
customOptionLabel={singleIndicatorValuePeriodLabel(
normalized.indicatorType,
getConditionValuePeriod(normalized, def),
)}
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 });
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 });
}