From b95b232e5b7177ea3082dcb923f242f565504892 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 24 May 2026 13:15:26 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=EB=B9=8C=EB=8D=94=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/StrategyDslToTa4jAdapter.java | 17 ++ .../src/components/StrategyEditorPage.tsx | 180 ++++++++++++++-- frontend/src/components/StrategyPage.tsx | 17 +- .../strategyEditor/paletteCategories.ts | 2 +- frontend/src/styles/strategyEditor.css | 10 + frontend/src/utils/strategyEditorShared.tsx | 17 +- frontend/src/utils/strategyImportExport.ts | 196 ++++++++++++++++++ frontend/src/utils/strategyPresets.ts | 76 +++++++ frontend/src/utils/strategyTypes.ts | 17 ++ 9 files changed, 516 insertions(+), 16 deletions(-) create mode 100644 frontend/src/utils/strategyImportExport.ts diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 14d6107..19f2708 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -12,6 +12,9 @@ import org.ta4j.core.indicators.adx.*; import org.ta4j.core.indicators.averages.*; import org.ta4j.core.indicators.bollinger.*; import org.ta4j.core.indicators.helpers.*; +import org.ta4j.core.indicators.donchian.DonchianChannelLowerIndicator; +import org.ta4j.core.indicators.donchian.DonchianChannelMiddleIndicator; +import org.ta4j.core.indicators.donchian.DonchianChannelUpperIndicator; import org.ta4j.core.indicators.ichimoku.*; import org.ta4j.core.indicators.statistics.StandardDeviationIndicator; import org.ta4j.core.indicators.CachedIndicator; @@ -81,6 +84,7 @@ public class StrategyDslToTa4jAdapter { Map.entry("BWI", "BBBandWidth"), Map.entry("VR", "VR"), Map.entry("DISPARITY", "Disparity"), + Map.entry("DONCHIAN", "DonchianChannels"), Map.entry("VOLUME", "VOLUME") ); @@ -352,6 +356,19 @@ public class StrategyDslToTa4jAdapter { return newPsychIndicator(s, intP(p, "length", 10), true); // Williams %R if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14)); + // Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사 + if (field.startsWith("DC_UPPER_")) { + int period = parseTrailingInt(field, "DC_UPPER_", intP(p, "length", 20)); + return new DonchianChannelUpperIndicator(s, period); + } + if (field.startsWith("DC_LOWER_")) { + int period = parseTrailingInt(field, "DC_LOWER_", intP(p, "length", 20)); + return new DonchianChannelLowerIndicator(s, period); + } + if (field.startsWith("DC_MIDDLE_")) { + int period = parseTrailingInt(field, "DC_MIDDLE_", intP(p, "length", 20)); + return new DonchianChannelMiddleIndicator(s, period); + } // Bollinger Bands if (field.equals("UPPER_BAND") || field.equals("LOWER_BAND") || field.equals("MIDDLE_BAND")) { int len = intP(p, "length", 20); diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 27de866..b7063a9 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -27,6 +27,7 @@ import { deleteStrategyFlowLayout, type SignalFlowLayoutSnapshot, type FlowLayoutChangePayload, + type StrategyFlowLayoutStore, } from '../utils/strategyEditorLayoutStorage'; import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview'; import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas'; @@ -42,6 +43,15 @@ import { simpleTemplateToNode, type StrategyTemplateDef, } from '../utils/strategyPresets'; +import { + buildStrategyExportPayload, + buildStrategyListExportPayload, + downloadStrategyJson, + listImportItemToStrategyDto, + parseStrategyImportFile, + pickJsonFile, + strategyDtoToListExportItem, +} from '../utils/strategyImportExport'; import PaletteChip from './strategyEditor/PaletteChip'; import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import '../styles/strategyEditor.css'; @@ -393,6 +403,113 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro showSnack('전략이 삭제되었습니다'); }; + const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: 'buy' | 'sell' = signalTab) => { + if (layout) { + setBuyLayout({ + positions: layout.buy.positions ?? {}, + edgeHandles: layout.buy.edgeHandles ?? {}, + }); + setSellLayout({ + positions: layout.sell.positions ?? {}, + edgeHandles: layout.sell.edgeHandles ?? {}, + }); + setBuyOrphans(layout.buy.orphans ?? []); + setSellOrphans(layout.sell.orphans ?? []); + saveStrategyFlowLayout('draft', layout); + } else { + resetFlowLayout('draft', tab); + } + bumpLayoutSeed('draft', tab); + }, [resetFlowLayout, bumpLayoutSeed, signalTab]); + + const handleExport = useCallback(() => { + if (!buyCondition && !sellCondition) { + showSnack('내보낼 조건이 없습니다', false); + return; + } + if (editorMode === 'graph') layoutFlushRef.current?.(); + const payload = buildStrategyExportPayload({ + name: stratName, + description: stratDesc, + buyCondition, + sellCondition, + flowLayout: { + buy: { ...buyLayoutRef.current, orphans: buyOrphans }, + sell: { ...sellLayoutRef.current, orphans: sellOrphans }, + }, + editorMode, + }); + const safeName = (stratName || '전략').replace(/[^\w\uAC00-\uD7A3-]+/g, '_'); + downloadStrategyJson(`${safeName}_${new Date().toISOString().slice(0, 10)}`, payload); + showSnack('JSON으로 내보냈습니다'); + }, [buyCondition, sellCondition, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]); + + const handleImport = useCallback(async () => { + const text = await pickJsonFile(); + if (!text) return; + try { + const result = parseStrategyImportFile(text); + if (result.kind === 'list') { + showSnack('단일 전략 파일이 아닙니다. 전체 가져오기(⬆)를 사용하세요', false); + return; + } + if (editorMode === 'graph') layoutFlushRef.current?.(); + const { data } = result; + setBuyCondition(data.buyCondition ?? null); + setSellCondition(data.sellCondition ?? null); + setStratName(data.name ?? '가져온 전략'); + setStratDesc(data.description ?? ''); + setSelectedId(null); + setSelectedNodeId(null); + applyImportedFlowLayout(data.flowLayout); + if (data.editorMode === 'list' || data.editorMode === 'graph') { + setEditorMode(data.editorMode); + saveEditorMode(data.editorMode); + } + showSnack('전략을 가져왔습니다'); + } catch (e) { + showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false); + } + }, [editorMode, applyImportedFlowLayout]); + + const handleExportAll = useCallback(() => { + if (strategies.length === 0) { + showSnack('내보낼 전략이 없습니다', false); + return; + } + const items = strategies.map(s => strategyDtoToListExportItem( + s, + loadStrategyFlowLayout(String(s.id)), + )); + downloadStrategyJson( + `전략목록_${new Date().toISOString().slice(0, 10)}`, + buildStrategyListExportPayload(items), + ); + showSnack(`${strategies.length}개 전략을 내보냈습니다`); + }, [strategies]); + + const handleImportAll = useCallback(async () => { + const text = await pickJsonFile(); + if (!text) return; + try { + const result = parseStrategyImportFile(text); + if (result.kind !== 'list') { + showSnack('전략 목록 형식 파일이 필요합니다', false); + return; + } + const baseId = Date.now(); + const imported = result.data.strategies.map((item, index) => { + const id = baseId + index; + if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout); + return listImportItemToStrategyDto(item, id); + }); + setStrategies(prev => [...prev, ...imported]); + showSnack(`${imported.length}개 전략을 가져왔습니다`); + } catch (e) { + showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false); + } + }, []); + const applyPalette = useCallback((type: string, value: string, label: string) => { const newNode = makeNode(type, value, signalTab, DEF); const root = currentRoot; @@ -404,21 +521,23 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => { - if (tmpl.signal !== signalTab) switchSignalTab(tmpl.signal); - const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition; - const root = tmpl.signal === 'buy' ? buyCondition : sellCondition; + if (editorMode === 'graph') layoutFlushRef.current?.(); - if (tmpl.kind === 'composite') { - setRoot(tmpl.build()); - showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); - return; - } + const targetTab = tmpl.signal; + const nextRoot = tmpl.kind === 'composite' ? tmpl.build() : simpleTemplateToNode(tmpl); - const newNode = simpleTemplateToNode(tmpl); - if (!root) setRoot(newNode); - else setRoot(mergeAtRoot(root, newNode, false)); - showSnack(`"${tmpl.label}" 추가됨`); - }, [signalTab, switchSignalTab, buyCondition, sellCondition, setBuyCondition, setSellCondition]); + setBuyCondition(null); + setSellCondition(null); + setSelectedNodeId(null); + + if (targetTab === 'buy') setBuyCondition(nextRoot); + else setSellCondition(nextRoot); + + resetFlowLayout(layoutStrategyKey, targetTab); + if (targetTab !== signalTab) setSignalTab(targetTab); + + showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); + }, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]); const operators = [ { type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' }, @@ -430,6 +549,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro { type: 'indicator' as const, value: 'MA', label: 'MA', desc: '이동평균', color: 'band' }, { type: 'indicator' as const, value: 'EMA', label: 'EMA', desc: '지수이동평균', color: 'band' }, { type: 'indicator' as const, value: 'BOLLINGER', label: 'Bollinger', desc: '볼린저밴드', color: 'band' }, + { type: 'indicator' as const, value: 'DONCHIAN', label: 'Donchian', desc: '돈치안 채널', color: 'band' }, { type: 'indicator' as const, value: 'ICHIMOKU', label: 'Ichimoku', desc: '일목균형표', color: 'band' }, ]; @@ -484,6 +604,40 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro + + + + diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx index 38b2061..78e7c74 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -459,6 +459,18 @@ const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[ { 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}일)` }, @@ -475,7 +487,6 @@ const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[ }; 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); @@ -502,6 +513,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' }; @@ -1218,6 +1232,7 @@ export const StrategyPage: React.FC = ({ activeIndicators = [] }) => { { type:'indicator' as const, value:'MA', label:'MA (이동평균)', color:'primary' }, { type:'indicator' as const, value:'EMA', label:'EMA (지수이동평균)', color:'primary' }, { type:'indicator' as const, value:'BOLLINGER',label:'볼린저밴드', color:'primary' }, + { type:'indicator' as const, value:'DONCHIAN', label:'돈치안 채널', color:'primary' }, { type:'indicator' as const, value:'ICHIMOKU', label:'일목균형표', color:'primary' }, ]; const indicatorItems = [ diff --git a/frontend/src/components/strategyEditor/paletteCategories.ts b/frontend/src/components/strategyEditor/paletteCategories.ts index 8fc0592..faf3294 100644 --- a/frontend/src/components/strategyEditor/paletteCategories.ts +++ b/frontend/src/components/strategyEditor/paletteCategories.ts @@ -1,6 +1,6 @@ /** 팔레트·Logic Expression 공통 — 지표 카테고리 */ -export const BAND_INDICATORS = new Set(['MA', 'EMA', 'BOLLINGER', 'ICHIMOKU']); +export const BAND_INDICATORS = new Set(['MA', 'EMA', 'BOLLINGER', 'DONCHIAN', 'ICHIMOKU']); export type PaletteIndicatorCategory = 'band' | 'ind'; diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 7fb26ae..ff573c8 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -112,6 +112,16 @@ } .se-btn:hover { border-color: color-mix(in srgb, var(--se-accent) 50%, transparent); } .se-btn--ghost { background: transparent; } +.se-btn--icon { + min-width: 34px; + padding: 7px 10px; + font-size: 0.9rem; + line-height: 1; +} +.se-btn--icon:disabled { + opacity: 0.35; + cursor: not-allowed; +} .se-btn--gold { background: var(--se-btn-gold-bg); border-color: var(--se-btn-gold-border); diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 75e1d54..58516f3 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -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' }; diff --git a/frontend/src/utils/strategyImportExport.ts b/frontend/src/utils/strategyImportExport.ts new file mode 100644 index 0000000..65c4aa5 --- /dev/null +++ b/frontend/src/utils/strategyImportExport.ts @@ -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): 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; + + 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 { + 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, + }; +} diff --git a/frontend/src/utils/strategyPresets.ts b/frontend/src/utils/strategyPresets.ts index 847fbca..8062e5d 100644 --- a/frontend/src/utils/strategyPresets.ts +++ b/frontend/src/utils/strategyPresets.ts @@ -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'), + }, ]; } diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 48a02a0..31002a7 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -62,6 +62,7 @@ export const INDICATOR_CONDITIONS: Record = { 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 = { { 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 = { { 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' }, ];