From ea39f7df2731f34682d0fc9ca17fd40089c46bad Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 24 May 2026 06:20:18 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/goldenchart/auth/MenuIds.java | 2 +- frontend/package-lock.json | 236 ++++- frontend/package.json | 1 + frontend/src/App.css | 16 + frontend/src/App.tsx | 5 + .../src/components/StrategyEditorPage.tsx | 550 ++++++++++ frontend/src/components/TopMenuBar.tsx | 16 +- .../src/components/TradeSignalSnackbar.tsx | 25 +- .../components/strategyEditor/FlowNodes.tsx | 206 ++++ .../strategyEditor/LogicExpressionPreview.tsx | 121 +++ .../MultiSelectionDeleteButton.tsx | 51 + .../components/strategyEditor/PaletteChip.tsx | 52 + .../strategyEditor/StrategyEditorCanvas.tsx | 820 +++++++++++++++ .../strategyEditor/StrategyFlowEdge.tsx | 53 + .../components/strategyEditor/minimapTheme.ts | 36 + .../strategyEditor/paletteCategories.ts | 18 + .../strategyEditor/strategyEditorCallbacks.ts | 4 + .../strategyEditor/usePanelResize.ts | 65 ++ frontend/src/styles/strategyEditor.css | 995 ++++++++++++++++++ frontend/src/styles/strategyEditorTheme.css | 223 ++++ frontend/src/utils/permissions.ts | 9 +- frontend/src/utils/strategyEditorShared.tsx | 729 +++++++++++++ frontend/src/utils/strategyFlowLayout.ts | 666 ++++++++++++ frontend/src/utils/tradeSignalDisplay.ts | 27 +- 24 files changed, 4897 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/StrategyEditorPage.tsx create mode 100644 frontend/src/components/strategyEditor/FlowNodes.tsx create mode 100644 frontend/src/components/strategyEditor/LogicExpressionPreview.tsx create mode 100644 frontend/src/components/strategyEditor/MultiSelectionDeleteButton.tsx create mode 100644 frontend/src/components/strategyEditor/PaletteChip.tsx create mode 100644 frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx create mode 100644 frontend/src/components/strategyEditor/StrategyFlowEdge.tsx create mode 100644 frontend/src/components/strategyEditor/minimapTheme.ts create mode 100644 frontend/src/components/strategyEditor/paletteCategories.ts create mode 100644 frontend/src/components/strategyEditor/strategyEditorCallbacks.ts create mode 100644 frontend/src/components/strategyEditor/usePanelResize.ts create mode 100644 frontend/src/styles/strategyEditor.css create mode 100644 frontend/src/styles/strategyEditorTheme.css create mode 100644 frontend/src/utils/strategyEditorShared.tsx create mode 100644 frontend/src/utils/strategyFlowLayout.ts diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index a747814..1008bb1 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -7,7 +7,7 @@ public final class MenuIds { private MenuIds() {} public static final List ALL = List.of( - "dashboard", "chart", "paper", "strategy", "backtest", "notifications", "settings", + "dashboard", "chart", "paper", "strategy", "strategy-editor", "backtest", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin" ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ca816d6..e39a256 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@stomp/stompjs": "^7.3.0", + "@xyflow/react": "^12.10.2", "lightweight-charts": "^5.2.0", "lightweight-charts-indicators": "^0.4.1", "oakscriptjs": "^0.2.8", @@ -1195,6 +1196,55 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1206,14 +1256,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1258,6 +1308,38 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", @@ -1326,6 +1408,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1337,9 +1425,114 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1854,6 +2047,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1943,6 +2145,34 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 1dc607d..1545e90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@stomp/stompjs": "^7.3.0", + "@xyflow/react": "^12.10.2", "lightweight-charts": "^5.2.0", "lightweight-charts-indicators": "^0.4.1", "oakscriptjs": "^0.2.8", diff --git a/frontend/src/App.css b/frontend/src/App.css index 243529d..0f8c5a6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 690b748..19c930e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { )} + {menuPage === 'strategy-editor' && ( + + )} + {/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */} {menuPage === 'backtest' && ( diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx new file mode 100644 index 0000000..a448ae2 --- /dev/null +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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(() => loadStratsLocal()); + const [selectedId, setSelectedId] = useState(null); + const [buyCondition, setBuyCondition] = useState(null); + const [sellCondition, setSellCondition] = useState(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(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [buyOrphans, setBuyOrphans] = useState([]); + const [sellOrphans, setSellOrphans] = useState([]); + 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 ( +
+ {saveToast && ( +
전략이 DB에 성공적으로 저장되었습니다.
+ )} + +
+
+

전략 빌더

+ Strategy Builder +
+
+ + +
+
+ +
+ + +
+ +
+
+
+
+ + +
+ {stratName && {stratName}} +
+ + + + + + {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) || '—'} +
+ )} + +
+ +
+ +
+
LOGIC EXPRESSION
+ +
+
+ + +
+ + {saveOpen && ( + setSaveOpen(false)} title="전략 저장"> +
+ + setStratName(e.target.value)} placeholder="예: Golden_RSI_V1" /> + +