diff --git a/frontend/src/App.css b/frontend/src/App.css index 0f8c5a6..2153793 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5862,10 +5862,211 @@ html.theme-blue { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } .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 { display: flex; align-items: center; justify-content: space-between; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text2); diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 627b4ff..721c8fe 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -30,7 +30,13 @@ import { } from '../utils/strategyEditorLayoutStorage'; import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview'; import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas'; +import StrategyListEditor from './strategyEditor/StrategyListEditor'; import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks'; +import { + loadEditorMode, + saveEditorMode, + type StrategyEditorMode, +} from '../utils/strategyEditorModeStorage'; import PaletteChip from './strategyEditor/PaletteChip'; import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import '../styles/strategyEditor.css'; @@ -105,6 +111,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro terminalHeightRef.current = terminalHeight; const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0'); + const [editorMode, setEditorMode] = useState(() => loadEditorMode()); const onLeftSplitter = usePanelResize( 'vertical', @@ -216,13 +223,27 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro }, [layoutStrategyKey, schedulePersistFlowLayout]); const switchSignalTab = useCallback((tab: 'buy' | 'sell') => { - layoutFlushRef.current?.(); + if (editorMode === 'graph') layoutFlushRef.current?.(); persistFlowLayout(layoutStrategyKey); layoutRevisionRef.current += 1; setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`); setSignalTab(tab); 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 source = signalTab === 'buy' ? buyLayout : sellLayout; @@ -444,6 +465,22 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro Strategy Builder
+
+ + +
@@ -539,43 +576,54 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro {stratName && {stratName}} - - - + {editorMode === 'graph' ? ( + <> + + + - {selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && ( -
- {selectedLogicNode.condition.indicatorType} - { - 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 }))); - } - }} - /> - 차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'} -
+ {selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && ( +
+ {selectedLogicNode.condition.indicatorType} + { + 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 }))); + } + }} + /> + 차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'} +
+ )} + + ) : ( + )} diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx index b230c51..9945b49 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -1269,47 +1269,98 @@ export const StrategyPage: React.FC = ({ activeIndicators = [] }) => {
{/* 전략 목록 */} {rightTab === 'list' && ( -
- {isLoading ? ( -
로딩 중...
- ) : strategies.length === 0 ? ( -
저장된 전략이 없습니다
- ) : ( - <> -
- 전략: {strategies.length}개 - -
- {sortedStrategies.map(s => ( -
handleSelectStrategy(s)}> -
- {s.name} - 드래그방식 - - {s.enabled ? '활성' : '비활성'} - +
+
+

전략 목록

+ {strategies.length > 0 && ( + + )} +
+ +
+ {isLoading ? ( +

로딩 중...

+ ) : strategies.length === 0 ? ( +

저장된 전략이 없습니다

+ ) : ( + sortedStrategies.map(s => { + const isSel = selectedId === s.id; + return ( +
handleSelectStrategy(s)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelectStrategy(s); + } + }} + > + {s.name} +
+ + + + + {s.enabled ? '활성' : '비활성'} + +
-
e.stopPropagation()}> - - - -
-
- ))} - - )} + ); + }) + )} +
)} diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx new file mode 100644 index 0000000..941e923 --- /dev/null +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -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 = { + 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 = ({ + 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) => 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 ( +
0 ? 12 : 0 }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > +
+ + {node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type} + + {label} + +
+ + {node.type === 'CONDITION' && node.condition && ( + + )} + + {isLogic && ( +
+ {(node.children ?? []).map(child => ( + handleChildUpdate(child.id, u)} + onDelete={() => handleChildDelete(child.id)} + onDropOnNode={onDropOnNode} + dragOverId={dragOverId} + setDragOverId={setDragOverId} + depth={depth + 1} + def={def} + /> + ))} + {dragOverId === node.id && ( +
여기에 드롭하세요
+ )} +
+ )} +
+ ); +}; + +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(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 ( +
e.preventDefault()} + onDrop={handleRootDrop} + > + {!root ? ( +
+ 우측 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요. +
+ ) : ( + onChange(null)} + onDropOnNode={handleDropOnNode} + dragOverId={dragOverId} + setDragOverId={setDragOverId} + def={def} + /> + )} +
+ ); +} diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index b622cee..d485ba5 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -58,7 +58,47 @@ .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 { border: 1px solid var(--se-border); diff --git a/frontend/src/utils/strategyEditorModeStorage.ts b/frontend/src/utils/strategyEditorModeStorage.ts new file mode 100644 index 0000000..e891fef --- /dev/null +++ b/frontend/src/utils/strategyEditorModeStorage.ts @@ -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 */ + } +}