Files
goldenChart/frontend/src/utils/conditionPeriods.ts
T
2026-05-29 02:07:53 +09:00

477 lines
16 KiB
TypeScript

/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */
import type { ConditionDSL } from './strategyTypes';
import {
compositeFieldLabel,
compositeValueField,
getCompositeDefaultPeriods,
getDonchianFieldOpts,
isDonchianComposite,
isThresholdField,
parseDonchianPeriod,
parsePeriodFromCompositeField,
syncCompositeFields,
type CompositePeriodDef,
} from './compositeIndicators';
import {
isPriceExtremeIndicator,
migratePriceExtremeCondition,
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';
import {
getIndicatorPrimaryPeriod,
formatIndicatorPeriodLabel,
} from './strategyIndicatorPeriods';
import type { DefType } from './strategyEditorShared';
export interface IndicatorPeriodDef {
rsiPeriod: number;
cciPeriod: number;
adxPeriod: number;
williamsR: number;
trixPeriod: number;
vrPeriod: number;
newPsy: number;
investPsy: number;
}
export 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 | DefType,
): number {
return getIndicatorPrimaryPeriod(indicatorType, def as DefType);
}
/** @internal DefType 전체 필드 — 팔레트 라벨 등 */
export function getDefaultIndicatorPeriodLabel(
indicatorType: string,
def: IndicatorPeriodDef | DefType,
): string {
return formatIndicatorPeriodLabel(indicatorType, def as DefType);
}
function parseFieldPeriod(field?: string): number | null {
if (!field || isThresholdField(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 등 값(좌측) 기간 — true=전략 전용, false/미설정=보조지표 설정(DEF) 상속 */
export function isValuePeriodOverridden(cond: ConditionDSL): boolean {
return cond.valuePeriodOverride === true;
}
export function isRightPeriodOverridden(cond: ConditionDSL): boolean {
return cond.rightPeriodOverride === true;
}
export function isThresholdOverridden(cond: ConditionDSL): boolean {
return cond.thresholdOverride === true;
}
/** 조건 노드의 RSI 등 값(좌측) 기간 */
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
if (isPriceExtremeIndicator(cond.indicatorType)) {
if (isValuePeriodOverridden(cond)) {
if (cond.period && cond.period > 0) return cond.period;
const fromRight = parsePriorExtremePeriod(cond.rightField);
if (fromRight) return fromRight;
}
return getDefaultIndicatorPeriod(cond.indicatorType, def);
}
if (cond.composite) {
if (isValuePeriodOverridden(cond)) {
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
if (fromField) return fromField;
}
const { short } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef);
return short;
}
if (isValuePeriodOverridden(cond)) {
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 (isRightPeriodOverridden(cond)) {
if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod;
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
if (fromField) return fromField;
}
const { long } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef);
return long;
}
if (isValuePeriodOverridden(cond)) {
const fromField = parseFieldPeriod(cond.rightField);
if (fromField) return fromField;
}
return getDefaultIndicatorPeriod(cond.indicatorType, def);
}
/** 조건대상 직접입력 — 기간(일) 또는 임계값 */
export function resolveFieldCustomKind(
indicatorType: string,
field: string | undefined,
): 'period' | 'threshold' | 'none' {
if (!field) return 'none';
if (isThresholdField(field)) return 'threshold';
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (prefix && (field === prefix || field.startsWith(`${prefix}_`))) return 'period';
return 'none';
}
export function isValuePeriodFieldStored(indicatorType: string, field?: string): boolean {
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix || !field) return false;
return field.startsWith(`${prefix}_`) && field.length > prefix.length + 1;
}
export function usesValuePeriodField(cond: ConditionDSL): boolean {
if (isPriceExtremeIndicator(cond.indicatorType)) return true;
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 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=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) && 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,
_def: IndicatorPeriodDef,
): ConditionDSL {
const p = Math.max(1, Math.min(500, Math.round(period)));
if (isPriceExtremeIndicator(cond.indicatorType)) {
return syncPriceExtremeSimpleFields({ ...cond, period: p, valuePeriodOverride: true });
}
if (cond.composite) {
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p, valuePeriodOverride: true });
}
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
const next: ConditionDSL = { ...cond, period: p, valuePeriodOverride: true };
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, rightPeriodOverride: true });
}
/** 신규 조건 노드 — 보조지표 설정 기본값 상속(오버라이드 없음) */
export function initConditionPeriodsInherit(
indicatorType: string,
condition: ConditionDSL,
def: IndicatorPeriodDef,
): ConditionDSL {
const base = {
...condition,
valuePeriodOverride: false as const,
thresholdOverride: false as const,
rightPeriodOverride: false as const,
};
if (isPriceExtremeIndicator(indicatorType)) {
const period = getDefaultIndicatorPeriod(indicatorType, def);
return syncPriceExtremeSimpleFields({ ...base, period });
}
if (condition.composite) return base;
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix) {
const { period: _p, ...rest } = base;
return rest;
}
const lf = condition.leftField ?? '';
if (lf === prefix || lf.startsWith(`${prefix}_`)) {
const { period: _p, ...rest } = base;
return { ...rest, leftField: prefix };
}
const { period: _p, ...rest } = base;
return rest;
}
export function initConditionPeriods(
indicatorType: string,
condition: ConditionDSL,
def: IndicatorPeriodDef,
): ConditionDSL {
if (isPriceExtremeIndicator(indicatorType)) {
const period = condition.period ?? getDefaultIndicatorPeriod(indicatorType, def);
return syncPriceExtremeSimpleFields({ ...condition, period });
}
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);
if (isPriceExtremeIndicator(indicatorType)) {
return [...new Set([base, 5, 9, 10, 20, 55, ...COMMON_PERIODS])].sort((a, b) => a - b);
}
return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b);
}
export function getPeriodSettingsLabel(cond: ConditionDSL): string | null {
if (isPriceExtremeIndicator(cond.indicatorType)) {
return cond.indicatorType === 'NEW_LOW' ? '신저가 기간 (봉)' : '신고가 기간 (봉)';
}
return null;
}
/** 복합지표 요소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);
}
/** 복합지표 조건대상1/2 드롭다운 — CCI 1 라인(9일) 형식 */
export function getCompositeLeftCandleType(cond: ConditionDSL): string {
return normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
}
export function getCompositeRightCandleType(cond: ConditionDSL): string {
return normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
}
export function setCompositeLeftCandleType(cond: ConditionDSL, candleType: string): ConditionDSL {
return syncCompositeFields({
...cond,
composite: true,
leftCandleType: normalizeStartCandleType(candleType),
});
}
export function setCompositeRightCandleType(cond: ConditionDSL, candleType: string): ConditionDSL {
return syncCompositeFields({
...cond,
composite: true,
rightCandleType: normalizeStartCandleType(candleType),
});
}
export function getCompositeFieldOpts(
indicatorType: string,
slot: 1 | 2,
def: IndicatorPeriodDef,
cond: ConditionDSL,
): { value: string; label: string }[] {
const side = slot === 1 ? 'left' : 'right';
const current = slot === 1
? getConditionValuePeriod(cond, def)
: getConditionRightPeriod(cond, def);
const presets = getCompositePeriodPresetOptions(indicatorType, def, side);
const periods = [...new Set([...presets, current])].sort((a, b) => a - b);
if (isDonchianComposite(indicatorType)) {
const opts = getDonchianFieldOpts(periods);
const cur = slot === 1 ? cond.leftField : cond.rightField;
if (cur && !opts.some(o => o.value === cur)) {
const p = parseDonchianPeriod(cur);
const label = p != null ? compositeFieldLabel(indicatorType, slot, p) : cur;
return [{ value: cur, label }, ...opts];
}
return opts;
}
return periods.map(p => ({
value: compositeValueField(indicatorType, p),
label: compositeFieldLabel(indicatorType, slot, p),
}));
}
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 };
}
}