전략편집기 기준값 보조지표 설정값 동기화

This commit is contained in:
Macbook
2026-05-28 09:20:06 +09:00
parent 9137864f48
commit e2816b037f
18 changed files with 710 additions and 153 deletions
+3 -2
View File
@@ -103,9 +103,10 @@ export function compositeValueField(ind: string, period: number): string {
}
}
/** K_ 접두 임계값 필드 — 기간 파싱·복합지표 승격에서 제외 */
/** K_ 접두 임계값·hline 역할 심볼 — 기간 파싱·복합지표 승격에서 제외 */
export function isThresholdField(field?: string): boolean {
return !!field && field.startsWith('K_');
return !!field && (field.startsWith('K_')
|| field === 'HL_OVER' || field === 'HL_MID' || field === 'HL_UNDER');
}
const COMPOSITE_VALUE_PREFIX: Record<string, string> = {
+86 -22
View File
@@ -18,6 +18,15 @@ import {
parsePriorExtremePeriod,
syncPriceExtremeSimpleFields,
} from './priceExtremeIndicators';
import {
isThresholdFieldOrSymbol,
isThresholdSymbol,
resolveInheritedThresholdField,
resolveThresholdFromDef,
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
THRESHOLD_HL_UNDER,
} from './thresholdSymbols';
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
export interface IndicatorPeriodDef {
@@ -164,29 +173,99 @@ export function thresholdField(value: number): string {
}
export function hasEditableThreshold(cond: ConditionDSL): boolean {
return parseThresholdField(cond.rightField) != null
|| parseThresholdField(cond.leftField) != null;
return isThresholdFieldOrSymbol(cond.rightField)
|| isThresholdFieldOrSymbol(cond.leftField)
|| parseThresholdField(cond.rightField) != null;
}
/** 보조지표 차트(DB) hline 기준값 — 전략 오버라이드와 무관하게 항상 DEF에서 조회 */
export function getChartReferenceThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
inheritedField: string | undefined,
def: { hlThresh: Record<string, { over?: number; mid?: number; under?: number }> },
): number | null {
const raw = side === 'left' ? cond.leftField : (inheritedField ?? cond.rightField);
if (!raw) return null;
const field = isThresholdOverridden(cond)
? raw
: resolveInheritedThresholdField(raw, cond.indicatorType, def.hlThresh);
if (!field) return null;
return resolveThresholdFromDef(field, cond.indicatorType, def.hlThresh);
}
/**
* 임계값 표시 — thresholdOverride 가 false 이면 inheritedField(보조지표 설정 DEF) 우선.
* 전략 평가용 임계값 — thresholdOverride=true 이면 targetValue/K_, 아니면 차트 DEF.
*/
export function getConditionThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
inheritedField?: string,
def?: { hlThresh: Record<string, { over?: number; mid?: number; under?: number }> },
): number | null {
if (!isThresholdOverridden(cond) && inheritedField) {
const fromDef = parseThresholdField(inheritedField);
if (fromDef != null) return fromDef;
}
if (isThresholdOverridden(cond) && cond.targetValue != null && Number.isFinite(cond.targetValue)) {
return cond.targetValue;
}
if (!isThresholdOverridden(cond)) {
const rawField = inheritedField ?? (side === 'left' ? cond.leftField : cond.rightField);
const roleField = def
? resolveInheritedThresholdField(rawField, cond.indicatorType, def.hlThresh)
: rawField;
if (def && roleField) {
const fromDef = resolveThresholdFromDef(roleField, cond.indicatorType, def.hlThresh);
if (fromDef != null) return fromDef;
}
if (inheritedField && def) {
const inheritedRole = resolveInheritedThresholdField(
inheritedField,
cond.indicatorType,
def.hlThresh,
);
const fromInherited = resolveThresholdFromDef(
inheritedRole,
cond.indicatorType,
def.hlThresh,
);
if (fromInherited != null) return fromInherited;
}
}
const field = side === 'left' ? cond.leftField : cond.rightField;
return parseThresholdField(field);
}
export function setConditionThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
value: number,
def?: { hlThresh: Record<string, { over?: number; mid?: number; under?: number }> },
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
const current = cond[fieldKey];
if (!isThresholdFieldOrSymbol(current)) return cond;
const inheritedField = side === 'right'
? (current?.startsWith('K_') || isThresholdSymbol(current) ? current : THRESHOLD_HL_OVER)
: current;
const chartRef = def
? getChartReferenceThreshold(cond, side, inheritedField, def)
: null;
if (chartRef != null && Math.abs(chartRef - value) < 0.0001) {
const role = isThresholdSymbol(inheritedField)
? inheritedField
: (side === 'right' ? THRESHOLD_HL_OVER : inheritedField);
const { targetValue: _t, ...rest } = cond;
return { ...rest, [fieldKey]: role, thresholdOverride: false };
}
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
}
export function setConditionValuePeriod(
cond: ConditionDSL,
period: number,
@@ -213,21 +292,6 @@ export function setConditionRightPeriod(cond: ConditionDSL, period: number): Con
return syncCompositeFields({ ...cond, composite: true, rightPeriod: p, rightPeriodOverride: true });
}
export function setConditionThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
value: number,
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
const current = cond[fieldKey];
if (!current?.startsWith('K_')) return cond;
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
}
/** 신규 조건 노드 — 보조지표 설정 기본값 상속(오버라이드 없음) */
export function initConditionPeriodsInherit(
@@ -0,0 +1,141 @@
/** 전략 조건 DSL — 보조지표 DB 설정과 임계값·기간 상inherit 정규화 */
import type { ConditionDSL, LogicNode } from './strategyTypes';
import type { DefType } from './strategyEditorShared';
import { getDefaultConditionFields } from './strategyEditorShared';
import {
isThresholdOverridden,
isValuePeriodOverridden,
parseThresholdField,
usesValuePeriodField,
VALUE_FIELD_PREFIX,
} from './conditionPeriods';
import {
defaultInheritedThresholdField,
inferThresholdSymbolFromLegacy,
isThresholdFieldOrSymbol,
isThresholdSymbol,
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
} from './thresholdSymbols';
function migrateConditionThreshold(
cond: ConditionDSL,
def: DefType,
signalType: 'buy' | 'sell',
): ConditionDSL {
if (isThresholdOverridden(cond)) return cond;
let rightField = cond.rightField;
if (rightField?.startsWith('K_')) {
rightField = inferThresholdSymbolFromLegacy(rightField, cond.indicatorType, def.hlThresh);
}
if (!rightField || (!isThresholdSymbol(rightField) && !rightField.startsWith('K_'))) {
const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def);
if (isThresholdFieldOrSymbol(defaults.r)) {
rightField = defaults.r;
}
}
const next: ConditionDSL = {
...cond,
rightField,
thresholdOverride: false,
};
const { targetValue: _t, ...rest } = next;
return rest;
}
function migrateConditionPeriod(cond: ConditionDSL): ConditionDSL {
if (isValuePeriodOverridden(cond)) return cond;
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
if (!prefix || !usesValuePeriodField(cond)) return cond;
const lf = cond.leftField ?? '';
if (lf.startsWith(`${prefix}_`)) {
const { period: _p, ...rest } = cond;
return { ...rest, leftField: prefix, valuePeriodOverride: false };
}
return cond;
}
export function migrateConditionForEditor(
cond: ConditionDSL,
def: DefType,
signalType: 'buy' | 'sell',
): ConditionDSL {
return migrateConditionPeriod(migrateConditionThreshold(cond, def, signalType));
}
function migrateLogicNode(
node: LogicNode,
def: DefType,
signalType: 'buy' | 'sell',
): LogicNode {
let next = node;
if (node.type === 'CONDITION' && node.condition) {
next = { ...node, condition: migrateConditionForEditor(node.condition, def, signalType) };
}
if (next.children?.length) {
next = { ...next, children: next.children.map(c => migrateLogicNode(c, def, signalType)) };
}
return next;
}
/** 전략 로드·편집기 진입 시 레거시 K_ 스냅샷 → hline 역할 심볼 */
export function migrateLogicRootForEditor(
root: LogicNode | null,
orphans: LogicNode[],
def: DefType,
signalType: 'buy' | 'sell',
): { root: LogicNode | null; orphans: LogicNode[] } {
return {
root: root ? migrateLogicNode(root, def, signalType) : null,
orphans: orphans.map(n => migrateLogicNode(n, def, signalType)),
};
}
/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 */
export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL {
let next: ConditionDSL = { ...cond };
if (!isThresholdOverridden(next)) {
if (next.rightField?.startsWith('K_')) {
next.rightField = defaultInheritedThresholdField(next.indicatorType);
}
if (!isThresholdSymbol(next.rightField)) {
const role = next.indicatorType === 'DISPARITY' || next.indicatorType === 'VOLUME_OSC'
|| next.indicatorType === 'TRIX'
? THRESHOLD_HL_MID : THRESHOLD_HL_OVER;
if (parseThresholdField(next.rightField) != null || !next.rightField) {
next.rightField = role;
}
}
const { targetValue: _t, ...rest } = next;
next = { ...rest, thresholdOverride: false };
}
if (!isValuePeriodOverridden(next)) {
const prefix = VALUE_FIELD_PREFIX[next.indicatorType];
if (prefix && usesValuePeriodField(next)) {
next.leftField = prefix;
}
const { period: _p, ...rest } = next;
next = rest;
}
return next;
}
function normalizeLogicNode(node: LogicNode): LogicNode {
let next = node;
if (node.type === 'CONDITION' && node.condition) {
next = { ...node, condition: normalizeConditionForPersistence(node.condition) };
}
if (next.children?.length) {
next = { ...next, children: next.children.map(normalizeLogicNode) };
}
return next;
}
export function normalizeLogicRootForPersistence(root: LogicNode | null): LogicNode | null {
return root ? normalizeLogicNode(root) : null;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { normalizeLogicRootForPersistence } from './strategyConditionNormalize';
import type { LogicNode, ConditionDSL } from './strategyTypes';
import { generateNodeId } from './strategyTypes';
import { subtreeToFlatOrphans } from './strategyFlowLayout';
@@ -283,7 +284,7 @@ export function encodeConditionForSave(state: EditorConditionState): LogicNode |
if (branches.length === 1) {
const { candleType, candleTypes, root } = branches[0];
const types = candleTypes?.length ? candleTypes : [candleType];
return wrapTimeframes(types, root);
return wrapTimeframes(types, normalizeLogicRootForPersistence(root)!);
}
const combineOp = normalizeStartCombineOp(state.startCombineOp);
+12 -7
View File
@@ -15,12 +15,14 @@ import {
isRightPeriodOverridden,
isThresholdOverridden,
isValuePeriodOverridden,
parseThresholdField,
usesValuePeriodField,
VALUE_FIELD_PREFIX,
} from './conditionPeriods';
import {
defaultInheritedThresholdField,
inferThresholdSymbolFromLegacy,
} from './thresholdSymbols';
import type { DefType } from './strategyEditorShared';
import { getDefaultConditionFields } from './strategyEditorShared';
function applyGlobalDefToCondition(
cond: ConditionDSL,
@@ -61,11 +63,14 @@ function applyGlobalDefToCondition(
}
}
if (!isThresholdOverridden(cond) && parseThresholdField(next.rightField) != null) {
const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def);
next.rightField = defaults.r;
const parsed = parseThresholdField(defaults.r);
if (parsed != null) next.targetValue = parsed;
if (!isThresholdOverridden(cond) && next.rightField?.startsWith('K_')) {
next.rightField = inferThresholdSymbolFromLegacy(
next.rightField,
cond.indicatorType,
def.hlThresh,
) ?? defaultInheritedThresholdField(cond.indicatorType);
const { targetValue: _t, ...rest } = next;
next = { ...rest, thresholdOverride: false };
}
return next;
+83 -63
View File
@@ -27,12 +27,14 @@ import {
getCompositePeriodPresetOptions,
getConditionRightPeriod,
getConditionThreshold,
getChartReferenceThreshold,
getConditionValuePeriod,
getDefaultIndicatorPeriod,
getPeriodPresetOptions,
getThresholdBounds,
getThresholdPresetOptions,
initConditionPeriodsInherit,
isThresholdOverridden,
isValuePeriodFieldStored,
parseThresholdField,
resolveFieldCustomKind,
@@ -44,6 +46,14 @@ import {
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 } from '../utils/strategyStartNodes';
export interface StrategyDto {
@@ -292,13 +302,6 @@ export function buildStrategyEditorDef(): DefType {
return buildDef([]);
}
/**
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
* 예: 70 → "K_70", -100 → "K_-100"
* 백엔드 StrategyDslToTa4jAdapter에서 "K_" 접두어를 파싱하여 FixedDecimalIndicator 생성.
*/
const kv = (price: number) => `K_${price}`;
// ─── 공통 조건 옵션 ───────────────────────────────────────────────────────────
const COND_OPTIONS = [
{ v: 'GT', l: '초과 (>)' },
@@ -361,9 +364,9 @@ export const getFieldOpts = (
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
return condOpts([
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'STOCHASTIC': {
@@ -371,9 +374,9 @@ export const getFieldOpts = (
return condOpts([
{ value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` },
{ value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'CCI': {
@@ -381,18 +384,18 @@ export const getFieldOpts = (
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
return condOpts([
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'ADX': {
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
return condOpts([
{ value:'ADX_VALUE', label:`ADX 라인(${DEF.adxPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'TRIX': {
@@ -400,7 +403,7 @@ export const getFieldOpts = (
return condOpts([
{ value:'TRIX_VALUE', label:`TRIX 라인(${DEF.trixPeriod}일)` },
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
}
case 'DMI': {
@@ -408,9 +411,9 @@ export const getFieldOpts = (
return condOpts([
{ value:'PDI', label:`DI+(${DEF.dmiPeriod}일)` },
{ value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` },
{ value:kv(over), label:`과열(${over})` },
{ value:kv(mid), label:`중간값(${mid})` },
{ value:kv(under), label:`침체(${under})` },
{ value:THRESHOLD_HL_OVER, label:`과열(${over})` },
{ value:THRESHOLD_HL_MID, label:`중간값(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`침체(${under})` },
]);
}
case 'OBV': return condOpts([
@@ -431,64 +434,67 @@ export const getFieldOpts = (
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
{ value:'CLOSE_PRICE', label:'종가' },
];
case 'DISPARITY': return condOpts([
case 'DISPARITY': {
const { mid } = th({ mid: 100 });
return condOpts([
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
{ value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
{ value:`DISPARITY${DEF.dispMid}`, label:`중기 이격도(${DEF.dispMid}일)` },
{ value:`DISPARITY${DEF.dispLong}`, label:`장기 이격도(${DEF.dispLong}일)` },
{ value:kv(100), label:'중앙선(100)' },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
}
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([
{ value:'PSY_VALUE', label:`심리도 라인(${DEF.newPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'INVEST_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${DEF.investPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'WILLIAMS_R': {
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
return condOpts([
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${DEF.williamsR}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'BWI': {
const { over, mid, under } = th({ over:80, mid:50, under:20 });
return condOpts([
{ value:'BWI_VALUE', label:`BWI 라인(${DEF.bwiPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'VR': {
const { over, mid, under } = th({ over:200, mid:100, under:50 });
return condOpts([
{ value:'VR_VALUE', label:`VR 라인(${DEF.vrPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]);
}
case 'VOLUME_OSC': {
const { mid } = th({ mid:0 });
return condOpts([
{ value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터 값(${DEF.volOscShort}일/${DEF.volOscLong}일)` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
}
case 'MACD': {
@@ -497,7 +503,7 @@ export const getFieldOpts = (
{ value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` },
{ value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` },
{ value:'HISTOGRAM', label:'히스토그램' },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]);
}
case 'BOLLINGER': return condOpts([
@@ -559,30 +565,25 @@ export const getFieldOpts = (
};
const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
// 저장된 hline 과열선 값 우선 사용
const over = (def: number) => kv(DEF.hlThresh[ind]?.over ?? def);
const mid = (def: number) => kv(DEF.hlThresh[ind]?.mid ?? def);
const adxMid = DEF.hlThresh['ADX']?.mid ?? 25;
const map: Record<string, {l:string;r:string}> = {
RSI: { l:'RSI_VALUE', r: over(70) },
RSI: { l:'RSI_VALUE', r: THRESHOLD_HL_OVER },
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
CCI: { l:'CCI_VALUE', r: over(100) },
ADX: { l:'ADX_VALUE', r: kv(adxMid) },
CCI: { l:'CCI_VALUE', r: THRESHOLD_HL_OVER },
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:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) },
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
NEW_PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: over(75) },
WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: over(-20) },
BWI: { l:'BWI_VALUE', r: over(80) },
VR: { l:'VR_VALUE', r: over(200) },
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: mid(0) },
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID },
PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
NEW_PSYCHOLOGICAL: { l:'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'
@@ -682,10 +683,17 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
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 rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? indName;
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 (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`;
return `${indName} - ${L} ${C}`;
@@ -800,7 +808,13 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
return isValid(v) ? v! : defaults.l;
};
const getRightValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.rightField);
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;
};
@@ -812,6 +826,11 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
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,
@@ -1005,7 +1024,8 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
fieldValue={rightValue}
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField)
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def)
?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def)
?? parseThresholdField(normalized.rightField)
?? 0}
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
@@ -1014,7 +1034,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
allowDecimal={normalized.indicatorType === 'CCI'}
disabled={isSlopeType || isHoldType}
onFieldChange={handleRight}
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v))}
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v, def))}
/>
</div>
{/* 조건 */}
+93
View File
@@ -0,0 +1,93 @@
/** 보조지표 차트 hline 역할 — DSL에 숫자(K_70) 대신 저장해 DB 설정 변경 시 자동 반영 */
export const THRESHOLD_HL_OVER = 'HL_OVER';
export const THRESHOLD_HL_MID = 'HL_MID';
export const THRESHOLD_HL_UNDER = 'HL_UNDER';
export type ThresholdHlineRole = typeof THRESHOLD_HL_OVER | typeof THRESHOLD_HL_MID | typeof THRESHOLD_HL_UNDER;
export function isThresholdSymbol(field?: string): boolean {
return field === THRESHOLD_HL_OVER || field === THRESHOLD_HL_MID || field === THRESHOLD_HL_UNDER;
}
export function isThresholdFieldOrSymbol(field?: string): boolean {
return !!field && (field.startsWith('K_') || isThresholdSymbol(field));
}
type HlThresh = { over?: number; mid?: number; under?: number };
export function resolveThresholdFromDef(
field: string | undefined,
indicatorType: string,
hlThresh: Record<string, HlThresh>,
): number | null {
if (!field) return null;
const th = hlThresh[indicatorType] ?? {};
if (field === THRESHOLD_HL_OVER) return th.over ?? null;
if (field === THRESHOLD_HL_MID) return th.mid ?? null;
if (field === THRESHOLD_HL_UNDER) return th.under ?? null;
if (field.startsWith('K_')) {
const n = parseFloat(field.slice(2));
return Number.isFinite(n) ? n : null;
}
return null;
}
/** 레거시 K_ 값 → hline 역할 심볼 (정확 일치 우선, 없으면 가장 가까운 역할) */
export function inferThresholdSymbolFromLegacy(
field: string | undefined,
indicatorType: string,
hlThresh: Record<string, HlThresh>,
): string | undefined {
if (!field?.startsWith('K_')) return field;
const val = parseFloat(field.slice(2));
if (!Number.isFinite(val)) return field;
const th = hlThresh[indicatorType] ?? {};
const eps = 0.0001;
if (th.over != null && Math.abs(th.over - val) < eps) return THRESHOLD_HL_OVER;
if (th.mid != null && Math.abs(th.mid - val) < eps) return THRESHOLD_HL_MID;
if (th.under != null && Math.abs(th.under - val) < eps) return THRESHOLD_HL_UNDER;
const candidates: { role: ThresholdHlineRole; v: number }[] = [];
if (th.over != null) candidates.push({ role: THRESHOLD_HL_OVER, v: th.over });
if (th.mid != null) candidates.push({ role: THRESHOLD_HL_MID, v: th.mid });
if (th.under != null) candidates.push({ role: THRESHOLD_HL_UNDER, v: th.under });
if (candidates.length === 0) return defaultInheritedThresholdField(indicatorType);
let best = candidates[0];
let bestDiff = Math.abs(best.v - val);
for (const c of candidates.slice(1)) {
const d = Math.abs(c.v - val);
if (d < bestDiff) {
best = c;
bestDiff = d;
}
}
return best.role;
}
/** 상속 모드(thresholdOverride=false) — K_ 스냅샷을 hline 역할 심볼로 정규화 */
export function resolveInheritedThresholdField(
field: string | undefined,
indicatorType: string,
hlThresh: Record<string, HlThresh>,
): string | undefined {
if (!field) return field;
if (isThresholdSymbol(field)) return field;
if (field.startsWith('K_')) {
return inferThresholdSymbolFromLegacy(field, indicatorType, hlThresh)
?? defaultInheritedThresholdField(indicatorType);
}
return field;
}
/** 상속 모드 기본 rightField — 과열선 역할 */
export function defaultInheritedThresholdField(indicatorType: string): string {
switch (indicatorType) {
case 'DISPARITY':
case 'VOLUME_OSC':
case 'TRIX':
return THRESHOLD_HL_MID;
default:
return THRESHOLD_HL_OVER;
}
}