전략빌더 수정
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user