전략편집기 복합지표 기능 반영
This commit is contained in:
@@ -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));
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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[] } {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user