Files
goldenChart/frontend/src/utils/compositeIndicators.ts
T
2026-05-29 23:03:47 +09:00

323 lines
12 KiB
TypeScript

/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
import type { ConditionDSL } from './strategyTypes';
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
import { migratePriceExtremeCondition } from './priceExtremeIndicators';
export interface CompositePeriodDef {
rsiPeriod: number;
cciPeriod: number;
adxPeriod: number;
williamsR: number;
trixPeriod: number;
vrPeriod: number;
psyPeriod: number;
newPsy: number;
investPsy: number;
dispShort: number;
dispMid: number;
}
export interface CompositeIndicatorItem {
value: string;
label: string;
desc: string;
}
/** 돈치안 채널 — 단기·장기 고가/저가선 비교 */
export function isDonchianComposite(ind: string): boolean {
return ind === 'DONCHIAN';
}
/** @deprecated DONCHIAN 전용 — isDonchianComposite 사용 */
export function isPriceExtremeComposite(ind: string): boolean {
return isDonchianComposite(ind);
}
export function donchianUpperField(period: number): string {
return `DC_UPPER_${period}`;
}
export function donchianLowerField(period: number): string {
return `DC_LOWER_${period}`;
}
export function donchianMiddleField(period: number): string {
return `DC_MIDDLE_${period}`;
}
/** DC_UPPER_9 / DC_LOWER_20 등에서 기간 추출 */
export function parseDonchianPeriod(field?: string): number | null {
if (!field) return null;
const m = field.match(/^DC_(?:UPPER|LOWER|MIDDLE)_(\d+)$/);
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
/** 복합지표 팔레트 */
export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [
{ value: 'DONCHIAN', label: '돈치안 채널', desc: '단기·장기 고가/저가 채널 비교 (9·20일)' },
{ 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 'DONCHIAN':
return { short: 9, long: 20 };
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': return { short: def.psyPeriod, long: Math.max(def.psyPeriod * 2, 24) };
case 'NEW_PSYCHOLOGICAL': return { short: def.newPsy, long: Math.max(def.newPsy * 2, 20) };
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 'DONCHIAN':
return donchianUpperField(period);
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': return `PSY_VALUE_${period}`;
case 'NEW_PSYCHOLOGICAL': return `NEW_PSY_VALUE_${period}`;
case 'INVEST_PSYCHOLOGICAL': return `INVEST_PSY_VALUE_${period}`;
case 'DISPARITY': return `DISPARITY${period}`;
default: return `${ind}_VALUE_${period}`;
}
}
/** K_ 접두 임계값·hline 역할 심볼 — 기간 파싱·복합지표 승격에서 제외 */
export function isThresholdField(field?: string): boolean {
return !!field && (field.startsWith('K_')
|| field === 'HL_OVER' || field === 'HL_MID' || field === 'HL_UNDER');
}
const COMPOSITE_VALUE_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: 'NEW_PSY_VALUE',
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
};
/** 복합지표용 값 라인 필드(CCI_VALUE_9 등)인지 — K_100 임계값은 false */
export function isCompositeValueField(ind: string, field?: string): boolean {
if (!field || isThresholdField(field)) return false;
if (isDonchianComposite(ind)) {
return parseDonchianPeriod(field) != null;
}
if (ind === 'DISPARITY') {
return field.startsWith('DISPARITY') && field.length > 'DISPARITY'.length
&& /^\d+$/.test(field.slice('DISPARITY'.length));
}
const prefix = COMPOSITE_VALUE_PREFIX[ind];
if (!prefix) return false;
return field === prefix || field.startsWith(`${prefix}_`);
}
export function parsePeriodFromCompositeField(ind: string, field?: string): number | null {
if (!field || isThresholdField(field)) return null;
if (isDonchianComposite(ind)) {
return parseDonchianPeriod(field);
}
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 syncDonchianCompositeFields(cond: ConditionDSL): ConditionDSL {
const leftP = cond.leftPeriod ?? 9;
const rightP = cond.rightPeriod ?? 20;
const leftCt = normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
const rightCt = normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
const defaultBand = (period: number) => donchianUpperField(period);
return {
...cond,
composite: true,
leftPeriod: leftP,
rightPeriod: rightP,
leftField: cond.leftField ?? defaultBand(leftP),
rightField: cond.rightField ?? defaultBand(rightP),
leftCandleType: leftCt,
rightCandleType: rightCt,
period: undefined,
};
}
export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
if (!cond.composite) return cond;
if (isDonchianComposite(cond.indicatorType)) {
return syncDonchianCompositeFields(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),
};
}
const leftCt = normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
const rightCt = normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
return {
...cond,
composite: true,
leftPeriod: leftP,
rightPeriod: rightP,
leftField: compositeValueField(cond.indicatorType, leftP),
rightField: compositeValueField(cond.indicatorType, rightP),
leftCandleType: leftCt,
rightCandleType: rightCt,
period: undefined,
};
}
export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
const migrated = migratePriceExtremeCondition(cond);
if (migrated !== cond) return migrated;
if (cond.composite) {
if (isThresholdField(cond.leftField) || isThresholdField(cond.rightField)) {
return {
...cond,
composite: false,
leftPeriod: undefined,
rightPeriod: undefined,
};
}
return syncCompositeFields(cond);
}
const ind = cond.indicatorType;
if (parseDonchianPeriod(cond.leftField) != null && parseDonchianPeriod(cond.rightField) != null) {
const leftP = parseDonchianPeriod(cond.leftField)!;
const rightP = parseDonchianPeriod(cond.rightField)!;
if (leftP !== rightP) {
return syncDonchianCompositeFields({
...cond,
composite: true,
indicatorType: 'DONCHIAN',
leftPeriod: leftP,
rightPeriod: rightP,
});
}
}
if (!isCompositeValueField(ind, cond.leftField) || !isCompositeValueField(ind, cond.rightField)) {
return migratePriceExtremeCondition(cond);
}
const leftP = parsePeriodFromCompositeField(ind, cond.leftField);
const rightP = parsePeriodFromCompositeField(ind, cond.rightField);
if (leftP && rightP && leftP !== rightP) {
return syncCompositeFields({
...cond,
composite: true,
leftPeriod: leftP,
rightPeriod: rightP,
});
}
return migratePriceExtremeCondition(cond);
}
export function makeCompositeCondition(
ind: string,
signalType: 'buy' | 'sell',
def: CompositePeriodDef,
): ConditionDSL {
const { short, long } = getCompositeDefaultPeriods(ind, def);
if (isDonchianComposite(ind)) {
return syncDonchianCompositeFields({
indicatorType: ind,
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
composite: true,
leftPeriod: short,
rightPeriod: long,
leftCandleType: DEFAULT_START_CANDLE,
rightCandleType: DEFAULT_START_CANDLE,
candleRange: 1,
});
}
return syncCompositeFields({
indicatorType: ind,
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
composite: true,
leftPeriod: short,
rightPeriod: long,
leftCandleType: DEFAULT_START_CANDLE,
rightCandleType: DEFAULT_START_CANDLE,
candleRange: 1,
});
}
/** 돈치안 복합 — 조건대상 드롭다운 옵션 */
export function getDonchianFieldOpts(
allPeriods: number[],
): { value: string; label: string }[] {
return [
...allPeriods.map(p => ({ value: donchianUpperField(p), label: `${p}일 최고가선` })),
...allPeriods.map(p => ({ value: donchianLowerField(p), label: `${p}일 최저가선` })),
];
}
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 (ind === 'DONCHIAN') return '돈치안 채널 (9·20일)';
if (item) return `${item.label} + ${item.label}`;
return `${ind} + ${ind}`;
}
export function compositeElementLabel(ind: string, slot: 1 | 2): string {
if (ind === 'DONCHIAN') {
return slot === 1 ? '채널 단기' : '채널 장기';
}
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
const name = item?.label ?? ind;
return `${name} ${slot}`;
}
/** 복합지표 조건대상 라벨 */
export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string {
if (ind === 'DONCHIAN') {
return `${period}${slot === 1 ? '단기' : '장기'} 고가선`;
}
return `${compositeElementLabel(ind, slot)} 라인(${period}일)`;
}