전략편집기 메뉴 추가

This commit is contained in:
Macbook
2026-05-24 06:20:18 +09:00
parent bbe82e510b
commit ea39f7df27
24 changed files with 4897 additions and 29 deletions
+16
View File
@@ -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;
+5
View File
@@ -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>
);
}
+14 -2
View File
@@ -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 */ }
}
+995
View File
@@ -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; }
}
+223
View File
@@ -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;
}
+7 -2
View File
@@ -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;
}
+729
View File
@@ -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,
},
};
};
+666
View File
@@ -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)),
};
}
+23 -4
View File
@@ -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)) });
}