전략편집기 메뉴 추가
This commit is contained in:
@@ -10027,6 +10027,22 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
.tsn-card--compact .tsn-card-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
.tsn-card--compact .tsd-body {
|
||||
padding: 8px 10px 6px;
|
||||
}
|
||||
.tsn-card--compact .tsd-headline-price {
|
||||
font-size: 16px;
|
||||
}
|
||||
.tsn-card--compact .tsd-detail-grid {
|
||||
grid-template-columns: 64px 1fr;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.tsn-card--compact .tsd-dt {
|
||||
font-size: 10px;
|
||||
}
|
||||
.tsn-card--compact .tsd-dd {
|
||||
font-size: 11px;
|
||||
}
|
||||
.tsn-card-actions--compact {
|
||||
padding: 6px 8px 8px;
|
||||
gap: 4px;
|
||||
|
||||
@@ -55,6 +55,7 @@ import { useAppSettings } from './hooks/useAppSettings';
|
||||
import MarketPanel from './components/MarketPanel';
|
||||
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
||||
import StrategyPage from './components/StrategyPage';
|
||||
import StrategyEditorPage from './components/StrategyEditorPage';
|
||||
import BacktestPanel from './components/BacktestPanel';
|
||||
import { useBacktest } from './hooks/useBacktest';
|
||||
import LiveStrategyPanel from './components/LiveStrategyPanel';
|
||||
@@ -1491,6 +1492,10 @@ function App() {
|
||||
<StrategyPage theme={theme} activeIndicators={indicators} />
|
||||
)}
|
||||
|
||||
{menuPage === 'strategy-editor' && (
|
||||
<StrategyEditorPage theme={theme} activeIndicators={indicators} />
|
||||
)}
|
||||
|
||||
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
|
||||
{menuPage === 'backtest' && (
|
||||
<BacktestHistoryPage />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import type { Theme } from '../types';
|
||||
import type { AuthSession } from '../utils/auth';
|
||||
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { canAccessMenu } from '../utils/permissions';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'strategy' | 'backtest' | 'notifications' | 'settings';
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
|
||||
|
||||
interface TopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
@@ -129,11 +130,22 @@ const THEME_CONFIG: Record<Theme, { icon: string; label: string; next: Theme }>
|
||||
light: { icon: '☀️', label: '라이트', next: 'dark' },
|
||||
};
|
||||
|
||||
const IcStrategyEditor = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="1.5"/>
|
||||
<circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<circle cx="8" cy="8" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<circle cx="11.5" cy="10" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<path d="M5.5 6 L7 8 L10.5 10"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
||||
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
||||
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
|
||||
{ page: 'strategy', label: '투자전략', icon: <IcStrategy /> },
|
||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
||||
];
|
||||
@@ -158,7 +170,7 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
}: TopMenuBarProps) {
|
||||
const tc = THEME_CONFIG[theme];
|
||||
const visibleItems = menuPermissions
|
||||
? MENU_ITEMS.filter(({ page }) => menuPermissions[page] === true)
|
||||
? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page))
|
||||
: MENU_ITEMS;
|
||||
const showNotifications = menuPermissions == null || menuPermissions.notifications === true;
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ const ToastCard: React.FC<{
|
||||
left: position!.x,
|
||||
top: position!.y,
|
||||
right: 'auto',
|
||||
width: compact ? 260 : 380,
|
||||
width: compact ? 320 : 380,
|
||||
zIndex: 10050 + stackIndex,
|
||||
}
|
||||
: undefined
|
||||
@@ -329,27 +329,18 @@ const ToastCard: React.FC<{
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!compact && <TradeSignalDetailBody item={item} variant="compact" />}
|
||||
<TradeSignalDetailBody item={item} variant="compact" />
|
||||
|
||||
<div className={`tsn-card-actions${compact ? ' tsn-card-actions--compact' : ''}`}>
|
||||
<button type="button" className="tsn-action-btn tsn-action-btn--primary" onClick={onChart}>
|
||||
차트
|
||||
</button>
|
||||
{!compact && (
|
||||
<>
|
||||
<button type="button" className="tsn-action-btn" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
<button type="button" className="tsn-action-btn" onClick={onList}>
|
||||
목록
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{compact && (
|
||||
<button type="button" className="tsn-action-btn" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="tsn-action-btn" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
<button type="button" className="tsn-action-btn" onClick={onList}>
|
||||
목록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
||||
|
||||
const LOGIC_COLORS: Record<string, string> = {
|
||||
AND: '#00aaff',
|
||||
OR: '#00d4ff',
|
||||
NOT: '#ffcc00',
|
||||
};
|
||||
|
||||
const SIDES: { side: HandleSide; position: Position }[] = [
|
||||
{ side: 'top', position: Position.Top },
|
||||
{ side: 'right', position: Position.Right },
|
||||
{ side: 'bottom', position: Position.Bottom },
|
||||
{ side: 'left', position: Position.Left },
|
||||
];
|
||||
|
||||
type ConnectHover = {
|
||||
handleId: string | null | undefined;
|
||||
handleType: 'source' | 'target';
|
||||
nodeId: string;
|
||||
isValid: boolean;
|
||||
};
|
||||
|
||||
function MultiSideHandles({
|
||||
nodeId,
|
||||
sourceOnly = false,
|
||||
targetOnly = false,
|
||||
activeSourceSide = null,
|
||||
canSourceConnect = true,
|
||||
canTargetConnect = true,
|
||||
}: {
|
||||
nodeId: string;
|
||||
sourceOnly?: boolean;
|
||||
targetOnly?: boolean;
|
||||
activeSourceSide?: HandleSide | null;
|
||||
canSourceConnect?: boolean;
|
||||
canTargetConnect?: boolean;
|
||||
}) {
|
||||
const connectHover = useConnection((s): ConnectHover | null => {
|
||||
if (!s.inProgress || s.isValid === null || !s.toHandle) return null;
|
||||
return {
|
||||
nodeId: s.toHandle.nodeId,
|
||||
handleId: s.toHandle.id,
|
||||
handleType: s.toHandle.type,
|
||||
isValid: s.isValid,
|
||||
};
|
||||
});
|
||||
|
||||
const hoverClass = (handleId: string, handleType: 'source' | 'target') => {
|
||||
if (!connectHover) return '';
|
||||
if (connectHover.nodeId !== nodeId || connectHover.handleType !== handleType) return '';
|
||||
if (connectHover.handleId !== handleId) return '';
|
||||
return connectHover.isValid ? ' se-flow-handle--valid-hover' : ' se-flow-handle--invalid';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{SIDES.map(({ side, position }) => (
|
||||
<React.Fragment key={side}>
|
||||
{!targetOnly && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={position}
|
||||
id={`s-${side}`}
|
||||
isConnectable={canSourceConnect}
|
||||
className={`se-flow-handle se-flow-handle--src${
|
||||
!canSourceConnect ? ' se-flow-handle--disabled' : ''
|
||||
}${activeSourceSide === side && canSourceConnect ? ' se-flow-handle--active' : ''}${
|
||||
hoverClass(`s-${side}`, 'source')
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{!sourceOnly && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={position}
|
||||
id={`t-${side}`}
|
||||
isConnectable={canTargetConnect}
|
||||
className={`se-flow-handle se-flow-handle--tgt${
|
||||
!canTargetConnect ? ' se-flow-handle--disabled' : ''
|
||||
}${hoverClass(`t-${side}`, 'target')}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes('application/json')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
d.onDragOverTarget?.(id, flowPos);
|
||||
}, [d, id, screenToFlowPosition]);
|
||||
|
||||
const onDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.stopPropagation();
|
||||
const rel = e.relatedTarget as Node | null;
|
||||
if (rel && e.currentTarget.contains(rel)) return;
|
||||
d.onDragLeaveTarget?.(id);
|
||||
}, [d, id]);
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
d.onDropTarget?.(id, payload, flowPos);
|
||||
} catch { /* ignore */ }
|
||||
}, [d, id, screenToFlowPosition]);
|
||||
|
||||
return { onDragOver, onDragLeave, onDrop };
|
||||
}
|
||||
|
||||
export const StartNode = memo(function StartNode({ data }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(START_NODE_ID, d);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-flow-node se-flow-node--start${isSell ? ' se-flow-node--sell-mode' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<MultiSideHandles
|
||||
nodeId={START_NODE_ID}
|
||||
sourceOnly
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-start-dot" />
|
||||
<span>START</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const node = d.logicNode!;
|
||||
const color = LOGIC_COLORS[node.type] ?? '#00aaff';
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-flow-node se-flow-node--logic${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
||||
style={{ '--se-gate-color': color } as React.CSSProperties}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<MultiSideHandles
|
||||
nodeId={id}
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-gate-head">
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
</div>
|
||||
{(node.children ?? []).length === 0 && (
|
||||
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
||||
)}
|
||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, selected }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const node = d.logicNode!;
|
||||
const ind = node.condition?.indicatorType ?? 'COND';
|
||||
const condType = node.condition?.conditionType ?? '';
|
||||
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
|
||||
const tone = isCross ? 'cross' : 'value';
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-flow-node se-flow-node--cond se-flow-node--${tone}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<MultiSideHandles
|
||||
nodeId={id}
|
||||
targetOnly
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-cond-badge">{ind}</div>
|
||||
<div className="se-flow-cond-text">{d.label ?? ind}</div>
|
||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
|
||||
import { getIndicatorPaletteCategory } from './paletteCategories';
|
||||
|
||||
interface Props {
|
||||
buyCondition: LogicNode | null;
|
||||
sellCondition: LogicNode | null;
|
||||
orphanCount: number;
|
||||
def: DefType;
|
||||
}
|
||||
|
||||
function renderCondition(node: LogicNode, def: DefType): React.ReactNode {
|
||||
const full = nodeToText(node, def);
|
||||
const ind = node.condition?.indicatorType ?? '';
|
||||
const tail = ind && full.startsWith(`${ind} -`) ? full.slice(ind.length) : full;
|
||||
const cat = getIndicatorPaletteCategory(ind);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-punc">(</span>
|
||||
{ind ? (
|
||||
<>
|
||||
<span className={`se-formula-ind se-formula-ind--${cat}`}>{ind}</span>
|
||||
{tail ? <span className="se-formula-detail">{tail}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="se-formula-detail">{full || '?'}</span>
|
||||
)}
|
||||
<span className="se-formula-punc">)</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNode(node: LogicNode, def: DefType): React.ReactNode {
|
||||
if (node.type === 'CONDITION') return renderCondition(node, def);
|
||||
|
||||
if (node.type === 'AND') {
|
||||
const children = node.children ?? [];
|
||||
if (children.length === 0) return <span className="se-formula-empty">(빈 AND)</span>;
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-punc">(</span>
|
||||
{children.map((child, i) => (
|
||||
<React.Fragment key={child.id}>
|
||||
{i > 0 ? <span className="se-formula-logic se-formula-logic--and"> AND </span> : null}
|
||||
{renderNode(child, def)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="se-formula-punc">)</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'OR') {
|
||||
const children = node.children ?? [];
|
||||
if (children.length === 0) return <span className="se-formula-empty">(빈 OR)</span>;
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-punc">(</span>
|
||||
{children.map((child, i) => (
|
||||
<React.Fragment key={child.id}>
|
||||
{i > 0 ? <span className="se-formula-logic se-formula-logic--or"> OR </span> : null}
|
||||
{renderNode(child, def)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="se-formula-punc">)</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'NOT') {
|
||||
const child = node.children?.[0];
|
||||
return child ? (
|
||||
<>
|
||||
<span className="se-formula-logic se-formula-logic--not">NOT </span>
|
||||
{renderNode(child, def)}
|
||||
</>
|
||||
) : (
|
||||
<span className="se-formula-empty">(빈 NOT)</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LogicExpressionPreview({
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
orphanCount,
|
||||
def,
|
||||
}: Props) {
|
||||
const hasBody = buyCondition || sellCondition;
|
||||
|
||||
return (
|
||||
<div className="se-terminal-text se-terminal-text--rich">
|
||||
{!hasBody && (
|
||||
<span className="se-formula-empty">(연결된 조건을 구성하세요)</span>
|
||||
)}
|
||||
{buyCondition && (
|
||||
<div className="se-formula-line">
|
||||
<span className="se-formula-signal se-formula-signal--buy">[매수]</span>
|
||||
{' '}
|
||||
{renderNode(buyCondition, def)}
|
||||
</div>
|
||||
)}
|
||||
{sellCondition && (
|
||||
<div className="se-formula-line">
|
||||
<span className="se-formula-signal se-formula-signal--sell">[매도]</span>
|
||||
{' '}
|
||||
{renderNode(sellCondition, def)}
|
||||
</div>
|
||||
)}
|
||||
{orphanCount > 0 && (
|
||||
<div className="se-formula-line se-formula-comment">
|
||||
{'// 미연결 요소 '}{orphanCount}{'개 — 전략 코드 미포함'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback } from 'react';
|
||||
import { getNodesBounds, useReactFlow, useStore } from '@xyflow/react';
|
||||
import { START_NODE_ID } from '../../utils/strategyFlowLayout';
|
||||
|
||||
type Props = {
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function MultiSelectionDeleteButton({ onDelete }: Props) {
|
||||
const { getNodes, flowToScreenPosition } = useReactFlow();
|
||||
|
||||
const selectionKey = useStore(useCallback((state) => {
|
||||
const selected = state.nodes.filter(n => n.selected && n.id !== START_NODE_ID);
|
||||
if (selected.length < 2) return null;
|
||||
return selected
|
||||
.map(n => `${n.id}:${Math.round(n.position.x)}:${Math.round(n.position.y)}`)
|
||||
.join('|');
|
||||
}, []));
|
||||
|
||||
if (!selectionKey) return null;
|
||||
|
||||
const selectedNodes = getNodes().filter(n => n.selected && n.id !== START_NODE_ID);
|
||||
if (selectedNodes.length < 2) return null;
|
||||
|
||||
const bounds = getNodesBounds(selectedNodes);
|
||||
const anchor = flowToScreenPosition({
|
||||
x: bounds.x + bounds.width,
|
||||
y: bounds.y,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="se-selection-delete"
|
||||
title="선택 항목 모두 삭제 (Logic Expression에서 제외)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: anchor.x + 6,
|
||||
top: anchor.y - 6,
|
||||
transform: 'translate(0, -100%)',
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface PaletteDragPayload {
|
||||
type: 'operator' | 'indicator';
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: 'operator' | 'indicator';
|
||||
value: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
color?: string;
|
||||
period?: string;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function PaletteChip({ type, value, label, desc, color, period, onAdd }: Props) {
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
const payload: PaletteDragPayload = { type, value, label };
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}`}
|
||||
onDragStart={onDragStart}
|
||||
onClick={onAdd}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onAdd(); }}
|
||||
>
|
||||
<div className="se-palette-card-head">
|
||||
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
|
||||
<div className="se-palette-card-meta">
|
||||
<span className="se-palette-card-name">{label}</span>
|
||||
{desc ? <span className="se-palette-card-desc">{desc}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{period ? (
|
||||
<div className="se-palette-card-period">
|
||||
<span>기간</span>
|
||||
<input readOnly value={period} className="se-palette-period-input" tabIndex={-1} />
|
||||
<span className="se-palette-sync" title="차트 지표 설정과 동기화">⟳</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
ConnectionMode,
|
||||
SelectionMode,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type OnNodesChange,
|
||||
type OnSelectionChangeFunc,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type { Theme } from '../../types/index';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import {
|
||||
addChild,
|
||||
deleteNode,
|
||||
mergeAtRoot,
|
||||
makeNode,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
applyEdgeHandles,
|
||||
buildConnectionFromPositions,
|
||||
canAcceptPaletteAsParent,
|
||||
connectionSidesFromDrop,
|
||||
disconnectTreeEdge,
|
||||
extractNode,
|
||||
findNodeAtFlowPosition,
|
||||
findNodeInTree,
|
||||
FLOW_NODE_H,
|
||||
FLOW_NODE_W,
|
||||
getNodeDimensions,
|
||||
isDescendant,
|
||||
isOrphanNode,
|
||||
isPaletteConnectTarget,
|
||||
isValidStrategyConnection,
|
||||
resolveStrategyConnection,
|
||||
resolveOrphanDragConnection,
|
||||
logicNodeToFlow,
|
||||
spawnPositionNearAnchor,
|
||||
type EdgeHandleBinding,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||
import { edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||
import { getMinimapColors } from './minimapTheme';
|
||||
|
||||
const nodeTypes = {
|
||||
start: StartNode,
|
||||
logic: LogicGateNode,
|
||||
condition: ConditionFlowNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
strategy: StrategyFlowEdge,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
root: LogicNode | null;
|
||||
orphans: LogicNode[];
|
||||
onOrphansChange: (nodes: LogicNode[]) => void;
|
||||
def: DefType;
|
||||
signalTab: 'buy' | 'sell';
|
||||
onChange: (root: LogicNode | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function collectTreeIds(node: LogicNode | null): string[] {
|
||||
if (!node) return [];
|
||||
const ids = [node.id];
|
||||
for (const c of node.children ?? []) ids.push(...collectTreeIds(c));
|
||||
return ids;
|
||||
}
|
||||
|
||||
function saveEdgeHandles(
|
||||
handles: Map<string, EdgeHandleBinding>,
|
||||
edgeId: string,
|
||||
conn: Pick<Connection, 'sourceHandle' | 'targetHandle'>,
|
||||
manual = true,
|
||||
) {
|
||||
handles.set(edgeId, {
|
||||
sourceHandle: conn.sourceHandle,
|
||||
targetHandle: conn.targetHandle,
|
||||
manual,
|
||||
});
|
||||
}
|
||||
|
||||
function applyTreeConnection(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
) {
|
||||
if (sourceId === START_NODE_ID) {
|
||||
const { tree, node } = extractNode(root, targetId);
|
||||
if (!node) return;
|
||||
onChange(node);
|
||||
if (tree && tree.children?.length) {
|
||||
onChange(mergeAtRoot(node, tree, false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = findNodeInTree(root, sourceId);
|
||||
if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return;
|
||||
if (isDescendant(root, targetId, sourceId)) return;
|
||||
|
||||
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
|
||||
if (!moved) return;
|
||||
const base = afterRemove ?? root;
|
||||
if (sourceId === targetId) return;
|
||||
onChange(addChild(base, sourceId, moved));
|
||||
}
|
||||
|
||||
function pruneEdgeHandles(handles: Map<string, EdgeHandleBinding>, edges: Edge[]) {
|
||||
const ids = new Set(edges.map(e => e.id));
|
||||
for (const id of handles.keys()) {
|
||||
if (!ids.has(id)) handles.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function connectOrphanIntoTree(
|
||||
conn: Connection,
|
||||
orphan: LogicNode,
|
||||
root: LogicNode | null,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
): void {
|
||||
const sourceId = conn.source!;
|
||||
if (sourceId === START_NODE_ID) {
|
||||
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
onChange(addChild(root, sourceId, orphan));
|
||||
}
|
||||
|
||||
function connectOrphanAsParent(
|
||||
conn: Connection,
|
||||
orphan: LogicNode,
|
||||
root: LogicNode | null,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
): void {
|
||||
if (!root) return;
|
||||
const { tree, node: moved } = extractNode(root, conn.target!);
|
||||
if (!moved) return;
|
||||
const isOp = ['AND', 'OR', 'NOT'].includes(orphan.type);
|
||||
const combined = { ...orphan, children: [...(orphan.children ?? []), moved] };
|
||||
onChange(tree ? mergeAtRoot(tree, combined, isOp) : combined);
|
||||
}
|
||||
|
||||
function StrategyEditorCanvasInner({
|
||||
theme,
|
||||
root,
|
||||
orphans,
|
||||
onOrphansChange,
|
||||
def,
|
||||
signalTab,
|
||||
onChange,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: Props) {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow();
|
||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
|
||||
const structureKeyRef = useRef('');
|
||||
const orphanKeyRef = useRef('');
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const nodesRef = useRef(nodes);
|
||||
const edgesRef = useRef(edges);
|
||||
nodesRef.current = nodes;
|
||||
edgesRef.current = edges;
|
||||
|
||||
const treeStructureKey = useMemo(
|
||||
() => `${signalTab}:${collectTreeIds(root).join('|')}`,
|
||||
[root, signalTab],
|
||||
);
|
||||
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
|
||||
|
||||
/** START에서 연결된 트리 전체 (미연결 고아 제외) */
|
||||
const treeLinkedIds = useMemo(() => {
|
||||
const ids = new Set<string>([START_NODE_ID]);
|
||||
for (const id of collectTreeIds(root)) ids.add(id);
|
||||
return ids;
|
||||
}, [root]);
|
||||
|
||||
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
|
||||
|
||||
const minimapNodeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
if (node.type === 'condition') return minimapColors.condition;
|
||||
return minimapColors.logic;
|
||||
}, [minimapColors]);
|
||||
|
||||
const minimapNodeStrokeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
return minimapColors.stroke;
|
||||
}, [minimapColors]);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
if (id === START_NODE_ID) return;
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== id));
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
if (root.id === id) {
|
||||
onChange(null);
|
||||
onSelectNode(null);
|
||||
positionsRef.current.delete(id);
|
||||
return;
|
||||
}
|
||||
const next = deleteNode(root, id);
|
||||
onChange(next);
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId]);
|
||||
|
||||
const deleteSelectedNodes = useCallback(() => {
|
||||
const ids = nodesRef.current
|
||||
.filter(n => n.selected && n.id !== START_NODE_ID)
|
||||
.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
const orphanIds = new Set(ids.filter(id => isOrphanNode(orphans, id)));
|
||||
if (orphanIds.size) {
|
||||
onOrphansChange(orphans.filter(o => !orphanIds.has(o.id)));
|
||||
orphanIds.forEach(id => positionsRef.current.delete(id));
|
||||
}
|
||||
|
||||
const treeIds = ids.filter(id => !orphanIds.has(id));
|
||||
if (treeIds.length && root) {
|
||||
let tree: LogicNode | null = root;
|
||||
for (const id of treeIds) {
|
||||
if (!tree) break;
|
||||
if (tree.id === id) {
|
||||
tree = null;
|
||||
positionsRef.current.delete(id);
|
||||
continue;
|
||||
}
|
||||
const next = deleteNode(tree, id);
|
||||
if (next !== tree) positionsRef.current.delete(id);
|
||||
tree = next;
|
||||
}
|
||||
onChange(tree);
|
||||
}
|
||||
onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode]);
|
||||
|
||||
const clearDropPreview = useCallback(() => {
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: false,
|
||||
activeSourceSide: undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes]);
|
||||
|
||||
const updateDropPreview = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
const canParent = canAcceptPaletteAsParent(anchorId, root);
|
||||
if (!canParent) {
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: { ...n.data, dragOver: false, activeSourceSide: undefined },
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const dim = getNodeDimensions(anchorId);
|
||||
const { sourceSide } = connectionSidesFromDrop(anchor.position, dim, flowPos);
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: n.id === anchorId,
|
||||
activeSourceSide: n.id === anchorId ? sourceSide : undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes, root]);
|
||||
|
||||
const orphanDragTargetRef = useRef<{ parentId: string; childId: string } | null>(null);
|
||||
|
||||
const updateOrphanConnectPreview = useCallback((
|
||||
resolved: { parentId: string; childId: string },
|
||||
draggedOrphanId: string,
|
||||
orphanPos: { x: number; y: number },
|
||||
) => {
|
||||
const { parentId, childId } = resolved;
|
||||
const nodesSnapshot = nodesRef.current;
|
||||
const parentNode = nodesSnapshot.find(n => n.id === parentId);
|
||||
const childNode = nodesSnapshot.find(n => n.id === childId);
|
||||
if (!parentNode || !childNode) return;
|
||||
|
||||
const parentDim = getNodeDimensions(parentId);
|
||||
const childDim = getNodeDimensions(childId);
|
||||
const parentPos = parentId === draggedOrphanId ? orphanPos : parentNode.position;
|
||||
const childPos = childId === draggedOrphanId ? orphanPos : childNode.position;
|
||||
const childCenter = {
|
||||
x: childPos.x + childDim.w / 2,
|
||||
y: childPos.y + childDim.h / 2,
|
||||
};
|
||||
const { sourceSide } = connectionSidesFromDrop(parentPos, parentDim, childCenter);
|
||||
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: n.id === parentId,
|
||||
activeSourceSide: n.id === parentId ? sourceSide : undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes]);
|
||||
|
||||
const addOrphanAt = useCallback((
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
positionsRef.current.set(newNode.id, {
|
||||
x: flowPos.x - FLOW_NODE_W / 2,
|
||||
y: flowPos.y - FLOW_NODE_H / 2,
|
||||
});
|
||||
onOrphansChange([...orphans, newNode]);
|
||||
}, [orphans, onOrphansChange, signalTab, def]);
|
||||
|
||||
const applyDropWithAttach = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
const anchorDim = getNodeDimensions(anchorId);
|
||||
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
|
||||
positionsRef.current.set(newNode.id, spawnPositionNearAnchor(anchor.position, anchorDim, sourceSide));
|
||||
|
||||
const saveAttachHandles = (parentId: string) => {
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
|
||||
};
|
||||
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
return;
|
||||
}
|
||||
if (anchorId === START_NODE_ID) {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorNode = findNodeInTree(root, anchorId);
|
||||
if (anchorNode?.type === 'CONDITION') {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAcceptPaletteAsParent(anchorId, root)) return;
|
||||
|
||||
onChange(addChild(root, anchorId, newNode));
|
||||
saveAttachHandles(anchorId);
|
||||
}, [root, signalTab, def, onChange]);
|
||||
|
||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
updateDropPreview(anchorId, flowPos);
|
||||
}, [updateDropPreview]);
|
||||
|
||||
const handleDragLeaveTarget = useCallback((anchorId: string) => {
|
||||
setNodes(nds => nds.map(n => (
|
||||
n.id === anchorId
|
||||
? { ...n, data: { ...n.data, dragOver: false, activeSourceSide: undefined } }
|
||||
: n
|
||||
)));
|
||||
}, [setNodes]);
|
||||
|
||||
const handleDropTarget = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
clearDropPreview();
|
||||
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
||||
applyDropWithAttach(anchorId, data, flowPos);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
}), [handleDelete, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
root,
|
||||
def,
|
||||
signalTab,
|
||||
positionsRef.current,
|
||||
flowCallbacks,
|
||||
null,
|
||||
orphans,
|
||||
), [root, def, signalTab, flowCallbacks, orphans]);
|
||||
|
||||
useEffect(() => {
|
||||
positionsRef.current = new Map();
|
||||
edgeHandlesRef.current = new Map();
|
||||
structureKeyRef.current = '';
|
||||
orphanKeyRef.current = '';
|
||||
onOrphansChange([]);
|
||||
}, [signalTab, onOrphansChange]);
|
||||
|
||||
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
||||
useEffect(() => {
|
||||
for (const n of nodesRef.current) {
|
||||
positionsRef.current.set(n.id, n.position);
|
||||
}
|
||||
|
||||
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
|
||||
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
|
||||
pruneEdgeHandles(edgeHandlesRef.current, mergedEdges);
|
||||
const structureChanged = structureKeyRef.current !== treeStructureKey;
|
||||
const orphansChanged = orphanKeyRef.current !== orphanKey;
|
||||
structureKeyRef.current = treeStructureKey;
|
||||
orphanKeyRef.current = orphanKey;
|
||||
|
||||
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
|
||||
const prev = nodesRef.current.find(p => p.id === n.id);
|
||||
return { ...n, selected: prev?.selected ?? false };
|
||||
}));
|
||||
|
||||
if (structureChanged || orphansChanged) {
|
||||
syncNodesFromLayout();
|
||||
setEdges(mergedEdges);
|
||||
if (structureChanged) {
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.4, duration: 200 });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setNodes(prev => prev.map(n => {
|
||||
const fresh = layoutNodes.find(ln => ln.id === n.id);
|
||||
if (!fresh) return n;
|
||||
return { ...n, data: fresh.data };
|
||||
}));
|
||||
setEdges(mergedEdges);
|
||||
}
|
||||
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView]);
|
||||
|
||||
const handleDisconnectEdge = useCallback((edgeId: string) => {
|
||||
const edge = edgesRef.current.find(e => e.id === edgeId);
|
||||
if (!edge?.source || !edge.target) return;
|
||||
|
||||
edgeHandlesRef.current.delete(edgeId);
|
||||
const { root: nextRoot, orphans: nextOrphans } = disconnectTreeEdge(
|
||||
edge.source,
|
||||
edge.target,
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
|
||||
if (nextRoot === root && nextOrphans.length === orphans.length) return;
|
||||
|
||||
onChange(nextRoot);
|
||||
onOrphansChange(nextOrphans);
|
||||
onSelectNode(null);
|
||||
setEdges(prev => prev.map(e => ({ ...e, selected: false })));
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
edgeDisconnectRef.current = handleDisconnectEdge;
|
||||
return () => {
|
||||
edgeDisconnectRef.current = null;
|
||||
};
|
||||
}, [handleDisconnectEdge]);
|
||||
|
||||
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
|
||||
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
|
||||
if (picked.length === 1) onSelectNode(picked[0].id);
|
||||
else onSelectNode(null);
|
||||
if (selEdges.length === 1) {
|
||||
// 연결선만 선택된 경우 노드 선택 해제
|
||||
if (picked.length === 0) onSelectNode(null);
|
||||
}
|
||||
}, [onSelectNode]);
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(conn: Connection | Edge) => isValidStrategyConnection(
|
||||
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
|
||||
root,
|
||||
orphans,
|
||||
),
|
||||
[root, orphans],
|
||||
);
|
||||
|
||||
const applyResolvedConnection = useCallback((
|
||||
parentId: string,
|
||||
childId: string,
|
||||
conn: Connection,
|
||||
) => {
|
||||
const wired: Connection = { ...conn, source: parentId, target: childId };
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${childId}`, wired);
|
||||
|
||||
const childOrphan = orphans.find(o => o.id === childId);
|
||||
const parentOrphan = orphans.find(o => o.id === parentId);
|
||||
|
||||
if (childOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== childId));
|
||||
connectOrphanIntoTree(wired, childOrphan, root, onChange);
|
||||
return;
|
||||
}
|
||||
if (parentOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== parentId));
|
||||
connectOrphanAsParent(wired, parentOrphan, root, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root) return;
|
||||
applyTreeConnection(parentId, childId, root, onChange);
|
||||
}, [root, orphans, onChange, onOrphansChange]);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
const positionChanges = changes.filter(
|
||||
(ch): ch is Extract<typeof ch, { type: 'position' }> => ch.type === 'position' && !!ch.position,
|
||||
);
|
||||
const startChange = positionChanges.find(ch => ch.id === START_NODE_ID);
|
||||
const otherTreePositionChanges = positionChanges.filter(
|
||||
ch => ch.id !== START_NODE_ID && treeLinkedIds.has(ch.id),
|
||||
);
|
||||
|
||||
let startGroupDelta: { dx: number; dy: number } | null = null;
|
||||
const startPos = startChange?.position;
|
||||
if (startChange && startPos && otherTreePositionChanges.length === 0) {
|
||||
const prev = positionsRef.current.get(START_NODE_ID);
|
||||
if (prev) {
|
||||
startGroupDelta = {
|
||||
dx: startPos.x - prev.x,
|
||||
dy: startPos.y - prev.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onNodesChangeBase(changes);
|
||||
|
||||
let dragEnded = false;
|
||||
|
||||
if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) {
|
||||
positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y });
|
||||
for (const id of treeLinkedIds) {
|
||||
if (id === START_NODE_ID) continue;
|
||||
const p = positionsRef.current.get(id);
|
||||
if (p) {
|
||||
positionsRef.current.set(id, {
|
||||
x: p.x + startGroupDelta.dx,
|
||||
y: p.y + startGroupDelta.dy,
|
||||
});
|
||||
}
|
||||
}
|
||||
setNodes(nds => nds.map(n => {
|
||||
if (!treeLinkedIds.has(n.id)) return n;
|
||||
const p = positionsRef.current.get(n.id);
|
||||
return p ? { ...n, position: { x: p.x, y: p.y } } : n;
|
||||
}));
|
||||
if (startChange?.dragging === false) dragEnded = true;
|
||||
}
|
||||
|
||||
for (const ch of positionChanges) {
|
||||
if (!ch.position) continue;
|
||||
if (startGroupDelta && treeLinkedIds.has(ch.id)) continue;
|
||||
|
||||
if (isOrphanNode(orphans, ch.id)) {
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
|
||||
if (ch.dragging === true) {
|
||||
const nodesSnapshot = nodesRef.current.map(n => (
|
||||
n.id === ch.id
|
||||
? { id: n.id, position: ch.position! }
|
||||
: { id: n.id, position: n.position }
|
||||
));
|
||||
const resolved = resolveOrphanDragConnection(
|
||||
ch.id,
|
||||
ch.position,
|
||||
nodesSnapshot,
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
orphanDragTargetRef.current = resolved;
|
||||
if (resolved) {
|
||||
updateOrphanConnectPreview(resolved, ch.id, ch.position);
|
||||
} else {
|
||||
clearDropPreview();
|
||||
}
|
||||
} else if (ch.dragging === false) {
|
||||
const pending = orphanDragTargetRef.current;
|
||||
orphanDragTargetRef.current = null;
|
||||
clearDropPreview();
|
||||
if (pending) {
|
||||
const conn = buildConnectionFromPositions(
|
||||
pending.parentId,
|
||||
pending.childId,
|
||||
positionsRef.current,
|
||||
);
|
||||
applyResolvedConnection(pending.parentId, pending.childId, conn);
|
||||
}
|
||||
dragEnded = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
if (ch.dragging === false) dragEnded = true;
|
||||
}
|
||||
|
||||
if (dragEnded) {
|
||||
setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current));
|
||||
}
|
||||
}, [
|
||||
onNodesChangeBase,
|
||||
setNodes,
|
||||
setEdges,
|
||||
treeLinkedIds,
|
||||
orphans,
|
||||
root,
|
||||
clearDropPreview,
|
||||
updateOrphanConnectPreview,
|
||||
applyResolvedConnection,
|
||||
]);
|
||||
|
||||
const onConnect = useCallback((conn: Connection) => {
|
||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
||||
if (!resolved) return;
|
||||
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
|
||||
}, [root, orphans, applyResolvedConnection]);
|
||||
|
||||
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
||||
const resolved = resolveStrategyConnection(newConnection, root, orphans);
|
||||
if (!resolved) return;
|
||||
|
||||
edgeHandlesRef.current.delete(oldEdge.id);
|
||||
|
||||
const endpointsChanged = oldEdge.source !== resolved.parentId || oldEdge.target !== resolved.childId;
|
||||
if (endpointsChanged) {
|
||||
applyResolvedConnection(resolved.parentId, resolved.childId, newConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection);
|
||||
setEdges(prev => prev.map(e => (
|
||||
e.id === oldEdge.id
|
||||
? {
|
||||
...e,
|
||||
sourceHandle: newConnection.sourceHandle,
|
||||
targetHandle: newConnection.targetHandle,
|
||||
}
|
||||
: e
|
||||
)));
|
||||
}, [root, orphans, applyResolvedConnection, setEdges]);
|
||||
|
||||
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
|
||||
|
||||
const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => {
|
||||
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
|
||||
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
|
||||
return { mode: 'connect' as const, anchorId: hit.id };
|
||||
}
|
||||
return { mode: 'orphan' as const };
|
||||
}, [root, orphans]);
|
||||
|
||||
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
clearDropPreview();
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
const drop = resolvePaletteDrop(flowPos);
|
||||
if (drop.mode === 'connect') {
|
||||
applyDropWithAttach(drop.anchorId, data, flowPos);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, resolvePaletteDrop]);
|
||||
|
||||
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isPaletteDrag(e)) return;
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
const drop = resolvePaletteDrop(flowPos);
|
||||
if (drop.mode === 'connect') {
|
||||
updateDropPreview(drop.anchorId, flowPos);
|
||||
} else {
|
||||
clearDropPreview();
|
||||
}
|
||||
}, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
|
||||
|
||||
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const rel = e.relatedTarget as Element | null;
|
||||
if (rel && e.currentTarget.contains(rel)) return;
|
||||
clearDropPreview();
|
||||
}, [clearDropPreview]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
|
||||
const selectedEdge = edgesRef.current.find(ed => ed.selected);
|
||||
if (selectedEdge) {
|
||||
e.preventDefault();
|
||||
handleDisconnectEdge(selectedEdge.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = nodesRef.current.filter(n => n.selected && n.id !== START_NODE_ID).length;
|
||||
if (selectedCount > 1) {
|
||||
e.preventDefault();
|
||||
deleteSelectedNodes();
|
||||
} else if (selectedNodeId && selectedNodeId !== START_NODE_ID) {
|
||||
e.preventDefault();
|
||||
handleDelete(selectedNodeId);
|
||||
}
|
||||
}, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-canvas-wrap se-canvas-wrap--${signalTab}`}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onReconnect={onReconnect}
|
||||
onDrop={onPaneDrop}
|
||||
onDragOver={onPaneDragOver}
|
||||
onDragLeave={onPaneDragLeave}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
edgesReconnectable
|
||||
reconnectRadius={24}
|
||||
minZoom={0.25}
|
||||
maxZoom={1.8}
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
selectionOnDrag
|
||||
selectionMode={SelectionMode.Partial}
|
||||
panOnDrag={[1, 2]}
|
||||
selectNodesOnDrag={false}
|
||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||
deleteKeyCode={null}
|
||||
defaultEdgeOptions={{
|
||||
type: 'strategy',
|
||||
className: 'se-flow-edge',
|
||||
style: { strokeWidth: 2 },
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={24} size={1} className="se-flow-bg" />
|
||||
<Controls className="se-flow-controls" showInteractive={false} />
|
||||
<MiniMap
|
||||
className="se-flow-minimap"
|
||||
zoomable
|
||||
pannable
|
||||
nodeStrokeWidth={2}
|
||||
bgColor={minimapColors.bg}
|
||||
maskColor={minimapColors.mask}
|
||||
maskStrokeColor={minimapColors.maskStroke}
|
||||
nodeColor={minimapNodeColor}
|
||||
nodeStrokeColor={minimapNodeStrokeColor}
|
||||
/>
|
||||
<Panel position="top-left" className="se-canvas-toolbar">
|
||||
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
||||
<span className="se-canvas-toolbar-hint">
|
||||
다중 선택 × 일괄삭제 · 연결선 × 끊기 · Del 삭제
|
||||
{orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
|
||||
</span>
|
||||
</Panel>
|
||||
{!root && (
|
||||
<Panel position="top-center" className="se-canvas-empty">
|
||||
우측에서 지표·연산자를 드래그하거나 클릭하여 조건을 구성하세요
|
||||
</Panel>
|
||||
)}
|
||||
<MultiSelectionDeleteButton onDelete={deleteSelectedNodes} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyEditorCanvas(props: Props) {
|
||||
return <StrategyEditorCanvasInner {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react';
|
||||
import { edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
|
||||
export function StrategyFlowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
}: EdgeProps) {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
className={`se-flow-edge${selected ? ' se-flow-edge--selected' : ''}`}
|
||||
style={{ strokeWidth: selected ? 3 : 2 }}
|
||||
/>
|
||||
{selected && edgeDisconnectRef.current && (
|
||||
<EdgeLabelRenderer>
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-edge-del"
|
||||
title="연결 끊기 (전략 코드에서 제외)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
edgeDisconnectRef.current?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Theme } from '../../types/index';
|
||||
|
||||
export function getMinimapColors(theme: Theme, signalTab: 'buy' | 'sell') {
|
||||
const sell = signalTab === 'sell';
|
||||
if (theme === 'light') {
|
||||
return {
|
||||
bg: 'rgba(255, 255, 255, 0.98)',
|
||||
mask: sell ? 'rgba(198, 40, 40, 0.08)' : 'rgba(25, 118, 210, 0.08)',
|
||||
maskStroke: sell ? 'rgba(198, 40, 40, 0.45)' : 'rgba(25, 118, 210, 0.45)',
|
||||
start: 'rgba(245, 127, 17, 0.9)',
|
||||
condition: sell ? 'rgba(198, 40, 40, 0.75)' : 'rgba(25, 118, 210, 0.72)',
|
||||
logic: sell ? 'rgba(198, 40, 40, 0.5)' : 'rgba(25, 118, 210, 0.48)',
|
||||
stroke: sell ? 'rgba(198, 40, 40, 0.85)' : 'rgba(25, 118, 210, 0.85)',
|
||||
};
|
||||
}
|
||||
if (theme === 'blue') {
|
||||
return {
|
||||
bg: 'rgba(12, 25, 41, 0.96)',
|
||||
mask: sell ? 'rgba(255, 85, 85, 0.1)' : 'rgba(94, 181, 255, 0.1)',
|
||||
maskStroke: sell ? 'rgba(255, 85, 85, 0.55)' : 'rgba(94, 181, 255, 0.55)',
|
||||
start: 'rgba(255, 213, 79, 0.9)',
|
||||
condition: sell ? 'rgba(255, 85, 85, 0.82)' : 'rgba(94, 181, 255, 0.78)',
|
||||
logic: sell ? 'rgba(255, 85, 85, 0.52)' : 'rgba(94, 181, 255, 0.55)',
|
||||
stroke: sell ? 'rgba(255, 120, 140, 0.9)' : 'rgba(94, 181, 255, 0.85)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'rgba(26, 27, 38, 0.96)',
|
||||
mask: sell ? 'rgba(247, 118, 142, 0.1)' : 'rgba(122, 162, 247, 0.1)',
|
||||
maskStroke: sell ? 'rgba(247, 118, 142, 0.55)' : 'rgba(122, 162, 247, 0.55)',
|
||||
start: 'rgba(230, 194, 0, 0.9)',
|
||||
condition: sell ? 'rgba(247, 118, 142, 0.82)' : 'rgba(122, 162, 247, 0.78)',
|
||||
logic: sell ? 'rgba(247, 118, 142, 0.52)' : 'rgba(122, 162, 247, 0.55)',
|
||||
stroke: sell ? 'rgba(247, 118, 142, 0.9)' : 'rgba(122, 162, 247, 0.85)',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 팔레트·Logic Expression 공통 — 지표 카테고리 */
|
||||
|
||||
export const BAND_INDICATORS = new Set(['MA', 'EMA', 'BOLLINGER', 'ICHIMOKU']);
|
||||
|
||||
export type PaletteIndicatorCategory = 'band' | 'ind';
|
||||
|
||||
export function getIndicatorPaletteCategory(indicatorType: string): PaletteIndicatorCategory {
|
||||
return BAND_INDICATORS.has(indicatorType) ? 'band' : 'ind';
|
||||
}
|
||||
|
||||
export type LogicOperatorKind = 'and' | 'or' | 'not';
|
||||
|
||||
export function logicOperatorKind(type: string): LogicOperatorKind | null {
|
||||
if (type === 'AND') return 'and';
|
||||
if (type === 'OR') return 'or';
|
||||
if (type === 'NOT') return 'not';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** 연결선 끊기 — 커스텀 엣지 컴포넌트에서 캔버스 핸들러 참조 */
|
||||
export const edgeDisconnectRef = {
|
||||
current: null as ((edgeId: string) => void) | null,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
type Axis = 'horizontal' | 'vertical';
|
||||
|
||||
export function usePanelResize(
|
||||
axis: Axis,
|
||||
onResize: (next: number) => void,
|
||||
getCurrent: () => number,
|
||||
min: number,
|
||||
max: number,
|
||||
onCommit?: (value: number) => void,
|
||||
) {
|
||||
return useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const start = getCurrent();
|
||||
const cursor = axis === 'vertical' ? 'col-resize' : 'row-resize';
|
||||
|
||||
document.body.style.cursor = cursor;
|
||||
document.body.style.userSelect = 'none';
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.classList.add('se-splitter--active');
|
||||
const splitter = e.currentTarget;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = axis === 'vertical' ? ev.clientX - startX : startY - ev.clientY;
|
||||
onResize(clamp(start + delta, min, max));
|
||||
};
|
||||
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
splitter.releasePointerCapture(ev.pointerId);
|
||||
splitter.classList.remove('se-splitter--active');
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
onCommit?.(getCurrent());
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}, [axis, onResize, getCurrent, min, max, onCommit]);
|
||||
}
|
||||
|
||||
export function readStoredSize(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeSize(key: string, value: number): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(Math.round(value)));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
/* ── 전략편집기 (Strategy Builder) — app theme tokens (strategyEditorTheme.css) ── */
|
||||
|
||||
.se-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: calc(100vh - 44px);
|
||||
min-height: 0;
|
||||
background: var(--se-bg);
|
||||
color: var(--se-text);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.se-save-toast {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 200;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
border: 1px solid rgba(0, 255, 136, 0.45);
|
||||
color: #00ff88;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 24px rgba(0, 255, 136, 0.25);
|
||||
animation: se-toast-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes se-toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.se-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--se-header-border);
|
||||
background: var(--se-header-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-header-left { display: flex; align-items: baseline; gap: 10px; }
|
||||
|
||||
.se-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
background: var(--se-title-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
|
||||
|
||||
.se-header-actions { display: flex; gap: 8px; }
|
||||
|
||||
.se-btn {
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-btn-bg);
|
||||
color: var(--se-text);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.se-btn:hover { border-color: color-mix(in srgb, var(--se-accent) 50%, transparent); }
|
||||
.se-btn--ghost { background: transparent; }
|
||||
.se-btn--gold {
|
||||
background: var(--se-btn-gold-bg);
|
||||
border-color: var(--se-btn-gold-border);
|
||||
color: var(--se-btn-gold-text);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--se-gold) 15%, transparent);
|
||||
}
|
||||
.se-btn--gold:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--se-gold) 30%, transparent); }
|
||||
.se-btn--danger { border-color: color-mix(in srgb, var(--se-danger) 50%, transparent); color: var(--se-danger); }
|
||||
|
||||
.se-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.se-splitter {
|
||||
flex-shrink: 0;
|
||||
background: var(--se-splitter-bg);
|
||||
transition: background 0.12s, box-shadow 0.12s;
|
||||
touch-action: none;
|
||||
z-index: 6;
|
||||
}
|
||||
.se-splitter:hover,
|
||||
.se-splitter.se-splitter--active {
|
||||
background: var(--se-splitter-active);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--se-accent) 20%, transparent);
|
||||
}
|
||||
.se-splitter--v {
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.se-splitter--h {
|
||||
height: 5px;
|
||||
margin: 0 10px;
|
||||
border-radius: 3px;
|
||||
cursor: row-resize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Left: strategy list card ── */
|
||||
.se-left {
|
||||
flex-shrink: 0;
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--se-panel-shell-bg);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.se-strat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 12px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--se-panel-card-bg);
|
||||
border: 1px solid var(--se-panel-card-border);
|
||||
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
|
||||
}
|
||||
|
||||
.se-strat-panel-head {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
}
|
||||
|
||||
.se-panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.se-new-strat-btn {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
margin: 0 0 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-btn-bg);
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.se-new-strat-btn:hover {
|
||||
background: var(--se-item-hover-bg);
|
||||
border-color: color-mix(in srgb, var(--se-accent) 28%, transparent);
|
||||
color: var(--se-accent);
|
||||
}
|
||||
|
||||
.se-strat-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 2px 1px 4px;
|
||||
}
|
||||
|
||||
.se-strat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 13px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--se-strat-item-border);
|
||||
background: var(--se-strat-item-bg);
|
||||
box-shadow: var(--se-strat-item-shadow);
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||
outline: none;
|
||||
}
|
||||
.se-strat-item:hover {
|
||||
background: var(--se-item-hover-bg);
|
||||
border-color: color-mix(in srgb, var(--se-accent) 28%, transparent);
|
||||
box-shadow: var(--se-strat-item-hover-shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.se-strat-item--sel {
|
||||
background: var(--se-item-selected-bg);
|
||||
border-color: var(--se-item-selected-border);
|
||||
box-shadow:
|
||||
var(--se-strat-item-hover-shadow),
|
||||
0 0 0 1px color-mix(in srgb, var(--se-item-selected-border) 45%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.se-strat-item--sel:hover {
|
||||
background: var(--se-item-selected-bg);
|
||||
box-shadow:
|
||||
var(--se-strat-item-hover-shadow),
|
||||
0 0 0 1px color-mix(in srgb, var(--se-item-selected-border) 45%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
}
|
||||
.se-strat-item:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--se-accent) 55%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.se-strat-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.se-strat-item--sel .se-strat-name {
|
||||
color: var(--se-item-selected-text);
|
||||
}
|
||||
|
||||
.se-strat-item-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.se-strat-del {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--se-text-dim);
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
.se-strat-item--sel .se-strat-del {
|
||||
color: color-mix(in srgb, var(--se-item-selected-text) 85%, transparent);
|
||||
}
|
||||
.se-strat-del:hover {
|
||||
color: var(--se-danger);
|
||||
background: color-mix(in srgb, var(--se-danger) 15%, transparent);
|
||||
}
|
||||
|
||||
.se-strat-status {
|
||||
flex-shrink: 0;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
color: var(--se-text-muted);
|
||||
background: color-mix(in srgb, var(--se-text-muted) 10%, transparent);
|
||||
border: 1px solid var(--se-border);
|
||||
}
|
||||
.se-strat-status--on {
|
||||
color: var(--se-status-on);
|
||||
background: color-mix(in srgb, var(--se-status-on) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--se-status-on) 28%, transparent);
|
||||
}
|
||||
.se-strat-item--sel .se-strat-status--on {
|
||||
color: var(--se-item-selected-text);
|
||||
background: color-mix(in srgb, var(--se-gold) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--se-gold) 35%, transparent);
|
||||
}
|
||||
|
||||
.se-empty {
|
||||
font-size: 0.78rem;
|
||||
color: var(--se-text-dim);
|
||||
text-align: center;
|
||||
padding: 20px 8px;
|
||||
}
|
||||
|
||||
/* ── Center ── */
|
||||
.se-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--se-center-bg);
|
||||
}
|
||||
|
||||
.se-center-work {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.se-center-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-signal-tabs { display: flex; gap: 6px; }
|
||||
|
||||
.se-signal-tab {
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-btn-bg);
|
||||
color: var(--se-text-muted);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 7px 16px;
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.se-signal-tab--buy-on {
|
||||
color: var(--se-buy);
|
||||
border-color: color-mix(in srgb, var(--se-buy) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--se-buy) 10%, transparent);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--se-buy) 15%, transparent);
|
||||
}
|
||||
.se-signal-tab--sell-on {
|
||||
color: var(--se-sell);
|
||||
border-color: color-mix(in srgb, var(--se-sell) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--se-sell) 10%, transparent);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--se-sell) 15%, transparent);
|
||||
}
|
||||
|
||||
.se-editing-name {
|
||||
font-size: 0.72rem;
|
||||
color: color-mix(in srgb, var(--se-gold) 85%, transparent);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold) 25%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.se-canvas-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 10px;
|
||||
border: 1px solid var(--se-canvas-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--se-canvas-inset);
|
||||
}
|
||||
.se-canvas-wrap--sell {
|
||||
border-color: var(--se-canvas-sell-border);
|
||||
box-shadow: var(--se-canvas-sell-inset);
|
||||
}
|
||||
|
||||
.se-canvas-wrap .react-flow { background: transparent; }
|
||||
|
||||
.se-canvas-wrap .react-flow__selection {
|
||||
background: var(--se-selection-bg);
|
||||
border: 1px dashed var(--se-selection-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.se-flow-bg { opacity: 0.4; }
|
||||
|
||||
.se-canvas-toolbar {
|
||||
background: var(--se-toolbar-bg);
|
||||
border: 1px solid var(--se-border);
|
||||
border-radius: 8px;
|
||||
padding: 5px 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.se-canvas-toolbar-title { font-size: 0.72rem; font-weight: 700; color: var(--se-toolbar-title); }
|
||||
.se-canvas-toolbar-hint { font-size: 0.65rem; color: var(--se-text-muted); }
|
||||
|
||||
.se-canvas-empty {
|
||||
background: var(--se-toolbar-bg);
|
||||
border: 1px dashed var(--se-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
/* ── React Flow controls & minimap (editor theme) ── */
|
||||
.se-flow-controls,
|
||||
.se-flow-minimap {
|
||||
background: var(--se-control-bg) !important;
|
||||
border: 1px solid var(--se-control-border) !important;
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35), 0 0 16px color-mix(in srgb, var(--se-accent) 6%, transparent) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se-canvas-wrap .se-flow-controls .react-flow__controls-button {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
background: var(--se-control-bg) !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--se-accent) 12%, transparent) !important;
|
||||
color: var(--se-control-icon) !important;
|
||||
fill: var(--se-control-icon) !important;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.se-canvas-wrap .se-flow-controls .react-flow__controls-button:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.se-canvas-wrap .se-flow-controls .react-flow__controls-button:hover {
|
||||
background: var(--se-control-hover) !important;
|
||||
color: var(--se-accent) !important;
|
||||
fill: var(--se-accent) !important;
|
||||
}
|
||||
.se-canvas-wrap .se-flow-controls .react-flow__controls-button svg {
|
||||
fill: currentColor !important;
|
||||
max-width: 14px;
|
||||
max-height: 14px;
|
||||
}
|
||||
|
||||
.se-canvas-wrap--sell .se-flow-controls .react-flow__controls-button {
|
||||
color: color-mix(in srgb, var(--se-sell) 88%, transparent) !important;
|
||||
fill: color-mix(in srgb, var(--se-sell) 88%, transparent) !important;
|
||||
border-bottom-color: color-mix(in srgb, var(--se-sell) 12%, transparent) !important;
|
||||
}
|
||||
.se-canvas-wrap--sell .se-flow-controls .react-flow__controls-button:hover {
|
||||
background: color-mix(in srgb, var(--se-sell) 14%, transparent) !important;
|
||||
color: var(--se-sell) !important;
|
||||
fill: var(--se-sell) !important;
|
||||
}
|
||||
|
||||
.se-canvas-wrap--sell .se-flow-minimap {
|
||||
border-color: color-mix(in srgb, var(--se-sell) 22%, transparent) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35), 0 0 16px color-mix(in srgb, var(--se-sell) 6%, transparent) !important;
|
||||
}
|
||||
|
||||
.se-flow-edge {
|
||||
stroke: var(--se-edge);
|
||||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--se-accent) 50%, transparent));
|
||||
}
|
||||
.se-flow-edge--selected {
|
||||
stroke: var(--se-accent);
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--se-accent) 85%, transparent));
|
||||
}
|
||||
.se-canvas-wrap--sell .se-flow-edge {
|
||||
stroke: color-mix(in srgb, var(--se-sell) 65%, transparent);
|
||||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--se-sell) 40%, transparent));
|
||||
}
|
||||
.se-canvas-wrap--sell .se-flow-edge--selected {
|
||||
stroke: var(--se-sell);
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--se-sell) 75%, transparent));
|
||||
}
|
||||
|
||||
.se-flow-edge-del {
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(248, 113, 113, 0.6);
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
color: #f87171;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
}
|
||||
.se-flow-edge-del:hover {
|
||||
background: rgba(248, 113, 113, 0.25);
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
.se-selection-delete {
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(248, 113, 113, 0.65);
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
color: #f87171;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.55), 0 0 12px rgba(248, 113, 113, 0.35);
|
||||
transition: background 0.15s, scale 0.15s;
|
||||
}
|
||||
.se-selection-delete:hover {
|
||||
background: rgba(248, 113, 113, 0.28);
|
||||
scale: 1.08;
|
||||
}
|
||||
|
||||
/* ── Flow nodes ── */
|
||||
.se-flow-node {
|
||||
position: relative;
|
||||
min-width: 130px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.se-flow-node--start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--se-node-start-bg);
|
||||
border: 1px solid var(--se-node-start-border);
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
.se-flow-start-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--se-text-dim);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--se-text-dim) 60%, transparent);
|
||||
}
|
||||
|
||||
.se-flow-node--logic {
|
||||
background: var(--se-node-logic-bg);
|
||||
border: 1px solid var(--se-gate-color, var(--se-accent));
|
||||
padding: 12px 16px;
|
||||
min-width: 110px;
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--se-accent) 20%, transparent);
|
||||
}
|
||||
.se-flow-node--logic.se-flow-node--sell-mode {
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--se-sell) 18%, transparent), color-mix(in srgb, var(--se-bg) 92%, transparent));
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--se-sell) 15%, transparent);
|
||||
}
|
||||
|
||||
.se-flow-gate-badge {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
color: var(--se-accent);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.se-flow-gate-hint { font-size: 0.62rem; color: var(--se-text-dim); display: block; margin-top: 4px; }
|
||||
|
||||
.se-flow-node--cond {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--se-node-cond-border);
|
||||
background: var(--se-node-cond-bg);
|
||||
min-width: 170px;
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--se-success) 12%, transparent);
|
||||
}
|
||||
.se-flow-node--cross {
|
||||
border-color: color-mix(in srgb, var(--se-gold) 50%, transparent);
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--se-gold) 14%, transparent), color-mix(in srgb, var(--se-bg) 92%, transparent));
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--se-gold) 12%, transparent);
|
||||
}
|
||||
.se-flow-node--cond.se-flow-node--sell-mode {
|
||||
border-color: color-mix(in srgb, var(--se-sell) 45%, transparent);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--se-sell) 12%, transparent);
|
||||
}
|
||||
|
||||
.se-flow-cond-badge { font-size: 0.65rem; font-weight: 800; color: var(--se-success); margin-bottom: 4px; }
|
||||
.se-flow-node--cross .se-flow-cond-badge { color: var(--se-gold); }
|
||||
.se-flow-node--sell-mode .se-flow-cond-badge { color: var(--se-sell); }
|
||||
.se-flow-cond-text { font-size: 0.7rem; line-height: 1.35; color: var(--se-node-text); word-break: break-word; }
|
||||
|
||||
.se-flow-node--selected {
|
||||
outline: 2px solid color-mix(in srgb, var(--se-gold) 70%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.se-flow-node--orphan {
|
||||
border-style: dashed !important;
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 0 12px rgba(148, 163, 184, 0.2) !important;
|
||||
}
|
||||
.se-flow-node--orphan .se-flow-cond-badge,
|
||||
.se-flow-node--orphan .se-flow-gate-badge {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.se-flow-node--drop {
|
||||
box-shadow: 0 0 0 2px rgba(255, 204, 0, 0.55), 0 0 28px rgba(0, 212, 255, 0.25);
|
||||
}
|
||||
|
||||
.se-flow-del {
|
||||
position: absolute; top: 4px; right: 6px;
|
||||
border: none; background: rgba(0, 0, 0, 0.4);
|
||||
color: rgba(255, 77, 109, 0.9);
|
||||
width: 18px; height: 18px; border-radius: 4px;
|
||||
cursor: pointer; font-size: 0.85rem; line-height: 1;
|
||||
opacity: 0; transition: opacity 0.12s;
|
||||
}
|
||||
.se-flow-node:hover .se-flow-del { opacity: 1; }
|
||||
|
||||
.se-flow-handle {
|
||||
width: 9px !important; height: 9px !important;
|
||||
background: var(--se-handle) !important;
|
||||
border: 2px solid color-mix(in srgb, var(--se-bg) 80%, var(--se-accent)) !important;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--se-accent) 60%, transparent);
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.12s, transform 0.12s;
|
||||
}
|
||||
.se-flow-node:hover .se-flow-handle,
|
||||
.se-flow-node.se-flow-node--selected .se-flow-handle,
|
||||
.se-flow-node.se-flow-node--drop .se-flow-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.se-flow-handle:hover {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.se-flow-handle--active {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1.45);
|
||||
background: var(--se-gold) !important;
|
||||
border-color: color-mix(in srgb, var(--se-gold) 40%, var(--se-bg)) !important;
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--se-gold) 95%, transparent);
|
||||
z-index: 2;
|
||||
}
|
||||
.se-flow-handle--valid-hover {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1.35);
|
||||
background: #34d399 !important;
|
||||
border-color: #065f46 !important;
|
||||
box-shadow: 0 0 12px rgba(52, 211, 153, 0.85);
|
||||
z-index: 2;
|
||||
}
|
||||
.se-flow-handle--invalid {
|
||||
opacity: 1 !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
transform: scale(1.35);
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
border-color: #f87171 !important;
|
||||
box-shadow: 0 0 14px rgba(248, 113, 113, 0.85);
|
||||
z-index: 3;
|
||||
}
|
||||
.se-flow-handle--invalid::after {
|
||||
content: '×';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: #f87171;
|
||||
pointer-events: none;
|
||||
}
|
||||
.se-flow-handle--disabled {
|
||||
opacity: 0.08 !important;
|
||||
pointer-events: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.se-flow-handle--out { opacity: 0.35; }
|
||||
|
||||
/* ── Node config bar ── */
|
||||
.se-node-config-bar {
|
||||
flex-shrink: 0;
|
||||
margin: 6px 10px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-accent) 25%, transparent);
|
||||
background: var(--se-toolbar-bg);
|
||||
box-shadow: 0 0 24px color-mix(in srgb, var(--se-accent) 8%, transparent);
|
||||
}
|
||||
.se-node-config-label {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
color: var(--se-accent);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-accent) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.se-sync-tip {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 0.65rem;
|
||||
color: color-mix(in srgb, var(--se-success) 75%, transparent);
|
||||
}
|
||||
.se-node-config-bar .sp-cond-editor { margin: 0; }
|
||||
.se-node-config-bar .sp-cond-sel,
|
||||
.se-node-config-bar .sp-cond-num {
|
||||
background: var(--se-input-bg);
|
||||
border-color: var(--se-input-border);
|
||||
color: var(--se-text);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* ── Terminal ── */
|
||||
.se-terminal {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 10px 10px;
|
||||
border: 1px solid var(--se-terminal-border);
|
||||
border-radius: 10px;
|
||||
background: var(--se-terminal-bg);
|
||||
overflow: hidden;
|
||||
min-height: 88px;
|
||||
}
|
||||
.se-terminal-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--se-terminal-label);
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
}
|
||||
.se-terminal-text {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
color: var(--se-terminal-code);
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.se-terminal-text--rich {
|
||||
display: block;
|
||||
}
|
||||
.se-formula-line + .se-formula-line {
|
||||
margin-top: 0.35em;
|
||||
}
|
||||
.se-formula-punc {
|
||||
color: var(--se-text-dim);
|
||||
}
|
||||
.se-formula-detail {
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
.se-formula-empty,
|
||||
.se-formula-comment {
|
||||
color: var(--se-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.se-formula-signal--buy {
|
||||
color: var(--se-buy);
|
||||
font-weight: 700;
|
||||
}
|
||||
.se-formula-signal--sell {
|
||||
color: var(--se-sell);
|
||||
font-weight: 700;
|
||||
}
|
||||
.se-formula-logic {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.se-formula-logic--and { color: var(--se-palette-logic-and-accent); }
|
||||
.se-formula-logic--or { color: var(--se-palette-logic-or-accent); }
|
||||
.se-formula-logic--not { color: var(--se-palette-logic-not-accent); }
|
||||
.se-formula-ind {
|
||||
font-weight: 800;
|
||||
}
|
||||
.se-formula-ind--band { color: var(--se-palette-band-accent); }
|
||||
.se-formula-ind--ind { color: var(--se-palette-ind-accent); }
|
||||
|
||||
/* ── Right palette ── */
|
||||
.se-right {
|
||||
flex: 0 0 260px;
|
||||
width: 260px;
|
||||
border-left: 1px solid var(--se-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--se-palette-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se-right-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
}
|
||||
.se-right-tab {
|
||||
flex: 1;
|
||||
padding: 10px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-right-tab--on {
|
||||
color: var(--se-tab-active);
|
||||
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
||||
}
|
||||
|
||||
.se-palette-search {
|
||||
margin: 10px 10px 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.se-palette-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--se-input-focus);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--se-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.se-palette-section { padding: 0 10px 10px; }
|
||||
.se-palette-section--scroll { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.se-palette-section h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--se-text-dim);
|
||||
}
|
||||
.se-palette-section--logic h3 { color: var(--se-palette-section-logic); }
|
||||
.se-palette-section--band h3 { color: var(--se-palette-section-band); }
|
||||
.se-palette-section--aux h3 { color: var(--se-palette-section-ind); }
|
||||
|
||||
.se-palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.se-palette-grid--3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.se-palette-card {
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: grab;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-palette-card-bg);
|
||||
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.se-palette-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── 논리 연산: AND / OR / NOT ── */
|
||||
.se-palette-card--logic-and {
|
||||
border-color: color-mix(in srgb, var(--se-palette-logic-and-accent) 42%, transparent);
|
||||
background: var(--se-palette-logic-and-bg);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-and-accent);
|
||||
}
|
||||
.se-palette-card--logic-and .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-logic-and-accent) 22%, transparent);
|
||||
color: var(--se-palette-logic-and-accent);
|
||||
}
|
||||
.se-palette-card--logic-and:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-logic-and-accent) 65%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-and-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-logic-and-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.se-palette-card--logic-or {
|
||||
border-color: color-mix(in srgb, var(--se-palette-logic-or-accent) 42%, transparent);
|
||||
background: var(--se-palette-logic-or-bg);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-or-accent);
|
||||
}
|
||||
.se-palette-card--logic-or .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-logic-or-accent) 22%, transparent);
|
||||
color: var(--se-palette-logic-or-accent);
|
||||
}
|
||||
.se-palette-card--logic-or:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-logic-or-accent) 65%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-or-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-logic-or-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.se-palette-card--logic-not {
|
||||
border-color: color-mix(in srgb, var(--se-palette-logic-not-accent) 42%, transparent);
|
||||
background: var(--se-palette-logic-not-bg);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-not-accent);
|
||||
}
|
||||
.se-palette-card--logic-not .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-logic-not-accent) 22%, transparent);
|
||||
color: var(--se-palette-logic-not-accent);
|
||||
}
|
||||
.se-palette-card--logic-not:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-logic-not-accent) 65%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-not-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-logic-not-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── 밴드 · 추세 ── */
|
||||
.se-palette-card--band {
|
||||
border-color: color-mix(in srgb, var(--se-palette-band-accent) 42%, transparent);
|
||||
background: var(--se-palette-band-bg);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-band-accent);
|
||||
}
|
||||
.se-palette-card--band .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-band-accent) 22%, transparent);
|
||||
color: var(--se-palette-band-accent);
|
||||
}
|
||||
.se-palette-card--band:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-band-accent) 65%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-band-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-band-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── 보조지표 ── */
|
||||
.se-palette-card--ind {
|
||||
border-color: color-mix(in srgb, var(--se-palette-ind-accent) 42%, transparent);
|
||||
background: var(--se-palette-ind-bg);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-ind-accent);
|
||||
}
|
||||
.se-palette-card--ind .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
||||
color: var(--se-palette-ind-accent);
|
||||
}
|
||||
.se-palette-card--ind:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-ind-accent) 65%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-ind-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
||||
}
|
||||
.se-palette-card-head { display: flex; align-items: flex-start; gap: 6px; }
|
||||
.se-palette-card-icon {
|
||||
width: 24px; height: 24px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.72rem; font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.se-palette-card-meta { min-width: 0; }
|
||||
.se-palette-card-name { font-size: 0.68rem; font-weight: 700; line-height: 1.2; display: block; }
|
||||
.se-palette-card-desc { font-size: 0.58rem; color: var(--se-text-dim); display: block; }
|
||||
|
||||
.se-palette-card-period {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
margin-top: 6px; font-size: 0.58rem; color: var(--se-text-dim);
|
||||
}
|
||||
.se-palette-period-input {
|
||||
width: 36px; text-align: center;
|
||||
border: 1px solid var(--se-input-border);
|
||||
border-radius: 4px;
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text); font-size: 0.58rem; padding: 2px;
|
||||
}
|
||||
.se-palette-sync { color: var(--se-success); font-size: 0.65rem; }
|
||||
|
||||
.se-template-list { padding: 10px; overflow-y: auto; flex: 1; }
|
||||
.se-template-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 10px 12px; margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-palette-card-bg);
|
||||
color: var(--se-text); cursor: pointer; text-align: left;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.se-template-item:hover {
|
||||
border-color: var(--se-palette-hover-border);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--se-gold) 10%, transparent);
|
||||
}
|
||||
.se-template-label { font-size: 0.76rem; }
|
||||
.se-template-signal { font-size: 0.62rem; font-weight: 700; padding: 2px 6px; border-radius: 4px; }
|
||||
.se-template-signal--buy { color: var(--se-buy); background: color-mix(in srgb, var(--se-buy) 12%, transparent); }
|
||||
.se-template-signal--sell { color: var(--se-sell); background: color-mix(in srgb, var(--se-sell) 12%, transparent); }
|
||||
|
||||
/* ── Modal / snack ── */
|
||||
.se-field-lbl { display: block; font-size: 0.72rem; color: var(--se-text-muted); margin: 8px 0 4px; }
|
||||
.se-field-inp, .se-field-ta {
|
||||
width: 100%; box-sizing: border-box;
|
||||
border: 1px solid var(--se-input-border);
|
||||
border-radius: 8px;
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text); padding: 7px 10px; font-size: 0.8rem;
|
||||
}
|
||||
.se-modal-body { padding: 4px 0; }
|
||||
.se-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||
|
||||
.se-snack {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
padding: 10px 18px; border-radius: 10px; font-size: 0.82rem; z-index: 9999;
|
||||
background: color-mix(in srgb, var(--se-success) 92%, #000);
|
||||
color: var(--se-success);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.se-snack--err {
|
||||
background: color-mix(in srgb, var(--se-danger) 92%, #000);
|
||||
color: var(--se-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.se-right { flex: 0 0 220px; width: 220px; }
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/* ── Strategy Editor theme tokens (follows app dark / blue / light) ── */
|
||||
|
||||
.se-page {
|
||||
--se-bg: var(--bg);
|
||||
--se-bg-elevated: var(--bg2);
|
||||
--se-bg-muted: var(--bg3);
|
||||
--se-text: var(--text);
|
||||
--se-text-muted: var(--text2);
|
||||
--se-text-dim: var(--text3);
|
||||
--se-border: var(--border);
|
||||
--se-accent: var(--accent);
|
||||
--se-accent-hover: var(--accent-h);
|
||||
--se-up: var(--up);
|
||||
--se-down: var(--down);
|
||||
--se-gold: #e6c200;
|
||||
--se-success: #9ece6a;
|
||||
--se-danger: #f7768e;
|
||||
--se-buy: #7aa2f7;
|
||||
--se-sell: #f7768e;
|
||||
--se-header-bg: linear-gradient(180deg, var(--bg2), var(--bg));
|
||||
--se-header-border: var(--border);
|
||||
--se-title-gradient: linear-gradient(90deg, #e6c200, #ffe566);
|
||||
--se-panel-shell-bg: var(--bg);
|
||||
--se-panel-card-bg: linear-gradient(165deg, var(--bg2), var(--bg));
|
||||
--se-panel-card-border: var(--border);
|
||||
--se-panel-card-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
--se-item-hover-bg: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
--se-strat-item-bg: color-mix(in srgb, var(--bg2) 88%, transparent);
|
||||
--se-strat-item-border: var(--border);
|
||||
--se-strat-item-shadow: 0 1px 3px rgba(0, 0, 0, 0.22), inset 0 1px 0 color-mix(in srgb, var(--se-text) 5%, transparent);
|
||||
--se-strat-item-hover-shadow: 0 3px 10px rgba(0, 0, 0, 0.28);
|
||||
--se-item-selected-bg: linear-gradient(135deg, color-mix(in srgb, var(--se-gold) 22%, transparent), color-mix(in srgb, var(--se-gold) 8%, transparent));
|
||||
--se-item-selected-border: color-mix(in srgb, var(--se-gold) 38%, transparent);
|
||||
--se-item-selected-text: var(--se-gold);
|
||||
--se-center-bg: radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--accent) 6%, transparent) 0%, transparent 50%), var(--bg);
|
||||
--se-canvas-border: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
--se-canvas-inset: inset 0 0 60px rgba(0, 0, 0, 0.35);
|
||||
--se-canvas-sell-border: color-mix(in srgb, var(--se-sell) 28%, transparent);
|
||||
--se-canvas-sell-inset: inset 0 0 40px color-mix(in srgb, var(--se-sell) 6%, transparent);
|
||||
--se-selection-bg: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
--se-selection-border: color-mix(in srgb, var(--se-gold) 75%, transparent);
|
||||
--se-toolbar-bg: color-mix(in srgb, var(--bg2) 92%, transparent);
|
||||
--se-toolbar-title: var(--se-gold);
|
||||
--se-terminal-bg: color-mix(in srgb, var(--bg) 92%, #000);
|
||||
--se-terminal-border: color-mix(in srgb, var(--se-gold) 18%, transparent);
|
||||
--se-terminal-label: color-mix(in srgb, var(--se-gold) 70%, transparent);
|
||||
--se-terminal-code: var(--accent);
|
||||
--se-splitter-bg: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
--se-splitter-active: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
--se-control-bg: color-mix(in srgb, var(--bg2) 94%, transparent);
|
||||
--se-control-border: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
--se-control-icon: var(--accent);
|
||||
--se-control-hover: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
--se-input-bg: color-mix(in srgb, var(--bg3) 88%, transparent);
|
||||
--se-input-border: var(--border);
|
||||
--se-input-focus: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
--se-palette-bg: color-mix(in srgb, var(--bg2) 88%, transparent);
|
||||
--se-palette-card-bg: color-mix(in srgb, var(--bg2) 78%, transparent);
|
||||
--se-palette-hover-border: color-mix(in srgb, var(--se-gold) 45%, transparent);
|
||||
--se-palette-logic-and-accent: #4dabf7;
|
||||
--se-palette-logic-and-bg: color-mix(in srgb, #4dabf7 14%, var(--bg2));
|
||||
--se-palette-logic-or-accent: #22d3ee;
|
||||
--se-palette-logic-or-bg: color-mix(in srgb, #22d3ee 14%, var(--bg2));
|
||||
--se-palette-logic-not-accent: #fbbf24;
|
||||
--se-palette-logic-not-bg: color-mix(in srgb, #fbbf24 14%, var(--bg2));
|
||||
--se-palette-band-accent: #a78bfa;
|
||||
--se-palette-band-bg: color-mix(in srgb, #a78bfa 14%, var(--bg2));
|
||||
--se-palette-ind-accent: #34d399;
|
||||
--se-palette-ind-bg: color-mix(in srgb, #34d399 14%, var(--bg2));
|
||||
--se-palette-section-logic: #4dabf7;
|
||||
--se-palette-section-band: #a78bfa;
|
||||
--se-palette-section-ind: #34d399;
|
||||
--se-tab-active: var(--se-gold);
|
||||
--se-handle: var(--accent);
|
||||
--se-edge: color-mix(in srgb, var(--accent) 72%, transparent);
|
||||
--se-node-text: var(--text);
|
||||
--se-node-logic-bg: linear-gradient(145deg, color-mix(in srgb, var(--accent) 18%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
||||
--se-node-cond-bg: linear-gradient(145deg, color-mix(in srgb, var(--se-success) 16%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
||||
--se-node-cond-border: color-mix(in srgb, var(--se-success) 42%, transparent);
|
||||
--se-node-start-bg: color-mix(in srgb, var(--bg3) 88%, transparent);
|
||||
--se-node-start-border: var(--border);
|
||||
--se-btn-bg: color-mix(in srgb, var(--bg2) 90%, transparent);
|
||||
--se-btn-gold-bg: linear-gradient(135deg, color-mix(in srgb, var(--se-gold) 25%, transparent), color-mix(in srgb, var(--se-gold) 12%, transparent));
|
||||
--se-btn-gold-border: color-mix(in srgb, var(--se-gold) 55%, transparent);
|
||||
--se-btn-gold-text: var(--se-gold);
|
||||
--se-status-on: var(--se-success);
|
||||
--se-status-off: var(--text3);
|
||||
--se-minimap-bg: color-mix(in srgb, var(--bg) 96%, transparent);
|
||||
--se-minimap-mask: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
--se-minimap-mask-stroke: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.se-page--light {
|
||||
--se-gold: #f57f17;
|
||||
--se-success: #2e7d32;
|
||||
--se-danger: #c62828;
|
||||
--se-buy: #1976d2;
|
||||
--se-sell: #c62828;
|
||||
--se-title-gradient: linear-gradient(90deg, #1565c0, #1976d2);
|
||||
--se-panel-card-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
--se-item-selected-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04));
|
||||
--se-strat-item-bg: #ffffff;
|
||||
--se-strat-item-border: rgba(0, 0, 0, 0.1);
|
||||
--se-strat-item-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--se-strat-item-hover-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
|
||||
--se-item-selected-border: rgba(25, 118, 210, 0.35);
|
||||
--se-item-selected-text: #1565c0;
|
||||
--se-center-bg: var(--bg);
|
||||
--se-canvas-border: rgba(0, 0, 0, 0.1);
|
||||
--se-canvas-inset: inset 0 0 40px rgba(0, 0, 0, 0.04);
|
||||
--se-canvas-sell-inset: inset 0 0 30px rgba(198, 40, 40, 0.04);
|
||||
--se-selection-border: rgba(25, 118, 210, 0.55);
|
||||
--se-toolbar-title: #1565c0;
|
||||
--se-terminal-bg: #ffffff;
|
||||
--se-terminal-border: rgba(0, 0, 0, 0.1);
|
||||
--se-terminal-label: #757575;
|
||||
--se-terminal-code: #1565c0;
|
||||
--se-tab-active: #1565c0;
|
||||
--se-palette-hover-border: rgba(25, 118, 210, 0.45);
|
||||
--se-node-logic-bg: linear-gradient(145deg, rgba(25, 118, 210, 0.08), #ffffff);
|
||||
--se-node-cond-bg: linear-gradient(145deg, rgba(46, 125, 50, 0.08), #ffffff);
|
||||
--se-node-cond-border: rgba(46, 125, 50, 0.35);
|
||||
--se-node-start-bg: #f5f5f5;
|
||||
--se-node-start-border: rgba(0, 0, 0, 0.12);
|
||||
--se-btn-gold-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04));
|
||||
--se-btn-gold-border: rgba(25, 118, 210, 0.45);
|
||||
--se-btn-gold-text: #1565c0;
|
||||
--se-status-on: #2e7d32;
|
||||
--se-minimap-bg: rgba(255, 255, 255, 0.98);
|
||||
--se-edge: rgba(25, 118, 210, 0.55);
|
||||
--se-palette-logic-and-accent: #1976d2;
|
||||
--se-palette-logic-and-bg: rgba(25, 118, 210, 0.08);
|
||||
--se-palette-logic-or-accent: #0288d1;
|
||||
--se-palette-logic-or-bg: rgba(2, 136, 209, 0.08);
|
||||
--se-palette-logic-not-accent: #f57f17;
|
||||
--se-palette-logic-not-bg: rgba(245, 127, 23, 0.08);
|
||||
--se-palette-band-accent: #7b1fa2;
|
||||
--se-palette-band-bg: rgba(123, 31, 162, 0.07);
|
||||
--se-palette-ind-accent: #2e7d32;
|
||||
--se-palette-ind-bg: rgba(46, 125, 50, 0.08);
|
||||
--se-palette-section-logic: #1565c0;
|
||||
--se-palette-section-band: #7b1fa2;
|
||||
--se-palette-section-ind: #2e7d32;
|
||||
}
|
||||
|
||||
.se-page--blue {
|
||||
--se-gold: #ffd54f;
|
||||
--se-success: #69f0ae;
|
||||
--se-buy: #5eb5ff;
|
||||
--se-sell: #ff5555;
|
||||
--se-title-gradient: linear-gradient(90deg, #ffd54f, #ffe082);
|
||||
--se-item-selected-bg: linear-gradient(135deg, rgba(255, 213, 79, 0.18), rgba(94, 181, 255, 0.08));
|
||||
--se-strat-item-bg: color-mix(in srgb, var(--bg2) 92%, transparent);
|
||||
--se-strat-item-border: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
--se-strat-item-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
--se-strat-item-hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.32);
|
||||
--se-item-selected-border: rgba(255, 213, 79, 0.38);
|
||||
--se-item-selected-text: #ffd54f;
|
||||
--se-center-bg: radial-gradient(circle at 50% 40%, rgba(94, 181, 255, 0.06) 0%, transparent 50%), var(--bg);
|
||||
--se-toolbar-title: #ffd54f;
|
||||
--se-terminal-code: #5eb5ff;
|
||||
--se-tab-active: #ffd54f;
|
||||
--se-node-logic-bg: linear-gradient(145deg, rgba(94, 181, 255, 0.14), rgba(12, 25, 41, 0.92));
|
||||
--se-node-cond-bg: linear-gradient(145deg, rgba(105, 240, 174, 0.12), rgba(12, 25, 41, 0.92));
|
||||
--se-node-cond-border: rgba(105, 240, 174, 0.38);
|
||||
--se-node-start-bg: rgba(21, 39, 61, 0.88);
|
||||
--se-btn-gold-text: #ffd54f;
|
||||
--se-minimap-bg: rgba(12, 25, 41, 0.96);
|
||||
--se-palette-logic-and-accent: #5eb5ff;
|
||||
--se-palette-logic-and-bg: rgba(94, 181, 255, 0.12);
|
||||
--se-palette-logic-or-accent: #4dd0e1;
|
||||
--se-palette-logic-or-bg: rgba(77, 208, 225, 0.12);
|
||||
--se-palette-logic-not-accent: #ffd54f;
|
||||
--se-palette-logic-not-bg: rgba(255, 213, 79, 0.12);
|
||||
--se-palette-band-accent: #ce93d8;
|
||||
--se-palette-band-bg: rgba(206, 147, 216, 0.12);
|
||||
--se-palette-ind-accent: #69f0ae;
|
||||
--se-palette-ind-bg: rgba(105, 240, 174, 0.1);
|
||||
--se-palette-section-logic: #5eb5ff;
|
||||
--se-palette-section-band: #ce93d8;
|
||||
--se-palette-section-ind: #69f0ae;
|
||||
}
|
||||
|
||||
/* Light theme — flow node & handle tweaks */
|
||||
.se-page--light .se-flow-node--logic.se-flow-node--sell-mode {
|
||||
background: linear-gradient(145deg, rgba(198, 40, 40, 0.08), #ffffff);
|
||||
box-shadow: 0 2px 12px rgba(198, 40, 40, 0.08);
|
||||
}
|
||||
.se-page--light .se-flow-node--cond.se-flow-node--sell-mode {
|
||||
border-color: rgba(198, 40, 40, 0.35);
|
||||
box-shadow: 0 2px 12px rgba(198, 40, 40, 0.06);
|
||||
}
|
||||
.se-page--light .se-flow-gate-badge { color: #1565c0; }
|
||||
.se-page--light .se-flow-cond-badge { color: #2e7d32; }
|
||||
.se-page--light .se-flow-node--cross .se-flow-cond-badge { color: #f57f17; }
|
||||
.se-page--light .se-flow-node--sell-mode .se-flow-cond-badge { color: #c62828; }
|
||||
.se-page--light .se-flow-handle {
|
||||
background: #1976d2 !important;
|
||||
border-color: #0d47a1 !important;
|
||||
box-shadow: 0 0 6px rgba(25, 118, 210, 0.35);
|
||||
}
|
||||
.se-page--light .se-flow-handle--active {
|
||||
background: #f57f17 !important;
|
||||
border-color: #e65100 !important;
|
||||
}
|
||||
.se-page--light .se-flow-edge { filter: none; }
|
||||
.se-page--light .se-flow-edge--selected { filter: none; }
|
||||
.se-page--light .se-canvas-wrap .react-flow__controls-button {
|
||||
color: #1976d2 !important;
|
||||
fill: #1976d2 !important;
|
||||
}
|
||||
.se-page--light .se-node-config-bar .sp-cond-sel,
|
||||
.se-page--light .se-node-config-bar .sp-cond-num {
|
||||
background: #fff;
|
||||
color: #212121;
|
||||
}
|
||||
|
||||
.se-page--blue .se-flow-gate-badge { color: #5eb5ff; }
|
||||
.se-page--blue .se-flow-cond-badge { color: #69f0ae; }
|
||||
.se-page--blue .se-flow-handle {
|
||||
background: #5eb5ff !important;
|
||||
border-color: #1565c0 !important;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'backtest', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'strategy-editor', 'backtest', 'settings',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'backtest', 'notifications', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||
] as const;
|
||||
|
||||
@@ -31,6 +31,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
chart: '실시간차트',
|
||||
paper: '모의투자',
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
notifications: '알림',
|
||||
settings: '설정',
|
||||
@@ -65,6 +66,10 @@ export function canAccessMenu(
|
||||
menuId: string,
|
||||
): boolean {
|
||||
if (!permissions) return menuId === 'chart';
|
||||
if (menuId === 'strategy-editor') {
|
||||
if (permissions['strategy-editor'] === true) return true;
|
||||
return permissions.strategy === true;
|
||||
}
|
||||
return permissions[menuId] === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
/** Shared strategy editor utilities — extracted from StrategyPage */
|
||||
import React from 'react';
|
||||
import type { IndicatorConfig } from '../types/index';
|
||||
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
|
||||
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
||||
|
||||
export interface StrategyDto {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
buyCondition?: LogicNode | null;
|
||||
sellCondition?: LogicNode | null;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: { message: string }[];
|
||||
warnings: { message: string }[];
|
||||
}
|
||||
|
||||
export type DefType = typeof DEF_DEFAULTS;
|
||||
|
||||
let _cnt = 0;
|
||||
export const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||||
|
||||
export const STORAGE_KEY = 'gc_strat_v2';
|
||||
export const loadStratsLocal = (): StrategyDto[] => {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
|
||||
};
|
||||
export const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
|
||||
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
|
||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||
interface HLThresh { over?: number; mid?: number; under?: number; }
|
||||
|
||||
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
|
||||
const DEF_DEFAULTS = {
|
||||
rsiPeriod: 9,
|
||||
macdFast: 12, macdSlow: 26, macdSignal: 9,
|
||||
cciPeriod: 13,
|
||||
stochK: 12, stochD: 5,
|
||||
adxPeriod: 14,
|
||||
trixPeriod: 12, trixSignal: 9,
|
||||
dmiPeriod: 14,
|
||||
obvPeriod: 9, obvSignal: 9,
|
||||
bbPeriod: 20, bbStdDev: 2,
|
||||
ichTenkan: 9, ichKijun: 26, ichSenkouB: 52,
|
||||
newPsy: 12, investPsy: 10,
|
||||
williamsR: 14,
|
||||
bwiPeriod: 20,
|
||||
vrPeriod: 10,
|
||||
volOscShort: 5, volOscLong: 10,
|
||||
dispUltra: 5, dispShort: 10, dispMid: 20, dispLong: 60,
|
||||
maLines: [5, 10, 20, 60, 120] as number[],
|
||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||
hlThresh: {
|
||||
RSI: { over: 70, mid: 50, under: 30 },
|
||||
STOCHASTIC: { over: 80, mid: 50, under: 20 },
|
||||
CCI: { over: 100, mid: 0, under: -100 },
|
||||
WILLIAMS_R: { over: -20, mid: -50, under: -80 },
|
||||
BWI: { over: 80, mid: 50, under: 20 },
|
||||
VR: { over: 200, mid: 100, under: 50 },
|
||||
PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
|
||||
NEW_PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
|
||||
INVEST_PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
|
||||
MACD: { mid: 0 },
|
||||
ADX: { over: 40, mid: 25, under: 20 },
|
||||
DMI: { over: 30, mid: 20, under: 10 },
|
||||
TRIX: { mid: 0 },
|
||||
VOLUME_OSC: { mid: 0 },
|
||||
} as Record<string, HLThresh>,
|
||||
};
|
||||
/**
|
||||
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
|
||||
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
|
||||
*/
|
||||
export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
||||
// 레지스트리 type명으로 조회
|
||||
const p = (registryType: string) =>
|
||||
activeIndicators.find(i => i.type === registryType)?.params ?? {};
|
||||
const num = (params: Record<string, number | string | boolean>, key: string, fallback: number) =>
|
||||
typeof params[key] === 'number' ? (params[key] as number) : fallback;
|
||||
|
||||
// ── 각 지표별 실제 레지스트리 type명 + params 키 ──────────────────────────
|
||||
// RSI → type:'RSI', params.length
|
||||
const rsi = p('RSI');
|
||||
// MACD → type:'MACD', params.fastLength / slowLength / signalLength
|
||||
const macd = p('MACD');
|
||||
// CCI → type:'CCI', params.length
|
||||
const cci = p('CCI');
|
||||
// Stochastic → type:'Stochastic', params.kLength / dSmoothing
|
||||
const stoch = p('Stochastic');
|
||||
// ADX → type:'ADX', params.adxSmoothing / diLength
|
||||
const adx = p('ADX');
|
||||
// TRIX → type:'TRIX', params.length / signalLength
|
||||
const trix = p('TRIX');
|
||||
// DMI → type:'DMI', params.diLength / adxSmoothing
|
||||
const dmi = p('DMI');
|
||||
// BollingerBands→ type:'BollingerBands', params.length / mult
|
||||
const bb = p('BollingerBands');
|
||||
// IchimokuCloud → type:'IchimokuCloud', params.conversionPeriods / basePeriods / laggingSpan2Periods
|
||||
const ich = p('IchimokuCloud');
|
||||
// Williams %R → type:'WilliamsPercentRange', params.length
|
||||
const wr = p('WilliamsPercentRange');
|
||||
const vo = p('VolumeOscillator');
|
||||
const vrInd = p('VR');
|
||||
const disp = p('Disparity');
|
||||
const nPsy = p('Psychological') ?? p('NewPsychological');
|
||||
const iPsy = p('InvestPsychological');
|
||||
const obvP = p('OBV');
|
||||
// SMA (MA lines)→ type:'SMA', params keys depend on smaConfig
|
||||
const sma = p('SMA');
|
||||
|
||||
// MA 기간 배열: SMA 설정에서 period1~period11 추출 (smaConfig 구조)
|
||||
let maLines = DEF_DEFAULTS.maLines;
|
||||
const smaPeriods = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
.map(i => sma[`period${i}`])
|
||||
.filter(v => typeof v === 'number' && (v as number) > 0) as number[];
|
||||
if (smaPeriods.length >= 2) maLines = smaPeriods;
|
||||
|
||||
// ── hline 임계값 추출 ───────────────────────────────────────────────────────
|
||||
// DSL 지표타입 → 레지스트리 type명 매핑
|
||||
const DSL_TO_REGISTRY: Record<string, string> = {
|
||||
RSI: 'RSI',
|
||||
STOCHASTIC: 'Stochastic',
|
||||
CCI: 'CCI',
|
||||
WILLIAMS_R: 'WilliamsPercentRange',
|
||||
MACD: 'MACD',
|
||||
DMI: 'DMI',
|
||||
ADX: 'ADX',
|
||||
TRIX: 'TRIX',
|
||||
VOLUME_OSC: 'VolumeOscillator',
|
||||
VR: 'VR',
|
||||
DISPARITY: 'Disparity',
|
||||
PSYCHOLOGICAL: 'Psychological',
|
||||
NEW_PSYCHOLOGICAL: 'Psychological',
|
||||
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
|
||||
OBV: 'OBV',
|
||||
BOLLINGER: 'BollingerBands',
|
||||
};
|
||||
|
||||
const extractThresh = (dslType: string): HLThresh => {
|
||||
const regType = DSL_TO_REGISTRY[dslType];
|
||||
if (!regType) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
|
||||
const config = activeIndicators.find(i => i.type === regType);
|
||||
const defHl = getIndicatorDef(regType)?.hlines ?? [];
|
||||
const hlines = config?.hlines ?? defHl;
|
||||
if (hlines.length === 0) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
|
||||
|
||||
const prices = hlines.map(h => h.price);
|
||||
const find = (lbl: string) => hlines.find(h =>
|
||||
(h.label ?? getHLineLabel(h.price, prices)) === lbl
|
||||
);
|
||||
const result: HLThresh = {};
|
||||
const ov = find('과열선'); if (ov) result.over = ov.price;
|
||||
const md = find('중앙선') ?? find('기준선'); if (md) result.mid = md.price;
|
||||
const un = find('침체선'); if (un) result.under = un.price;
|
||||
// 값이 하나도 없으면 기본값 사용
|
||||
if (result.over === undefined && result.mid === undefined && result.under === undefined)
|
||||
return DEF_DEFAULTS.hlThresh[dslType] ?? {};
|
||||
return {
|
||||
...DEF_DEFAULTS.hlThresh[dslType],
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const hlThresh: Record<string, HLThresh> = {};
|
||||
for (const dslType of Object.keys(DEF_DEFAULTS.hlThresh)) {
|
||||
hlThresh[dslType] = extractThresh(dslType);
|
||||
}
|
||||
|
||||
return {
|
||||
rsiPeriod: num(rsi, 'length', DEF_DEFAULTS.rsiPeriod),
|
||||
macdFast: num(macd, 'fastLength', DEF_DEFAULTS.macdFast),
|
||||
macdSlow: num(macd, 'slowLength', DEF_DEFAULTS.macdSlow),
|
||||
macdSignal: num(macd, 'signalLength', DEF_DEFAULTS.macdSignal),
|
||||
cciPeriod: num(cci, 'length', DEF_DEFAULTS.cciPeriod),
|
||||
stochK: num(stoch, 'kLength', DEF_DEFAULTS.stochK),
|
||||
stochD: num(stoch, 'dSmoothing', DEF_DEFAULTS.stochD),
|
||||
adxPeriod: num(adx, 'adxSmoothing', DEF_DEFAULTS.adxPeriod),
|
||||
trixPeriod: num(trix, 'length', DEF_DEFAULTS.trixPeriod),
|
||||
trixSignal: num(trix, 'signalLength', DEF_DEFAULTS.trixSignal),
|
||||
dmiPeriod: num(dmi, 'diLength', num(dmi, 'length', DEF_DEFAULTS.dmiPeriod)),
|
||||
obvPeriod: num(obvP, 'maLength', DEF_DEFAULTS.obvPeriod),
|
||||
obvSignal: num(obvP, 'maLength', DEF_DEFAULTS.obvSignal),
|
||||
bbPeriod: num(bb, 'length', DEF_DEFAULTS.bbPeriod),
|
||||
bbStdDev: num(bb, 'mult', DEF_DEFAULTS.bbStdDev),
|
||||
ichTenkan: num(ich, 'conversionPeriods', DEF_DEFAULTS.ichTenkan),
|
||||
ichKijun: num(ich, 'basePeriods', DEF_DEFAULTS.ichKijun),
|
||||
ichSenkouB: num(ich, 'laggingSpan2Periods', DEF_DEFAULTS.ichSenkouB),
|
||||
newPsy: num(nPsy, 'length', DEF_DEFAULTS.newPsy),
|
||||
investPsy: num(iPsy, 'length', DEF_DEFAULTS.investPsy),
|
||||
williamsR: num(wr, 'length', DEF_DEFAULTS.williamsR),
|
||||
bwiPeriod: DEF_DEFAULTS.bwiPeriod, // BWI는 레지스트리 미등록
|
||||
vrPeriod: num(vrInd, 'length', DEF_DEFAULTS.vrPeriod),
|
||||
volOscShort: num(vo, 'shortLength', DEF_DEFAULTS.volOscShort),
|
||||
volOscLong: num(vo, 'longLength', DEF_DEFAULTS.volOscLong),
|
||||
dispUltra: num(disp, 'ultraLength', DEF_DEFAULTS.dispUltra),
|
||||
dispShort: num(disp, 'shortLength', DEF_DEFAULTS.dispShort),
|
||||
dispMid: num(disp, 'midLength', DEF_DEFAULTS.dispMid),
|
||||
dispLong: num(disp, 'longLength', DEF_DEFAULTS.dispLong),
|
||||
maLines,
|
||||
hlThresh,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
|
||||
* 예: 70 → "K_70", -100 → "K_-100"
|
||||
* 백엔드 StrategyDslToTa4jAdapter에서 "K_" 접두어를 파싱하여 FixedDecimalIndicator 생성.
|
||||
*/
|
||||
const kv = (price: number) => `K_${price}`;
|
||||
|
||||
// ─── 공통 조건 옵션 ───────────────────────────────────────────────────────────
|
||||
const COND_OPTIONS = [
|
||||
{ v: 'GT', l: '초과 (>)' },
|
||||
{ v: 'LT', l: '미만 (<)' },
|
||||
{ v: 'GTE', l: '이상 (>=)' },
|
||||
{ v: 'LTE', l: '이하 (<=)' },
|
||||
{ v: 'EQ', l: '같음 (==)' },
|
||||
{ v: 'NEQ', l: '다름 (!=)' },
|
||||
{ v: 'CROSS_UP', l: '상향 돌파' },
|
||||
{ v: 'CROSS_DOWN', l: '하향 돌파' },
|
||||
{ v: 'SLOPE_UP', l: '상승 기울기' },
|
||||
{ v: 'SLOPE_DOWN', l: '하락 기울기' },
|
||||
{ v: 'DIFF_GT', l: '차이 > 값' },
|
||||
{ v: 'DIFF_LT', l: '차이 < 값' },
|
||||
{ v: 'HOLD_N_DAYS', l: 'N일 연속 유지' },
|
||||
];
|
||||
|
||||
const ICHIMOKU_CONDS = [
|
||||
...COND_OPTIONS,
|
||||
{ v: 'ABOVE_CLOUD', l: '구름 위' },
|
||||
{ v: 'BELOW_CLOUD', l: '구름 아래' },
|
||||
{ v: 'IN_CLOUD', l: '구름 안' },
|
||||
{ v: 'CLOUD_BREAK_UP', l: '구름 상향 돌파' },
|
||||
{ v: 'CLOUD_BREAK_DOWN', l: '구름 하향 돌파' },
|
||||
{ v: 'SPAN1_GT_SPAN2', l: '선행스팬1 > 선행스팬2' },
|
||||
{ v: 'SPAN1_LT_SPAN2', l: '선행스팬1 < 선행스팬2' },
|
||||
{ v: 'SPAN1_CROSS_UP_SPAN2', l: '선행스팬1 상향돌파 선행스팬2' },
|
||||
{ v: 'SPAN1_CROSS_DOWN_SPAN2', l: '선행스팬1 하향돌파 선행스팬2' },
|
||||
{ v: 'LAGGING_GT_PRICE', l: '후행스팬 > 가격' },
|
||||
{ v: 'LAGGING_LT_PRICE', l: '후행스팬 < 가격' },
|
||||
];
|
||||
|
||||
// ─── 지표별 fieldOptions 빌더 ────────────────────────────────────────────────
|
||||
type Opt = { value: string; label: string };
|
||||
const NONE: Opt = { value: 'NONE', label: '선택안함' };
|
||||
|
||||
export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => {
|
||||
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
|
||||
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
||||
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
|
||||
|
||||
/** 해당 지표의 hline 임계값 — 없으면 기본값 */
|
||||
const th = (defaults: HLThresh): Required<HLThresh> => {
|
||||
const saved = DEF.hlThresh[ind] ?? {};
|
||||
return {
|
||||
over: saved.over ?? defaults.over ?? 100,
|
||||
mid: saved.mid ?? defaults.mid ?? 0,
|
||||
under: saved.under ?? defaults.under ?? -100,
|
||||
};
|
||||
};
|
||||
|
||||
switch(ind) {
|
||||
case 'RSI': {
|
||||
const { over, mid, under } = th({ over:70, mid:50, under:30 });
|
||||
return condOpts([
|
||||
{ value:'RSI_VALUE', label:`RSI 값(${DEF.rsiPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'STOCHASTIC': {
|
||||
const { over, mid, under } = th({ over:80, mid:50, under:20 });
|
||||
return condOpts([
|
||||
{ value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` },
|
||||
{ value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'CCI': {
|
||||
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
|
||||
return condOpts([
|
||||
{ value:'CCI_VALUE', label:`CCI 값(${DEF.cciPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'ADX': {
|
||||
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
|
||||
return condOpts([
|
||||
{ value:'ADX_VALUE', label:`ADX 값(${DEF.adxPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'TRIX': {
|
||||
const { mid } = th({ mid:0 });
|
||||
return condOpts([
|
||||
{ value:'TRIX_VALUE', label:`TRIX 값(${DEF.trixPeriod}일)` },
|
||||
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
]);
|
||||
}
|
||||
case 'DMI': {
|
||||
const { over, mid, under } = th({ over:30, mid:20, under:10 });
|
||||
return condOpts([
|
||||
{ value:'PDI', label:`DI+(${DEF.dmiPeriod}일)` },
|
||||
{ value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` },
|
||||
{ value:kv(over), label:`과열(${over})` },
|
||||
{ value:kv(mid), label:`중간값(${mid})` },
|
||||
{ value:kv(under), label:`침체(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'OBV': return condOpts([
|
||||
{ value:'OBV_LINE', label:`OBV선(${DEF.obvPeriod}일)` },
|
||||
{ value:'OBV_SIGNAL', label:`신호선(${DEF.obvSignal}일)` },
|
||||
]);
|
||||
case 'VOLUME': return condOpts([
|
||||
{ value:'VOLUME_VALUE', label:'거래량' },
|
||||
{ value:'VOLUME_MA', label:'거래량 이동평균' },
|
||||
]);
|
||||
case 'MA': return [
|
||||
NONE,
|
||||
...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })),
|
||||
{ value:'CLOSE_PRICE', label:'종가' },
|
||||
];
|
||||
case 'EMA': return [
|
||||
NONE,
|
||||
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
|
||||
{ value:'CLOSE_PRICE', label:'종가' },
|
||||
];
|
||||
case 'DISPARITY': return condOpts([
|
||||
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
|
||||
{ value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
|
||||
{ value:`DISPARITY${DEF.dispMid}`, label:`중기 이격도(${DEF.dispMid}일)` },
|
||||
{ value:`DISPARITY${DEF.dispLong}`, label:`장기 이격도(${DEF.dispLong}일)` },
|
||||
{ value:kv(100), label:'중앙선(100)' },
|
||||
]);
|
||||
case 'PSYCHOLOGICAL':
|
||||
case 'NEW_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'PSY_VALUE', label:`심리도 값(${DEF.newPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'INVEST_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'INVEST_PSY_VALUE', label:`투자심리도 값(${DEF.investPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'WILLIAMS_R': {
|
||||
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
|
||||
return condOpts([
|
||||
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 값(${DEF.williamsR}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'BWI': {
|
||||
const { over, mid, under } = th({ over:80, mid:50, under:20 });
|
||||
return condOpts([
|
||||
{ value:'BWI_VALUE', label:`BWI 값(${DEF.bwiPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'VR': {
|
||||
const { over, mid, under } = th({ over:200, mid:100, under:50 });
|
||||
return condOpts([
|
||||
{ value:'VR_VALUE', label:`VR 값(${DEF.vrPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'VOLUME_OSC': {
|
||||
const { mid } = th({ mid:0 });
|
||||
return condOpts([
|
||||
{ value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터 값(${DEF.volOscShort}일/${DEF.volOscLong}일)` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
]);
|
||||
}
|
||||
case 'MACD': {
|
||||
const { mid } = th({ mid:0 });
|
||||
return condOpts([
|
||||
{ value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` },
|
||||
{ value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` },
|
||||
{ value:'HISTOGRAM', label:'히스토그램' },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
]);
|
||||
}
|
||||
case 'BOLLINGER': return condOpts([
|
||||
{ value:'UPPER_BAND', label:`상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
||||
{ value:'MIDDLE_BAND', label:`중심선(${DEF.bbPeriod}일)` },
|
||||
{ value:'LOWER_BAND', label:`하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
|
||||
{ value:'CLOSE_PRICE', label:'종가' },
|
||||
{ value:'OPEN_PRICE', label:'시가' },
|
||||
{ value:'HIGH_PRICE', label:'고가' },
|
||||
{ value:'LOW_PRICE', label:'저가' },
|
||||
]);
|
||||
case 'ICHIMOKU': return condOpts([
|
||||
{ value:'CONVERSION_LINE', label:`전환선(${DEF.ichTenkan}일)` },
|
||||
{ value:'BASE_LINE', label:`기준선(${DEF.ichKijun}일)` },
|
||||
{ value:'LEADING_SPAN1', label:'선행스팬1' },
|
||||
{ value:'LEADING_SPAN2', label:`선행스팬2(${DEF.ichSenkouB}일)` },
|
||||
{ value:'LAGGING_SPAN', label:'후행스팬' },
|
||||
{ value:'CLOSE_PRICE', label:'종가' },
|
||||
{ value:'OPEN_PRICE', label:'시가' },
|
||||
{ value:'HIGH_PRICE', label:'고가' },
|
||||
{ value:'LOW_PRICE', label:'저가' },
|
||||
]);
|
||||
default: return [NONE];
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const adxMid = DEF.hlThresh['ADX']?.mid ?? 25;
|
||||
|
||||
const map: Record<string, {l:string;r:string}> = {
|
||||
RSI: { l:'RSI_VALUE', r: over(70) },
|
||||
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
|
||||
CCI: { l:'CCI_VALUE', r: over(100) },
|
||||
ADX: { l:'ADX_VALUE', r: kv(adxMid) },
|
||||
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
|
||||
DMI: { l:'PDI', r:'MDI' },
|
||||
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
||||
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
||||
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
|
||||
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
|
||||
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) },
|
||||
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
|
||||
NEW_PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
|
||||
INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: over(75) },
|
||||
WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: over(-20) },
|
||||
BWI: { l:'BWI_VALUE', r: over(80) },
|
||||
VR: { l:'VR_VALUE', r: over(200) },
|
||||
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: mid(0) },
|
||||
MACD: { l:'MACD_LINE', r:'SIGNAL_LINE' },
|
||||
BOLLINGER: { l:'CLOSE_PRICE', r:'UPPER_BAND' },
|
||||
ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' },
|
||||
};
|
||||
return map[ind] ?? { l:'NONE', r:'NONE' };
|
||||
};
|
||||
|
||||
// conditionType 변경 시 자동 필드 조정
|
||||
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
||||
const updated = { ...cond, conditionType: newCondType };
|
||||
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||
const def = getDefaultFields(cond.indicatorType, 'buy', DEF);
|
||||
|
||||
if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) {
|
||||
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
||||
if (!isValid(updated.rightField)) updated.rightField = def.r;
|
||||
if (['DIFF_GT','DIFF_LT'].includes(newCondType)) updated.compareValue = updated.compareValue ?? 0;
|
||||
} else if (['SLOPE_UP','SLOPE_DOWN'].includes(newCondType)) {
|
||||
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
||||
updated.rightField = 'NONE';
|
||||
updated.slopePeriod = updated.slopePeriod ?? 3;
|
||||
} else if (newCondType === 'HOLD_N_DAYS') {
|
||||
updated.leftField = 'NONE'; updated.rightField = 'NONE';
|
||||
updated.holdDays = updated.holdDays ?? 3;
|
||||
}
|
||||
|
||||
// Ichimoku 특수처리
|
||||
if (cond.indicatorType === 'ICHIMOKU') {
|
||||
if (['ABOVE_CLOUD','BELOW_CLOUD','IN_CLOUD','CLOUD_BREAK_UP','CLOUD_BREAK_DOWN'].includes(newCondType)) {
|
||||
updated.leftField = 'NONE'; updated.rightField = 'NONE';
|
||||
} else if (['SPAN1_GT_SPAN2','SPAN1_LT_SPAN2','SPAN1_CROSS_UP_SPAN2','SPAN1_CROSS_DOWN_SPAN2'].includes(newCondType)) {
|
||||
updated.leftField = 'LEADING_SPAN1'; updated.rightField = 'LEADING_SPAN2';
|
||||
} else if (['LAGGING_GT_PRICE','LAGGING_LT_PRICE'].includes(newCondType)) {
|
||||
updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE';
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
// ─── 자연어 변환 ──────────────────────────────────────────────────────────────
|
||||
const condLabel = (v: string) => COND_OPTIONS.concat(ICHIMOKU_CONDS as any).find(o => o.v === v)?.l ?? v;
|
||||
|
||||
export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
|
||||
if (!node) return '';
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = node.condition;
|
||||
const opts = getFieldOpts(c.indicatorType, 'buy', DEF);
|
||||
const L = opts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType;
|
||||
const R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
|
||||
const C = condLabel(c.conditionType);
|
||||
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
|
||||
return `${c.indicatorType} - ${L} ${C}`;
|
||||
}
|
||||
if (node.type === 'AND') {
|
||||
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
||||
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
|
||||
}
|
||||
if (node.type === 'OR') {
|
||||
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
||||
return parts.length === 0 ? '(OR 그룹)' : parts.join('\nOR ');
|
||||
}
|
||||
if (node.type === 'NOT') {
|
||||
const c = node.children?.[0];
|
||||
return c ? `NOT (${nodeToText(c, DEF)})` : '(NOT 그룹)';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const nodeToFormula = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
|
||||
if (!node) return '';
|
||||
if (node.type === 'CONDITION') return `(${nodeToText(node, DEF)})`;
|
||||
if (node.type === 'AND') {
|
||||
const parts = (node.children ?? []).map(c => nodeToFormula(c, DEF));
|
||||
return parts.length === 0 ? '(빈 AND)' : `(${parts.join(' AND ')})`;
|
||||
}
|
||||
if (node.type === 'OR') {
|
||||
const parts = (node.children ?? []).map(c => nodeToFormula(c, DEF));
|
||||
return parts.length === 0 ? '(빈 OR)' : `(${parts.join(' OR ')})`;
|
||||
}
|
||||
if (node.type === 'NOT') {
|
||||
const c = node.children?.[0];
|
||||
return c ? `NOT ${nodeToFormula(c, DEF)}` : '(빈 NOT)';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// ─── 트리 조작 ────────────────────────────────────────────────────────────────
|
||||
export const updateNode = (root: LogicNode, id: string, fn: (n: LogicNode) => LogicNode): LogicNode => {
|
||||
if (root.id === id) return fn(root);
|
||||
if (!root.children) return root;
|
||||
return { ...root, children: root.children.map(c => updateNode(c, id, fn)) };
|
||||
};
|
||||
|
||||
export const deleteNode = (root: LogicNode, id: string): LogicNode | null => {
|
||||
if (root.id === id) return null;
|
||||
if (!root.children) return root;
|
||||
const children = root.children.map(c => deleteNode(c, id)).filter(Boolean) as LogicNode[];
|
||||
return { ...root, children };
|
||||
};
|
||||
|
||||
export const addChild = (root: LogicNode, parentId: string, child: LogicNode): LogicNode => {
|
||||
if (root.id === parentId) {
|
||||
if (root.type === 'NOT' && (root.children?.length ?? 0) >= 1) { alert('NOT는 자식 1개만 가능합니다.'); return root; }
|
||||
return { ...root, children: [...(root.children ?? []), child] };
|
||||
}
|
||||
if (!root.children) return root;
|
||||
return { ...root, children: root.children.map(c => addChild(c, parentId, child)) };
|
||||
};
|
||||
|
||||
/** 팔레트·터치로 루트에 추가할 때 — OR/AND 그룹이면 자식으로 붙이고, 단일 조건만 AND로 묶음 */
|
||||
export const mergeAtRoot = (root: LogicNode | null, newNode: LogicNode, isOperator: boolean): LogicNode => {
|
||||
if (!root) return newNode;
|
||||
if (isOperator) return { ...newNode, children: [root] };
|
||||
if (root.type === 'AND' || root.type === 'OR') {
|
||||
return { ...root, children: [...(root.children ?? []), newNode] };
|
||||
}
|
||||
return { id: genId(), type: 'AND', children: [root, newNode] };
|
||||
};
|
||||
|
||||
// ─── 검증 ─────────────────────────────────────────────────────────────────────
|
||||
export const validateTree = (node: LogicNode): ValidationResult => {
|
||||
const errors: { message: string }[] = [];
|
||||
const warnings: { message: string }[] = [];
|
||||
const check = (n: LogicNode) => {
|
||||
if (n.type === 'AND' || n.type === 'OR') {
|
||||
if (!n.children || n.children.length === 0) errors.push({ message: `${n.type} 노드에 자식 조건이 없습니다.` });
|
||||
else if (n.children.length === 1) warnings.push({ message: `${n.type} 노드에 자식이 1개뿐입니다.` });
|
||||
n.children?.forEach(check);
|
||||
} else if (n.type === 'NOT') {
|
||||
if (!n.children || n.children.length === 0) errors.push({ message: 'NOT 노드에 자식 조건이 없습니다.' });
|
||||
else if (n.children.length > 1) errors.push({ message: 'NOT 노드는 자식이 1개여야 합니다.' });
|
||||
n.children?.forEach(check);
|
||||
} else if (n.type === 'CONDITION') {
|
||||
if (!n.condition) errors.push({ message: '조건 내용이 비어있습니다.' });
|
||||
}
|
||||
};
|
||||
check(node);
|
||||
return { isValid: errors.length === 0, errors, warnings };
|
||||
};
|
||||
|
||||
// ─── 조건 편집 UI (인라인 4컬럼) ─────────────────────────────────────────────
|
||||
|
||||
interface CondEditorProps {
|
||||
cond: ConditionDSL;
|
||||
signalType: 'buy'|'sell';
|
||||
onChange: (c: ConditionDSL) => void;
|
||||
def: DefType;
|
||||
}
|
||||
|
||||
export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
|
||||
const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def);
|
||||
const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
||||
|
||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||
const defaults = getDefaultFields(cond.indicatorType, signalType, def);
|
||||
|
||||
const getLeftValue = () => isValid(cond.leftField) ? cond.leftField! : defaults.l;
|
||||
const getRightValue = () => isValid(cond.rightField) ? cond.rightField! : defaults.r;
|
||||
|
||||
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(cond.conditionType);
|
||||
const isHoldType = cond.conditionType === 'HOLD_N_DAYS';
|
||||
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(cond.conditionType);
|
||||
|
||||
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
|
||||
|
||||
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(cond, newType, def));
|
||||
const handleRight = (v: string) => {
|
||||
// rightField 변경 시 임계값 자동 설정
|
||||
const thresholds: Record<string,number> = {
|
||||
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
|
||||
OVERBOUGHT_80: 80, OVERSOLD_20: 20,
|
||||
OVERBOUGHT_100: 100, NEUTRAL_0: 0, OVERSOLD_NEG100: -100,
|
||||
ADX_25: 25, ADX_50: 50,
|
||||
OVERBOUGHT_75: 75, OVERSOLD_25: 25,
|
||||
OVERBOUGHT_NEG20: -20, NEUTRAL_NEG50: -50, OVERSOLD_NEG80: -80,
|
||||
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
|
||||
ZERO_LINE: 0,
|
||||
};
|
||||
const upd: ConditionDSL = { ...cond, rightField: v };
|
||||
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
|
||||
onChange(upd);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sp-cond-editor">
|
||||
{/* 4컬럼 row */}
|
||||
<div className="sp-cond-row4">
|
||||
{/* 캔들 범위 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">캔들 범위</label>
|
||||
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
|
||||
onChange={e => onChange({ ...cond, candleRange: Number(e.target.value) })}>
|
||||
<option value={1}>현재 캔들</option>
|
||||
<option value={2}>2개 캔들</option>
|
||||
<option value={3}>3개 캔들</option>
|
||||
<option value={4}>4개 캔들</option>
|
||||
<option value={5}>5개 캔들</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* 조건대상1 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">조건대상1</label>
|
||||
<select className="sp-cond-sel"
|
||||
value={isHoldType ? 'NONE' : getLeftValue()}
|
||||
onChange={e => onChange({ ...cond, leftField: e.target.value })}>
|
||||
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{/* 조건대상2 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">조건대상2</label>
|
||||
<select className="sp-cond-sel"
|
||||
value={rightValue}
|
||||
onChange={e => handleRight(e.target.value)}>
|
||||
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{/* 조건 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">조건</label>
|
||||
<select className="sp-cond-sel"
|
||||
value={cond.conditionType}
|
||||
onChange={e => handleCondType(e.target.value)}>
|
||||
{condOpts.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 추가 필드 */}
|
||||
{isDiffType && (
|
||||
<div className="sp-cond-extra">
|
||||
<label className="sp-cond-lbl">비교값</label>
|
||||
<input type="number" className="sp-cond-num"
|
||||
value={cond.compareValue ?? ''} placeholder="예: 0.5"
|
||||
onChange={e => onChange({ ...cond, compareValue: parseFloat(e.target.value) || 0 })} />
|
||||
</div>
|
||||
)}
|
||||
{isSlopeType && (
|
||||
<div className="sp-cond-extra">
|
||||
<label className="sp-cond-lbl">기울기 기간 (일)</label>
|
||||
<input type="number" className="sp-cond-num" min={1} max={100}
|
||||
value={cond.slopePeriod ?? ''} placeholder="예: 5"
|
||||
onChange={e => onChange({ ...cond, slopePeriod: parseInt(e.target.value) || 3 })} />
|
||||
</div>
|
||||
)}
|
||||
{isHoldType && (
|
||||
<div className="sp-cond-extra">
|
||||
<label className="sp-cond-lbl">유지 일수 (일)</label>
|
||||
<input type="number" className="sp-cond-num" min={1} max={30}
|
||||
value={cond.holdDays ?? ''} placeholder="예: 3"
|
||||
onChange={e => onChange({ ...cond, holdDays: parseInt(e.target.value) || 3 })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
|
||||
export const makeNode = (type: string, value: string, signalType: 'buy'|'sell', DEF: DefType): LogicNode => {
|
||||
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
|
||||
const def = getDefaultFields(value, signalType, DEF);
|
||||
return {
|
||||
id: genId(), type: 'CONDITION',
|
||||
condition: {
|
||||
indicatorType: value, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||
leftField: def.l, rightField: def.r, candleRange: 1,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,666 @@
|
||||
import type { Connection, Edge, Node } from '@xyflow/react';
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import { nodeToText, type DefType } from './strategyEditorShared';
|
||||
|
||||
export const START_NODE_ID = '__strategy_start__';
|
||||
|
||||
const H_GAP = 280;
|
||||
const V_GAP = 130;
|
||||
|
||||
export type HandleSide = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
const OPPOSE_SIDE: Record<HandleSide, HandleSide> = {
|
||||
top: 'bottom',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
right: 'left',
|
||||
};
|
||||
|
||||
export type StrategyFlowNodeData = {
|
||||
logicNode?: LogicNode;
|
||||
label?: string;
|
||||
def?: DefType;
|
||||
signalTab?: 'buy' | 'sell';
|
||||
selected?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
||||
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
|
||||
onDragLeaveTarget?: (targetId: string) => void;
|
||||
dragOver?: boolean;
|
||||
activeSourceSide?: HandleSide | null;
|
||||
/** 논리 트리상 자식을 가질 수 있으면 true (START·AND·OR·빈 NOT) */
|
||||
canSourceConnect?: boolean;
|
||||
/** 논리 트리상 부모를 가질 수 있으면 true (조건·연산자, START 제외) */
|
||||
canTargetConnect?: boolean;
|
||||
/** 전략 트리 미연결 — 편집기에만 표시, 수식·저장 제외 */
|
||||
isOrphan?: boolean;
|
||||
};
|
||||
|
||||
export function isOrphanNode(orphans: LogicNode[], id: string): boolean {
|
||||
return orphans.some(o => o.id === id);
|
||||
}
|
||||
|
||||
export function findLogicNode(
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
id: string,
|
||||
): LogicNode | null {
|
||||
if (id === START_NODE_ID) return null;
|
||||
const inTree = findNodeInTree(root, id);
|
||||
if (inTree) return inTree;
|
||||
return orphans.find(o => o.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** 팔레트 드롭 시 트리에 즉시 연결되는 대상(START·트리 내 노드) */
|
||||
export function isPaletteConnectTarget(
|
||||
anchorId: string,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
): boolean {
|
||||
if (isOrphanNode(orphans, anchorId)) return false;
|
||||
if (anchorId === START_NODE_ID) return true;
|
||||
if (!root) return false;
|
||||
return !!findNodeInTree(root, anchorId);
|
||||
}
|
||||
|
||||
/** 노드별 연결 가능 역할 (DSL 트리 규칙) */
|
||||
export function getNodeConnectionCaps(
|
||||
nodeId: string,
|
||||
logicNode: LogicNode | undefined,
|
||||
): { canSource: boolean; canTarget: boolean } {
|
||||
if (nodeId === START_NODE_ID) return { canSource: true, canTarget: false };
|
||||
if (!logicNode) return { canSource: false, canTarget: false };
|
||||
switch (logicNode.type) {
|
||||
case 'CONDITION':
|
||||
return { canSource: false, canTarget: true };
|
||||
case 'NOT':
|
||||
return { canSource: (logicNode.children?.length ?? 0) < 1, canTarget: true };
|
||||
case 'AND':
|
||||
case 'OR':
|
||||
return { canSource: true, canTarget: true };
|
||||
default:
|
||||
return { canSource: false, canTarget: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** 팔레트 드롭 시 부모(출발) 노드로 쓸 수 있는지 */
|
||||
export function canAcceptPaletteAsParent(anchorId: string, root: LogicNode | null): boolean {
|
||||
if (anchorId === START_NODE_ID) return true;
|
||||
if (!root) return false;
|
||||
const node = findNodeInTree(root, anchorId);
|
||||
if (!node || node.type === 'CONDITION') return false;
|
||||
return getNodeConnectionCaps(anchorId, node).canSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* DSL 부모→자식 방향으로 연결 가능한지 (source=부모, target=자식).
|
||||
* React Flow Loose 모드에서는 conn.source/target이 드래그 방향에 따라 뒤집힐 수 있음.
|
||||
*/
|
||||
export function isValidStrategyConnectionDirected(
|
||||
parentId: string,
|
||||
childId: string,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[] = [],
|
||||
): boolean {
|
||||
if (!parentId || !childId || parentId === childId) return false;
|
||||
if (childId === START_NODE_ID) return false;
|
||||
|
||||
const childOrphan = orphans.find(o => o.id === childId);
|
||||
const parentOrphan = orphans.find(o => o.id === parentId);
|
||||
|
||||
if (childOrphan) {
|
||||
if (parentId === START_NODE_ID) return true;
|
||||
const parentNode = findNodeInTree(root, parentId);
|
||||
if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parentOrphan) {
|
||||
if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false;
|
||||
const childNode = findNodeInTree(root, childId);
|
||||
if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false;
|
||||
if (root && isDescendant(root, childId, parentId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!root) return false;
|
||||
|
||||
const parentNode = parentId === START_NODE_ID ? null : findNodeInTree(root, parentId);
|
||||
const childNode = findNodeInTree(root, childId);
|
||||
if (parentId !== START_NODE_ID && !parentNode) return false;
|
||||
if (!childNode) return false;
|
||||
|
||||
const parentCaps = getNodeConnectionCaps(parentId, parentNode ?? undefined);
|
||||
const childCaps = getNodeConnectionCaps(childId, childNode);
|
||||
if (!parentCaps.canSource || !childCaps.canTarget) return false;
|
||||
|
||||
if (parentId !== START_NODE_ID && isDescendant(root, childId, parentId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 수동 연결·재연결 — 드래그 방향과 무관하게 부모→자식이 성립하면 유효 */
|
||||
export function isValidStrategyConnection(
|
||||
conn: Connection,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[] = [],
|
||||
): boolean {
|
||||
const { source, target } = conn;
|
||||
if (!source || !target) return false;
|
||||
return (
|
||||
isValidStrategyConnectionDirected(source, target, root, orphans)
|
||||
|| isValidStrategyConnectionDirected(target, source, root, orphans)
|
||||
);
|
||||
}
|
||||
|
||||
/** onConnect용 — 부모·자식 ID (드래그 시작 방향 우선, 불가 시 반대 방향) */
|
||||
export function resolveStrategyConnection(
|
||||
conn: Connection,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[] = [],
|
||||
): { parentId: string; childId: string } | null {
|
||||
const { source, target } = conn;
|
||||
if (!source || !target) return null;
|
||||
if (isValidStrategyConnectionDirected(source, target, root, orphans)) {
|
||||
return { parentId: source, childId: target };
|
||||
}
|
||||
if (isValidStrategyConnectionDirected(target, source, root, orphans)) {
|
||||
return { parentId: target, childId: source };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNodeDimensions(nodeId: string): { w: number; h: number } {
|
||||
return nodeId === START_NODE_ID
|
||||
? { w: FLOW_START_W, h: FLOW_START_H }
|
||||
: { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
||||
}
|
||||
|
||||
/** 커서/드롭 위치가 노드 중심 기준 어느 방향인지 */
|
||||
export function pickSideFromPoint(
|
||||
flowPos: { x: number; y: number },
|
||||
nodePos: { x: number; y: number },
|
||||
dim: { w: number; h: number },
|
||||
): HandleSide {
|
||||
const cx = nodePos.x + dim.w / 2;
|
||||
const cy = nodePos.y + dim.h / 2;
|
||||
const dx = flowPos.x - cx;
|
||||
const dy = flowPos.y - cy;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left';
|
||||
return dy >= 0 ? 'bottom' : 'top';
|
||||
}
|
||||
|
||||
/** 드롭이 앵커의 좌/우/상/하 쪽이면 해당 면 연결점 활성화 */
|
||||
export function connectionSidesFromDrop(
|
||||
anchorPos: { x: number; y: number },
|
||||
anchorDim: { w: number; h: number },
|
||||
dropPos: { x: number; y: number },
|
||||
): { sourceSide: HandleSide; targetSide: HandleSide; sourceHandle: string; targetHandle: string } {
|
||||
const sourceSide = pickSideFromPoint(dropPos, anchorPos, anchorDim);
|
||||
const targetSide = OPPOSE_SIDE[sourceSide];
|
||||
return {
|
||||
sourceSide,
|
||||
targetSide,
|
||||
sourceHandle: `s-${sourceSide}`,
|
||||
targetHandle: `t-${targetSide}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnPositionNearAnchor(
|
||||
anchorPos: { x: number; y: number },
|
||||
anchorDim: { w: number; h: number },
|
||||
side: HandleSide,
|
||||
): { x: number; y: number } {
|
||||
const gap = 48;
|
||||
const child = { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
||||
switch (side) {
|
||||
case 'right':
|
||||
return { x: anchorPos.x + anchorDim.w + gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 };
|
||||
case 'left':
|
||||
return { x: anchorPos.x - child.w - gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 };
|
||||
case 'bottom':
|
||||
return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y + anchorDim.h + gap };
|
||||
case 'top':
|
||||
return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y - child.h - gap };
|
||||
}
|
||||
}
|
||||
|
||||
export function findNodeAtFlowPosition(
|
||||
flowPos: { x: number; y: number },
|
||||
nodes: { id: string; position: { x: number; y: number } }[],
|
||||
padding = 16,
|
||||
): { id: string; position: { x: number; y: number } } | null {
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i];
|
||||
const dim = getNodeDimensions(n.id);
|
||||
if (
|
||||
flowPos.x >= n.position.x - padding
|
||||
&& flowPos.x <= n.position.x + dim.w + padding
|
||||
&& flowPos.y >= n.position.y - padding
|
||||
&& flowPos.y <= n.position.y + dim.h + padding
|
||||
) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 미연결 고아 노드를 드래그해 다른 노드 위에 올렸을 때 연결 방향 해석 */
|
||||
export function resolveOrphanDragConnection(
|
||||
orphanId: string,
|
||||
orphanPos: { x: number; y: number },
|
||||
nodes: { id: string; position: { x: number; y: number } }[],
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
): { parentId: string; childId: string } | null {
|
||||
const dim = getNodeDimensions(orphanId);
|
||||
const center = {
|
||||
x: orphanPos.x + dim.w / 2,
|
||||
y: orphanPos.y + dim.h / 2,
|
||||
};
|
||||
const hit = findNodeAtFlowPosition(
|
||||
center,
|
||||
nodes.filter(n => n.id !== orphanId),
|
||||
);
|
||||
if (!hit) return null;
|
||||
if (isOrphanNode(orphans, hit.id)) return null;
|
||||
|
||||
return resolveStrategyConnection(
|
||||
{ source: hit.id, target: orphanId, sourceHandle: null, targetHandle: null },
|
||||
root,
|
||||
orphans,
|
||||
) ?? resolveStrategyConnection(
|
||||
{ source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null },
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
}
|
||||
|
||||
/** 드래그 연결 시 부모→자식 핸들 방향 */
|
||||
export function buildConnectionFromPositions(
|
||||
parentId: string,
|
||||
childId: string,
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
): Connection {
|
||||
const parentPos = positions.get(parentId) ?? { x: 0, y: 0 };
|
||||
const childPos = positions.get(childId) ?? { x: 0, y: 0 };
|
||||
const parentDim = getNodeDimensions(parentId);
|
||||
const childDim = getNodeDimensions(childId);
|
||||
const childCenter = {
|
||||
x: childPos.x + childDim.w / 2,
|
||||
y: childPos.y + childDim.h / 2,
|
||||
};
|
||||
const { sourceHandle, targetHandle } = connectionSidesFromDrop(parentPos, parentDim, childCenter);
|
||||
return {
|
||||
source: parentId,
|
||||
target: childId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
};
|
||||
}
|
||||
|
||||
export type EdgeHandleBinding = {
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
manual?: boolean;
|
||||
};
|
||||
|
||||
export const FLOW_NODE_W = 200;
|
||||
export const FLOW_NODE_H = 72;
|
||||
export const FLOW_START_W = 100;
|
||||
export const FLOW_START_H = 56;
|
||||
|
||||
function nodeCenter(
|
||||
id: string,
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
): { x: number; y: number } {
|
||||
const p = positions.get(id) ?? { x: 0, y: 0 };
|
||||
const w = id === START_NODE_ID ? FLOW_START_W : FLOW_NODE_W;
|
||||
const h = id === START_NODE_ID ? FLOW_START_H : FLOW_NODE_H;
|
||||
return { x: p.x + w / 2, y: p.y + h / 2 };
|
||||
}
|
||||
|
||||
/** 두 노드 상대 위치에 따라 최적 연결 방향(핸들) 선택 */
|
||||
export function pickHandleSides(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
): { sourceHandle: string; targetHandle: string } {
|
||||
const s = nodeCenter(sourceId, positions);
|
||||
const t = nodeCenter(targetId, positions);
|
||||
const dx = t.x - s.x;
|
||||
const dy = t.y - s.y;
|
||||
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
return dx >= 0
|
||||
? { sourceHandle: 's-right', targetHandle: 't-left' }
|
||||
: { sourceHandle: 's-left', targetHandle: 't-right' };
|
||||
}
|
||||
return dy >= 0
|
||||
? { sourceHandle: 's-bottom', targetHandle: 't-top' }
|
||||
: { sourceHandle: 's-top', targetHandle: 't-bottom' };
|
||||
}
|
||||
|
||||
export function applyEdgeHandles(
|
||||
edges: Edge[],
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
saved: Map<string, EdgeHandleBinding>,
|
||||
): Edge[] {
|
||||
return edges.map(edge => {
|
||||
const binding = saved.get(edge.id);
|
||||
if (binding?.manual && binding.sourceHandle && binding.targetHandle) {
|
||||
return { ...edge, sourceHandle: binding.sourceHandle, targetHandle: binding.targetHandle };
|
||||
}
|
||||
const picked = pickHandleSides(edge.source, edge.target, positions);
|
||||
const sourceHandle = binding?.sourceHandle ?? picked.sourceHandle;
|
||||
const targetHandle = binding?.targetHandle ?? picked.targetHandle;
|
||||
saved.set(edge.id, { sourceHandle, targetHandle, manual: binding?.manual });
|
||||
return { ...edge, sourceHandle, targetHandle };
|
||||
});
|
||||
}
|
||||
|
||||
function layoutTree(
|
||||
node: LogicNode,
|
||||
parentId: string,
|
||||
depth: number,
|
||||
siblingIndex: number,
|
||||
siblingCount: number,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
dragOverId: string | null,
|
||||
): void {
|
||||
const yBase = 220;
|
||||
const y = yBase + (siblingIndex - (siblingCount - 1) / 2) * V_GAP;
|
||||
const pos = positions.get(node.id) ?? { x: 80 + depth * H_GAP, y };
|
||||
const flowType = node.type === 'CONDITION' ? 'condition' : 'logic';
|
||||
const caps = getNodeConnectionCaps(node.id, node);
|
||||
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: flowType,
|
||||
position: pos,
|
||||
data: {
|
||||
logicNode: node,
|
||||
label: nodeToText(node, def),
|
||||
def,
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
dragOver: dragOverId === node.id,
|
||||
canSourceConnect: caps.canSource,
|
||||
canTargetConnect: caps.canTarget,
|
||||
} satisfies StrategyFlowNodeData,
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
edges.push({
|
||||
id: `${parentId}-${node.id}`,
|
||||
source: parentId,
|
||||
target: node.id,
|
||||
type: 'strategy',
|
||||
animated: node.type === 'CONDITION',
|
||||
className: 'se-flow-edge',
|
||||
});
|
||||
|
||||
const children = node.children ?? [];
|
||||
children.forEach((child, i) => {
|
||||
layoutTree(child, node.id, depth + 1, i, children.length, nodes, edges, positions, def, signalTab, callbacks, dragOverId);
|
||||
});
|
||||
}
|
||||
|
||||
function appendOrphanFlowNodes(
|
||||
orphans: LogicNode[],
|
||||
nodes: Node[],
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
): void {
|
||||
let orphanY = 80;
|
||||
for (const node of orphans) {
|
||||
const pos = positions.get(node.id) ?? { x: 40, y: orphanY };
|
||||
orphanY += V_GAP;
|
||||
const flowType = node.type === 'CONDITION' ? 'condition' : 'logic';
|
||||
const caps = getNodeConnectionCaps(node.id, node);
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: flowType,
|
||||
position: pos,
|
||||
className: 'se-flow-node-orphan',
|
||||
data: {
|
||||
logicNode: node,
|
||||
label: nodeToText(node, def),
|
||||
def,
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
canSourceConnect: caps.canSource,
|
||||
canTargetConnect: caps.canTarget,
|
||||
isOrphan: true,
|
||||
} satisfies StrategyFlowNodeData,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function logicNodeToFlow(
|
||||
root: LogicNode | null,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
dragOverId: string | null = null,
|
||||
orphans: LogicNode[] = [],
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
nodes.push({
|
||||
id: START_NODE_ID,
|
||||
type: 'start',
|
||||
position: positions.get(START_NODE_ID) ?? { x: 0, y: 220 },
|
||||
data: {
|
||||
label: 'START',
|
||||
signalTab,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
canSourceConnect: true,
|
||||
canTargetConnect: false,
|
||||
} satisfies StrategyFlowNodeData,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
});
|
||||
|
||||
if (root) {
|
||||
layoutTree(root, START_NODE_ID, 1, 0, 1, nodes, edges, positions, def, signalTab, callbacks, dragOverId);
|
||||
}
|
||||
|
||||
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export function getIndicatorPeriodLabel(indicator: string, def: DefType): string {
|
||||
switch (indicator) {
|
||||
case 'RSI': return String(def.rsiPeriod);
|
||||
case 'MACD': return `${def.macdFast}/${def.macdSlow}`;
|
||||
case 'CCI': return String(def.cciPeriod);
|
||||
case 'STOCHASTIC': return `${def.stochK}/${def.stochD}`;
|
||||
case 'ADX': return String(def.adxPeriod);
|
||||
case 'MA': return String(def.maLines[0] ?? 5);
|
||||
case 'EMA': return String(def.maLines[0] ?? 5);
|
||||
case 'BOLLINGER': return String(def.bbPeriod);
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 트리에서 노드 추출 (부모에서 제거 후 반환) */
|
||||
export function extractNode(root: LogicNode, id: string): { tree: LogicNode | null; node: LogicNode | null } {
|
||||
if (root.id === id) return { tree: null, node: root };
|
||||
if (!root.children?.length) return { tree: root, node: null };
|
||||
|
||||
for (let i = 0; i < root.children.length; i++) {
|
||||
const child = root.children[i];
|
||||
if (child.id === id) {
|
||||
return {
|
||||
tree: { ...root, children: root.children.filter(c => c.id !== id) },
|
||||
node: child,
|
||||
};
|
||||
}
|
||||
const sub = extractNode(child, id);
|
||||
if (sub.node) {
|
||||
const newChildren = root.children.map(c => (c.id === child.id ? sub.tree! : c)).filter(Boolean) as LogicNode[];
|
||||
return { tree: { ...root, children: newChildren }, node: sub.node };
|
||||
}
|
||||
}
|
||||
return { tree: root, node: null };
|
||||
}
|
||||
|
||||
export function findNodeInTree(root: LogicNode | null, id: string): LogicNode | null {
|
||||
if (!root) return null;
|
||||
if (root.id === id) return root;
|
||||
for (const c of root.children ?? []) {
|
||||
const hit = findNodeInTree(c, id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 트리에서 nodeId의 직계 부모 ID (루트의 부모는 START) */
|
||||
export function findParentIdInTree(
|
||||
root: LogicNode,
|
||||
nodeId: string,
|
||||
parentId: string = START_NODE_ID,
|
||||
): string | null {
|
||||
if (root.id === nodeId) return parentId;
|
||||
for (const c of root.children ?? []) {
|
||||
const hit = findParentIdInTree(c, nodeId, root.id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 연결 해제 시 미연결 목록으로 펼침 — Logic Expression·캔버스 모두 트리에서 제외 */
|
||||
export function subtreeToFlatOrphans(node: LogicNode): LogicNode[] {
|
||||
switch (node.type) {
|
||||
case 'CONDITION':
|
||||
return [{ ...node, children: undefined }];
|
||||
case 'NOT': {
|
||||
const inner = node.children?.[0];
|
||||
return inner ? subtreeToFlatOrphans(inner) : [];
|
||||
}
|
||||
case 'AND':
|
||||
case 'OR': {
|
||||
const kids = node.children ?? [];
|
||||
if (kids.length === 0) return [];
|
||||
return kids.flatMap(subtreeToFlatOrphans);
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 자식이 없는 AND/OR/NOT 제거 — 수식에서 (빈 AND) 등 방지 */
|
||||
export function pruneEmptyGates(root: LogicNode | null): LogicNode | null {
|
||||
if (!root) return null;
|
||||
if (root.type === 'CONDITION') return root;
|
||||
|
||||
const prunedChildren = (root.children ?? [])
|
||||
.map(c => pruneEmptyGates(c))
|
||||
.filter((c): c is LogicNode => c != null);
|
||||
|
||||
if (root.type === 'NOT') {
|
||||
if (prunedChildren.length === 0) return null;
|
||||
return { ...root, children: [prunedChildren[0]] };
|
||||
}
|
||||
|
||||
if (root.type === 'AND' || root.type === 'OR') {
|
||||
if (prunedChildren.length === 0) return null;
|
||||
return { ...root, children: prunedChildren };
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function mergeOrphans(existing: LogicNode[], added: LogicNode[]): LogicNode[] {
|
||||
const seen = new Set(existing.map(o => o.id));
|
||||
const next = [...existing];
|
||||
for (const o of added) {
|
||||
if (!seen.has(o.id)) {
|
||||
seen.add(o.id);
|
||||
next.push(o);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveDisconnectEndpoints(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode,
|
||||
orphans: LogicNode[],
|
||||
): { parentId: string; childId: string } | null {
|
||||
const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null };
|
||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
||||
if (resolved) return resolved;
|
||||
|
||||
if (sourceId === START_NODE_ID && root.id === targetId) {
|
||||
return { parentId: START_NODE_ID, childId: targetId };
|
||||
}
|
||||
|
||||
const parentInTree = findParentIdInTree(root, targetId);
|
||||
if (parentInTree === sourceId) {
|
||||
return { parentId: sourceId, childId: targetId };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isDescendant(root: LogicNode, ancestorId: string, nodeId: string): boolean {
|
||||
const ancestor = findNodeInTree(root, ancestorId);
|
||||
if (!ancestor) return false;
|
||||
return findNodeInTree(ancestor, nodeId) != null && ancestorId !== nodeId;
|
||||
}
|
||||
|
||||
/** 연결선 제거 — 자식 서브트리를 트리에서 분리·미연결(고아)로 → Logic Expression 제외 */
|
||||
export function disconnectTreeEdge(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
): { root: LogicNode | null; orphans: LogicNode[] } {
|
||||
if (!root) return { root, orphans };
|
||||
|
||||
const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans);
|
||||
if (!endpoints) return { root, orphans };
|
||||
|
||||
const { parentId, childId } = endpoints;
|
||||
if (childId === START_NODE_ID) return { root, orphans };
|
||||
|
||||
const childInTree = root.id === childId || !!findNodeInTree(root, childId);
|
||||
if (!childInTree) return { root, orphans };
|
||||
|
||||
if (parentId === START_NODE_ID && root.id === childId) {
|
||||
return {
|
||||
root: null,
|
||||
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)),
|
||||
};
|
||||
}
|
||||
|
||||
const { tree, node } = extractNode(root, childId);
|
||||
if (!node) return { root, orphans };
|
||||
|
||||
return {
|
||||
root: pruneEmptyGates(tree),
|
||||
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,26 @@ export function getSignalTypeKo(type: 'BUY' | 'SELL'): string {
|
||||
return type === 'BUY' ? '매수' : '매도';
|
||||
}
|
||||
|
||||
const CANDLE_TYPE_KO: Record<string, string> = {
|
||||
'1m': '1분봉',
|
||||
'3m': '3분봉',
|
||||
'5m': '5분봉',
|
||||
'15m': '15분봉',
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1d': '일봉',
|
||||
'1D': '일봉',
|
||||
};
|
||||
|
||||
/** 전략 체크·시그널 평가에 사용된 분봉 (한글) */
|
||||
export function formatCandleTypeKo(candleType?: string | null): string {
|
||||
const raw = (candleType ?? '1m').trim();
|
||||
if (!raw) return '1분봉';
|
||||
const lower = raw.toLowerCase();
|
||||
return CANDLE_TYPE_KO[raw] ?? CANDLE_TYPE_KO[lower] ?? raw;
|
||||
}
|
||||
|
||||
export function getSignalHeadline(item: TradeSignalDisplaySource): string {
|
||||
const korean = getKoreanName(item.market);
|
||||
const { coin } = parseMarket(item.market);
|
||||
@@ -47,8 +67,7 @@ export function getSignalHeadline(item: TradeSignalDisplaySource): string {
|
||||
}
|
||||
|
||||
export function getExecutionLabel(executionType?: string, candleType?: string): string {
|
||||
const candle = candleType ?? '1m';
|
||||
const candleKo = candle === '1m' ? '1분봉' : candle;
|
||||
const candleKo = formatCandleTypeKo(candleType);
|
||||
if (executionType === 'REALTIME_TICK') return `실시간 틱 · ${candleKo}`;
|
||||
if (executionType === 'CANDLE_CLOSE') return `봉 마감 · ${candleKo}`;
|
||||
return candleKo;
|
||||
@@ -68,12 +87,12 @@ export function buildSignalDetailRows(item: TradeSignalDisplaySource): Array<{ l
|
||||
const { primary, secondary } = getMarketDisplayLine(item.market);
|
||||
const rows: Array<{ label: string; value: string; highlight?: boolean }> = [
|
||||
{ label: '종목', value: primary !== secondary ? `${secondary} · ${primary}` : secondary },
|
||||
{ label: '매매 구분', value: `${getSignalTypeKo(item.signalType)} (${item.signalType})`, highlight: true },
|
||||
{ label: '매매 구분', value: getSignalTypeKo(item.signalType), highlight: true },
|
||||
{ label: '기준 가격', value: formatSignalPrice(item.price), highlight: true },
|
||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${item.candleType ?? '1m'}` },
|
||||
];
|
||||
|
||||
if (item.receivedAt && Math.abs(item.receivedAt - item.candleTime * 1000) > 2000) {
|
||||
if (item.receivedAt) {
|
||||
rows.push({ label: '수신 시각', value: formatSignalTime(Math.floor(item.receivedAt / 1000)) });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user