Files
goldenChart/frontend/src/utils/priceExtremeIndicators.ts
T
2026-06-12 13:26:53 +09:00

132 lines
4.8 KiB
TypeScript

/** 신고가·신저가 — 단일(보조) 지표: 종가/고가 vs 직전 N봉 최고·최저 */
import type { ConditionDSL } from './strategyTypes';
import { parseDonchianPeriod } from './compositeIndicators';
export function isPriceExtremeIndicator(ind?: string): boolean {
return ind === 'NEW_HIGH' || ind === 'NEW_LOW';
}
export function nhPriorField(period: number): string {
return `NH_PRIOR_${Math.max(1, Math.round(period))}`;
}
export function nlPriorField(period: number): string {
return `NL_PRIOR_${Math.max(1, Math.round(period))}`;
}
export function parsePriorExtremePeriod(field?: string): number | null {
if (!field) return null;
const m = field.match(/^(?:NH|NL)_PRIOR_(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
return parseDonchianPeriod(field);
}
export function priorExtremeField(ind: string, period: number): string {
return ind === 'NEW_LOW' ? nlPriorField(period) : nhPriorField(period);
}
/** 단일 지표 — 기간에 맞춰 직전 N봉 신고가/신저가선 필드 동기화 */
export function syncPriceExtremeSimpleFields(cond: ConditionDSL): ConditionDSL {
if (!isPriceExtremeIndicator(cond.indicatorType)) return cond;
const period = cond.period
?? parsePriorExtremePeriod(cond.rightField)
?? parsePriorExtremePeriod(cond.leftField)
?? 9;
const p = Math.max(1, Math.min(500, Math.round(period)));
const band = priorExtremeField(cond.indicatorType, p);
const priceFields = new Set(['CLOSE_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'OPEN_PRICE']);
let leftField = cond.leftField;
let rightField = cond.rightField;
if (cond.indicatorType === 'NEW_HIGH') {
leftField = leftField && priceFields.has(leftField) ? leftField : 'CLOSE_PRICE';
rightField = band;
} else {
leftField = leftField && priceFields.has(leftField) ? leftField : 'CLOSE_PRICE';
rightField = band;
}
return {
...cond,
composite: false,
period: p,
leftPeriod: undefined,
rightPeriod: undefined,
leftCandleType: undefined,
rightCandleType: undefined,
leftField,
rightField,
};
}
/** 저장 DSL — 복합지표 승격·DC_UPPER_N 필드를 단일 신고가/신저가로 정규화 */
export function migratePriceExtremeCondition(cond: ConditionDSL): ConditionDSL {
const ind = cond.indicatorType;
if (!isPriceExtremeIndicator(ind)) return cond;
if (cond.composite && isPriceExtremeIndicator(ind)) {
const period = cond.leftPeriod ?? cond.rightPeriod ?? 9;
const priceFields = new Set(['CLOSE_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'OPEN_PRICE']);
const leftField = cond.leftField && priceFields.has(cond.leftField)
? cond.leftField
: 'CLOSE_PRICE';
const rightField = priorExtremeField(ind, period);
let conditionType = cond.conditionType;
if (conditionType === 'CROSS_UP' && ind === 'NEW_HIGH' && !rightField.startsWith('NH_PRIOR_')) {
conditionType = 'GTE';
} else if (conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW' && !rightField.startsWith('NL_PRIOR_')) {
conditionType = 'LTE';
}
return syncPriceExtremeSimpleFields({
...cond,
composite: false,
leftField,
rightField,
period,
conditionType,
});
}
if (!isPriceExtremeIndicator(ind)) return cond;
const fromRight = parsePriorExtremePeriod(cond.rightField);
const fromLeft = parsePriorExtremePeriod(cond.leftField);
const period = cond.period ?? fromRight ?? fromLeft ?? 9;
let migrated = syncPriceExtremeSimpleFields({ ...cond, period });
if (migrated.rightField?.startsWith('DC_UPPER_') || migrated.rightField?.startsWith('DC_LOWER_')) {
migrated = syncPriceExtremeSimpleFields({
...migrated,
period: parseDonchianPeriod(migrated.rightField) ?? period,
});
}
if (migrated.conditionType === 'CROSS_UP' && ind === 'NEW_HIGH'
&& !migrated.rightField?.startsWith('NH_PRIOR_')) {
migrated = { ...migrated, conditionType: 'GTE' };
}
if (migrated.conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW'
&& !migrated.rightField?.startsWith('NL_PRIOR_')) {
migrated = { ...migrated, conditionType: 'LTE' };
}
// 종가 vs NH/NL_PRIOR — GTE/LTE 유지형은 봉마다 시그널 → CROSS 돌파 1봉으로 정규화
if (ind === 'NEW_HIGH' && migrated.leftField === 'CLOSE_PRICE'
&& migrated.conditionType === 'GTE'
&& migrated.rightField?.startsWith('NH_PRIOR_')) {
migrated = { ...migrated, conditionType: 'CROSS_UP' };
}
if (ind === 'NEW_LOW' && migrated.leftField === 'CLOSE_PRICE'
&& migrated.conditionType === 'LTE'
&& migrated.rightField?.startsWith('NL_PRIOR_')) {
migrated = { ...migrated, conditionType: 'CROSS_DOWN' };
}
return migrated;
}
export function priceExtremePeriodLabel(ind: string, period: number): string {
return ind === 'NEW_LOW' ? `${period}일 신저가선` : `${period}일 신고가선`;
}