전략편집기 메뉴 추가
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* 전략편집기 — 노드 기반 논리 트리 UI (React Flow)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import type { Theme, IndicatorConfig } from '../types/index';
|
||||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import type { LogicNode } from '../utils/strategyTypes';
|
||||
import {
|
||||
buildDef,
|
||||
loadStratsLocal,
|
||||
saveStratsLocal,
|
||||
mergeAtRoot,
|
||||
makeNode,
|
||||
updateNode,
|
||||
CondEditor,
|
||||
genId,
|
||||
type StrategyDto,
|
||||
} from '../utils/strategyEditorShared';
|
||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
||||
import StrategyEditorCanvas from './strategyEditor/StrategyEditorCanvas';
|
||||
import PaletteChip from './strategyEditor/PaletteChip';
|
||||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||
import '../styles/strategyEditor.css';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
|
||||
const LEFT_PANEL_MIN = 220;
|
||||
const LEFT_PANEL_MAX = 520;
|
||||
const LEFT_PANEL_DEFAULT = 280;
|
||||
const TERMINAL_MIN = 88;
|
||||
const TERMINAL_MAX = 420;
|
||||
const TERMINAL_DEFAULT = 140;
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
activeIndicators?: IndicatorConfig[];
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme, activeIndicators = [] }: Props) {
|
||||
const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]);
|
||||
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
|
||||
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
||||
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
||||
const [paletteSearch, setPaletteSearch] = useState('');
|
||||
const [stratName, setStratName] = useState('');
|
||||
const [stratDesc, setStratDesc] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
|
||||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
|
||||
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
const [saveToast, setSaveToast] = useState(false);
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||||
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const terminalHeightRef = useRef(terminalHeight);
|
||||
leftWidthRef.current = leftWidth;
|
||||
terminalHeightRef.current = terminalHeight;
|
||||
|
||||
const onLeftSplitter = usePanelResize(
|
||||
'vertical',
|
||||
setLeftWidth,
|
||||
() => leftWidthRef.current,
|
||||
LEFT_PANEL_MIN,
|
||||
LEFT_PANEL_MAX,
|
||||
v => storeSize('se-left-width', v),
|
||||
);
|
||||
|
||||
const onTerminalSplitter = usePanelResize(
|
||||
'horizontal',
|
||||
setTerminalHeight,
|
||||
() => terminalHeightRef.current,
|
||||
TERMINAL_MIN,
|
||||
TERMINAL_MAX,
|
||||
v => storeSize('se-terminal-height', v),
|
||||
);
|
||||
|
||||
const bodyStyle = useMemo(() => ({
|
||||
'--se-left-width': `${leftWidth}px`,
|
||||
'--se-terminal-height': `${terminalHeight}px`,
|
||||
}) as React.CSSProperties, [leftWidth, terminalHeight]);
|
||||
|
||||
const showSnack = (msg: string, ok = true) => {
|
||||
setSnack({ msg, ok });
|
||||
setTimeout(() => setSnack(null), 3000);
|
||||
};
|
||||
|
||||
const currentRoot = signalTab === 'buy' ? buyCondition : sellCondition;
|
||||
const setCurrentRoot = signalTab === 'buy' ? setBuyCondition : setSellCondition;
|
||||
const currentOrphans = signalTab === 'buy' ? buyOrphans : sellOrphans;
|
||||
const setCurrentOrphans = signalTab === 'buy' ? setBuyOrphans : setSellOrphans;
|
||||
|
||||
const selectedLogicNode = useMemo(() => {
|
||||
if (!selectedNodeId) return null;
|
||||
return findLogicNode(currentRoot, currentOrphans, selectedNodeId);
|
||||
}, [selectedNodeId, currentRoot, currentOrphans]);
|
||||
|
||||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||||
|
||||
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStrategies().then(async list => {
|
||||
if (list?.length) {
|
||||
setStrategies(list.map(s => ({
|
||||
id: s.id ?? Date.now(),
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
buyCondition: s.buyCondition as LogicNode | null ?? null,
|
||||
sellCondition: s.sellCondition as LogicNode | null ?? null,
|
||||
enabled: s.enabled ?? true,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
})));
|
||||
} else {
|
||||
const local = loadStratsLocal();
|
||||
if (!local.length) return;
|
||||
const migrated: StrategyDto[] = [];
|
||||
for (const s of local) {
|
||||
try {
|
||||
const saved = await saveStrategy({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
buyCondition: s.buyCondition,
|
||||
sellCondition: s.sellCondition,
|
||||
enabled: s.enabled ?? true,
|
||||
});
|
||||
if (saved?.id) {
|
||||
migrated.push({
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
description: saved.description,
|
||||
buyCondition: saved.buyCondition as LogicNode | null ?? null,
|
||||
sellCondition: saved.sellCondition as LogicNode | null ?? null,
|
||||
enabled: saved.enabled ?? true,
|
||||
createdAt: saved.createdAt,
|
||||
updatedAt: saved.updatedAt,
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
if (migrated.length) setStrategies(migrated);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSelectStrategy = (s: StrategyDto) => {
|
||||
setSelectedId(s.id);
|
||||
setStratName(s.name);
|
||||
setStratDesc(s.description ?? '');
|
||||
setBuyCondition(s.buyCondition ?? null);
|
||||
setSellCondition(s.sellCondition ?? null);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setSelectedId(null);
|
||||
setStratName('');
|
||||
setStratDesc('');
|
||||
setBuyCondition(null);
|
||||
setSellCondition(null);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; }
|
||||
if (!buyCondition && !sellCondition) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload: ApiStrategyDto = {
|
||||
id: selectedId ?? undefined,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
enabled: true,
|
||||
};
|
||||
const saved = await saveStrategy(payload);
|
||||
const now = new Date().toISOString();
|
||||
const dbId = saved?.id ?? selectedId ?? Date.now();
|
||||
setStrategies(prev => {
|
||||
const existing = prev.find(s => s.id === selectedId);
|
||||
if (existing && selectedId) {
|
||||
return prev.map(s => s.id === selectedId
|
||||
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, updatedAt: saved?.updatedAt ?? now }
|
||||
: s);
|
||||
}
|
||||
setSelectedId(dbId);
|
||||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||||
});
|
||||
if (!selectedId) setSelectedId(dbId);
|
||||
setSaveOpen(false);
|
||||
setSaveToast(true);
|
||||
setTimeout(() => setSaveToast(false), 2500);
|
||||
showSnack(selectedId ? '전략이 수정되었습니다' : '전략이 저장되었습니다');
|
||||
} catch (e) {
|
||||
showSnack(e instanceof Error ? e.message : '저장 실패', false);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteId) return;
|
||||
try { await deleteStrategy(deleteId); } catch { /* local */ }
|
||||
setStrategies(prev => prev.filter(s => s.id !== deleteId));
|
||||
if (selectedId === deleteId) handleNew();
|
||||
setDeleteOpen(false);
|
||||
setDeleteId(null);
|
||||
showSnack('전략이 삭제되었습니다');
|
||||
};
|
||||
|
||||
const applyPalette = useCallback((type: string, value: string, label: string) => {
|
||||
const newNode = makeNode(type, value, signalTab, DEF);
|
||||
const root = currentRoot;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
|
||||
|
||||
const templates = useMemo(() => [
|
||||
{ ind: 'MA', cond: 'CROSS_UP', l: `MA${DEF.maLines[0]}`, r: `MA${DEF.maLines[1]}`, signal: 'buy' as const, label: 'MA 골든크로스 매수' },
|
||||
{ ind: 'MA', cond: 'CROSS_DOWN', l: `MA${DEF.maLines[0]}`, r: `MA${DEF.maLines[1]}`, signal: 'sell' as const, label: 'MA 데드크로스 매도' },
|
||||
{ ind: 'RSI', cond: 'LTE', l: 'RSI_VALUE', r: 'K_30', signal: 'buy' as const, label: 'RSI 과매도 매수' },
|
||||
{ ind: 'RSI', cond: 'GTE', l: 'RSI_VALUE', r: 'K_70', signal: 'sell' as const, label: 'RSI 과매수 매도' },
|
||||
{ ind: 'MACD', cond: 'CROSS_UP', l: 'MACD_LINE', r: 'SIGNAL_LINE', signal: 'buy' as const, label: 'MACD 상향 돌파' },
|
||||
{ ind: 'MACD', cond: 'CROSS_DOWN', l: 'MACD_LINE', r: 'SIGNAL_LINE', signal: 'sell' as const, label: 'MACD 하향 돌파' },
|
||||
], [DEF.maLines]);
|
||||
|
||||
const handleTemplate = (tmpl: typeof templates[0]) => {
|
||||
const newNode: LogicNode = {
|
||||
id: genId(), type: 'CONDITION',
|
||||
condition: { indicatorType: tmpl.ind, conditionType: tmpl.cond, leftField: tmpl.l, rightField: tmpl.r, candleRange: 1 },
|
||||
};
|
||||
if (tmpl.signal !== signalTab) setSignalTab(tmpl.signal);
|
||||
const root = tmpl.signal === 'buy' ? buyCondition : sellCondition;
|
||||
const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition;
|
||||
if (!root) setRoot(newNode);
|
||||
else setRoot(mergeAtRoot(root, newNode, false));
|
||||
showSnack(`"${tmpl.label}" 추가됨`);
|
||||
};
|
||||
|
||||
const operators = [
|
||||
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
|
||||
{ type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' },
|
||||
{ type: 'operator' as const, value: 'NOT', label: 'NOT', color: 'logic-not' },
|
||||
];
|
||||
|
||||
const maBandItems = [
|
||||
{ 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: 'ICHIMOKU', label: 'Ichimoku', desc: '일목균형표', color: 'band' },
|
||||
];
|
||||
|
||||
const indicatorItems = [
|
||||
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', color: 'ind' as const },
|
||||
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', color: 'ind' as const },
|
||||
{ value: 'STOCHASTIC', label: 'Stochastic', desc: '스토캐스틱', color: 'ind' as const },
|
||||
{ value: 'CCI', label: 'CCI', desc: '상품채널지수', color: 'ind' as const },
|
||||
{ value: 'ADX', label: 'ADX', desc: '평균방향지수', color: 'ind' as const },
|
||||
{ value: 'DMI', label: 'DMI', desc: '방향성 지표', color: 'ind' as const },
|
||||
{ value: 'OBV', label: 'OBV', desc: '거래량 균형', color: 'ind' as const },
|
||||
{ value: 'WILLIAMS_R', label: 'Williams %R', desc: '윌리엄스 %R', color: 'ind' as const },
|
||||
{ value: 'TRIX', label: 'TRIX', desc: '삼중지수이동평균', color: 'ind' as const },
|
||||
{ value: 'VOLUME_OSC', label: 'Volume OSC', desc: '거래량 오실레이터', color: 'ind' as const },
|
||||
{ value: 'VR', label: 'VR', desc: '거래량 비율', color: 'ind' as const },
|
||||
{ value: 'DISPARITY', label: '이격도', desc: 'Disparity', color: 'ind' as const },
|
||||
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', color: 'ind' as const },
|
||||
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', color: 'ind' as const },
|
||||
{ value: 'VOLUME', label: '거래량', desc: 'Volume', color: 'ind' as const },
|
||||
];
|
||||
|
||||
const q = paletteSearch.trim().toLowerCase();
|
||||
const match = (label: string, desc?: string) =>
|
||||
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
||||
|
||||
return (
|
||||
<div className={`se-page se-page--${theme}`}>
|
||||
{saveToast && (
|
||||
<div className="se-save-toast">전략이 DB에 성공적으로 저장되었습니다.</div>
|
||||
)}
|
||||
|
||||
<header className="se-header">
|
||||
<div className="se-header-left">
|
||||
<h1 className="se-title">전략 빌더</h1>
|
||||
<span className="se-subtitle">Strategy Builder</span>
|
||||
</div>
|
||||
<div className="se-header-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={handleNew}>+ 새 전략</button>
|
||||
<button type="button" className="se-btn se-btn--gold" onClick={() => setSaveOpen(true)}>저장하기</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="se-body" style={bodyStyle}>
|
||||
<aside className="se-left" style={{ width: leftWidth }}>
|
||||
<div className="se-strat-panel">
|
||||
<div className="se-strat-panel-head">
|
||||
<h2 className="se-panel-title">전략 목록</h2>
|
||||
</div>
|
||||
<button type="button" className="se-new-strat-btn" onClick={handleNew}>
|
||||
+ 새 전략 만들기
|
||||
</button>
|
||||
<div className="se-strat-list">
|
||||
{strategies.length === 0 ? (
|
||||
<p className="se-empty">저장된 전략이 없습니다</p>
|
||||
) : (
|
||||
strategies.map(s => {
|
||||
const isSel = selectedId === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`se-strat-item${isSel ? ' se-strat-item--sel' : ''}`}
|
||||
onClick={() => handleSelectStrategy(s)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelectStrategy(s);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="se-strat-name" title={s.name}>{s.name}</span>
|
||||
<div className="se-strat-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="se-strat-del"
|
||||
title="전략 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(s.id);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className={`se-strat-status${s.enabled ? ' se-strat-status--on' : ''}`}>
|
||||
{s.enabled ? '활성' : '비활성'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className="se-splitter se-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="전략 목록 너비 조절"
|
||||
onPointerDown={onLeftSplitter}
|
||||
/>
|
||||
|
||||
<main className="se-center">
|
||||
<div className="se-center-work">
|
||||
<div className="se-center-head">
|
||||
<div className="se-signal-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-signal-tab${signalTab === 'buy' ? ' se-signal-tab--buy-on' : ''}`}
|
||||
onClick={() => { setSignalTab('buy'); setSelectedNodeId(null); }}
|
||||
>
|
||||
매수 조건 (Entry)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-signal-tab${signalTab === 'sell' ? ' se-signal-tab--sell-on' : ''}`}
|
||||
onClick={() => { setSignalTab('sell'); setSelectedNodeId(null); }}
|
||||
>
|
||||
매도 조건 (Exit)
|
||||
</button>
|
||||
</div>
|
||||
{stratName && <span className="se-editing-name">{stratName}</span>}
|
||||
</div>
|
||||
|
||||
<ReactFlowProvider>
|
||||
<StrategyEditorCanvas
|
||||
theme={theme}
|
||||
root={currentRoot}
|
||||
orphans={currentOrphans}
|
||||
onOrphansChange={setCurrentOrphans}
|
||||
def={DEF}
|
||||
signalTab={signalTab}
|
||||
onChange={setCurrentRoot}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
{selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
|
||||
<div className="se-node-config-bar">
|
||||
<span className="se-node-config-label">{selectedLogicNode.condition.indicatorType}</span>
|
||||
<CondEditor
|
||||
cond={selectedLogicNode.condition}
|
||||
signalType={signalTab}
|
||||
def={DEF}
|
||||
onChange={c => {
|
||||
if (!selectedNodeId) return;
|
||||
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
||||
if (inOrphans) {
|
||||
setCurrentOrphans(currentOrphans.map(o => (
|
||||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||||
)));
|
||||
} else if (currentRoot) {
|
||||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="se-sync-tip">차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="se-splitter se-splitter--h"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Logic Expression 높이 조절"
|
||||
onPointerDown={onTerminalSplitter}
|
||||
/>
|
||||
|
||||
<footer className="se-terminal" style={{ height: terminalHeight }}>
|
||||
<div className="se-terminal-label">LOGIC EXPRESSION</div>
|
||||
<LogicExpressionPreview
|
||||
buyCondition={buyCondition}
|
||||
sellCondition={sellCondition}
|
||||
orphanCount={orphanTotal}
|
||||
def={DEF}
|
||||
/>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<aside className="se-right">
|
||||
<div className="se-right-tabs">
|
||||
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}>지표</button>
|
||||
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>템플릿</button>
|
||||
</div>
|
||||
{rightTab === 'indicators' && (
|
||||
<>
|
||||
<input
|
||||
className="se-palette-search"
|
||||
placeholder="지표 검색 (RSI, MACD…)"
|
||||
value={paletteSearch}
|
||||
onChange={e => setPaletteSearch(e.target.value)}
|
||||
/>
|
||||
<div className="se-palette-section se-palette-section--logic">
|
||||
<h3>논리 연산</h3>
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
{operators.map(op => (
|
||||
<PaletteChip key={op.value} {...op} onAdd={() => applyPalette(op.type, op.value, op.label)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="se-palette-section se-palette-section--band">
|
||||
<h3>밴드 · 추세</h3>
|
||||
<div className="se-palette-grid">
|
||||
{maBandItems.filter(i => match(i.label, i.desc)).map(item => (
|
||||
<PaletteChip
|
||||
key={item.value}
|
||||
{...item}
|
||||
period={getIndicatorPeriodLabel(item.value, DEF)}
|
||||
onAdd={() => applyPalette(item.type, item.value, item.label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="se-palette-section se-palette-section--aux se-palette-section--scroll">
|
||||
<h3>보조지표</h3>
|
||||
<div className="se-palette-grid">
|
||||
{indicatorItems.filter(i => match(i.label, i.desc)).map(item => (
|
||||
<PaletteChip
|
||||
key={item.value}
|
||||
type="indicator"
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
desc={item.desc}
|
||||
color={item.color}
|
||||
period={getIndicatorPeriodLabel(item.value, DEF)}
|
||||
onAdd={() => applyPalette('indicator', item.value, item.label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{rightTab === 'templates' && (
|
||||
<div className="se-template-list">
|
||||
{templates.map((t, i) => (
|
||||
<button key={i} type="button" className="se-template-item" onClick={() => handleTemplate(t)}>
|
||||
<span className="se-template-label">{t.label}</span>
|
||||
<span className={`se-template-signal se-template-signal--${t.signal}`}>{t.signal === 'buy' ? '매수' : '매도'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{saveOpen && (
|
||||
<DraggableModalFrame onClose={() => setSaveOpen(false)} title="전략 저장">
|
||||
<div className="se-modal-body">
|
||||
<label className="se-field-lbl">전략명 *</label>
|
||||
<input className="se-field-inp" value={stratName} onChange={e => setStratName(e.target.value)} placeholder="예: Golden_RSI_V1" />
|
||||
<label className="se-field-lbl">설명</label>
|
||||
<textarea className="se-field-ta" value={stratDesc} onChange={e => setStratDesc(e.target.value)} rows={2} />
|
||||
<div className="se-modal-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={() => setSaveOpen(false)}>취소</button>
|
||||
<button type="button" className="se-btn se-btn--gold" disabled={isSaving} onClick={handleSave}>{isSaving ? '저장 중…' : '저장'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
)}
|
||||
|
||||
{deleteOpen && (
|
||||
<DraggableModalFrame onClose={() => setDeleteOpen(false)} title="전략 삭제">
|
||||
<p>이 전략을 삭제하시겠습니까?</p>
|
||||
<div className="se-modal-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={() => setDeleteOpen(false)}>취소</button>
|
||||
<button type="button" className="se-btn se-btn--danger" onClick={handleDeleteConfirm}>삭제</button>
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
)}
|
||||
|
||||
{snack && <div className={`se-snack${snack.ok ? '' : ' se-snack--err'}`}>{snack.msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user