전략빌더 수정

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
+167 -13
View File
@@ -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
</button>
</div>
<button type="button" className="se-btn se-btn--ghost" onClick={handleNew}> </button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="현재 전략 JSON 내보내기"
onClick={handleExport}
disabled={!buyCondition && !sellCondition}
>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="JSON 파일에서 전략 가져오기"
onClick={() => { void handleImport(); }}
>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="전체 전략 목록 JSON 내보내기"
onClick={handleExportAll}
disabled={strategies.length === 0}
>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="전체 전략 목록 JSON 가져오기"
onClick={() => { void handleImportAll(); }}
>
</button>
<button type="button" className="se-btn se-btn--gold" onClick={() => setSaveOpen(true)}></button>
</div>
</header>
+16 -1
View File
@@ -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<Props> = ({ 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 = [
@@ -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';