가상투자 종목카드박스 레이아웃 수정
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
|
||||
import type { ConditionDSL } from './strategyTypes';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { migratePriceExtremeCondition } from './priceExtremeIndicators';
|
||||
|
||||
export interface CompositePeriodDef {
|
||||
rsiPeriod: number;
|
||||
@@ -21,8 +22,40 @@ export interface CompositeIndicatorItem {
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 복합지표 팔레트 — 동일 지표 타입 2인스턴스 교차 */
|
||||
/** 돈치안 채널 — 단기·장기 고가/저가선 비교 */
|
||||
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 기간 교차' },
|
||||
@@ -36,6 +69,8 @@ export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [
|
||||
|
||||
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) };
|
||||
@@ -52,6 +87,8 @@ export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef)
|
||||
|
||||
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}`;
|
||||
@@ -86,6 +123,9 @@ const COMPOSITE_VALUE_PREFIX: Record<string, string> = {
|
||||
/** 복합지표용 값 라인 필드(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));
|
||||
@@ -97,6 +137,9 @@ export function isCompositeValueField(ind: string, field?: string): boolean {
|
||||
|
||||
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;
|
||||
@@ -107,8 +150,32 @@ export function parsePeriodFromCompositeField(ind: string, field?: string): numb
|
||||
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;
|
||||
@@ -139,6 +206,9 @@ export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -151,8 +221,21 @@ export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
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 cond;
|
||||
return migratePriceExtremeCondition(cond);
|
||||
}
|
||||
const leftP = parsePeriodFromCompositeField(ind, cond.leftField);
|
||||
const rightP = parsePeriodFromCompositeField(ind, cond.rightField);
|
||||
@@ -164,7 +247,7 @@ export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
rightPeriod: rightP,
|
||||
});
|
||||
}
|
||||
return cond;
|
||||
return migratePriceExtremeCondition(cond);
|
||||
}
|
||||
|
||||
export function makeCompositeCondition(
|
||||
@@ -173,6 +256,18 @@ export function makeCompositeCondition(
|
||||
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',
|
||||
@@ -185,6 +280,16 @@ export function makeCompositeCondition(
|
||||
});
|
||||
}
|
||||
|
||||
/** 돈치안 복합 — 조건대상 드롭다운 옵션 */
|
||||
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}`;
|
||||
@@ -192,17 +297,24 @@ export function compositePeriodLabel(ind: string, def: CompositePeriodDef): stri
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
/** 복합지표 조건대상 라벨 — 예: CCI 1 라인(9일) */
|
||||
/** 복합지표 조건대상 라벨 */
|
||||
export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string {
|
||||
if (ind === 'DONCHIAN') {
|
||||
return `${period}일 ${slot === 1 ? '단기' : '장기'} 고가선`;
|
||||
}
|
||||
return `${compositeElementLabel(ind, slot)} 라인(${period}일)`;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ import {
|
||||
compositeFieldLabel,
|
||||
compositeValueField,
|
||||
getCompositeDefaultPeriods,
|
||||
getDonchianFieldOpts,
|
||||
isDonchianComposite,
|
||||
isThresholdField,
|
||||
parseDonchianPeriod,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
type CompositePeriodDef,
|
||||
} from './compositeIndicators';
|
||||
import {
|
||||
isPriceExtremeIndicator,
|
||||
migratePriceExtremeCondition,
|
||||
parsePriorExtremePeriod,
|
||||
syncPriceExtremeSimpleFields,
|
||||
} from './priceExtremeIndicators';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
export interface IndicatorPeriodDef {
|
||||
@@ -36,6 +45,9 @@ const VALUE_FIELD_PREFIX: Record<string, string> = {
|
||||
|
||||
export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number {
|
||||
switch (indicatorType) {
|
||||
case 'NEW_HIGH':
|
||||
case 'NEW_LOW':
|
||||
return 9;
|
||||
case 'RSI': return def.rsiPeriod;
|
||||
case 'CCI': return def.cciPeriod;
|
||||
case 'ADX': return def.adxPeriod;
|
||||
@@ -59,6 +71,12 @@ function parseFieldPeriod(field?: string): number | null {
|
||||
|
||||
/** 조건 노드의 RSI 등 값(좌측) 기간 */
|
||||
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||
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 (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
|
||||
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
||||
@@ -100,6 +118,7 @@ export function isValuePeriodFieldStored(indicatorType: string, field?: string):
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -133,6 +152,9 @@ export function setConditionValuePeriod(
|
||||
_def: IndicatorPeriodDef,
|
||||
): ConditionDSL {
|
||||
const p = Math.max(1, Math.min(500, Math.round(period)));
|
||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||
return syncPriceExtremeSimpleFields({ ...cond, period: p });
|
||||
}
|
||||
if (cond.composite) {
|
||||
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p });
|
||||
}
|
||||
@@ -170,6 +192,10 @@ export function initConditionPeriods(
|
||||
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;
|
||||
@@ -191,9 +217,19 @@ 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,
|
||||
@@ -242,6 +278,18 @@ export function getCompositeFieldOpts(
|
||||
: 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),
|
||||
|
||||
@@ -384,8 +384,18 @@ export function getIndicatorDef(type: string): IndicatorDef | undefined {
|
||||
return INDICATOR_REGISTRY.find(d => d.type === type);
|
||||
}
|
||||
|
||||
const DSL_DISPLAY_ALIASES: Record<string, { koreanName: string; shortName: string }> = {
|
||||
NEW_HIGH: { koreanName: '신고가', shortName: 'NH' },
|
||||
NEW_LOW: { koreanName: '신저가', shortName: 'NL' },
|
||||
DONCHIAN: { koreanName: '돈치안채널', shortName: 'DC' },
|
||||
};
|
||||
|
||||
/** 가상투자·조건 목록 — 한글명 (영문약어) 예: 상품채널지수 (CCI) */
|
||||
export function formatIndicatorDisplayLabel(indicatorType: string): string {
|
||||
const alias = DSL_DISPLAY_ALIASES[indicatorType];
|
||||
if (alias) {
|
||||
return `${alias.koreanName} (${alias.shortName})`;
|
||||
}
|
||||
const def = getIndicatorDef(indicatorType);
|
||||
const abbr = def?.shortName ?? indicatorType;
|
||||
const ko = def?.koreanName ?? abbr;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** 신고가·신저가 — 단일(보조) 지표: 종가/고가 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';
|
||||
return syncPriceExtremeSimpleFields({
|
||||
...cond,
|
||||
composite: false,
|
||||
leftField,
|
||||
period,
|
||||
conditionType: cond.conditionType === 'CROSS_UP' ? 'GTE'
|
||||
: cond.conditionType === 'CROSS_DOWN' ? 'LTE'
|
||||
: cond.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 = { ...migrated, conditionType: 'GTE' };
|
||||
}
|
||||
if (migrated.conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW') {
|
||||
migrated = { ...migrated, conditionType: 'LTE' };
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
export function priceExtremePeriodLabel(ind: string, period: number): string {
|
||||
return ind === 'NEW_LOW' ? `${period}일 신저가선` : `${period}일 신고가선`;
|
||||
}
|
||||
@@ -9,9 +9,17 @@ import {
|
||||
compositeFieldLabel,
|
||||
makeCompositeCondition,
|
||||
normalizeCompositeCondition,
|
||||
parseDonchianPeriod,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
} from '../utils/compositeIndicators';
|
||||
import {
|
||||
isPriceExtremeIndicator,
|
||||
nhPriorField,
|
||||
nlPriorField,
|
||||
parsePriorExtremePeriod,
|
||||
syncPriceExtremeSimpleFields,
|
||||
} from '../utils/priceExtremeIndicators';
|
||||
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
||||
import {
|
||||
getCompositeFieldOpts,
|
||||
@@ -177,6 +185,8 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
||||
OBV: 'OBV',
|
||||
BOLLINGER: 'BollingerBands',
|
||||
DONCHIAN: 'DonchianChannels',
|
||||
NEW_HIGH: 'PriceExtreme',
|
||||
NEW_LOW: 'PriceExtreme',
|
||||
};
|
||||
|
||||
const extractThresh = (dslType: string): HLThresh => {
|
||||
@@ -469,13 +479,35 @@ export const getFieldOpts = (
|
||||
{ value:'HIGH_PRICE', label:'고가' },
|
||||
{ value:'LOW_PRICE', label:'저가' },
|
||||
]);
|
||||
case 'NEW_HIGH': {
|
||||
const p = cond?.period ?? 9;
|
||||
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
||||
return condOpts([
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'HIGH_PRICE', label: '고가' },
|
||||
{ value: 'OPEN_PRICE', label: '시가' },
|
||||
...periods.map(n => ({ value: nhPriorField(n), label: `${n}일 신고가선(직전 N봉)` })),
|
||||
]);
|
||||
}
|
||||
case 'NEW_LOW': {
|
||||
const p = cond?.period ?? 9;
|
||||
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
||||
return condOpts([
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'LOW_PRICE', label: '저가' },
|
||||
{ value: 'OPEN_PRICE', label: '시가' },
|
||||
...periods.map(n => ({ value: nlPriorField(n), label: `${n}일 신저가선(직전 N봉)` })),
|
||||
]);
|
||||
}
|
||||
case 'DONCHIAN': return condOpts([
|
||||
{ value:'DC_UPPER_9', label:'상단(9일 최고가·신고가)' },
|
||||
{ value:'DC_UPPER_10', label:'상단(10일 최고가)' },
|
||||
{ value:'DC_UPPER_20', label:'상단(20일 최고가)' },
|
||||
{ value:'DC_UPPER_20', label:'상단(20일 최고가·신고가)' },
|
||||
{ value:'DC_UPPER_55', label:'상단(55일 최고가)' },
|
||||
{ value:'DC_MIDDLE_20', label:'중심(20일)' },
|
||||
{ value:'DC_LOWER_9', label:'하단(9일 최저가·신저가)' },
|
||||
{ value:'DC_LOWER_10', label:'하단(10일 최저가)' },
|
||||
{ value:'DC_LOWER_20', label:'하단(20일 최저가)' },
|
||||
{ value:'DC_LOWER_20', label:'하단(20일 최저가·신저가)' },
|
||||
{ value:'DC_LOWER_55', label:'하단(55일 최저가)' },
|
||||
{ value:'CLOSE_PRICE', label:'종가' },
|
||||
{ value:'LOW_PRICE', label:'저가' },
|
||||
@@ -526,6 +558,8 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
|
||||
DONCHIAN: signalType === 'buy'
|
||||
? { l:'CLOSE_PRICE', r:'DC_UPPER_20' }
|
||||
: { l:'CLOSE_PRICE', r:'DC_LOWER_20' },
|
||||
NEW_HIGH: { l:'CLOSE_PRICE', r: nhPriorField(9) },
|
||||
NEW_LOW: { l:'CLOSE_PRICE', r: nlPriorField(9) },
|
||||
ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' },
|
||||
};
|
||||
return map[ind] ?? { l:'NONE', r:'NONE' };
|
||||
@@ -569,6 +603,9 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def
|
||||
updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE';
|
||||
}
|
||||
}
|
||||
if (isPriceExtremeIndicator(updated.indicatorType)) {
|
||||
return syncPriceExtremeSimpleFields(updated);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
@@ -751,8 +788,12 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
|
||||
ZERO_LINE: 0,
|
||||
};
|
||||
const upd: ConditionDSL = { ...normalized, rightField: v };
|
||||
let upd: ConditionDSL = { ...normalized, rightField: v };
|
||||
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
|
||||
const priorP = parsePriorExtremePeriod(v);
|
||||
if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) {
|
||||
upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP });
|
||||
}
|
||||
onChange(upd);
|
||||
};
|
||||
|
||||
@@ -769,7 +810,8 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right');
|
||||
|
||||
const handleCompositeField = (side: 'left' | 'right', field: string) => {
|
||||
const p = parsePeriodFromCompositeField(normalized.indicatorType, field);
|
||||
const p = parseDonchianPeriod(field)
|
||||
?? parsePeriodFromCompositeField(normalized.indicatorType, field);
|
||||
if (p == null) return;
|
||||
if (side === 'left') {
|
||||
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field }));
|
||||
@@ -1035,16 +1077,22 @@ export const makeNode = (
|
||||
}
|
||||
const def = getDefaultFields(value, signalType, DEF);
|
||||
const period = options?.period ?? getDefaultIndicatorPeriod(value, DEF);
|
||||
let conditionType = signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN';
|
||||
if (value === 'NEW_HIGH' && signalType === 'buy') conditionType = 'CROSS_UP';
|
||||
if (value === 'NEW_LOW' && signalType === 'sell') conditionType = 'CROSS_DOWN';
|
||||
const baseCondition: ConditionDSL = {
|
||||
indicatorType: value,
|
||||
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||
conditionType,
|
||||
leftField: def.l,
|
||||
rightField: def.r,
|
||||
candleRange: 1,
|
||||
period,
|
||||
};
|
||||
const condition = initConditionPeriods(value, baseCondition, DEF);
|
||||
return {
|
||||
id: genId(), type: 'CONDITION',
|
||||
condition: initConditionPeriods(value, baseCondition, DEF),
|
||||
condition: isPriceExtremeIndicator(value)
|
||||
? syncPriceExtremeSimpleFields(condition)
|
||||
: condition,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', builtIn: true },
|
||||
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', builtIn: true },
|
||||
{ value: 'VOLUME', label: '거래량', desc: 'Volume', builtIn: true },
|
||||
{ value: 'NEW_HIGH', label: '신고가', desc: '직전 N봉 최고가 돌파 — 종가/고가 vs N일 신고가선 (9·20일)', builtIn: true, period: 9 },
|
||||
{ value: 'NEW_LOW', label: '신저가', desc: '직전 N봉 최저가 이탈 — 종가/저가 vs N일 신저가선 (9·20일)', builtIn: true, period: 9 },
|
||||
];
|
||||
|
||||
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] =
|
||||
@@ -85,13 +87,36 @@ function parseStored(raw: string | null): PaletteItem[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 복합지표 팔레트에서 단일 지표로 이전된 항목 제거 */
|
||||
const DEPRECATED_COMPOSITE_VALUES = new Set(['NEW_HIGH', 'NEW_LOW']);
|
||||
|
||||
function mergeMissingBuiltIns(
|
||||
stored: PaletteItem[],
|
||||
kind: PaletteItemKind,
|
||||
defaults: Omit<PaletteItem, 'id' | 'kind'>[],
|
||||
): PaletteItem[] {
|
||||
let base = stored;
|
||||
if (kind === 'composite') {
|
||||
base = stored.filter(i => !DEPRECATED_COMPOSITE_VALUES.has(i.value));
|
||||
}
|
||||
const existing = new Set(base.map(i => i.value));
|
||||
const added = defaults
|
||||
.filter(d => !existing.has(d.value))
|
||||
.map(d => ({
|
||||
...d,
|
||||
id: builtinId(kind, d.value),
|
||||
kind,
|
||||
}));
|
||||
return added.length > 0 ? [...base, ...added] : base;
|
||||
}
|
||||
|
||||
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
try {
|
||||
const stored = parseStored(localStorage.getItem(key));
|
||||
if (stored && stored.length > 0) {
|
||||
return stored.map(i => ({ ...i, kind }));
|
||||
return mergeMissingBuiltIns(stored.map(i => ({ ...i, kind })), kind, defaults);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return seedItems(kind, defaults);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import { genId, type DefType } from './strategyEditorShared';
|
||||
import { nhPriorField, nlPriorField } from './priceExtremeIndicators';
|
||||
|
||||
export type SimpleStrategyTemplate = {
|
||||
kind: 'simple';
|
||||
@@ -10,6 +11,7 @@ export type SimpleStrategyTemplate = {
|
||||
cond: string;
|
||||
l: string;
|
||||
r: string;
|
||||
period?: number;
|
||||
};
|
||||
|
||||
export type CompositeStrategyTemplate = {
|
||||
@@ -28,11 +30,19 @@ function condition(
|
||||
leftField: string,
|
||||
rightField: string,
|
||||
candleRange = 1,
|
||||
period?: number,
|
||||
): LogicNode {
|
||||
return {
|
||||
id: genId(),
|
||||
type: 'CONDITION',
|
||||
condition: { indicatorType, conditionType, leftField, rightField, candleRange },
|
||||
condition: {
|
||||
indicatorType,
|
||||
conditionType,
|
||||
leftField,
|
||||
rightField,
|
||||
candleRange,
|
||||
...(period != null ? { period } : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +60,16 @@ function donchianBreakdownSell(period: number): LogicNode {
|
||||
return condition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', `DC_LOWER_${period}`);
|
||||
}
|
||||
|
||||
/** N일 신고가 돌파 — 종가가 직전 N봉 최고가를 상향 돌파 */
|
||||
function newHighBreakoutBuy(period: number): LogicNode {
|
||||
return condition('NEW_HIGH', 'CROSS_UP', 'CLOSE_PRICE', nhPriorField(period), 1, period);
|
||||
}
|
||||
|
||||
/** N일 신저가 이탈 — 종가가 직전 N봉 최저가를 하향 이탈 */
|
||||
function newLowBreakdownSell(period: number): LogicNode {
|
||||
return condition('NEW_LOW', 'CROSS_DOWN', 'CLOSE_PRICE', nlPriorField(period), 1, period);
|
||||
}
|
||||
|
||||
/** 일목균형표 — 전환선(9일) / 기준선(26일) 골든·데드크로스 */
|
||||
function ichimokuTenkanKijunCross(signal: 'buy' | 'sell'): LogicNode {
|
||||
return condition(
|
||||
@@ -99,6 +119,50 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
|
||||
{ kind: 'simple', ind: 'PSYCHOLOGICAL', cond: 'CROSS_UP', l: 'PSY_VALUE', r: 'OVERBOUGHT_75', signal: 'buy', label: '심리도 상향 돌파' },
|
||||
{ kind: 'simple', ind: 'STOCHASTIC', cond: 'CROSS_UP', l: 'STOCH_K', r: 'STOCH_D', signal: 'buy', label: '스토캐스틱 골든크로스' },
|
||||
{ kind: 'simple', ind: 'ADX', cond: 'CROSS_UP', l: 'ADX_VALUE', r: 'ADX_25', signal: 'buy', label: 'ADX 중앙선 상향 돌파' },
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_HIGH',
|
||||
cond: 'CROSS_UP',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nhPriorField(9),
|
||||
period: 9,
|
||||
signal: 'buy',
|
||||
label: '9일 신고가 돌파',
|
||||
description: '종가가 직전 9봉 최고가 이상 — 단기 박스권 상단 돌파 매수',
|
||||
},
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_HIGH',
|
||||
cond: 'CROSS_UP',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nhPriorField(20),
|
||||
period: 20,
|
||||
signal: 'buy',
|
||||
label: '20일 신고가 돌파',
|
||||
description: '종가가 직전 20봉 최고가 이상 — 한 달 박스 돌파 매수',
|
||||
},
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_LOW',
|
||||
cond: 'CROSS_DOWN',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nlPriorField(9),
|
||||
period: 9,
|
||||
signal: 'sell',
|
||||
label: '9일 신저가 이탈',
|
||||
description: '종가가 직전 9봉 최저가 이하 — 단기 지지 붕괴 매도/손절',
|
||||
},
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_LOW',
|
||||
cond: 'CROSS_DOWN',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nlPriorField(20),
|
||||
period: 20,
|
||||
signal: 'sell',
|
||||
label: '20일 신저가 이탈',
|
||||
description: '종가가 직전 20봉 최저가 이하 — 추세 이탈 청산',
|
||||
},
|
||||
{
|
||||
kind: 'composite',
|
||||
signal: 'buy',
|
||||
@@ -173,5 +237,5 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
|
||||
}
|
||||
|
||||
export function simpleTemplateToNode(tmpl: SimpleStrategyTemplate): LogicNode {
|
||||
return condition(tmpl.ind, tmpl.cond, tmpl.l, tmpl.r);
|
||||
return condition(tmpl.ind, tmpl.cond, tmpl.l, tmpl.r, 1, tmpl.period);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const DSL_TO_REGISTRY: Record<string, string> = {
|
||||
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
|
||||
BWI: 'BBBandWidth',
|
||||
DONCHIAN: 'DonchianChannels',
|
||||
NEW_HIGH: 'DonchianChannels',
|
||||
NEW_LOW: 'DonchianChannels',
|
||||
ICHIMOKU: 'IchimokuCloud',
|
||||
ATR: 'ATR',
|
||||
MFI: 'MFI',
|
||||
|
||||
@@ -72,6 +72,8 @@ export const INDICATOR_CONDITIONS: Record<string, string[]> = {
|
||||
MA: COMMON_CONDITIONS, EMA: COMMON_CONDITIONS,
|
||||
BOLLINGER: COMMON_CONDITIONS,
|
||||
DONCHIAN: COMMON_CONDITIONS,
|
||||
NEW_HIGH: COMMON_CONDITIONS,
|
||||
NEW_LOW: COMMON_CONDITIONS,
|
||||
ICHIMOKU: [
|
||||
...COMMON_CONDITIONS,
|
||||
'ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN',
|
||||
@@ -123,6 +125,14 @@ export const LEFT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
|
||||
{ value: 'LOW_PRICE', label: '저가' },
|
||||
{ value: 'HIGH_PRICE', label: '고가' },
|
||||
],
|
||||
NEW_HIGH: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'HIGH_PRICE', label: '고가' },
|
||||
],
|
||||
NEW_LOW: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'LOW_PRICE', label: '저가' },
|
||||
],
|
||||
ICHIMOKU: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
|
||||
@@ -194,15 +204,29 @@ export const RIGHT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
|
||||
{ value: 'LOWER', label: '하단밴드' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
DONCHIAN: [
|
||||
{ value: 'DC_UPPER_9', label: '상단(9일 최고가)' },
|
||||
{ value: 'DC_UPPER_10', label: '상단(10일 최고가)' },
|
||||
{ value: 'DC_UPPER_20', label: '상단(20일 최고가)' },
|
||||
{ value: 'DC_UPPER_55', label: '상단(55일 최고가)' },
|
||||
{ value: 'DC_MIDDLE_20', label: '중심(20일)' },
|
||||
{ value: 'DC_LOWER_9', label: '하단(9일 최저가)' },
|
||||
{ value: 'DC_LOWER_10', label: '하단(10일 최저가)' },
|
||||
{ value: 'DC_LOWER_20', label: '하단(20일 최저가)' },
|
||||
{ value: 'DC_LOWER_55', label: '하단(55일 최저가)' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
NEW_HIGH: [
|
||||
{ value: 'NH_PRIOR_9', label: '9일 신고가선(직전 N봉)' },
|
||||
{ value: 'NH_PRIOR_20', label: '20일 신고가선(직전 N봉)' },
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
NEW_LOW: [
|
||||
{ value: 'NL_PRIOR_9', label: '9일 신저가선(직전 N봉)' },
|
||||
{ value: 'NL_PRIOR_20', label: '20일 신저가선(직전 N봉)' },
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
ICHIMOKU: [
|
||||
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
|
||||
{ value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { saveLiveStrategySettings } from './backendApi';
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
|
||||
import { resolveTargetCandleType } from './virtualTradingStorage';
|
||||
|
||||
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */
|
||||
export async function syncVirtualTargetsToBackend(
|
||||
@@ -21,6 +23,7 @@ export async function syncVirtualTargetsToBackend(
|
||||
saveLiveStrategySettings({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
candleType: normalizeStartCandleType(resolveTargetCandleType(t)),
|
||||
...shared,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -234,3 +234,19 @@ export function formatUpdatedTime(ts: number | undefined): string {
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 카드 푸터용 전략 조건 한 줄 요약 */
|
||||
export function formatVirtualConditionBrief(
|
||||
row: VirtualConditionRow & { currentValue?: number | null },
|
||||
): string {
|
||||
const sideTag = row.side === 'buy' ? '[매수]' : row.side === 'sell' ? '[매도]' : '';
|
||||
const threshold = row.thresholdLabel
|
||||
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel);
|
||||
return [sideTag, row.displayName, threshold].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function formatVirtualCardConditionLines(
|
||||
rows: Array<VirtualConditionRow & { currentValue?: number | null }>,
|
||||
): string[] {
|
||||
return rows.map(formatVirtualConditionBrief);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
/** 가상투자 대상 종목·세션 설정 localStorage */
|
||||
|
||||
export interface VirtualTargetItem {
|
||||
market: string;
|
||||
strategyId: number | null;
|
||||
/** 종목별 전략 평가 분봉 (gc_live_strategy_settings.candle_type) */
|
||||
candleType?: string;
|
||||
koreanName?: string;
|
||||
englishName?: string;
|
||||
}
|
||||
@@ -92,3 +96,14 @@ export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||
localStorage.setItem(CARD_VIEW_KEY, mode);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
|
||||
export function resolveTargetCandleType(
|
||||
item: Pick<VirtualTargetItem, 'candleType'>,
|
||||
snapshotTimeframe?: string | null,
|
||||
): string {
|
||||
if (item.candleType) return normalizeStartCandleType(item.candleType);
|
||||
const raw = snapshotTimeframe?.split(',')[0]?.trim();
|
||||
if (raw) return normalizeStartCandleType(raw);
|
||||
return '1m';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user