가상투자 종목카드박스 레이아웃 수정

This commit is contained in:
Macbook
2026-05-26 16:42:57 +09:00
parent 7cf43609dc
commit c0d21ac825
25 changed files with 1632 additions and 61 deletions
+116 -4
View File
@@ -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}일)`;
}
+48
View File
@@ -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),
+10
View File
@@ -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}일 신고가선`;
}
+54 -6
View File
@@ -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,
};
};
+26 -1
View File
@@ -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);
+66 -2
View File
@@ -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',
+24
View File
@@ -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';
}