전략빌더 수정

This commit is contained in:
Macbook
2026-05-24 13:15:26 +09:00
parent 5685deb2c7
commit b95b232e5b
9 changed files with 516 additions and 16 deletions
+16 -1
View File
@@ -140,6 +140,7 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
OBV: 'OBV',
BOLLINGER: 'BollingerBands',
DONCHIAN: 'DonchianChannels',
};
const extractThresh = (dslType: string): HLThresh => {
@@ -417,6 +418,18 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
{ value:'HIGH_PRICE', label:'고가' },
{ value:'LOW_PRICE', label:'저가' },
]);
case 'DONCHIAN': return condOpts([
{ 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_10', label:'하단(10일 최저가)' },
{ value:'DC_LOWER_20', label:'하단(20일 최저가)' },
{ value:'DC_LOWER_55', label:'하단(55일 최저가)' },
{ value:'CLOSE_PRICE', label:'종가' },
{ value:'LOW_PRICE', label:'저가' },
{ value:'HIGH_PRICE', label:'고가' },
]);
case 'ICHIMOKU': return condOpts([
{ value:'CONVERSION_LINE', label:`전환선(${DEF.ichTenkan}일)` },
{ value:'BASE_LINE', label:`기준선(${DEF.ichKijun}일)` },
@@ -433,7 +446,6 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
};
const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
void signalType;
// 저장된 hline 과열선 값 우선 사용
const over = (def: number) => kv(DEF.hlThresh[ind]?.over ?? def);
const mid = (def: number) => kv(DEF.hlThresh[ind]?.mid ?? def);
@@ -460,6 +472,9 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: mid(0) },
MACD: { l:'MACD_LINE', r:'SIGNAL_LINE' },
BOLLINGER: { l:'CLOSE_PRICE', r:'UPPER_BAND' },
DONCHIAN: signalType === 'buy'
? { l:'CLOSE_PRICE', r:'DC_UPPER_20' }
: { l:'CLOSE_PRICE', r:'DC_LOWER_20' },
ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' },
};
return map[ind] ?? { l:'NONE', r:'NONE' };
+196
View File
@@ -0,0 +1,196 @@
import type { LogicNode } from './strategyTypes';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
import type { StrategyEditorMode } from './strategyEditorModeStorage';
import type { StrategyDto } from './strategyEditorShared';
export const STRATEGY_EXPORT_VERSION = '1.1';
export interface StrategyExportPayload {
version: string;
name?: string;
description?: string;
buyCondition?: LogicNode | null;
sellCondition?: LogicNode | null;
exportedAt: string;
source?: 'strategy-builder' | 'strategy-page';
flowLayout?: StrategyFlowLayoutStore;
editorMode?: StrategyEditorMode;
}
export interface StrategyListExportItem {
name: string;
description?: string;
buyCondition?: LogicNode | null;
sellCondition?: LogicNode | null;
enabled?: boolean;
flowLayout?: StrategyFlowLayoutStore;
}
export interface StrategyListExportPayload {
version: string;
exportedAt: string;
totalCount: number;
source?: 'strategy-builder' | 'strategy-page';
strategies: StrategyListExportItem[];
}
export type StrategyImportResult =
| { kind: 'single'; data: StrategyExportPayload }
| { kind: 'list'; data: StrategyListExportPayload };
function isLogicNode(value: unknown): value is LogicNode {
return !!value && typeof value === 'object' && typeof (value as LogicNode).type === 'string';
}
function hasStrategyConditions(data: Record<string, unknown>): boolean {
return isLogicNode(data.buyCondition) || isLogicNode(data.sellCondition);
}
export function buildStrategyExportPayload(input: {
name?: string;
description?: string;
buyCondition?: LogicNode | null;
sellCondition?: LogicNode | null;
flowLayout?: StrategyFlowLayoutStore;
editorMode?: StrategyEditorMode;
}): StrategyExportPayload {
return {
version: STRATEGY_EXPORT_VERSION,
source: 'strategy-builder',
name: input.name,
description: input.description,
buyCondition: input.buyCondition ?? null,
sellCondition: input.sellCondition ?? null,
flowLayout: input.flowLayout,
editorMode: input.editorMode,
exportedAt: new Date().toISOString(),
};
}
export function buildStrategyListExportPayload(
strategies: StrategyListExportItem[],
): StrategyListExportPayload {
return {
version: STRATEGY_EXPORT_VERSION,
source: 'strategy-builder',
exportedAt: new Date().toISOString(),
totalCount: strategies.length,
strategies,
};
}
export function parseStrategyImportFile(text: string): StrategyImportResult {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('JSON 파싱에 실패했습니다');
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('유효하지 않은 JSON 형식입니다');
}
const data = parsed as Record<string, unknown>;
if (Array.isArray(data.strategies)) {
const strategies = data.strategies.filter((item): item is StrategyListExportItem => {
if (!item || typeof item !== 'object') return false;
const row = item as StrategyListExportItem;
return !!row.name && (isLogicNode(row.buyCondition) || isLogicNode(row.sellCondition));
});
if (strategies.length === 0) {
throw new Error('가져올 전략 조건이 없습니다');
}
return {
kind: 'list',
data: {
version: String(data.version ?? STRATEGY_EXPORT_VERSION),
exportedAt: String(data.exportedAt ?? new Date().toISOString()),
totalCount: strategies.length,
source: data.source === 'strategy-page' ? 'strategy-page' : 'strategy-builder',
strategies,
},
};
}
if (!hasStrategyConditions(data)) {
throw new Error('buyCondition 또는 sellCondition이 필요합니다');
}
return {
kind: 'single',
data: {
version: String(data.version ?? '1.0'),
source: data.source === 'strategy-page' ? 'strategy-page' : 'strategy-builder',
name: typeof data.name === 'string' ? data.name : undefined,
description: typeof data.description === 'string' ? data.description : undefined,
buyCondition: isLogicNode(data.buyCondition) ? data.buyCondition : null,
sellCondition: isLogicNode(data.sellCondition) ? data.sellCondition : null,
flowLayout: data.flowLayout as StrategyFlowLayoutStore | undefined,
editorMode: data.editorMode === 'list' || data.editorMode === 'graph'
? data.editorMode
: undefined,
exportedAt: String(data.exportedAt ?? new Date().toISOString()),
},
};
}
export function downloadStrategyJson(filename: string, data: unknown): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = filename.endsWith('.json') ? filename : `${filename}.json`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(href);
}
export function pickJsonFile(): Promise<string | null> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
try {
resolve(await file.text());
} catch {
resolve(null);
}
};
input.click();
});
}
export function strategyDtoToListExportItem(
strategy: StrategyDto,
flowLayout?: StrategyFlowLayoutStore | null,
): StrategyListExportItem {
return {
name: strategy.name,
description: strategy.description,
buyCondition: strategy.buyCondition ?? null,
sellCondition: strategy.sellCondition ?? null,
enabled: strategy.enabled,
flowLayout: flowLayout ?? undefined,
};
}
export function listImportItemToStrategyDto(item: StrategyListExportItem, id: number): StrategyDto {
const now = new Date().toISOString();
return {
id,
name: item.name,
description: item.description,
buyCondition: item.buyCondition ?? null,
sellCondition: item.sellCondition ?? null,
enabled: item.enabled ?? true,
createdAt: now,
updatedAt: now,
};
}
+76
View File
@@ -40,6 +40,26 @@ function andTree(...children: LogicNode[]): LogicNode {
return { id: genId(), type: 'AND', children };
}
/** 돈천 채널 — N일 최고가 상향 돌파 */
function donchianBreakoutBuy(period: number): LogicNode {
return condition('DONCHIAN', 'CROSS_UP', 'CLOSE_PRICE', `DC_UPPER_${period}`);
}
/** 돈천 채널 — N일 최저가 하향 이탈 */
function donchianBreakdownSell(period: number): LogicNode {
return condition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', `DC_LOWER_${period}`);
}
/** 일목균형표 — 전환선(9일) / 기준선(26일) 골든·데드크로스 */
function ichimokuTenkanKijunCross(signal: 'buy' | 'sell'): LogicNode {
return condition(
'ICHIMOKU',
signal === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
'CONVERSION_LINE',
'BASE_LINE',
);
}
/** 33변곡 매수 — 바닥권 시간변곡(약 33거래일 주기) 전환 구간 */
export function build33InflectionBuyTree(): LogicNode {
return andTree(
@@ -93,6 +113,62 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
description: '상승 5파 끝 변곡 — RSI 과매수 + MA 데드크로스 + 20일선·이격도 이탈',
build: build33InflectionSellTree,
},
{
kind: 'composite',
signal: 'buy',
label: '돈천 채널(20일) 매수',
description: '종가가 직전 20거래일 최고가(상단선)를 상향 돌파 — 한 달 박스권 돌파',
build: () => donchianBreakoutBuy(20),
},
{
kind: 'composite',
signal: 'sell',
label: '돈천 채널(20일) 매도',
description: '종가가 직전 20거래일 최저가(하단선)를 하향 이탈 — 추세 이탈 청산',
build: () => donchianBreakdownSell(20),
},
{
kind: 'composite',
signal: 'buy',
label: '터틀 S1 매수',
description: '단기 시스템 — 20일 최고가 돌파 진입 (리처드 데니스 터틀 트레이딩)',
build: () => donchianBreakoutBuy(20),
},
{
kind: 'composite',
signal: 'sell',
label: '터틀 S1 청산',
description: '단기 시스템 — 10일 최저가 이탈 시 즉시 청산 (진입보다 짧은 손절)',
build: () => donchianBreakdownSell(10),
},
{
kind: 'composite',
signal: 'buy',
label: '터틀 S2 매수',
description: '장기 시스템 — 55일 최고가 돌파 진입 (큰 추세 포착)',
build: () => donchianBreakoutBuy(55),
},
{
kind: 'composite',
signal: 'sell',
label: '터틀 S2 청산',
description: '장기 시스템 — 20일 최저가 이탈 시 청산 (S1보다 여유, S2보다 빠른 탈출)',
build: () => donchianBreakdownSell(20),
},
{
kind: 'composite',
signal: 'buy',
label: '일목 전환/기준선 매수',
description: '전환선(9일 중간값)이 기준선(26일 중간값)을 상향 돌파 — 골든크로스',
build: () => ichimokuTenkanKijunCross('buy'),
},
{
kind: 'composite',
signal: 'sell',
label: '일목 전환/기준선 매도',
description: '전환선(9일)이 기준선(26일)을 하향 이탈 — 데드크로스',
build: () => ichimokuTenkanKijunCross('sell'),
},
];
}
+17
View File
@@ -62,6 +62,7 @@ export const INDICATOR_CONDITIONS: Record<string, string[]> = {
VOLUME: COMMON_CONDITIONS,
MA: COMMON_CONDITIONS, EMA: COMMON_CONDITIONS,
BOLLINGER: COMMON_CONDITIONS,
DONCHIAN: COMMON_CONDITIONS,
ICHIMOKU: [
...COMMON_CONDITIONS,
'ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN',
@@ -108,6 +109,11 @@ export const LEFT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
{ value: 'UPPER', label: '상단밴드' }, { value: 'MIDDLE', label: '중단밴드' },
{ value: 'LOWER', label: '하단밴드' },
],
DONCHIAN: [
{ value: 'CLOSE_PRICE', label: '종가' },
{ value: 'LOW_PRICE', label: '저가' },
{ value: 'HIGH_PRICE', label: '고가' },
],
ICHIMOKU: [
{ value: 'CLOSE_PRICE', label: '종가' },
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
@@ -178,6 +184,16 @@ export const RIGHT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
{ value: 'UPPER', label: '상단밴드' }, { value: 'MIDDLE', label: '중단밴드' },
{ value: 'LOWER', label: '하단밴드' }, { value: 'CUSTOM', label: '직접 입력' },
],
DONCHIAN: [
{ 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_10', label: '하단(10일 최저가)' },
{ value: 'DC_LOWER_20', label: '하단(20일 최저가)' },
{ value: 'DC_LOWER_55', label: '하단(55일 최저가)' },
{ value: 'CUSTOM', label: '직접 입력' },
],
ICHIMOKU: [
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
{ value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' },
@@ -207,6 +223,7 @@ export const MA_BAND_ITEMS: PaletteItem[] = [
{ id: 'ind-ma', type: 'indicator', label: 'MA (이동평균)', value: 'MA', color: 'primary' },
{ id: 'ind-ema', type: 'indicator', label: 'EMA (지수이동평균)', value: 'EMA', color: 'primary' },
{ id: 'ind-bollinger',type: 'indicator', label: '볼린저밴드', value: 'BOLLINGER', color: 'primary' },
{ id: 'ind-donchian', type: 'indicator', label: '돈치안 채널', value: 'DONCHIAN', color: 'primary' },
{ id: 'ind-ichimoku', type: 'indicator', label: '일목균형표', value: 'ICHIMOKU', color: 'primary' },
];