전략편집기 병합
This commit is contained in:
+203
-2
@@ -5862,10 +5862,211 @@ html.theme-blue {
|
|||||||
color: var(--text); border-bottom-color: var(--accent); font-weight: 600;
|
color: var(--text); border-bottom-color: var(--accent); font-weight: 600;
|
||||||
}
|
}
|
||||||
.sp-panel-body {
|
.sp-panel-body {
|
||||||
flex: 1; overflow-y: auto; padding-bottom: 16px;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sp-panel-body > .sp-preview,
|
||||||
|
.sp-panel-body > div:not(.sp-strat-panel) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 전략 목록 ────────────────────────────────────────────────────────────── */
|
/* ── 전략 목록 (카드형 — 전략편집기와 동일) ───────────────────────────────── */
|
||||||
|
.sp-strat-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px 12px 12px;
|
||||||
|
margin: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in srgb, var(--bg2) 92%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18), inset 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent);
|
||||||
|
}
|
||||||
|
.sp-strat-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sp-strat-panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sp-strat-sort {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.sp-new-strat-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.sp-new-strat-btn:hover {
|
||||||
|
background: var(--bg4);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.sp-strat-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 1px 4px;
|
||||||
|
}
|
||||||
|
.sp-strat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--bg2) 88%, transparent);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), inset 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent);
|
||||||
|
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.sp-strat-item:hover {
|
||||||
|
background: var(--bg3);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.sp-strat-item--sel {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg2));
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
0 3px 10px rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.sp-strat-item--sel:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 14%, var(--bg2));
|
||||||
|
}
|
||||||
|
.sp-strat-item:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.sp-strat-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sp-strat-item--sel .sp-strat-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.sp-strat-item-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.sp-strat-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.sp-strat-action:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
}
|
||||||
|
.sp-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(--text3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.sp-strat-item--sel .sp-strat-del {
|
||||||
|
color: color-mix(in srgb, var(--accent) 85%, transparent);
|
||||||
|
}
|
||||||
|
.sp-strat-del:hover {
|
||||||
|
color: #ff6b6b;
|
||||||
|
background: color-mix(in srgb, #ff6b6b 15%, transparent);
|
||||||
|
}
|
||||||
|
.sp-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(--text3);
|
||||||
|
background: color-mix(in srgb, var(--text3) 10%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sp-strat-status--on {
|
||||||
|
color: #48c774;
|
||||||
|
background: color-mix(in srgb, #48c774 10%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #48c774 28%, transparent);
|
||||||
|
}
|
||||||
|
.sp-strat-item--sel .sp-strat-status--on {
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
.sp-strat-empty {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text3);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 전략 목록 (레거시) ─────────────────────────────────────────────────── */
|
||||||
.sp-list-header {
|
.sp-list-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text2);
|
padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text2);
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ import {
|
|||||||
} from '../utils/strategyEditorLayoutStorage';
|
} from '../utils/strategyEditorLayoutStorage';
|
||||||
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
||||||
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
|
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
|
||||||
|
import StrategyListEditor from './strategyEditor/StrategyListEditor';
|
||||||
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
||||||
|
import {
|
||||||
|
loadEditorMode,
|
||||||
|
saveEditorMode,
|
||||||
|
type StrategyEditorMode,
|
||||||
|
} from '../utils/strategyEditorModeStorage';
|
||||||
import PaletteChip from './strategyEditor/PaletteChip';
|
import PaletteChip from './strategyEditor/PaletteChip';
|
||||||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||||
import '../styles/strategyEditor.css';
|
import '../styles/strategyEditor.css';
|
||||||
@@ -105,6 +111,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
terminalHeightRef.current = terminalHeight;
|
terminalHeightRef.current = terminalHeight;
|
||||||
|
|
||||||
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
|
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
|
||||||
|
const [editorMode, setEditorMode] = useState<StrategyEditorMode>(() => loadEditorMode());
|
||||||
|
|
||||||
const onLeftSplitter = usePanelResize(
|
const onLeftSplitter = usePanelResize(
|
||||||
'vertical',
|
'vertical',
|
||||||
@@ -216,13 +223,27 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||||
|
|
||||||
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||||||
layoutFlushRef.current?.();
|
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||||
persistFlowLayout(layoutStrategyKey);
|
persistFlowLayout(layoutStrategyKey);
|
||||||
layoutRevisionRef.current += 1;
|
layoutRevisionRef.current += 1;
|
||||||
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
|
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||||||
setSignalTab(tab);
|
setSignalTab(tab);
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
}, [layoutStrategyKey, persistFlowLayout]);
|
}, [layoutStrategyKey, persistFlowLayout, editorMode]);
|
||||||
|
|
||||||
|
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
|
||||||
|
if (mode === editorMode) return;
|
||||||
|
if (editorMode === 'graph') {
|
||||||
|
layoutFlushRef.current?.();
|
||||||
|
persistFlowLayout(layoutStrategyKey);
|
||||||
|
}
|
||||||
|
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
|
||||||
|
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
|
||||||
|
}
|
||||||
|
setEditorMode(mode);
|
||||||
|
saveEditorMode(mode);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
}, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
|
||||||
|
|
||||||
const layoutSeed = useMemo((): FlowLayoutSeed => {
|
const layoutSeed = useMemo((): FlowLayoutSeed => {
|
||||||
const source = signalTab === 'buy' ? buyLayout : sellLayout;
|
const source = signalTab === 'buy' ? buyLayout : sellLayout;
|
||||||
@@ -444,6 +465,22 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
<span className="se-subtitle">Strategy Builder</span>
|
<span className="se-subtitle">Strategy Builder</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="se-header-actions">
|
<div className="se-header-actions">
|
||||||
|
<div className="se-editor-mode" role="group" aria-label="편집 방식">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`se-editor-mode-btn${editorMode === 'graph' ? ' se-editor-mode-btn--on' : ''}`}
|
||||||
|
onClick={() => handleEditorModeChange('graph')}
|
||||||
|
>
|
||||||
|
그래프 방식
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`se-editor-mode-btn${editorMode === 'list' ? ' se-editor-mode-btn--on' : ''}`}
|
||||||
|
onClick={() => handleEditorModeChange('list')}
|
||||||
|
>
|
||||||
|
목록 방식
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button type="button" className="se-btn se-btn--ghost" onClick={handleNew}>+ 새 전략</button>
|
<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>
|
<button type="button" className="se-btn se-btn--gold" onClick={() => setSaveOpen(true)}>저장하기</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,43 +576,54 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
{stratName && <span className="se-editing-name">{stratName}</span>}
|
{stratName && <span className="se-editing-name">{stratName}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReactFlowProvider>
|
{editorMode === 'graph' ? (
|
||||||
<StrategyEditorCanvas
|
<>
|
||||||
theme={theme}
|
<ReactFlowProvider>
|
||||||
root={currentRoot}
|
<StrategyEditorCanvas
|
||||||
orphans={currentOrphans}
|
theme={theme}
|
||||||
onOrphansChange={setCurrentOrphans}
|
root={currentRoot}
|
||||||
def={DEF}
|
orphans={currentOrphans}
|
||||||
signalTab={signalTab}
|
onOrphansChange={setCurrentOrphans}
|
||||||
onChange={setCurrentRoot}
|
def={DEF}
|
||||||
selectedNodeId={selectedNodeId}
|
signalTab={signalTab}
|
||||||
onSelectNode={setSelectedNodeId}
|
onChange={setCurrentRoot}
|
||||||
layoutSeed={layoutSeed}
|
selectedNodeId={selectedNodeId}
|
||||||
onLayoutChange={handleLayoutChange}
|
onSelectNode={setSelectedNodeId}
|
||||||
/>
|
layoutSeed={layoutSeed}
|
||||||
</ReactFlowProvider>
|
onLayoutChange={handleLayoutChange}
|
||||||
|
/>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
|
||||||
{selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
|
{selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
|
||||||
<div className="se-node-config-bar">
|
<div className="se-node-config-bar">
|
||||||
<span className="se-node-config-label">{selectedLogicNode.condition.indicatorType}</span>
|
<span className="se-node-config-label">{selectedLogicNode.condition.indicatorType}</span>
|
||||||
<CondEditor
|
<CondEditor
|
||||||
cond={selectedLogicNode.condition}
|
cond={selectedLogicNode.condition}
|
||||||
signalType={signalTab}
|
signalType={signalTab}
|
||||||
def={DEF}
|
def={DEF}
|
||||||
onChange={c => {
|
onChange={c => {
|
||||||
if (!selectedNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
||||||
if (inOrphans) {
|
if (inOrphans) {
|
||||||
setCurrentOrphans(currentOrphans.map(o => (
|
setCurrentOrphans(currentOrphans.map(o => (
|
||||||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||||||
)));
|
)));
|
||||||
} else if (currentRoot) {
|
} else if (currentRoot) {
|
||||||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="se-sync-tip">차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'}</span>
|
<span className="se-sync-tip">차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<StrategyListEditor
|
||||||
|
root={currentRoot}
|
||||||
|
signalTab={signalTab}
|
||||||
|
def={DEF}
|
||||||
|
onChange={setCurrentRoot}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1269,47 +1269,98 @@ export const StrategyPage: React.FC<Props> = ({ activeIndicators = [] }) => {
|
|||||||
<div className="sp-panel-body">
|
<div className="sp-panel-body">
|
||||||
{/* 전략 목록 */}
|
{/* 전략 목록 */}
|
||||||
{rightTab === 'list' && (
|
{rightTab === 'list' && (
|
||||||
<div>
|
<div className="sp-strat-panel">
|
||||||
{isLoading ? (
|
<div className="sp-strat-panel-head">
|
||||||
<div className="sp-loading">로딩 중...</div>
|
<h2 className="sp-strat-panel-title">전략 목록</h2>
|
||||||
) : strategies.length === 0 ? (
|
{strategies.length > 0 && (
|
||||||
<div className="sp-empty-info">저장된 전략이 없습니다</div>
|
<select
|
||||||
) : (
|
className="sp-strat-sort"
|
||||||
<>
|
value={sortOrder}
|
||||||
<div className="sp-list-header">
|
onChange={e => setSortOrder(e.target.value as typeof sortOrder)}
|
||||||
<span>전략: {strategies.length}개</span>
|
aria-label="전략 정렬"
|
||||||
<select className="sp-sort-sel" value={sortOrder}
|
>
|
||||||
onChange={e => setSortOrder(e.target.value as any)}>
|
<option value="name-asc">이름(A)</option>
|
||||||
<option value="name-asc">이름(A)</option>
|
<option value="name-desc">이름(D)</option>
|
||||||
<option value="name-desc">이름(D)</option>
|
<option value="date-asc">날짜(A)</option>
|
||||||
<option value="date-asc">날짜(A)</option>
|
<option value="date-desc">날짜(D)</option>
|
||||||
<option value="date-desc">날짜(D)</option>
|
</select>
|
||||||
</select>
|
)}
|
||||||
</div>
|
</div>
|
||||||
{sortedStrategies.map(s => (
|
<button type="button" className="sp-new-strat-btn" onClick={handleNew}>
|
||||||
<div key={s.id}
|
+ 새 전략 만들기
|
||||||
className={`sp-list-item ${selectedId === s.id ? 'sp-list-item--sel' : ''}`}
|
</button>
|
||||||
onClick={() => handleSelectStrategy(s)}>
|
<div className="sp-strat-list">
|
||||||
<div className="sp-li-row1">
|
{isLoading ? (
|
||||||
<span className="sp-li-name">{s.name}</span>
|
<p className="sp-strat-empty">로딩 중...</p>
|
||||||
<span className={`sp-li-chip sp-li-chip--type`}>드래그방식</span>
|
) : strategies.length === 0 ? (
|
||||||
<span className={`sp-li-chip ${s.enabled ? 'sp-li-chip--on' : 'sp-li-chip--off'}`}>
|
<p className="sp-strat-empty">저장된 전략이 없습니다</p>
|
||||||
{s.enabled ? '활성' : '비활성'}
|
) : (
|
||||||
</span>
|
sortedStrategies.map(s => {
|
||||||
|
const isSel = selectedId === s.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`sp-strat-item${isSel ? ' sp-strat-item--sel' : ''}`}
|
||||||
|
onClick={() => handleSelectStrategy(s)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectStrategy(s);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="sp-strat-name" title={s.name}>{s.name}</span>
|
||||||
|
<div className="sp-strat-item-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sp-strat-action"
|
||||||
|
title={s.enabled ? '알림 해제' : '알림 추가'}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleEnabled(s.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.enabled ? '🔔' : '🔕'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sp-strat-action"
|
||||||
|
title="복제"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDuplicate(s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⎘
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sp-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={`sp-strat-status${s.enabled ? ' sp-strat-status--on' : ''}`}>
|
||||||
|
{s.enabled ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sp-li-actions" onClick={e => e.stopPropagation()}>
|
);
|
||||||
<button className="sp-li-btn" title={s.enabled ? '알림 해제' : '알림 추가'}
|
})
|
||||||
onClick={() => handleToggleEnabled(s.id)}>
|
)}
|
||||||
{s.enabled ? '🔔' : '🔕'}
|
</div>
|
||||||
</button>
|
|
||||||
<button className="sp-li-btn" title="복제" onClick={() => handleDuplicate(s)}>⎘</button>
|
|
||||||
<button className="sp-li-btn sp-li-btn--del" title="삭제"
|
|
||||||
onClick={() => { setDeleteId(s.id); setDeleteOpen(true); }}>🗑</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import type { LogicNode } from '../../utils/strategyTypes';
|
||||||
|
import {
|
||||||
|
CondEditor,
|
||||||
|
addChild,
|
||||||
|
makeNode,
|
||||||
|
mergeAtRoot,
|
||||||
|
nodeToText,
|
||||||
|
type DefType,
|
||||||
|
} from '../../utils/strategyEditorShared';
|
||||||
|
|
||||||
|
const NODE_COLORS: Record<string, string> = {
|
||||||
|
AND: '#4caf50',
|
||||||
|
OR: '#2196f3',
|
||||||
|
NOT: '#ff9800',
|
||||||
|
};
|
||||||
|
const IND_COLOR = '#9c27b0';
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: LogicNode;
|
||||||
|
signalType: 'buy' | 'sell';
|
||||||
|
onUpdate: (n: LogicNode) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string }) => void;
|
||||||
|
dragOverId?: string | null;
|
||||||
|
setDragOverId?: (id: string | null) => void;
|
||||||
|
depth?: number;
|
||||||
|
def: DefType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||||
|
node,
|
||||||
|
signalType,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onDropOnNode,
|
||||||
|
dragOverId,
|
||||||
|
setDragOverId,
|
||||||
|
depth = 0,
|
||||||
|
def,
|
||||||
|
}) => {
|
||||||
|
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
||||||
|
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||||||
|
const label = node.type === 'CONDITION' && node.condition
|
||||||
|
? nodeToText(node, def)
|
||||||
|
: node.type === 'AND'
|
||||||
|
? 'AND (그리고)'
|
||||||
|
: node.type === 'OR'
|
||||||
|
? 'OR (또는)'
|
||||||
|
: 'NOT (반대)';
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
if (!isLogic) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOverId?.(node.id);
|
||||||
|
};
|
||||||
|
const handleDragLeave = () => setDragOverId?.(null);
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
if (!isLogic) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOverId?.(null);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
|
onDropOnNode?.(node.id, data);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCondChange = (c: NonNullable<LogicNode['condition']>) => onUpdate({ ...node, condition: c });
|
||||||
|
const handleChildUpdate = (childId: string, updated: LogicNode) =>
|
||||||
|
onUpdate({ ...node, children: (node.children ?? []).map(c => (c.id === childId ? updated : c)) });
|
||||||
|
const handleChildDelete = (childId: string) =>
|
||||||
|
onUpdate({ ...node, children: (node.children ?? []).filter(c => c.id !== childId) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sp-tree-node${dragOverId === node.id ? ' sp-tree-node--over' : ''}`}
|
||||||
|
style={{ marginLeft: depth > 0 ? 12 : 0 }}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="sp-node-head" style={{ borderLeftColor: color }}>
|
||||||
|
<span className="sp-node-badge" style={{ background: color }}>
|
||||||
|
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type}
|
||||||
|
</span>
|
||||||
|
<span className="sp-node-label">{label}</span>
|
||||||
|
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.type === 'CONDITION' && node.condition && (
|
||||||
|
<CondEditor cond={node.condition} signalType={signalType} onChange={handleCondChange} def={def} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLogic && (
|
||||||
|
<div className="sp-node-children">
|
||||||
|
{(node.children ?? []).map(child => (
|
||||||
|
<TreeNodeComp
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
signalType={signalType}
|
||||||
|
onUpdate={u => handleChildUpdate(child.id, u)}
|
||||||
|
onDelete={() => handleChildDelete(child.id)}
|
||||||
|
onDropOnNode={onDropOnNode}
|
||||||
|
dragOverId={dragOverId}
|
||||||
|
setDragOverId={setDragOverId}
|
||||||
|
depth={depth + 1}
|
||||||
|
def={def}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{dragOverId === node.id && (
|
||||||
|
<div className="sp-drop-hint-inner">여기에 드롭하세요</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
root: LogicNode | null;
|
||||||
|
signalTab: 'buy' | 'sell';
|
||||||
|
def: DefType;
|
||||||
|
onChange: (root: LogicNode | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StrategyListEditor({ root, signalTab, def, onChange }: Props) {
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const applyDrop = useCallback((
|
||||||
|
targetId: string | null,
|
||||||
|
data: { type: string; value: string; label: string },
|
||||||
|
) => {
|
||||||
|
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||||
|
if (!root) {
|
||||||
|
onChange(newNode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!targetId) {
|
||||||
|
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(addChild(root, targetId, newNode));
|
||||||
|
}, [root, signalTab, def, onChange]);
|
||||||
|
|
||||||
|
const handleRootDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverId(null);
|
||||||
|
try {
|
||||||
|
applyDrop(null, JSON.parse(e.dataTransfer.getData('application/json')));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string }) => {
|
||||||
|
applyDrop(targetId, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="se-list-editor sp-dropzone"
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
onDrop={handleRootDrop}
|
||||||
|
>
|
||||||
|
{!root ? (
|
||||||
|
<div className="sp-drop-empty">
|
||||||
|
우측 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TreeNodeComp
|
||||||
|
node={root}
|
||||||
|
signalType={signalTab}
|
||||||
|
onUpdate={onChange}
|
||||||
|
onDelete={() => onChange(null)}
|
||||||
|
onDropOnNode={handleDropOnNode}
|
||||||
|
dragOverId={dragOverId}
|
||||||
|
setDragOverId={setDragOverId}
|
||||||
|
def={def}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,7 +58,47 @@
|
|||||||
|
|
||||||
.se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
|
.se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
|
||||||
|
|
||||||
.se-header-actions { display: flex; gap: 8px; }
|
.se-header-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.se-editor-mode {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--se-btn-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-mode-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-mode-btn:hover {
|
||||||
|
color: var(--se-text);
|
||||||
|
background: color-mix(in srgb, var(--se-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-mode-btn--on {
|
||||||
|
color: var(--se-text);
|
||||||
|
background: color-mix(in srgb, var(--se-accent) 18%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--se-accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-list-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 8px 12px 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--se-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--se-center-bg) 92%, var(--se-accent) 8%);
|
||||||
|
}
|
||||||
|
|
||||||
.se-btn {
|
.se-btn {
|
||||||
border: 1px solid var(--se-border);
|
border: 1px solid var(--se-border);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type StrategyEditorMode = 'graph' | 'list';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'gc_se_editor_mode';
|
||||||
|
|
||||||
|
export function loadEditorMode(): StrategyEditorMode {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw === 'list' ? 'list' : 'graph';
|
||||||
|
} catch {
|
||||||
|
return 'graph';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveEditorMode(mode: StrategyEditorMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user