From 20dd257c29e4a005d719ba728db4f0c11c92d0bf Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 01:34:52 +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=B3=B5=ED=95=A9=EC=A7=80=ED=91=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/StrategyDslToTa4jAdapter.java | 58 +++- frontend/src/App.css | 275 +++++++++++++++++- frontend/src/App.tsx | 2 +- frontend/src/components/OrderbookPanel.tsx | 217 +++++++++++--- .../src/components/StrategyEditorPage.tsx | 51 +++- frontend/src/components/TradeOrderPanel.tsx | 53 ++-- frontend/src/components/TradingChart.tsx | 132 +++++++-- .../strategyEditor/ComboNumberInput.tsx | 92 ++++++ .../strategyEditor/ConditionNodeSettings.tsx | 123 ++++++++ .../components/strategyEditor/FlowNodes.tsx | 50 +++- .../components/strategyEditor/PaletteChip.tsx | 12 +- .../strategyEditor/StrategyEditorCanvas.tsx | 25 +- .../strategyEditor/StrategyListEditor.tsx | 43 ++- frontend/src/styles/paperDashboard.css | 31 ++ frontend/src/styles/strategyEditor.css | 149 +++++++++- frontend/src/utils/ChartManager.ts | 65 ++++- frontend/src/utils/compositeIndicators.ts | 153 ++++++++++ frontend/src/utils/conditionPeriods.ts | 219 ++++++++++++++ frontend/src/utils/strategyEditorShared.tsx | 199 +++++++++++-- frontend/src/utils/strategyFlowLayout.ts | 11 +- frontend/src/utils/strategyTypes.ts | 4 + 21 files changed, 1781 insertions(+), 183 deletions(-) create mode 100644 frontend/src/components/strategyEditor/ComboNumberInput.tsx create mode 100644 frontend/src/components/strategyEditor/ConditionNodeSettings.tsx create mode 100644 frontend/src/utils/compositeIndicators.ts create mode 100644 frontend/src/utils/conditionPeriods.ts diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 19f2708..a09324f 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -158,6 +158,9 @@ public class StrategyDslToTa4jAdapter { String condType = cond.path("conditionType").asText("GT"); String leftField = cond.path("leftField").asText("NONE"); String rightField = cond.path("rightField").asText("NONE"); + int condPeriod = cond.path("period").asInt(-1); + int leftPeriod = cond.path("leftPeriod").asInt(-1); + int rightPeriod = cond.path("rightPeriod").asInt(-1); int slopePeriod = cond.path("slopePeriod").asInt(3); int holdDays = cond.path("holdDays").asInt(3); @@ -168,8 +171,8 @@ public class StrategyDslToTa4jAdapter { : Map.of(); try { - Indicator left = resolveField(leftField, indType, indParams, series); - Indicator right = resolveField(rightField, indType, indParams, series); + Indicator left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod); + Indicator right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod); return switch (condType) { case "GT" -> new OverIndicatorRule(left, right); @@ -225,7 +228,8 @@ public class StrategyDslToTa4jAdapter { // ── 필드 Resolver ───────────────────────────────────────────────────────── private Indicator resolveField(String field, String indType, - Map p, BarSeries s) { + Map p, BarSeries s, + int condPeriod, int sidePeriod) { if (field == null || field.isBlank() || field.equals("NONE")) { return new ConstantIndicator<>(s, s.numFactory().numOf(0)); } @@ -240,7 +244,7 @@ public class StrategyDslToTa4jAdapter { if (!Double.isNaN(constant)) { yield new ConstantIndicator<>(s, s.numFactory().numOf(constant)); } - yield resolveIndicatorField(field, indType, p, s); + yield resolveIndicatorField(field, indType, p, s, condPeriod, sidePeriod); } }; } @@ -266,15 +270,25 @@ public class StrategyDslToTa4jAdapter { } private Indicator resolveIndicatorField(String field, String indType, - Map p, BarSeries s) { + Map p, BarSeries s, + int condPeriod, int sidePeriod) { ClosePriceIndicator close = new ClosePriceIndicator(s); + int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1); - // CCI — 프론트엔드 indicatorRegistry 기본값 20 과 통일 + // CCI — 기간 접미사(CCI_VALUE_13) 또는 기본 CCI_VALUE + if (field.startsWith("CCI_VALUE_")) { + int period = parseTrailingInt(field, "CCI_VALUE_", intP(p, "length", 13)); + return new CCIIndicator(s, period); + } if (field.equals("CCI_VALUE") || indType.equals("CCI")) - return new CCIIndicator(s, intP(p, "length", 13)); - // RSI — 표준 기본값 14 + return new CCIIndicator(s, periodOverride > 0 ? periodOverride : intP(p, "length", 13)); + // RSI — 기간 접미사(RSI_VALUE_9) 또는 기본 RSI_VALUE + if (field.startsWith("RSI_VALUE_")) { + int period = parseTrailingInt(field, "RSI_VALUE_", intP(p, "length", 14)); + return new RSIIndicator(close, period); + } if (field.equals("RSI_VALUE")) - return new RSIIndicator(close, intP(p, "length", 14)); + return new RSIIndicator(close, periodOverride > 0 ? periodOverride : intP(p, "length", 14)); // MACD if (field.equals("MACD_LINE")) { return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)); @@ -289,6 +303,10 @@ public class StrategyDslToTa4jAdapter { return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9))); } // ADX / DMI + if (field.startsWith("ADX_VALUE_")) { + int period = parseTrailingInt(field, "ADX_VALUE_", intP(p, "adxSmoothing", 14)); + return new ADXIndicator(s, intP(p, "diLength", 14), period); + } if (field.equals("ADX_VALUE")) return new ADXIndicator(s, intP(p, "diLength", 14), intP(p, "adxSmoothing", 14)); if (field.equals("PDI") || field.equals("PLUS_DI")) { @@ -316,6 +334,12 @@ public class StrategyDslToTa4jAdapter { return new SMAIndicator(slowK, dSmooth); } // TRIX — ln(종가) → 3×EMA → Δ×10000 (업비트) + if (field.startsWith("TRIX_VALUE_")) { + int len = parseTrailingInt(field, "TRIX_VALUE_", intP(p, "length", 12)); + EMAIndicator e3 = tripleLogEma(close, len); + PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); + return NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000); + } if (field.equals("TRIX_VALUE")) { int len = intP(p, "length", 12); EMAIndicator e3 = tripleLogEma(close, len); @@ -342,6 +366,10 @@ public class StrategyDslToTa4jAdapter { }; } // VR + if (field.startsWith("VR_VALUE_")) { + int period = parseTrailingInt(field, "VR_VALUE_", intP(p, "length", 10)); + return vrIndicator(s, period); + } if (field.equals("VR_VALUE") || indType.equals("VR")) return vrIndicator(s, intP(p, "length", 10)); // 이격도 DISPARITY5 → period 5 @@ -349,12 +377,24 @@ public class StrategyDslToTa4jAdapter { int period = parseTrailingInt(field, "DISPARITY", 20); return disparityIndicator(s, period, p); } + if (field.startsWith("PSY_VALUE_")) { + int period = parseTrailingInt(field, "PSY_VALUE_", intP(p, "length", 12)); + return newPsychIndicator(s, period, false); + } if (field.equals("PSY_VALUE") || field.equals("NEW_PSY_VALUE") || indType.equals("PSYCHOLOGICAL") || indType.equals("NEW_PSYCHOLOGICAL")) return newPsychIndicator(s, intP(p, "length", 12), false); + if (field.startsWith("INVEST_PSY_VALUE_")) { + int period = parseTrailingInt(field, "INVEST_PSY_VALUE_", intP(p, "length", 10)); + return newPsychIndicator(s, period, true); + } if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL")) return newPsychIndicator(s, intP(p, "length", 10), true); // Williams %R + if (field.startsWith("WILLIAMS_R_VALUE_")) { + int period = parseTrailingInt(field, "WILLIAMS_R_VALUE_", intP(p, "length", 14)); + return new WilliamsRIndicator(s, period); + } if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14)); // Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사 if (field.startsWith("DC_UPPER_")) { diff --git a/frontend/src/App.css b/frontend/src/App.css index 80b196c..3ec39ef 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5086,10 +5086,18 @@ html.theme-blue { .ob-col-price { width: 36%; } .ob-table--mid { - flex-shrink: 0; - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); - background: var(--bg2); + display: none; +} + +.ob-td-price--live { + box-shadow: inset 0 0 0 2px #1565c0; + font-weight: 800; + background: rgba(21, 101, 192, 0.12); + text-align: center; +} +.app.light .ob-td-price--live { + box-shadow: inset 0 0 0 2px #1976d2; + background: rgba(25, 118, 210, 0.1); } .ob-tr-mid .ob-td-price--current { @@ -5290,6 +5298,59 @@ html.theme-blue { border-left: 1px solid var(--border); border-right: 1px solid var(--border); } +.ob-summary-time-wrap { + position: relative; +} +.ob-summary-time-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + line-height: 1.2; +} +.ob-summary-time-btn:hover { + color: var(--accent, #2962ff); +} +.ob-summary-time-caret { + font-size: 9px; + opacity: 0.75; +} +.ob-summary-mode-menu { + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + z-index: 20; + min-width: 72px; + padding: 4px 0; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg2, #1e222d); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} +.ob-summary-mode-item { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: var(--text); + font-size: 11px; + font-weight: 600; + text-align: center; + cursor: pointer; +} +.ob-summary-mode-item:hover { + background: var(--bg3); +} +.ob-summary-mode-item--active { + color: var(--accent, #2962ff); +} .ob-summary-num { font-size: 12px; font-weight: 700; @@ -6213,6 +6274,119 @@ html.theme-blue { border-radius: var(--radius) var(--radius) 0 0; background: rgba(0,0,0,.15); } +.sp-node-head--cond { + position: relative; + flex-wrap: wrap; +} +.sp-node-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; +} +.sp-node-settings { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: var(--radius); + background: rgba(230, 194, 0, 0.12); + color: #e6c200; + cursor: pointer; + flex-shrink: 0; +} +.sp-node-settings:hover { + background: rgba(230, 194, 0, 0.22); +} +.sp-node-settings-pop { + position: absolute; + top: calc(100% + 4px); + right: 8px; + z-index: 50; + min-width: 160px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, #e6c200 45%, transparent); + background: var(--bg2); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} +.sp-node-settings-pop .se-flow-settings-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 0.62rem; + font-weight: 700; + color: #e6c200; + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.sp-node-settings-pop .se-flow-settings-close, +.se-flow-settings-close { + border: none; + background: transparent; + color: var(--text2); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 2px; + flex-shrink: 0; +} +.sp-node-settings-pop .se-flow-settings-close:hover, +.se-flow-settings-close:hover { + color: #e6c200; +} +.sp-node-settings-pop .se-flow-settings-field { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 6px; + font-size: 0.62rem; + color: var(--text2); +} +.sp-node-settings-pop .se-flow-settings-field input, +.se-flow-settings-field .se-combo-num-input, +.sp-node-settings-pop .se-combo-num-input { + width: 100%; + box-sizing: border-box; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text); + font-size: 0.72rem; +} +.se-combo-num { + width: 100%; +} +.se-combo-num-input { + width: 100%; + box-sizing: border-box; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--se-input-border, var(--border)); + background: var(--se-input-bg, var(--bg3)); + color: var(--se-text, var(--text)); + font-size: 0.72rem; +} +.se-combo-num-input:focus { + outline: none; + border-color: var(--se-input-focus, var(--accent)); +} +.se-flow-settings-field .se-combo-num-input { + border-color: var(--se-input-border); + background: var(--se-input-bg); +} +.sp-node-settings-pop .se-flow-settings-hint { + margin: 4px 0 0; + font-size: 0.58rem; + color: var(--text3); +} .sp-node-badge { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 99px; color: #fff; white-space: nowrap; @@ -6262,6 +6436,24 @@ html.theme-blue { color: var(--text); border-radius: var(--radius); } .sp-cond-num:focus { outline: none; border-color: var(--accent); } +.sp-cond-editor--composite { padding-top: 6px; } +.sp-cond-composite-badge { + margin-bottom: 8px; + padding: 4px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: #c084fc; + background: color-mix(in srgb, #c084fc 12%, transparent); + border: 1px solid color-mix(in srgb, #c084fc 28%, transparent); +} +.sp-cond-composite-val { + font-size: 12px; + font-weight: 600; + color: var(--text); + padding: 4px 0 2px; +} +.sp-cond-num--inline { margin-top: 2px; width: 100%; box-sizing: border-box; } /* ── 우측 팔레트 ──────────────────────────────────────────────────────────── */ .sp-palette { @@ -8809,9 +9001,12 @@ html.theme-blue { .rsp-trade-stack .rsp-trade-card-body { flex: 1; min-height: 0; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; display: flex; flex-direction: column; + container-type: size; + container-name: trade-card; } .rsp-trade-card { @@ -8893,6 +9088,7 @@ html.theme-blue { } .rsp-trade-card-body .top-actions { flex-shrink: 0; + margin-top: auto; } .rsp-trade-card-body .top-reset, .rsp-trade-card-body .top-submit { @@ -8900,6 +9096,75 @@ html.theme-blue { font-size: 13px; } +/* 가격·수량: 기본 세로 배치, 공간 부족 시 한 줄 */ +.top-price-qty-block { + display: flex; + flex-direction: column; + gap: 0; + flex-shrink: 0; +} +.top-price-qty-block .top-field { + margin-bottom: 10px; +} +.top-price-qty-block .top-pct-row--block { + margin-top: 0; + margin-bottom: 10px; +} + +.rsp-trade-card-body .top-price-qty-block .top-field { + margin-bottom: 0; +} +.rsp-trade-card-body .top-price-qty-block .top-pct-row--block { + margin-top: 4px; + margin-bottom: 0; +} + +@container trade-card (max-height: 360px) { + .rsp-trade-card-body .top-price-qty-block { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 8px; + row-gap: 4px; + } + .rsp-trade-card-body .top-price-qty-block .top-field--price { + grid-column: 1; + grid-row: 1; + } + .rsp-trade-card-body .top-price-qty-block .top-field--qty { + grid-column: 2; + grid-row: 1; + } + .rsp-trade-card-body .top-price-qty-block .top-pct-row--block { + grid-column: 1 / -1; + grid-row: 2; + margin-top: 0; + } +} + +@media (max-height: 780px) { + @supports not (container-type: size) { + .rsp-trade-card-body .top-price-qty-block { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 8px; + row-gap: 4px; + } + .rsp-trade-card-body .top-price-qty-block .top-field--price { + grid-column: 1; + grid-row: 1; + } + .rsp-trade-card-body .top-price-qty-block .top-field--qty { + grid-column: 2; + grid-row: 1; + } + .rsp-trade-card-body .top-price-qty-block .top-pct-row--block { + grid-column: 1 / -1; + grid-row: 2; + margin-top: 0; + } + } +} + /* 호가 탭: 카드 박스 + 실시간 호가 */ .rsp-ob-stack { flex: 1; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64bcfa7..c9e99c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1499,7 +1499,7 @@ function App() { )} {menuPage === 'strategy-editor' && ( - + )} {/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */} diff --git a/frontend/src/components/OrderbookPanel.tsx b/frontend/src/components/OrderbookPanel.tsx index c284e4f..42f1228 100644 --- a/frontend/src/components/OrderbookPanel.tsx +++ b/frontend/src/components/OrderbookPanel.tsx @@ -2,7 +2,7 @@ * 실시간 호가 — 증권앱형 통합 레이아웃 * 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계 */ -import React, { memo, useState, useEffect } from 'react'; +import React, { memo, useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { formatNowClock, useDisplayTimezone } from '../utils/timezone'; import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook'; import type { RecentTrade } from '../hooks/useUpbitRecentTrades'; @@ -35,16 +35,139 @@ function fmtPct(price: number, prevClose: number): string { return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`; } +function fmtTotalAmount(a: number): string { + if (a == null || !isFinite(a)) return '-'; + return a.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); +} + +/** 하단 합계·호가 잔량 표시 모드 — 기본 총액(KRW) */ +type SummaryDisplayMode = 'amount' | 'quantity'; + +function unitDisplayValue(unit: OrderbookDisplayUnit, mode: SummaryDisplayMode): number { + return mode === 'amount' ? unit.price * unit.size : unit.size; +} + +function formatDisplayValue(value: number, mode: SummaryDisplayMode): string { + return mode === 'amount' ? fmtTotalAmount(value) : fmtSize(value); +} + function fmtTotalSize(s: number): string { if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); if (s >= 1) return s.toFixed(2); return s.toFixed(4); } +function SummaryBar({ + asks, + bids, + totalAskSize, + totalBidSize, + clock, + mode, + onModeChange, +}: { + asks: OrderbookDisplayUnit[]; + bids: OrderbookDisplayUnit[]; + totalAskSize: number; + totalBidSize: number; + clock: string; + mode: SummaryDisplayMode; + onModeChange: (mode: SummaryDisplayMode) => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const wrapRef = useRef(null); + + const totalAskAmount = useMemo( + () => asks.reduce((sum, u) => sum + u.price * u.size, 0), + [asks], + ); + const totalBidAmount = useMemo( + () => bids.reduce((sum, u) => sum + u.price * u.size, 0), + [bids], + ); + + const askDisplay = mode === 'amount' + ? fmtTotalAmount(totalAskAmount) + : fmtTotalSize(totalAskSize); + const bidDisplay = mode === 'amount' + ? fmtTotalAmount(totalBidAmount) + : fmtTotalSize(totalBidSize); + + const closeMenu = useCallback(() => setMenuOpen(false), []); + + useEffect(() => { + if (!menuOpen) return; + const onDoc = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + closeMenu(); + } + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [menuOpen, closeMenu]); + + const pickMode = (next: SummaryDisplayMode) => { + onModeChange(next); + closeMenu(); + }; + + return ( +
+
+ {askDisplay} +
+
+ + {menuOpen && ( +
+ + +
+ )} +
+
+ {bidDisplay} +
+
+ ); +} + function coinCode(market: string): string { return market.replace(/^KRW-/, ''); } +/** 호가 가격과 현재가(체결가) 일치 여부 */ +function isLivePrice(levelPrice: number, currentPrice: number): boolean { + if (levelPrice <= 0 || currentPrice <= 0) return false; + const tol = Math.max(1e-8, Math.abs(currentPrice) * 1e-6); + return Math.abs(levelPrice - currentPrice) <= tol; +} + function pctClass(pct: string): string { if (pct.startsWith('+')) return 'ob-td-pct--up'; if (pct.startsWith('-')) return 'ob-td-pct--dn'; @@ -72,22 +195,28 @@ const COLGROUP = ( ); const AskTableRow = memo(function AskTableRow({ - unit, prevClose, onClick, + unit, prevClose, displayMode, maxDisplay, currentPrice, onClick, }: { unit: OrderbookDisplayUnit; prevClose: number; + displayMode: SummaryDisplayMode; + maxDisplay: number; + currentPrice: number; onClick?: (price: number, type: 'ask' | 'bid') => void; }) { const pct = fmtPct(unit.price, prevClose); + const displayVal = unitDisplayValue(unit, displayMode); + const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0; + const isLive = isLivePrice(unit.price, currentPrice); return ( onClick(unit.price, 'ask') : undefined}>
-
- {fmtSize(unit.size)} +
+ {formatDisplayValue(displayVal, displayMode)}
- {fmtPrice(unit.price)} + {fmtPrice(unit.price)} {pct} ); @@ -95,21 +224,27 @@ const AskTableRow = memo(function AskTableRow({ /** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */ const BidTableRow = memo(function BidTableRow({ - unit, prevClose, onClick, + unit, prevClose, displayMode, maxDisplay, currentPrice, onClick, }: { unit: OrderbookDisplayUnit; prevClose: number; + displayMode: SummaryDisplayMode; + maxDisplay: number; + currentPrice: number; onClick?: (price: number, type: 'ask' | 'bid') => void; }) { const pct = fmtPct(unit.price, prevClose); + const displayVal = unitDisplayValue(unit, displayMode); + const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0; + const isLive = isLivePrice(unit.price, currentPrice); return ( onClick(unit.price, 'bid') : undefined}> {pct} - {fmtPrice(unit.price)} + {fmtPrice(unit.price)}
-
- {fmtSize(unit.size)} +
+ {formatDisplayValue(displayVal, displayMode)}
@@ -257,10 +392,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({ prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick, }: OrderbookPanelProps) { const isEmpty = asks.length === 0 && bids.length === 0; - const midPrice = tickerInfo?.tradePrice ?? 0; - const midPct = midPrice > 0 ? fmtPct(midPrice, prevClose) : ''; + const currentPrice = tickerInfo?.tradePrice ?? 0; const clock = useClock(); - const isUp = (tickerInfo?.changeRate ?? 0) >= 0; + const [summaryMode, setSummaryMode] = useState('amount'); + + const maxDisplay = useMemo(() => { + const all = [...asks, ...bids]; + if (all.length === 0) return 0; + return Math.max(...all.map(u => unitDisplayValue(u, summaryMode)), 0); + }, [asks, bids, summaryMode]); return (
@@ -301,7 +441,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({ {COLGROUP} {asks.map((unit, i) => ( - + ))} @@ -310,21 +458,6 @@ export const OrderbookPanel = memo(function OrderbookPanel({ {tickerInfo && }
- {/* 현재가 (가운데 열 정렬) */} - - {COLGROUP} - - - - - -
- - {midPrice > 0 ? fmtPrice(midPrice) : '-'} - {midPct && {midPct}} - -
- {/* 매수 구간 + 좌측 체결 */}
@@ -334,7 +467,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({ {COLGROUP} {bids.map((unit, i) => ( - + ))} @@ -345,15 +486,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({ )} {!isEmpty && ( -
-
- {fmtTotalSize(totalAskSize)} -
-
{clock}
-
- {fmtTotalSize(totalBidSize)} -
-
+ )}
); diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 67aa14b..b673e0d 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -4,11 +4,11 @@ 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 type { Theme } from '../types/index'; import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; import type { LogicNode } from '../utils/strategyTypes'; import { - buildDef, + buildStrategyEditorDef, loadStratsLocal, saveStratsLocal, mergeAtRoot, @@ -19,6 +19,10 @@ import { type StrategyDto, } from '../utils/strategyEditorShared'; import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout'; +import { + COMPOSITE_INDICATOR_ITEMS, + compositePeriodLabel, +} from '../utils/compositeIndicators'; import { emptySignalFlowLayout, loadStrategyFlowLayout, @@ -66,7 +70,6 @@ const TERMINAL_DEFAULT = 140; interface Props { theme: Theme; - activeIndicators?: IndicatorConfig[]; } function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot { @@ -80,8 +83,8 @@ function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayo }; } -export default function StrategyEditorPage({ theme, activeIndicators = [] }: Props) { - const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]); +export default function StrategyEditorPage({ theme }: Props) { + const DEF = useMemo(() => buildStrategyEditorDef(), []); const [strategies, setStrategies] = useState(() => loadStratsLocal()); const [selectedId, setSelectedId] = useState(null); @@ -511,8 +514,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro } }, []); - const applyPalette = useCallback((type: string, value: string, label: string) => { - const newNode = makeNode(type, value, signalTab, DEF); + const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => { + const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined); const root = currentRoot; if (!root) setCurrentRoot(newNode); else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true)); @@ -576,11 +579,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro const match = (label: string, desc?: string) => !q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false); - const paletteKey = (type: 'operator' | 'indicator', value: string) => `${type}:${value}`; - const selectPalette = (type: 'operator' | 'indicator', value: string) => { + const paletteKey = (type: 'operator' | 'indicator' | 'composite', value: string) => `${type}:${value}`; + const selectPalette = (type: 'operator' | 'indicator' | 'composite', value: string) => { setSelectedPaletteKey(paletteKey(type, value)); }; - const isPaletteSelected = (type: 'operator' | 'indicator', value: string) => + const isPaletteSelected = (type: 'operator' | 'indicator' | 'composite', value: string) => selectedPaletteKey === paletteKey(type, value); return ( @@ -780,7 +783,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro } }} /> - 차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'} + 전략 조건 전용 설정 · 차트 보조지표와 무관
)} @@ -846,7 +849,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro

밴드 · 추세

-
+
{maBandItems.filter(i => match(i.label, i.desc)).map(item => (
-
+

보조지표

-
+
{indicatorItems.filter(i => match(i.label, i.desc)).map(item => (
+
+

복합지표

+
+ {COMPOSITE_INDICATOR_ITEMS.filter(i => match(i.label, i.desc)).map(item => ( + selectPalette('composite', item.value)} + onAdd={() => applyPalette('indicator', item.value, item.label, true)} + /> + ))} +
+
)} {rightTab === 'templates' && ( diff --git a/frontend/src/components/TradeOrderPanel.tsx b/frontend/src/components/TradeOrderPanel.tsx index 28564bd..2028308 100644 --- a/frontend/src/components/TradeOrderPanel.tsx +++ b/frontend/src/components/TradeOrderPanel.tsx @@ -323,38 +323,41 @@ const TradeOrderPanel: React.FC = ({
-
- -
+
+
+ +
+ + + +
+
+ +
+ - -
-
-
- - -
+
{PCT_BTNS.map(p => ( +
+ {condition.composite ? ( + <> + + + + ) : usesValuePeriodField(condition) ? ( + + ) : null} + {showThreshold && rightThreshold != null && ( + + )} + {condition.composite && ( +

{compositeDisplayName(condition.indicatorType)}

+ )} +
+ ); +} diff --git a/frontend/src/components/strategyEditor/FlowNodes.tsx b/frontend/src/components/strategyEditor/FlowNodes.tsx index 7d673f6..b85a67c 100644 --- a/frontend/src/components/strategyEditor/FlowNodes.tsx +++ b/frontend/src/components/strategyEditor/FlowNodes.tsx @@ -1,6 +1,8 @@ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react'; import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout'; +import { hasNodeSettings } from '../../utils/conditionPeriods'; +import ConditionNodeSettings from './ConditionNodeSettings'; const LOGIC_COLORS: Record = { AND: '#00aaff', @@ -179,16 +181,23 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: 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 cond = node.condition; + const ind = cond?.indicatorType ?? 'COND'; + const condType = cond?.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); + const [settingsOpen, setSettingsOpen] = useState(false); + const showSettings = cond && hasNodeSettings(cond); + + const handleSettingsChange = useCallback((next: NonNullable) => { + d.onUpdateCondition?.(id, next); + }, [d, id]); return (
-
{ind}
+
+
{ind}
+
+ {showSettings && ( + + )} + +
+
{d.label ?? ind}
- + {settingsOpen && cond && d.def && ( + setSettingsOpen(false)} + /> + )}
); }); diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index 9d70992..18bb5e8 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -4,6 +4,7 @@ export interface PaletteDragPayload { type: 'operator' | 'indicator'; value: string; label: string; + composite?: boolean; } interface Props { @@ -13,16 +14,17 @@ interface Props { desc?: string; color?: string; period?: string; + composite?: boolean; selected?: boolean; onSelect?: () => void; onAdd: () => void; } export default function PaletteChip({ - type, value, label, desc, color, period, selected = false, onSelect, onAdd, + type, value, label, desc, color, period, composite = false, selected = false, onSelect, onAdd, }: Props) { const onDragStart = (e: React.DragEvent) => { - const payload: PaletteDragPayload = { type, value, label }; + const payload: PaletteDragPayload = { type, value, label, composite: composite || undefined }; e.dataTransfer.setData('application/json', JSON.stringify(payload)); e.dataTransfer.effectAllowed = 'copy'; }; @@ -51,7 +53,7 @@ export default function PaletteChip({ return (
{label.slice(0, 1)}
- {label} + + {composite ? `${label} + ${label}` : label} + {desc ? {desc} : null}
diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 0c01031..256cef5 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -18,12 +18,13 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type { Theme } from '../../types/index'; -import type { LogicNode } from '../../utils/strategyTypes'; +import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes'; import { addChild, deleteNode, mergeAtRoot, makeNode, + updateNode, type DefType, } from '../../utils/strategyEditorShared'; import { @@ -408,10 +409,10 @@ function StrategyEditorCanvasInner({ }, [setNodes]); const addOrphanAt = useCallback(( - data: { type: string; value: string; label: string }, + data: { type: string; value: string; label: string; composite?: boolean }, flowPos: { x: number; y: number }, ) => { - const newNode = makeNode(data.type, data.value, signalTab, def); + const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined); positionsRef.current.set(newNode.id, { x: flowPos.x - FLOW_NODE_W / 2, y: flowPos.y - FLOW_NODE_H / 2, @@ -422,10 +423,10 @@ function StrategyEditorCanvasInner({ const applyDropWithAttach = useCallback(( anchorId: string, - data: { type: string; value: string; label: string }, + data: { type: string; value: string; label: string; composite?: boolean }, flowPos: { x: number; y: number }, ) => { - const newNode = makeNode(data.type, data.value, signalTab, def); + const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined); const anchor = nodesRef.current.find(n => n.id === anchorId); if (!anchor) return; @@ -488,12 +489,24 @@ function StrategyEditorCanvasInner({ } }, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]); + const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => { + if (isOrphanNode(orphans, id)) { + onOrphansChange(orphans.map(o => ( + o.id === id ? { ...o, condition } : o + ))); + return; + } + if (!root) return; + onChange(updateNode(root, id, n => ({ ...n, condition }))); + }, [root, orphans, onChange, onOrphansChange]); + const flowCallbacks = useMemo(() => ({ onDelete: handleDelete, + onUpdateCondition: handleUpdateCondition, onDropTarget: handleDropTarget, onDragOverTarget: handleDragOverTarget, onDragLeaveTarget: handleDragLeaveTarget, - }), [handleDelete, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]); + }), [handleDelete, handleUpdateCondition, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]); const rebuildFlow = useCallback(() => logicNodeToFlow( root, diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index 941e923..fe39510 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -8,6 +8,8 @@ import { nodeToText, type DefType, } from '../../utils/strategyEditorShared'; +import { hasNodeSettings } from '../../utils/conditionPeriods'; +import ConditionNodeSettings from './ConditionNodeSettings'; const NODE_COLORS: Record = { AND: '#4caf50', @@ -41,6 +43,8 @@ const TreeNodeComp: React.FC = ({ }) => { const isLogic = ['AND', 'OR', 'NOT'].includes(node.type); const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR; + const [settingsOpen, setSettingsOpen] = useState(false); + const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition); const label = node.type === 'CONDITION' && node.condition ? nodeToText(node, def) : node.type === 'AND' @@ -83,12 +87,41 @@ const TreeNodeComp: React.FC = ({ onDragLeave={handleDragLeave} onDrop={handleDrop} > -
+
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type} {label} - +
+ {showSettings && ( + + )} + +
+ {settingsOpen && node.condition && ( + setSettingsOpen(false)} + popoverClassName="sp-node-settings-pop" + /> + )}
{node.type === 'CONDITION' && node.condition && ( @@ -132,9 +165,9 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P const applyDrop = useCallback(( targetId: string | null, - data: { type: string; value: string; label: string }, + data: { type: string; value: string; label: string; composite?: boolean }, ) => { - const newNode = makeNode(data.type, data.value, signalTab, def); + const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined); if (!root) { onChange(newNode); return; @@ -156,7 +189,7 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P } }; - const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string }) => { + const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => { applyDrop(targetId, data); }; diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index a3ef9b6..2d798ba 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -529,10 +529,41 @@ overflow-y: auto; border: none; background: transparent; + container-type: size; + container-name: trade-card; } .ptd-order-card .ptd-order-stack { padding: 0 4px 4px; flex: 1; } .ptd-order-card .top-panel { padding: 4px 2px; background: transparent; } .ptd-order-card .top-field { margin-bottom: 4px; } +.ptd-order-card .top-price-qty-block .top-field { margin-bottom: 0; } +.ptd-order-card .top-price-qty-block .top-pct-row--block { margin-bottom: 4px; } + +@container trade-card (max-height: 360px) { + .ptd-order-card .top-price-qty-block { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 8px; + row-gap: 4px; + } + .ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; } + .ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; } + .ptd-order-card .top-price-qty-block .top-pct-row--block { grid-column: 1 / -1; grid-row: 2; margin-top: 0; } +} + +@media (max-height: 780px) { + @supports not (container-type: size) { + .ptd-order-card .top-price-qty-block { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 8px; + row-gap: 4px; + } + .ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; } + .ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; } + .ptd-order-card .top-price-qty-block .top-pct-row--block { grid-column: 1 / -1; grid-row: 2; margin-top: 0; } + } +} + .ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; } .ptd-order-card .top-submit--sell { background: var(--accent) !important; border-color: var(--accent) !important; } diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index ad295eb..a301d8c 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -702,7 +702,21 @@ 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-cond-badge { font-size: 0.65rem; font-weight: 800; color: var(--se-success); margin-bottom: 0; } +.se-flow-cond-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 6px; + margin-bottom: 4px; +} +.se-flow-cond-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} +.se-flow-node--cond { position: relative; } .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; } @@ -726,14 +740,108 @@ } .se-flow-del { - position: absolute; top: 4px; right: 6px; + position: static; 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; + opacity: 0; + transition: opacity 0.12s; + flex-shrink: 0; +} +.se-flow-settings { + border: none; + background: rgba(0, 0, 0, 0.35); + color: var(--se-gold); + width: 18px; height: 18px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + opacity: 0; + transition: opacity 0.12s, background 0.12s; + flex-shrink: 0; +} +.se-flow-settings:hover { + background: rgba(230, 194, 0, 0.2); +} +.se-flow-node:hover .se-flow-del, +.se-flow-node:hover .se-flow-settings, +.se-flow-node--selected .se-flow-del, +.se-flow-node--selected .se-flow-settings, +.se-flow-node--settings-open .se-flow-del, +.se-flow-node--settings-open .se-flow-settings { + opacity: 1; +} + +.se-flow-settings-pop { + position: absolute; + top: 6px; + right: 6px; + z-index: 30; + min-width: 148px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--se-gold) 45%, transparent); + background: var(--se-bg-elevated); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} +.se-flow-settings-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 0.62rem; + font-weight: 700; + color: var(--se-gold); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.se-flow-settings-close { + border: none; + background: transparent; + color: var(--se-text-muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 2px; + flex-shrink: 0; +} +.se-flow-settings-close:hover { + color: var(--se-gold); +} +.se-flow-settings-field { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 6px; + font-size: 0.62rem; + color: var(--se-text-muted); +} +.se-flow-settings-field input, +.se-flow-settings-field .se-combo-num-input { + width: 100%; + box-sizing: border-box; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--se-input-border); + background: var(--se-input-bg); + color: var(--se-text); + font-size: 0.72rem; +} +.se-combo-num { width: 100%; } +.se-combo-num-input:focus { + outline: none; + border-color: var(--se-input-focus); +} +.se-flow-settings-hint { + margin: 4px 0 0; + font-size: 0.58rem; + color: var(--se-text-dim); } -.se-flow-node:hover .se-flow-del { opacity: 1; } .se-flow-handle { width: 9px !important; height: 9px !important; @@ -907,8 +1015,8 @@ /* ── Right palette ── */ .se-right { - flex: 0 0 260px; - width: 260px; + flex: 0 0 380px; + width: 380px; display: flex; flex-direction: column; min-height: 0; @@ -952,7 +1060,8 @@ min-height: 0; display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .se-palette-search { @@ -982,13 +1091,14 @@ .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-section--composite h3 { color: var(--se-palette-section-composite, #c084fc); } .se-palette-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } -.se-palette-grid--3 { grid-template-columns: repeat(3, 1fr); } +.se-palette-grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .se-palette-card { border-radius: 8px; @@ -1082,6 +1192,25 @@ 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--composite { + border-color: color-mix(in srgb, #c084fc 42%, transparent); + background: color-mix(in srgb, #c084fc 8%, var(--se-palette-card-bg)); + box-shadow: inset 3px 0 0 #c084fc; +} +.se-palette-card--composite .se-palette-card-icon { + background: color-mix(in srgb, #c084fc 22%, transparent); + color: #c084fc; +} +.se-palette-card--composite:hover { + border-color: color-mix(in srgb, #c084fc 65%, transparent); + box-shadow: inset 3px 0 0 #c084fc, 0 4px 14px color-mix(in srgb, #c084fc 22%, transparent); +} +.se-palette-card--selected.se-palette-card--composite { + outline-color: #c084fc; +} + .se-palette-card-head { display: flex; align-items: flex-start; gap: 6px; } .se-palette-card-icon { width: 24px; height: 24px; border-radius: 6px; @@ -1164,5 +1293,5 @@ } @media (max-width: 1100px) { - .se-right { flex: 0 0 220px; width: 220px; } + .se-right { flex: 0 0 300px; width: 300px; } } diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 7eb0740..efb9517 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -210,6 +210,9 @@ export class ChartManager { /** 차트 하단 거래량 pane 표시 */ private _volumeVisible = true; + /** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */ + private _lastLayoutAvailableHeight?: number; + constructor(container: HTMLElement, theme: Theme) { this.container = container; const t = getTheme(theme); @@ -221,6 +224,7 @@ export class ChartManager { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor, fontSize: 12, + panes: { enableResize: false }, }, grid: { vertLines: { color: t.gridColor }, @@ -288,6 +292,7 @@ export class ChartManager { } this.indicators.clear(); this.patternMarkers = []; + this._removeExtraSubPanes(); this._disposeMainMarkersPlugin(); if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; } @@ -535,6 +540,10 @@ export class ChartManager { } } catch (e) { console.error(`[Indicator] ${config.type} error:`, e); + } finally { + if (this._activeIndicatorPaneIndices().size > 0) { + this.resetPaneHeights(this._lastLayoutAvailableHeight); + } } } @@ -557,6 +566,7 @@ export class ChartManager { this.indicators.clear(); this.patternMarkers = this.patternMarkers.filter(() => false); this._reapplyAllPatternMarkers(); + this._removeExtraSubPanes(); // ② 새 순서로 재추가 (병합 호스트 → 멤버 순) this._indRunning = false; @@ -590,7 +600,8 @@ export class ChartManager { this.patternMarkers = this.patternMarkers.filter(p => p.id !== id); this._reapplyAllPatternMarkers(); this.indicators.delete(id); - this.resetPaneHeights(); + this._removeExtraSubPanes(); + this.resetPaneHeights(this._lastLayoutAvailableHeight); this._notifyPaneLayout(); } @@ -1232,6 +1243,7 @@ export class ChartManager { this.indicators.clear(); this.patternMarkers = []; this._reapplyAllPatternMarkers(); + this._removeExtraSubPanes(); for (const cfg of configs) { await this.addIndicator(cfg); @@ -2047,6 +2059,20 @@ export class ChartManager { return indices; } + /** pane 2+ 빈 sub-pane 제거 — 제거·재로드 후 고아 pane 이 높이를 나눠 가져가는 것 방지 */ + private _removeExtraSubPanes(): void { + for (;;) { + const panes = this.chart.panes(); + if (panes.length <= 2) return; + const last = panes.length - 1; + try { + this.chart.removePane(last); + } catch { + return; + } + } + } + /** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */ getPaneIndexAtChartY(chartY: number): number { const layouts = this.getPaneLayouts(); @@ -2374,6 +2400,16 @@ export class ChartManager { return this.indicators.size; } + /** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */ + getLoadedIndicatorCount(): number { + let n = 0; + for (const entry of this.indicators.values()) { + if (entry.config?.hidden === true) continue; + if (entry.cloudPlugin || entry.seriesList.length > 0) n += 1; + } + return n; + } + /** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */ getRawBarsLength(): number { return this.rawBars.length; @@ -2732,12 +2768,16 @@ export class ChartManager { const N = activeIndPanes.size; const H = (availableHeight && availableHeight > 0) ? availableHeight - : this.container.clientHeight; + : (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0 + ? this._lastLayoutAvailableHeight + : this.container.clientHeight); const volumeShown = this._volumeVisible && !this._candleOnlyLayout; const VOL_FRAC = this._volumeFrac(H); const MIN_IND_PX = 80; const ORPHAN_STRETCH = 0.0001; + /** 보조지표 pane 은 동일 stretch weight → 항상 같은 높이 */ + const IND_EQUAL_WEIGHT = 1; const remaining = Math.max(0, 1 - mainFrac - (volumeShown ? VOL_FRAC : 0)); const eachIndFrac = N > 0 ? remaining / N : 0; @@ -2746,13 +2786,20 @@ export class ChartManager { ? eachIndFrac : MIN_IND_PX / H; + const indWeight = IND_EQUAL_WEIGHT; + const indUnit = Math.max(actualIndFrac, 1e-9); + const mainWeight = N > 0 ? mainFrac / indUnit : mainFrac; + const volWeight = volumeShown + ? (N > 0 ? VOL_FRAC / indUnit : VOL_FRAC) + : ORPHAN_STRETCH; + for (let i = 0; i < panes.length; i++) { if (i === 0) { - panes[i].setStretchFactor(mainFrac); + panes[i].setStretchFactor(mainWeight); } else if (i === 1) { - panes[i].setStretchFactor(volumeShown ? VOL_FRAC : ORPHAN_STRETCH); + panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH); } else if (activeIndPanes.has(i)) { - panes[i].setStretchFactor(actualIndFrac); + panes[i].setStretchFactor(indWeight); } else { panes[i].setStretchFactor(ORPHAN_STRETCH); } @@ -2801,13 +2848,19 @@ export class ChartManager { const panes = this.chart.panes(); if (panes.length === 0) return availableHeight ?? this.container.clientHeight; + if (availableHeight != null && availableHeight > 0) { + this._lastLayoutAvailableHeight = availableHeight; + } + if (this._candleOnlyLayout) { return this._resetPaneHeightsCandleFullscreen(availableHeight); } const H = (availableHeight && availableHeight > 0) ? availableHeight - : this.container.clientHeight; + : (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0 + ? this._lastLayoutAvailableHeight + : this.container.clientHeight); const N = this._activeIndicatorPaneIndices().size; const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H)); diff --git a/frontend/src/utils/compositeIndicators.ts b/frontend/src/utils/compositeIndicators.ts new file mode 100644 index 0000000..5d2710c --- /dev/null +++ b/frontend/src/utils/compositeIndicators.ts @@ -0,0 +1,153 @@ +/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */ +import type { ConditionDSL } from './strategyTypes'; + +export interface CompositePeriodDef { + rsiPeriod: number; + cciPeriod: number; + adxPeriod: number; + williamsR: number; + trixPeriod: number; + vrPeriod: number; + newPsy: number; + investPsy: number; + dispShort: number; + dispMid: number; +} + +export interface CompositeIndicatorItem { + value: string; + label: string; + desc: string; +} + +/** 복합지표 팔레트 — 동일 지표 타입 2인스턴스 교차 */ +export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [ + { value: 'RSI', label: 'RSI', desc: 'RSI 기간 교차' }, + { value: 'CCI', label: 'CCI', desc: 'CCI 기간 교차' }, + { value: 'ADX', label: 'ADX', desc: 'ADX 기간 교차' }, + { value: 'WILLIAMS_R', label: 'Williams %R', desc: 'Williams %R 교차' }, + { value: 'TRIX', label: 'TRIX', desc: 'TRIX 기간 교차' }, + { value: 'VR', label: 'VR', desc: 'VR 기간 교차' }, + { value: 'PSYCHOLOGICAL', label: '심리도', desc: '심리도 기간 교차' }, + { value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: '투자심리도 교차' }, + { value: 'DISPARITY', label: '이격도', desc: '이격도 기간 교차' }, +]; + +export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef): { short: number; long: number } { + switch (ind) { + case 'RSI': return { short: def.rsiPeriod, long: Math.max(def.rsiPeriod * 2, 20) }; + case 'CCI': return { short: def.cciPeriod, long: Math.max(def.cciPeriod * 2, 26) }; + case 'ADX': return { short: def.adxPeriod, long: Math.max(def.adxPeriod * 2, 28) }; + case 'WILLIAMS_R': return { short: def.williamsR, long: Math.max(def.williamsR * 2, 28) }; + case 'TRIX': return { short: def.trixPeriod, long: Math.max(def.trixPeriod * 2, 24) }; + case 'VR': return { short: def.vrPeriod, long: Math.max(def.vrPeriod * 2, 20) }; + case 'PSYCHOLOGICAL': + case 'NEW_PSYCHOLOGICAL': return { short: def.newPsy, long: Math.max(def.newPsy * 2, 24) }; + case 'INVEST_PSYCHOLOGICAL': return { short: def.investPsy, long: Math.max(def.investPsy * 2, 20) }; + case 'DISPARITY': return { short: def.dispShort, long: def.dispMid }; + default: return { short: 9, long: 20 }; + } +} + +export function compositeValueField(ind: string, period: number): string { + switch (ind) { + case 'RSI': return `RSI_VALUE_${period}`; + case 'CCI': return `CCI_VALUE_${period}`; + case 'ADX': return `ADX_VALUE_${period}`; + case 'WILLIAMS_R': return `WILLIAMS_R_VALUE_${period}`; + case 'TRIX': return `TRIX_VALUE_${period}`; + case 'VR': return `VR_VALUE_${period}`; + case 'PSYCHOLOGICAL': + case 'NEW_PSYCHOLOGICAL': return `PSY_VALUE_${period}`; + case 'INVEST_PSYCHOLOGICAL': return `INVEST_PSY_VALUE_${period}`; + case 'DISPARITY': return `DISPARITY${period}`; + default: return `${ind}_VALUE_${period}`; + } +} + +export function parsePeriodFromCompositeField(ind: string, field?: string): number | null { + if (!field) return null; + if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) { + const n = parseInt(field.slice('DISPARITY'.length), 10); + return Number.isFinite(n) && n > 0 ? n : null; + } + const suffix = field.match(/_(\d+)$/); + if (!suffix) return null; + const n = parseInt(suffix[1], 10); + return Number.isFinite(n) && n > 0 ? n : null; +} + +export function syncCompositeFields(cond: ConditionDSL): ConditionDSL { + if (!cond.composite) return cond; + const leftP = cond.leftPeriod + ?? parsePeriodFromCompositeField(cond.indicatorType, cond.leftField) + ?? undefined; + const rightP = cond.rightPeriod + ?? parsePeriodFromCompositeField(cond.indicatorType, cond.rightField) + ?? undefined; + if (leftP == null || rightP == null) { + return { + ...cond, + composite: true, + ...(leftP != null ? { leftPeriod: leftP } : null), + ...(rightP != null ? { rightPeriod: rightP } : null), + }; + } + return { + ...cond, + composite: true, + leftPeriod: leftP, + rightPeriod: rightP, + leftField: compositeValueField(cond.indicatorType, leftP), + rightField: compositeValueField(cond.indicatorType, rightP), + period: undefined, + }; +} + +export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL { + if (cond.composite) return syncCompositeFields(cond); + const leftP = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField); + const rightP = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField); + if (leftP && rightP && leftP !== rightP) { + return syncCompositeFields({ + ...cond, + composite: true, + leftPeriod: leftP, + rightPeriod: rightP, + }); + } + return cond; +} + +export function makeCompositeCondition( + ind: string, + signalType: 'buy' | 'sell', + def: CompositePeriodDef, +): ConditionDSL { + const { short, long } = getCompositeDefaultPeriods(ind, def); + return syncCompositeFields({ + indicatorType: ind, + conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', + composite: true, + leftPeriod: short, + rightPeriod: long, + candleRange: 1, + }); +} + +export function compositePeriodLabel(ind: string, def: CompositePeriodDef): string { + const { short, long } = getCompositeDefaultPeriods(ind, def); + return `${short} / ${long}`; +} + +export function compositeDisplayName(ind: string): string { + const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind); + if (item) return `${item.label} + ${item.label}`; + return `${ind} + ${ind}`; +} + +export function compositeElementLabel(ind: string, slot: 1 | 2): string { + const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind); + const name = item?.label ?? ind; + return `${name} ${slot}`; +} diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts new file mode 100644 index 0000000..038f240 --- /dev/null +++ b/frontend/src/utils/conditionPeriods.ts @@ -0,0 +1,219 @@ +/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */ +import type { ConditionDSL } from './strategyTypes'; +import { getCompositeDefaultPeriods, parsePeriodFromCompositeField, syncCompositeFields, type CompositePeriodDef } from './compositeIndicators'; + +export interface IndicatorPeriodDef { + rsiPeriod: number; + cciPeriod: number; + adxPeriod: number; + williamsR: number; + trixPeriod: number; + vrPeriod: number; + newPsy: number; + investPsy: number; +} + +const VALUE_FIELD_PREFIX: Record = { + RSI: 'RSI_VALUE', + CCI: 'CCI_VALUE', + ADX: 'ADX_VALUE', + WILLIAMS_R: 'WILLIAMS_R_VALUE', + TRIX: 'TRIX_VALUE', + VR: 'VR_VALUE', + PSYCHOLOGICAL: 'PSY_VALUE', + NEW_PSYCHOLOGICAL: 'PSY_VALUE', + INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE', +}; + +export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number { + switch (indicatorType) { + case 'RSI': return def.rsiPeriod; + case 'CCI': return def.cciPeriod; + case 'ADX': return def.adxPeriod; + case 'WILLIAMS_R': return def.williamsR; + case 'TRIX': return def.trixPeriod; + case 'VR': return def.vrPeriod; + case 'PSYCHOLOGICAL': + case 'NEW_PSYCHOLOGICAL': return def.newPsy; + case 'INVEST_PSYCHOLOGICAL': return def.investPsy; + default: return 14; + } +} + +function parseFieldPeriod(field?: string): number | null { + if (!field) return null; + const m = field.match(/_(\d+)$/); + if (!m) return null; + const n = parseInt(m[1], 10); + return Number.isFinite(n) && n > 0 ? n : null; +} + +/** 조건 노드의 RSI 등 값(좌측) 기간 */ +export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number { + if (cond.composite) { + if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod; + const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField); + if (fromField) return fromField; + } + if (cond.period && cond.period > 0) return cond.period; + const fromField = parseFieldPeriod(cond.leftField); + if (fromField) return fromField; + return getDefaultIndicatorPeriod(cond.indicatorType, def); +} + +export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number { + if (cond.composite) { + if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod; + const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField); + if (fromField) return fromField; + } + const fromField = parseFieldPeriod(cond.rightField); + if (fromField) return fromField; + return getDefaultIndicatorPeriod(cond.indicatorType, def); +} + +export function usesValuePeriodField(cond: ConditionDSL): boolean { + if (cond.composite) return true; + const prefix = VALUE_FIELD_PREFIX[cond.indicatorType]; + if (!prefix) return false; + const lf = cond.leftField ?? ''; + return lf === prefix || lf.startsWith(`${prefix}_`); +} + +export function parseThresholdField(field?: string): number | null { + if (!field?.startsWith('K_')) return null; + const n = parseFloat(field.slice(2)); + return Number.isFinite(n) ? n : null; +} + +export function thresholdField(value: number): string { + return `K_${value}`; +} + +export function hasEditableThreshold(cond: ConditionDSL): boolean { + return parseThresholdField(cond.rightField) != null + || parseThresholdField(cond.leftField) != null; +} + +export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null { + const field = side === 'left' ? cond.leftField : cond.rightField; + return parseThresholdField(field); +} + +export function setConditionValuePeriod( + cond: ConditionDSL, + period: number, + _def: IndicatorPeriodDef, +): ConditionDSL { + const p = Math.max(1, Math.min(500, Math.round(period))); + if (cond.composite) { + return syncCompositeFields({ ...cond, composite: true, leftPeriod: p }); + } + const prefix = VALUE_FIELD_PREFIX[cond.indicatorType]; + const next: ConditionDSL = { ...cond, period: p }; + if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) { + next.leftField = `${prefix}_${p}`; + } + return next; +} + +export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL { + const p = Math.max(1, Math.min(500, Math.round(period))); + if (!cond.composite) return cond; + return syncCompositeFields({ ...cond, composite: true, rightPeriod: p }); +} + +export function setConditionThreshold( + cond: ConditionDSL, + side: 'left' | 'right', + value: number, +): ConditionDSL { + const fieldKey = side === 'left' ? 'leftField' : 'rightField'; + const current = cond[fieldKey]; + if (!current?.startsWith('K_')) return cond; + return { + ...cond, + [fieldKey]: thresholdField(value), + targetValue: value, + }; +} + +export function initConditionPeriods( + indicatorType: string, + condition: ConditionDSL, + def: IndicatorPeriodDef, +): ConditionDSL { + if (condition.composite) return condition; + const prefix = VALUE_FIELD_PREFIX[indicatorType]; + if (!prefix) return condition; + const period = getDefaultIndicatorPeriod(indicatorType, def); + const lf = condition.leftField ?? ''; + if (lf === prefix || lf.startsWith(`${prefix}_`)) { + return { ...condition, period: condition.period ?? period, leftField: `${prefix}_${condition.period ?? period}` }; + } + return { ...condition, period: condition.period ?? period }; +} + +export function hasNodeSettings(cond: ConditionDSL): boolean { + return usesValuePeriodField(cond) + || hasEditableThreshold(cond) + || !!cond.composite; +} + +const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120]; + +export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] { + const base = getDefaultIndicatorPeriod(indicatorType, def); + return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b); +} + +/** 복합지표 요소1(단기)·요소2(장기) 기간 프리셋 */ +export function getCompositePeriodPresetOptions( + indicatorType: string, + def: IndicatorPeriodDef, + side: 'left' | 'right', +): number[] { + const { short, long } = getCompositeDefaultPeriods(indicatorType, def as CompositePeriodDef); + const base = side === 'left' ? short : long; + return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b); +} + +export function getThresholdPresetOptions(indicatorType: string): number[] { + switch (indicatorType) { + case 'RSI': + case 'PSYCHOLOGICAL': + case 'NEW_PSYCHOLOGICAL': + case 'INVEST_PSYCHOLOGICAL': + return [20, 25, 30, 50, 70, 75, 80]; + case 'STOCHASTIC': + return [20, 50, 80]; + case 'CCI': + return [-200, -100, -50, 0, 50, 100, 150, 200]; + case 'WILLIAMS_R': + return [-80, -50, -20]; + case 'ADX': + case 'DMI': + return [20, 25, 30, 40, 50]; + case 'VR': + return [70, 100, 150, 200, 250]; + default: + return [-100, -50, 0, 50, 100]; + } +} + +export function getThresholdBounds(indicatorType: string): { min: number; max: number } { + switch (indicatorType) { + case 'RSI': + case 'PSYCHOLOGICAL': + case 'NEW_PSYCHOLOGICAL': + case 'INVEST_PSYCHOLOGICAL': + case 'STOCHASTIC': + return { min: 0, max: 100 }; + case 'WILLIAMS_R': + return { min: -100, max: 0 }; + case 'CCI': + return { min: -500, max: 500 }; + default: + return { min: -1000, max: 1000 }; + } +} diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 58516f3..e2ca8b7 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -3,6 +3,24 @@ import React from 'react'; import type { IndicatorConfig } from '../types/index'; import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry'; import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes'; +import { + compositeDisplayName, + compositeElementLabel, + makeCompositeCondition, + normalizeCompositeCondition, + syncCompositeFields, +} from '../utils/compositeIndicators'; +import ComboNumberInput from '../components/strategyEditor/ComboNumberInput'; +import { + getCompositePeriodPresetOptions, + getConditionRightPeriod, + getConditionValuePeriod, + getDefaultIndicatorPeriod, + initConditionPeriods, + parseThresholdField, + setConditionRightPeriod, + setConditionValuePeriod, +} from '../utils/conditionPeriods'; export interface StrategyDto { id: number; @@ -76,11 +94,14 @@ const DEF_DEFAULTS = { /** * 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 ?? {}; + // params 객체 참조 공유 방지 + const p = (registryType: string) => { + const raw = activeIndicators.find(i => i.type === registryType)?.params; + return raw ? { ...raw } : {}; + }; const num = (params: Record, key: string, fallback: number) => typeof params[key] === 'number' ? (params[key] as number) : fallback; @@ -208,6 +229,14 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType { }; } +/** + * 전략편집기 전용 DEF — 차트 activeIndicators와 무관한 고정 기본값. + * 조건 노드의 기간·임계값은 ConditionDSL(leftPeriod/rightPeriod/period)에만 저장된다. + */ +export function buildStrategyEditorDef(): DefType { + return buildDef([]); +} + /** * hline 임계값을 K_ 접두어 DSL 필드값으로 변환. * 예: 70 → "K_70", -100 → "K_-100" @@ -251,7 +280,12 @@ const ICHIMOKU_CONDS = [ type Opt = { value: string; label: string }; const NONE: Opt = { value: 'NONE', label: '선택안함' }; -export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => { +export const getFieldOpts = ( + ind: string, + signalType: 'buy'|'sell', + DEF: DefType, + cond?: ConditionDSL, +): Opt[] => { const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈'; const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입'; const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds]; @@ -269,8 +303,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType switch(ind) { case 'RSI': { const { over, mid, under } = th({ over:70, mid:50, under:30 }); + const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod; return condOpts([ - { value:'RSI_VALUE', label:`RSI 값(${DEF.rsiPeriod}일)` }, + { value:'RSI_VALUE', label:`RSI 값(${rsiP}일)` }, { value:kv(over), label:`${overTerm}(${over})` }, { value:kv(mid), label:`중앙선(${mid})` }, { value:kv(under), label:`${underTerm}(${under})` }, @@ -288,8 +323,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType } case 'CCI': { const { over, mid, under } = th({ over:100, mid:0, under:-100 }); + const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod; return condOpts([ - { value:'CCI_VALUE', label:`CCI 값(${DEF.cciPeriod}일)` }, + { value:'CCI_VALUE', label:`CCI 값(${cciP}일)` }, { value:kv(over), label:`${overTerm}(${over})` }, { value:kv(mid), label:`중앙선(${mid})` }, { value:kv(under), label:`${underTerm}(${under})` }, @@ -483,6 +519,14 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): // conditionType 변경 시 자동 필드 조정 const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => { const updated = { ...cond, conditionType: newCondType }; + if (cond.composite) { + return syncCompositeFields({ + ...updated, + composite: true, + leftPeriod: cond.leftPeriod, + rightPeriod: cond.rightPeriod, + }); + } 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); @@ -516,14 +560,40 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def // ─── 자연어 변환 ────────────────────────────────────────────────────────────── const condLabel = (v: string) => COND_OPTIONS.concat(ICHIMOKU_CONDS as any).find(o => o.v === v)?.l ?? v; +/** RSI_VALUE_9 → RSI_VALUE (옵션 조회용) */ +export function resolveFieldOptionValue(indicatorType: string, field?: string): string | undefined { + if (!field) return field; + const bases: Record = { + RSI: 'RSI_VALUE', + CCI: 'CCI_VALUE', + ADX: 'ADX_VALUE', + WILLIAMS_R: 'WILLIAMS_R_VALUE', + TRIX: 'TRIX_VALUE', + VR: 'VR_VALUE', + PSYCHOLOGICAL: 'PSY_VALUE', + NEW_PSYCHOLOGICAL: 'PSY_VALUE', + INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE', + }; + const base = bases[indicatorType]; + if (base && (field === base || field.startsWith(`${base}_`))) return base; + return field; +} + 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 = normalizeCompositeCondition(node.condition); + const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c); const C = condLabel(c.conditionType); + if (c.composite && c.leftPeriod && c.rightPeriod) { + const name = compositeDisplayName(c.indicatorType).split(' + ')[0]; + return `${c.indicatorType} - ${name}(${c.leftPeriod}) ${C} ${name}(${c.rightPeriod})`; + } + const lv = resolveFieldOptionValue(c.indicatorType, c.leftField); + const rv = resolveFieldOptionValue(c.indicatorType, c.rightField); + const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? c.indicatorType; + const R = opts.find(o => o.value === rv)?.label + ?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? ''); if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`; return `${c.indicatorType} - ${L} ${C}`; } @@ -624,24 +694,30 @@ interface CondEditorProps { } export const CondEditor: React.FC = ({ cond, signalType, onChange, def }) => { - const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def); - const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS; + const normalized = normalizeCompositeCondition(cond); + const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized); + const condOpts = normalized.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 defaults = getDefaultFields(normalized.indicatorType, signalType, def); - const getLeftValue = () => isValid(cond.leftField) ? cond.leftField! : defaults.l; - const getRightValue = () => isValid(cond.rightField) ? cond.rightField! : defaults.r; + const getLeftValue = () => { + const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField); + return isValid(v) ? v! : defaults.l; + }; + const getRightValue = () => { + const v = resolveFieldOptionValue(normalized.indicatorType, normalized.rightField); + return isValid(v) ? v! : 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 isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType); + const isHoldType = normalized.conditionType === 'HOLD_N_DAYS'; + const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType); const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue(); - const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(cond, newType, def)); + const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def)); const handleRight = (v: string) => { - // rightField 변경 시 임계값 자동 설정 const thresholds: Record = { OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30, OVERBOUGHT_80: 80, OVERSOLD_20: 20, @@ -652,11 +728,62 @@ export const CondEditor: React.FC = ({ cond, signalType, onChan OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50, ZERO_LINE: 0, }; - const upd: ConditionDSL = { ...cond, rightField: v }; + const upd: ConditionDSL = { ...normalized, rightField: v }; if (thresholds[v] !== undefined) upd.targetValue = thresholds[v]; onChange(upd); }; + if (normalized.composite) { + const leftPeriod = getConditionValuePeriod(normalized, def); + const rightPeriod = getConditionRightPeriod(normalized, def); + return ( +
+
복합지표 · {compositeDisplayName(normalized.indicatorType)}
+
+
+ + +
+
+ + onChange(setConditionValuePeriod(normalized, p, def))} + /> +
+
+ + onChange(setConditionRightPeriod(normalized, p))} + /> +
+
+ + +
+
+
+ ); + } + return (
{/* 4컬럼 row */} @@ -731,14 +858,34 @@ export const CondEditor: React.FC = ({ cond, signalType, onChan }; // ─── 노드 생성 헬퍼 ─────────────────────────────────────────────────────────── -export const makeNode = (type: string, value: string, signalType: 'buy'|'sell', DEF: DefType): LogicNode => { +export type MakeNodeOptions = { composite?: boolean }; + +export const makeNode = ( + type: string, + value: string, + signalType: 'buy'|'sell', + DEF: DefType, + options?: MakeNodeOptions, +): LogicNode => { if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] }; + if (options?.composite) { + return { + id: genId(), + type: 'CONDITION', + condition: makeCompositeCondition(value, signalType, DEF), + }; + } const def = getDefaultFields(value, signalType, DEF); + const baseCondition: ConditionDSL = { + indicatorType: value, + conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', + leftField: def.l, + rightField: def.r, + candleRange: 1, + period: getDefaultIndicatorPeriod(value, DEF), + }; return { id: genId(), type: 'CONDITION', - condition: { - indicatorType: value, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', - leftField: def.l, rightField: def.r, candleRange: 1, - }, + condition: initConditionPeriods(value, baseCondition, DEF), }; }; diff --git a/frontend/src/utils/strategyFlowLayout.ts b/frontend/src/utils/strategyFlowLayout.ts index ee20cf0..336334f 100644 --- a/frontend/src/utils/strategyFlowLayout.ts +++ b/frontend/src/utils/strategyFlowLayout.ts @@ -1,5 +1,5 @@ import type { Connection, Edge, Node } from '@xyflow/react'; -import type { LogicNode } from './strategyTypes'; +import type { LogicNode, ConditionDSL } from './strategyTypes'; import { nodeToText, type DefType } from './strategyEditorShared'; export const START_NODE_ID = '__strategy_start__'; @@ -23,6 +23,7 @@ export type StrategyFlowNodeData = { signalTab?: 'buy' | 'sell'; selected?: boolean; onDelete?: (id: string) => void; + onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => 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; @@ -423,7 +424,7 @@ function layoutTree( positions: Map, def: DefType, signalTab: 'buy' | 'sell', - callbacks: Pick, + callbacks: Pick, dragOverId: string | null, ): void { const yBase = 220; @@ -442,6 +443,7 @@ function layoutTree( def, signalTab, onDelete: callbacks.onDelete, + onUpdateCondition: callbacks.onUpdateCondition, onDropTarget: callbacks.onDropTarget, onDragOverTarget: callbacks.onDragOverTarget, onDragLeaveTarget: callbacks.onDragLeaveTarget, @@ -473,7 +475,7 @@ function appendOrphanFlowNodes( positions: Map, def: DefType, signalTab: 'buy' | 'sell', - callbacks: Pick, + callbacks: Pick, ): void { let orphanY = 80; for (const node of orphans) { @@ -492,6 +494,7 @@ function appendOrphanFlowNodes( def, signalTab, onDelete: callbacks.onDelete, + onUpdateCondition: callbacks.onUpdateCondition, onDropTarget: callbacks.onDropTarget, onDragOverTarget: callbacks.onDragOverTarget, onDragLeaveTarget: callbacks.onDragLeaveTarget, @@ -510,7 +513,7 @@ export function logicNodeToFlow( def: DefType, signalTab: 'buy' | 'sell', positions: Map, - callbacks: Pick, + callbacks: Pick, dragOverId: string | null = null, orphans: LogicNode[] = [], ): { nodes: Node[]; edges: Edge[] } { diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 31002a7..ef42cfd 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -7,6 +7,10 @@ export interface ConditionDSL { conditionType: string; targetValue?: number; period?: number; + /** 복합지표 — 동일 지표 2기간 교차 */ + composite?: boolean; + leftPeriod?: number; + rightPeriod?: number; leftField?: string; rightField?: string; compareValue?: number;