전략편집기 복합지표 기능 반영

This commit is contained in:
Macbook
2026-05-25 01:34:52 +09:00
parent ca249ad318
commit 20dd257c29
21 changed files with 1781 additions and 183 deletions
+59 -6
View File
@@ -210,6 +210,9 @@ export class ChartManager {
/** 차트 하단 거래량 pane 표시 */
private _volumeVisible = true;
/** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
private _lastLayoutAvailableHeight?: number;
constructor(container: HTMLElement, theme: Theme) {
this.container = container;
const t = getTheme(theme);
@@ -221,6 +224,7 @@ export class ChartManager {
background: { type: ColorType.Solid, color: t.bgColor },
textColor: t.textColor,
fontSize: 12,
panes: { enableResize: false },
},
grid: {
vertLines: { color: t.gridColor },
@@ -288,6 +292,7 @@ export class ChartManager {
}
this.indicators.clear();
this.patternMarkers = [];
this._removeExtraSubPanes();
this._disposeMainMarkersPlugin();
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
@@ -535,6 +540,10 @@ export class ChartManager {
}
} catch (e) {
console.error(`[Indicator] ${config.type} error:`, e);
} finally {
if (this._activeIndicatorPaneIndices().size > 0) {
this.resetPaneHeights(this._lastLayoutAvailableHeight);
}
}
}
@@ -557,6 +566,7 @@ export class ChartManager {
this.indicators.clear();
this.patternMarkers = this.patternMarkers.filter(() => false);
this._reapplyAllPatternMarkers();
this._removeExtraSubPanes();
// ② 새 순서로 재추가 (병합 호스트 → 멤버 순)
this._indRunning = false;
@@ -590,7 +600,8 @@ export class ChartManager {
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
this._reapplyAllPatternMarkers();
this.indicators.delete(id);
this.resetPaneHeights();
this._removeExtraSubPanes();
this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout();
}
@@ -1232,6 +1243,7 @@ export class ChartManager {
this.indicators.clear();
this.patternMarkers = [];
this._reapplyAllPatternMarkers();
this._removeExtraSubPanes();
for (const cfg of configs) {
await this.addIndicator(cfg);
@@ -2047,6 +2059,20 @@ export class ChartManager {
return indices;
}
/** pane 2+ 빈 sub-pane 제거 — 제거·재로드 후 고아 pane 이 높이를 나눠 가져가는 것 방지 */
private _removeExtraSubPanes(): void {
for (;;) {
const panes = this.chart.panes();
if (panes.length <= 2) return;
const last = panes.length - 1;
try {
this.chart.removePane(last);
} catch {
return;
}
}
}
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
getPaneIndexAtChartY(chartY: number): number {
const layouts = this.getPaneLayouts();
@@ -2374,6 +2400,16 @@ export class ChartManager {
return this.indicators.size;
}
/** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
getLoadedIndicatorCount(): number {
let n = 0;
for (const entry of this.indicators.values()) {
if (entry.config?.hidden === true) continue;
if (entry.cloudPlugin || entry.seriesList.length > 0) n += 1;
}
return n;
}
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
getRawBarsLength(): number {
return this.rawBars.length;
@@ -2732,12 +2768,16 @@ export class ChartManager {
const N = activeIndPanes.size;
const H = (availableHeight && availableHeight > 0)
? availableHeight
: this.container.clientHeight;
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
? this._lastLayoutAvailableHeight
: this.container.clientHeight);
const volumeShown = this._volumeVisible && !this._candleOnlyLayout;
const VOL_FRAC = this._volumeFrac(H);
const MIN_IND_PX = 80;
const ORPHAN_STRETCH = 0.0001;
/** 보조지표 pane 은 동일 stretch weight → 항상 같은 높이 */
const IND_EQUAL_WEIGHT = 1;
const remaining = Math.max(0, 1 - mainFrac - (volumeShown ? VOL_FRAC : 0));
const eachIndFrac = N > 0 ? remaining / N : 0;
@@ -2746,13 +2786,20 @@ export class ChartManager {
? eachIndFrac
: MIN_IND_PX / H;
const indWeight = IND_EQUAL_WEIGHT;
const indUnit = Math.max(actualIndFrac, 1e-9);
const mainWeight = N > 0 ? mainFrac / indUnit : mainFrac;
const volWeight = volumeShown
? (N > 0 ? VOL_FRAC / indUnit : VOL_FRAC)
: ORPHAN_STRETCH;
for (let i = 0; i < panes.length; i++) {
if (i === 0) {
panes[i].setStretchFactor(mainFrac);
panes[i].setStretchFactor(mainWeight);
} else if (i === 1) {
panes[i].setStretchFactor(volumeShown ? VOL_FRAC : ORPHAN_STRETCH);
panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH);
} else if (activeIndPanes.has(i)) {
panes[i].setStretchFactor(actualIndFrac);
panes[i].setStretchFactor(indWeight);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
@@ -2801,13 +2848,19 @@ export class ChartManager {
const panes = this.chart.panes();
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
if (availableHeight != null && availableHeight > 0) {
this._lastLayoutAvailableHeight = availableHeight;
}
if (this._candleOnlyLayout) {
return this._resetPaneHeightsCandleFullscreen(availableHeight);
}
const H = (availableHeight && availableHeight > 0)
? availableHeight
: this.container.clientHeight;
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
? this._lastLayoutAvailableHeight
: this.container.clientHeight);
const N = this._activeIndicatorPaneIndices().size;
const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H));
+153
View File
@@ -0,0 +1,153 @@
/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
import type { ConditionDSL } from './strategyTypes';
export interface CompositePeriodDef {
rsiPeriod: number;
cciPeriod: number;
adxPeriod: number;
williamsR: number;
trixPeriod: number;
vrPeriod: number;
newPsy: number;
investPsy: number;
dispShort: number;
dispMid: number;
}
export interface CompositeIndicatorItem {
value: string;
label: string;
desc: string;
}
/** 복합지표 팔레트 — 동일 지표 타입 2인스턴스 교차 */
export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [
{ value: 'RSI', label: 'RSI', desc: 'RSI 기간 교차' },
{ value: 'CCI', label: 'CCI', desc: 'CCI 기간 교차' },
{ value: 'ADX', label: 'ADX', desc: 'ADX 기간 교차' },
{ value: 'WILLIAMS_R', label: 'Williams %R', desc: 'Williams %R 교차' },
{ value: 'TRIX', label: 'TRIX', desc: 'TRIX 기간 교차' },
{ value: 'VR', label: 'VR', desc: 'VR 기간 교차' },
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: '심리도 기간 교차' },
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: '투자심리도 교차' },
{ value: 'DISPARITY', label: '이격도', desc: '이격도 기간 교차' },
];
export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef): { short: number; long: number } {
switch (ind) {
case 'RSI': return { short: def.rsiPeriod, long: Math.max(def.rsiPeriod * 2, 20) };
case 'CCI': return { short: def.cciPeriod, long: Math.max(def.cciPeriod * 2, 26) };
case 'ADX': return { short: def.adxPeriod, long: Math.max(def.adxPeriod * 2, 28) };
case 'WILLIAMS_R': return { short: def.williamsR, long: Math.max(def.williamsR * 2, 28) };
case 'TRIX': return { short: def.trixPeriod, long: Math.max(def.trixPeriod * 2, 24) };
case 'VR': return { short: def.vrPeriod, long: Math.max(def.vrPeriod * 2, 20) };
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL': return { short: def.newPsy, long: Math.max(def.newPsy * 2, 24) };
case 'INVEST_PSYCHOLOGICAL': return { short: def.investPsy, long: Math.max(def.investPsy * 2, 20) };
case 'DISPARITY': return { short: def.dispShort, long: def.dispMid };
default: return { short: 9, long: 20 };
}
}
export function compositeValueField(ind: string, period: number): string {
switch (ind) {
case 'RSI': return `RSI_VALUE_${period}`;
case 'CCI': return `CCI_VALUE_${period}`;
case 'ADX': return `ADX_VALUE_${period}`;
case 'WILLIAMS_R': return `WILLIAMS_R_VALUE_${period}`;
case 'TRIX': return `TRIX_VALUE_${period}`;
case 'VR': return `VR_VALUE_${period}`;
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL': return `PSY_VALUE_${period}`;
case 'INVEST_PSYCHOLOGICAL': return `INVEST_PSY_VALUE_${period}`;
case 'DISPARITY': return `DISPARITY${period}`;
default: return `${ind}_VALUE_${period}`;
}
}
export function parsePeriodFromCompositeField(ind: string, field?: string): number | null {
if (!field) return null;
if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) {
const n = parseInt(field.slice('DISPARITY'.length), 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
const suffix = field.match(/_(\d+)$/);
if (!suffix) return null;
const n = parseInt(suffix[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
if (!cond.composite) return cond;
const leftP = cond.leftPeriod
?? parsePeriodFromCompositeField(cond.indicatorType, cond.leftField)
?? undefined;
const rightP = cond.rightPeriod
?? parsePeriodFromCompositeField(cond.indicatorType, cond.rightField)
?? undefined;
if (leftP == null || rightP == null) {
return {
...cond,
composite: true,
...(leftP != null ? { leftPeriod: leftP } : null),
...(rightP != null ? { rightPeriod: rightP } : null),
};
}
return {
...cond,
composite: true,
leftPeriod: leftP,
rightPeriod: rightP,
leftField: compositeValueField(cond.indicatorType, leftP),
rightField: compositeValueField(cond.indicatorType, rightP),
period: undefined,
};
}
export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
if (cond.composite) return syncCompositeFields(cond);
const leftP = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
const rightP = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
if (leftP && rightP && leftP !== rightP) {
return syncCompositeFields({
...cond,
composite: true,
leftPeriod: leftP,
rightPeriod: rightP,
});
}
return cond;
}
export function makeCompositeCondition(
ind: string,
signalType: 'buy' | 'sell',
def: CompositePeriodDef,
): ConditionDSL {
const { short, long } = getCompositeDefaultPeriods(ind, def);
return syncCompositeFields({
indicatorType: ind,
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
composite: true,
leftPeriod: short,
rightPeriod: long,
candleRange: 1,
});
}
export function compositePeriodLabel(ind: string, def: CompositePeriodDef): string {
const { short, long } = getCompositeDefaultPeriods(ind, def);
return `${short} / ${long}`;
}
export function compositeDisplayName(ind: string): string {
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
if (item) return `${item.label} + ${item.label}`;
return `${ind} + ${ind}`;
}
export function compositeElementLabel(ind: string, slot: 1 | 2): string {
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
const name = item?.label ?? ind;
return `${name} ${slot}`;
}
+219
View File
@@ -0,0 +1,219 @@
/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */
import type { ConditionDSL } from './strategyTypes';
import { getCompositeDefaultPeriods, parsePeriodFromCompositeField, syncCompositeFields, type CompositePeriodDef } from './compositeIndicators';
export interface IndicatorPeriodDef {
rsiPeriod: number;
cciPeriod: number;
adxPeriod: number;
williamsR: number;
trixPeriod: number;
vrPeriod: number;
newPsy: number;
investPsy: number;
}
const VALUE_FIELD_PREFIX: 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: 'PSY_VALUE',
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
};
export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number {
switch (indicatorType) {
case 'RSI': return def.rsiPeriod;
case 'CCI': return def.cciPeriod;
case 'ADX': return def.adxPeriod;
case 'WILLIAMS_R': return def.williamsR;
case 'TRIX': return def.trixPeriod;
case 'VR': return def.vrPeriod;
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL': return def.newPsy;
case 'INVEST_PSYCHOLOGICAL': return def.investPsy;
default: return 14;
}
}
function parseFieldPeriod(field?: string): number | null {
if (!field) return null;
const m = field.match(/_(\d+)$/);
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
/** 조건 노드의 RSI 등 값(좌측) 기간 */
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
if (cond.composite) {
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
if (fromField) return fromField;
}
if (cond.period && cond.period > 0) return cond.period;
const fromField = parseFieldPeriod(cond.leftField);
if (fromField) return fromField;
return getDefaultIndicatorPeriod(cond.indicatorType, def);
}
export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
if (cond.composite) {
if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod;
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
if (fromField) return fromField;
}
const fromField = parseFieldPeriod(cond.rightField);
if (fromField) return fromField;
return getDefaultIndicatorPeriod(cond.indicatorType, def);
}
export function usesValuePeriodField(cond: ConditionDSL): boolean {
if (cond.composite) return true;
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
if (!prefix) return false;
const lf = cond.leftField ?? '';
return lf === prefix || lf.startsWith(`${prefix}_`);
}
export function parseThresholdField(field?: string): number | null {
if (!field?.startsWith('K_')) return null;
const n = parseFloat(field.slice(2));
return Number.isFinite(n) ? n : null;
}
export function thresholdField(value: number): string {
return `K_${value}`;
}
export function hasEditableThreshold(cond: ConditionDSL): boolean {
return parseThresholdField(cond.rightField) != null
|| parseThresholdField(cond.leftField) != null;
}
export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null {
const field = side === 'left' ? cond.leftField : cond.rightField;
return parseThresholdField(field);
}
export function setConditionValuePeriod(
cond: ConditionDSL,
period: number,
_def: IndicatorPeriodDef,
): ConditionDSL {
const p = Math.max(1, Math.min(500, Math.round(period)));
if (cond.composite) {
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p });
}
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
const next: ConditionDSL = { ...cond, period: p };
if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) {
next.leftField = `${prefix}_${p}`;
}
return next;
}
export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL {
const p = Math.max(1, Math.min(500, Math.round(period)));
if (!cond.composite) return cond;
return syncCompositeFields({ ...cond, composite: true, rightPeriod: p });
}
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,
};
}
export function initConditionPeriods(
indicatorType: string,
condition: ConditionDSL,
def: IndicatorPeriodDef,
): ConditionDSL {
if (condition.composite) return condition;
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix) return condition;
const period = getDefaultIndicatorPeriod(indicatorType, def);
const lf = condition.leftField ?? '';
if (lf === prefix || lf.startsWith(`${prefix}_`)) {
return { ...condition, period: condition.period ?? period, leftField: `${prefix}_${condition.period ?? period}` };
}
return { ...condition, period: condition.period ?? period };
}
export function hasNodeSettings(cond: ConditionDSL): boolean {
return usesValuePeriodField(cond)
|| hasEditableThreshold(cond)
|| !!cond.composite;
}
const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120];
export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] {
const base = getDefaultIndicatorPeriod(indicatorType, def);
return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b);
}
/** 복합지표 요소1(단기)·요소2(장기) 기간 프리셋 */
export function getCompositePeriodPresetOptions(
indicatorType: string,
def: IndicatorPeriodDef,
side: 'left' | 'right',
): 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);
}
export function getThresholdPresetOptions(indicatorType: string): number[] {
switch (indicatorType) {
case 'RSI':
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL':
case 'INVEST_PSYCHOLOGICAL':
return [20, 25, 30, 50, 70, 75, 80];
case 'STOCHASTIC':
return [20, 50, 80];
case 'CCI':
return [-200, -100, -50, 0, 50, 100, 150, 200];
case 'WILLIAMS_R':
return [-80, -50, -20];
case 'ADX':
case 'DMI':
return [20, 25, 30, 40, 50];
case 'VR':
return [70, 100, 150, 200, 250];
default:
return [-100, -50, 0, 50, 100];
}
}
export function getThresholdBounds(indicatorType: string): { min: number; max: number } {
switch (indicatorType) {
case 'RSI':
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL':
case 'INVEST_PSYCHOLOGICAL':
case 'STOCHASTIC':
return { min: 0, max: 100 };
case 'WILLIAMS_R':
return { min: -100, max: 0 };
case 'CCI':
return { min: -500, max: 500 };
default:
return { min: -1000, max: 1000 };
}
}
+173 -26
View File
@@ -3,6 +3,24 @@ import React from 'react';
import type { IndicatorConfig } from '../types/index';
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
import {
compositeDisplayName,
compositeElementLabel,
makeCompositeCondition,
normalizeCompositeCondition,
syncCompositeFields,
} from '../utils/compositeIndicators';
import ComboNumberInput from '../components/strategyEditor/ComboNumberInput';
import {
getCompositePeriodPresetOptions,
getConditionRightPeriod,
getConditionValuePeriod,
getDefaultIndicatorPeriod,
initConditionPeriods,
parseThresholdField,
setConditionRightPeriod,
setConditionValuePeriod,
} from '../utils/conditionPeriods';
export interface StrategyDto {
id: number;
@@ -76,11 +94,14 @@ const DEF_DEFAULTS = {
/**
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
* (레거시 투자전략 화면용 — 차트 활성 지표와 동기화)
*/
export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
// 레지스트리 type명으로 조회
const p = (registryType: string) =>
activeIndicators.find(i => i.type === registryType)?.params ?? {};
// 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;
@@ -208,6 +229,14 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
};
}
/**
* 전략편집기 전용 DEF — 차트 activeIndicators와 무관한 고정 기본값.
* 조건 노드의 기간·임계값은 ConditionDSL(leftPeriod/rightPeriod/period)에만 저장된다.
*/
export function buildStrategyEditorDef(): DefType {
return buildDef([]);
}
/**
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
* 예: 70 → "K_70", -100 → "K_-100"
@@ -251,7 +280,12 @@ const ICHIMOKU_CONDS = [
type Opt = { value: string; label: string };
const NONE: Opt = { value: 'NONE', label: '선택안함' };
export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => {
export const getFieldOpts = (
ind: string,
signalType: 'buy'|'sell',
DEF: DefType,
cond?: ConditionDSL,
): Opt[] => {
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
@@ -269,8 +303,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
switch(ind) {
case 'RSI': {
const { over, mid, under } = th({ over:70, mid:50, under:30 });
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
return condOpts([
{ value:'RSI_VALUE', label:`RSI 값(${DEF.rsiPeriod}일)` },
{ value:'RSI_VALUE', label:`RSI 값(${rsiP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -288,8 +323,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
}
case 'CCI': {
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
return condOpts([
{ value:'CCI_VALUE', label:`CCI 값(${DEF.cciPeriod}일)` },
{ value:'CCI_VALUE', label:`CCI 값(${cciP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -483,6 +519,14 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
// conditionType 변경 시 자동 필드 조정
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 = getDefaultFields(cond.indicatorType, 'buy', DEF);
@@ -516,14 +560,40 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def
// ─── 자연어 변환 ──────────────────────────────────────────────────────────────
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: '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 = node.condition;
const opts = getFieldOpts(c.indicatorType, 'buy', DEF);
const L = opts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType;
const R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
const c = normalizeCompositeCondition(node.condition);
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
const C = condLabel(c.conditionType);
if (c.composite && c.leftPeriod && c.rightPeriod) {
const name = compositeDisplayName(c.indicatorType).split(' + ')[0];
return `${c.indicatorType} - ${name}(${c.leftPeriod}) ${C} ${name}(${c.rightPeriod})`;
}
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 ?? c.indicatorType;
const R = opts.find(o => o.value === rv)?.label
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
return `${c.indicatorType} - ${L} ${C}`;
}
@@ -624,24 +694,30 @@ interface CondEditorProps {
}
export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def);
const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
const normalized = normalizeCompositeCondition(cond);
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
const defaults = getDefaultFields(cond.indicatorType, signalType, def);
const defaults = getDefaultFields(normalized.indicatorType, signalType, def);
const getLeftValue = () => isValid(cond.leftField) ? cond.leftField! : defaults.l;
const getRightValue = () => isValid(cond.rightField) ? cond.rightField! : defaults.r;
const getLeftValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
return isValid(v) ? v! : defaults.l;
};
const getRightValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.rightField);
return isValid(v) ? v! : defaults.r;
};
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(cond.conditionType);
const isHoldType = cond.conditionType === 'HOLD_N_DAYS';
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(cond.conditionType);
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(cond, newType, def));
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
const handleRight = (v: string) => {
// rightField 변경 시 임계값 자동 설정
const thresholds: Record<string,number> = {
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
OVERBOUGHT_80: 80, OVERSOLD_20: 20,
@@ -652,11 +728,62 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
ZERO_LINE: 0,
};
const upd: ConditionDSL = { ...cond, rightField: v };
const upd: ConditionDSL = { ...normalized, rightField: v };
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
onChange(upd);
};
if (normalized.composite) {
const leftPeriod = getConditionValuePeriod(normalized, def);
const rightPeriod = getConditionRightPeriod(normalized, def);
return (
<div className="sp-cond-editor sp-cond-editor--composite">
<div className="sp-cond-composite-badge"> · {compositeDisplayName(normalized.indicatorType)}</div>
<div className="sp-cond-row4">
<div className="sp-cond-field">
<label className="sp-cond-lbl"> </label>
<select className="sp-cond-sel" value={normalized.candleRange ?? 1}
onChange={e => onChange({ ...normalized, candleRange: Number(e.target.value) })}>
<option value={1}> </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={4}>4 </option>
<option value={5}>5 </option>
</select>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} ()</label>
<ComboNumberInput
value={leftPeriod}
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left')}
min={1}
max={500}
onChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
/>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} ()</label>
<ComboNumberInput
value={rightPeriod}
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right')}
min={1}
max={500}
onChange={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 */}
@@ -731,14 +858,34 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
};
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
export const makeNode = (type: string, value: string, signalType: 'buy'|'sell', DEF: DefType): LogicNode => {
export type MakeNodeOptions = { composite?: boolean };
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?.composite) {
return {
id: genId(),
type: 'CONDITION',
condition: makeCompositeCondition(value, signalType, DEF),
};
}
const def = getDefaultFields(value, signalType, DEF);
const baseCondition: ConditionDSL = {
indicatorType: value,
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
leftField: def.l,
rightField: def.r,
candleRange: 1,
period: getDefaultIndicatorPeriod(value, DEF),
};
return {
id: genId(), type: 'CONDITION',
condition: {
indicatorType: value, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
leftField: def.l, rightField: def.r, candleRange: 1,
},
condition: initConditionPeriods(value, baseCondition, DEF),
};
};
+7 -4
View File
@@ -1,5 +1,5 @@
import type { Connection, Edge, Node } from '@xyflow/react';
import type { LogicNode } from './strategyTypes';
import type { LogicNode, ConditionDSL } from './strategyTypes';
import { nodeToText, type DefType } from './strategyEditorShared';
export const START_NODE_ID = '__strategy_start__';
@@ -23,6 +23,7 @@ export type StrategyFlowNodeData = {
signalTab?: 'buy' | 'sell';
selected?: boolean;
onDelete?: (id: string) => void;
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
onDragLeaveTarget?: (targetId: string) => void;
@@ -423,7 +424,7 @@ function layoutTree(
positions: Map<string, { x: number; y: number }>,
def: DefType,
signalTab: 'buy' | 'sell',
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
dragOverId: string | null,
): void {
const yBase = 220;
@@ -442,6 +443,7 @@ function layoutTree(
def,
signalTab,
onDelete: callbacks.onDelete,
onUpdateCondition: callbacks.onUpdateCondition,
onDropTarget: callbacks.onDropTarget,
onDragOverTarget: callbacks.onDragOverTarget,
onDragLeaveTarget: callbacks.onDragLeaveTarget,
@@ -473,7 +475,7 @@ function appendOrphanFlowNodes(
positions: Map<string, { x: number; y: number }>,
def: DefType,
signalTab: 'buy' | 'sell',
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
): void {
let orphanY = 80;
for (const node of orphans) {
@@ -492,6 +494,7 @@ function appendOrphanFlowNodes(
def,
signalTab,
onDelete: callbacks.onDelete,
onUpdateCondition: callbacks.onUpdateCondition,
onDropTarget: callbacks.onDropTarget,
onDragOverTarget: callbacks.onDragOverTarget,
onDragLeaveTarget: callbacks.onDragLeaveTarget,
@@ -510,7 +513,7 @@ export function logicNodeToFlow(
def: DefType,
signalTab: 'buy' | 'sell',
positions: Map<string, { x: number; y: number }>,
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
dragOverId: string | null = null,
orphans: LogicNode[] = [],
): { nodes: Node[]; edges: Edge[] } {
+4
View File
@@ -7,6 +7,10 @@ export interface ConditionDSL {
conditionType: string;
targetValue?: number;
period?: number;
/** 복합지표 — 동일 지표 2기간 교차 */
composite?: boolean;
leftPeriod?: number;
rightPeriod?: number;
leftField?: string;
rightField?: string;
compareValue?: number;