From aca895e9fd39769fd32568d1c217f90dc17cf49f Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 27 May 2026 16:51:57 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StrategyController.java | 8 + .../StrategyConditionTimeframeService.java | 8 +- .../StrategyDslTimeframeNormalizer.java | 137 +++++++ .../goldenchart/service/StrategyService.java | 40 +- .../StrategyTriggerBranchEvaluator.java | 3 +- frontend/src/App.css | 66 +++ frontend/src/App.tsx | 5 + .../components/TradeNotificationListPage.tsx | 382 ++++++++++++++---- .../src/components/TradeSignalSnackbar.tsx | 5 +- .../src/contexts/TradeNotificationContext.tsx | 82 ++-- frontend/src/utils/backendApi.ts | 5 + frontend/src/utils/strategyConditionSerde.ts | 76 +++- frontend/src/utils/strategyTimeframePin.ts | 7 +- 13 files changed, 701 insertions(+), 123 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java diff --git a/backend/src/main/java/com/goldenchart/controller/StrategyController.java b/backend/src/main/java/com/goldenchart/controller/StrategyController.java index 0afc2d1..81b8c97 100644 --- a/backend/src/main/java/com/goldenchart/controller/StrategyController.java +++ b/backend/src/main/java/com/goldenchart/controller/StrategyController.java @@ -57,6 +57,14 @@ public class StrategyController { return ResponseEntity.ok(conditionTimeframes.collectForStrategyList(id)); } + /** TIMEFRAME 래핑 누락 DSL 보정 (3분봉 전략이 1분봉으로 평가되는 경우) */ + @PostMapping("/{id}/repair-timeframes") + public ResponseEntity repairTimeframes(@PathVariable Long id) { + return service.repairTimeframeWrappers(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + /** 전략 삭제 */ @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable Long id) { diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index 76c7bab..e733093 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -72,7 +72,13 @@ public class StrategyConditionTimeframeService { private void collectFromNode(JsonNode node, Set out) { if (node == null || node.isNull()) return; if (collectTimeframesFromNode(node, out)) return; - // TIMEFRAME 래핑 없는 레거시 트리 — 기본 1m + + Set explicit = StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(node); + if (!explicit.isEmpty()) { + out.addAll(explicit); + return; + } + // TIMEFRAME·명시 분봉 없음 — 기본 1m out.add("1m"); } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java new file mode 100644 index 0000000..5ea972e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -0,0 +1,137 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +/** + * 전략 DSL에 TIMEFRAME 래핑이 없으면 평가 분봉을 추론해 래핑한다. + * (프론트 encodeConditionForSave 와 동일 목적) + */ +@Component +@RequiredArgsConstructor +public class StrategyDslTimeframeNormalizer { + + private final ObjectMapper objectMapper; + + public JsonNode normalize(JsonNode root) { + if (root == null || root.isNull()) return root; + if (hasTimeframeScope(root)) return root; + + String primary = inferPrimaryCandleType(root); + ObjectNode wrapped = objectMapper.createObjectNode(); + wrapped.put("id", UUID.randomUUID().toString()); + wrapped.put("type", "TIMEFRAME"); + wrapped.put("candleType", primary); + ArrayNode children = objectMapper.createArrayNode(); + children.add(root); + wrapped.set("children", children); + return wrapped; + } + + public String normalizeJson(String json) { + if (json == null || json.isBlank()) return json; + try { + JsonNode root = objectMapper.readTree(json); + JsonNode normalized = normalize(root); + return objectMapper.writeValueAsString(normalized); + } catch (Exception e) { + return json; + } + } + + /** 루트 TIMEFRAME 또는 AND|OR + 전부 TIMEFRAME 자식 */ + public static boolean hasTimeframeScope(JsonNode node) { + if (node == null || node.isNull()) return false; + String type = node.path("type").asText(""); + if ("TIMEFRAME".equals(type)) return true; + if ("AND".equals(type) || "OR".equals(type)) { + JsonNode children = node.path("children"); + if (children.isArray() && !children.isEmpty()) { + for (JsonNode c : children) { + if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false; + } + return true; + } + } + return findNestedTimeframe(node) != null; + } + + private static JsonNode findNestedTimeframe(JsonNode node) { + if (node == null || node.isNull()) return null; + if ("TIMEFRAME".equals(node.path("type").asText(""))) return node; + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode c : children) { + JsonNode found = findNestedTimeframe(c); + if (found != null) return found; + } + } + JsonNode child = node.path("child"); + if (!child.isMissingNode() && !child.isNull()) return findNestedTimeframe(child); + return null; + } + + /** + * 트리에서 명시적 분봉 수집 — CONDITION 의 left/rightCandleType, TIMEFRAME 노드. + */ + public static Set collectExplicitCandleTypes(JsonNode node) { + Set out = new LinkedHashSet<>(); + collectExplicitCandleTypes(node, out); + return out; + } + + private static void collectExplicitCandleTypes(JsonNode node, Set out) { + if (node == null || node.isNull()) return; + + String type = node.path("type").asText(""); + if ("TIMEFRAME".equals(type)) { + out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"))); + } + + if ("CONDITION".equals(type)) { + JsonNode cond = node.path("condition"); + if (!cond.isMissingNode() && !cond.isNull()) { + if (cond.has("leftCandleType") && !cond.path("leftCandleType").asText("").isBlank()) { + out.add(LiveStrategyTimeframeService.normalize(cond.path("leftCandleType").asText())); + } + if (cond.has("rightCandleType") && !cond.path("rightCandleType").asText("").isBlank()) { + out.add(LiveStrategyTimeframeService.normalize(cond.path("rightCandleType").asText())); + } + } + } + + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode c : children) collectExplicitCandleTypes(c, out); + } + JsonNode child = node.path("child"); + if (!child.isMissingNode() && !child.isNull()) collectExplicitCandleTypes(child, out); + } + + public static String inferPrimaryCandleType(JsonNode node) { + JsonNode nested = findNestedTimeframe(node); + if (nested != null) { + return LiveStrategyTimeframeService.normalize(nested.path("candleType").asText("1m")); + } + + Set explicit = collectExplicitCandleTypes(node); + if (explicit.isEmpty()) return "1m"; + + Set non1m = new LinkedHashSet<>(); + for (String ct : explicit) { + if (!"1m".equals(ct)) non1m.add(ct); + } + if (non1m.size() == 1) return non1m.iterator().next(); + if (non1m.size() > 1) return non1m.iterator().next(); + if (explicit.size() == 1) return explicit.iterator().next(); + return explicit.iterator().next(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java index 687e8e7..e2bca1f 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -22,6 +22,7 @@ public class StrategyService { private final ObjectMapper objectMapper; private final StrategyConditionTimeframeService conditionTimeframes; private final LiveStrategyEvaluator liveStrategyEvaluator; + private final StrategyDslTimeframeNormalizer dslTimeframeNormalizer; // ── 조회 ────────────────────────────────────────────────────────────────── @@ -56,9 +57,13 @@ public class StrategyService { try { entity.setBuyConditionJson( - dto.getBuyCondition() != null ? objectMapper.writeValueAsString(dto.getBuyCondition()) : null); + dto.getBuyCondition() != null + ? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getBuyCondition())) + : null); entity.setSellConditionJson( - dto.getSellCondition() != null ? objectMapper.writeValueAsString(dto.getSellCondition()) : null); + dto.getSellCondition() != null + ? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getSellCondition())) + : null); } catch (Exception e) { log.warn("전략 DSL 직렬화 실패: {}", e.getMessage()); } @@ -69,6 +74,37 @@ public class StrategyService { return toDto(saved); } + /** + * DB에 저장된 매수/매도 DSL에 TIMEFRAME 래핑이 없으면 보정 후 저장. + * (편집기 재저장 없이 기존 3분봉 전략 등을 즉시 수정할 때) + */ + @Transactional + public Optional repairTimeframeWrappers(Long id) { + return repository.findById(id).map(entity -> { + boolean changed = false; + if (entity.getBuyConditionJson() != null && !entity.getBuyConditionJson().isBlank()) { + String fixed = dslTimeframeNormalizer.normalizeJson(entity.getBuyConditionJson()); + if (!fixed.equals(entity.getBuyConditionJson())) { + entity.setBuyConditionJson(fixed); + changed = true; + } + } + if (entity.getSellConditionJson() != null && !entity.getSellConditionJson().isBlank()) { + String fixed = dslTimeframeNormalizer.normalizeJson(entity.getSellConditionJson()); + if (!fixed.equals(entity.getSellConditionJson())) { + entity.setSellConditionJson(fixed); + changed = true; + } + } + if (!changed) return toDto(entity); + GcStrategy saved = repository.save(entity); + conditionTimeframes.invalidate(saved.getId()); + liveStrategyEvaluator.invalidateStrategy(saved.getId()); + log.info("[Strategy] TIMEFRAME 래핑 보정 strategyId={}", id); + return toDto(saved); + }); + } + // ── 삭제 ────────────────────────────────────────────────────────────────── @Transactional diff --git a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java index 67153af..d9b17db 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java @@ -107,7 +107,8 @@ public class StrategyTriggerBranchEvaluator { return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub != null && !sub.isNull() ? sub : node))); } - return new BranchScope("SINGLE", List.of(new BranchDef("1m", node))); + String inferred = StrategyDslTimeframeNormalizer.inferPrimaryCandleType(node); + return new BranchScope("SINGLE", List.of(new BranchDef(inferred, node))); } /** 레거시 DSL — 루트가 TIMEFRAME이 아닐 때 하위 TIMEFRAME 래퍼 탐색 */ diff --git a/frontend/src/App.css b/frontend/src/App.css index 7ec4c87..76394c8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -11243,6 +11243,72 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } color: var(--text); overflow: hidden; } +.tnl-page--with-right .tnl-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + overflow: hidden; +} +.tnl-page--with-right .tnl-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.tnl-page--with-right .tnl-right { + flex: 0 0 min(380px, 38vw); + width: min(380px, 38vw); + min-width: 300px; + max-width: 420px; + display: flex; + flex-direction: column; + border-left: 1px solid var(--border); + background: var(--bg2); + overflow: hidden; +} +.tnl-right-tabs { + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} +.tnl-right-body { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} +.tnl-page--with-right .ptd-right-body { + flex: 1; + min-height: 0; +} +.tnl-page--with-right .ptd-split-panel--right { + height: 100%; +} +.tnl-page--with-right .ptd-ob-stack--fill { + height: 100%; +} +.tnl-row--selected { + border-color: var(--accent, #7aa2f7); + box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.35); +} +.tnl-row-action { + flex-shrink: 0; + align-self: center; + margin-right: 4px; + padding: 6px 10px; + font-size: 11px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.tnl-row-action:hover { + color: var(--text); + border-color: var(--accent, #7aa2f7); +} .tnl-header { padding: 20px 24px 12px; border-bottom: 1px solid var(--border); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 90f98cb..b527377 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1719,6 +1719,11 @@ function App() { {menuPage === 'notifications' && ( { goToMarketChart(market); setMenuPage('chart'); diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index 3e7c836..308acc6 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -1,18 +1,52 @@ /** - * 매매 시그널 알림 목록 화면 + * 매매 시그널 알림 목록 화면 — 좌: 알림 목록, 우: 매매·호가 (가상매매 우측 패널과 동일) */ -import React, { useCallback } from 'react'; -import type { Theme } from '../types'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Theme, TradeOrderFillRequest } from '../types'; +import type { TickerData } from '../hooks/useMarketTicker'; import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext'; import { buildSignalDetailRows, formatSignalPrice, getSignalHeadline } from '../utils/tradeSignalDisplay'; +import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi'; +import { coerceFiniteNumber } from '../utils/safeFormat'; +import TradeOrderPanel from './TradeOrderPanel'; +import { OrderbookPanel } from './OrderbookPanel'; +import PaperSplitPanel from './paper/PaperSplitPanel'; +import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; +import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; +import '../styles/virtualTradingDashboard.css'; + +type RightTab = 'trade' | 'orderbook'; interface Props { theme: Theme; onGoToChart: (market: string) => void; + tickers?: Map; + defaultMarket?: string; + paperTradingEnabled?: boolean; + paperAutoTradeEnabled?: boolean; + onPaperOrderFilled?: () => void; } +function fillBothSides( + market: string, + price: number, + setFillBuy: React.Dispatch>, + setFillSell: React.Dispatch>, +) { + const seq = Date.now(); + setFillBuy({ market, price, side: 'buy', seq }); + setFillSell({ market, price, side: 'sell', seq: seq + 1 }); +} -export const TradeNotificationListPage: React.FC = ({ theme, onGoToChart }) => { +export const TradeNotificationListPage: React.FC = ({ + theme, + onGoToChart, + tickers, + defaultMarket = 'KRW-BTC', + paperTradingEnabled = true, + paperAutoTradeEnabled = false, + onPaperOrderFilled, +}) => { const { allNotifications, unreadCount, @@ -22,104 +56,278 @@ export const TradeNotificationListPage: React.FC = ({ theme, onGoToChart refreshHistory, } = useTradeNotification(); - const [filter, setFilter] = React.useState<'all' | 'unread'>('all'); + const [filter, setFilter] = useState<'all' | 'unread'>('all'); + const [selectedMarket, setSelectedMarket] = useState(defaultMarket); + const [selectedNotifyId, setSelectedNotifyId] = useState(null); + const [rightTab, setRightTab] = useState('trade'); + const [fillBuy, setFillBuy] = useState(null); + const [fillSell, setFillSell] = useState(null); + const [summary, setSummary] = useState(null); + const orderAnchorRef = useRef(null); + + const refreshPaperData = useCallback(async () => { + try { + const s = await loadPaperSummary(); + setSummary(s); + } catch { + setSummary(null); + } + onPaperOrderFilled?.(); + }, [onPaperOrderFilled]); + + useEffect(() => { + void refreshPaperData(); + }, [refreshPaperData]); + + const tradePrice = useMemo( + () => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice), + [tickers, selectedMarket], + ); + + const posQty = useMemo(() => { + const p = summary?.positions?.find(x => x.symbol === selectedMarket); + return coerceFiniteNumber(p?.quantity) ?? 0; + }, [summary?.positions, selectedMarket]); + + const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket); + const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket); + const selectedTicker = tickers?.get(selectedMarket); + const orderbookPrevClose = selectedTicker + ? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0) + : 0; + const orderbookTickerInfo = selectedTicker ? { + tradePrice: selectedTicker.tradePrice, + changeRate: selectedTicker.changeRate, + changePrice: selectedTicker.changePrice, + accTradePrice24: selectedTicker.accTradePrice24, + accTradeVolume24: selectedTicker.accTradeVolume24, + highPrice: selectedTicker.highPrice, + lowPrice: selectedTicker.lowPrice, + openingPrice: selectedTicker.openingPrice, + } : undefined; + + const handleSelectMarket = useCallback((market: string, price?: number) => { + setSelectedMarket(market); + const p = price ?? coerceFiniteNumber(tickers?.get(market)?.tradePrice); + if (p != null && p > 0) { + fillBothSides(market, p, setFillBuy, setFillSell); + } + setRightTab('trade'); + }, [tickers]); + + const handleNotificationSelect = useCallback((item: TradeNotificationItem) => { + if (!item.isRead) markAsRead(item.id); + setSelectedNotifyId(item.id); + const price = coerceFiniteNumber(item.price); + if (price != null && price > 0) { + fillBothSides(item.market, price, setFillBuy, setFillSell); + } else { + handleSelectMarket(item.market); + } + setRightTab('trade'); + }, [markAsRead, handleSelectMarket]); + + const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { + setRightTab('trade'); + const side = rowType === 'bid' ? 'buy' : 'sell'; + const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() }; + if (side === 'buy') setFillBuy(req); + else setFillSell(req); + }, [selectedMarket]); const filtered = filter === 'unread' ? allNotifications.filter(n => !n.isRead) : allNotifications; - const handleRow = useCallback((item: TradeNotificationItem) => { - if (!item.isRead) markAsRead(item.id); - openDetail(item, { centered: true }); - }, [markAsRead, openDetail]); - return ( -
-
-

매매 시그널 알림

-

- 실시간 전략 실행 시 발생한 BUY/SELL 신호입니다. 화면·종목과 무관하게 수신됩니다. -

-
- 미확인 {unreadCount}건 -
+
+
+
+
+

매매 시그널 알림

+

+ 알림을 선택하면 우측 매매 패널에 종목·가격이 자동 입력됩니다. +

+
+ 미확인 {unreadCount}건 +
+ + +
+ + {unreadCount > 0 && ( + + )} +
+
+ +
+ {filtered.length === 0 ? ( +
표시할 알림이 없습니다.
+ ) : ( +
    + {filtered.map(item => { + const isBuy = item.signalType === 'BUY'; + const detailRows = buildSignalDetailRows(item); + const isSelected = selectedNotifyId === item.id; + return ( +
  • + + + +
  • + ); + })} +
+ )} +
+
+ +
-
- -
- {filtered.length === 0 ? ( -
표시할 알림이 없습니다.
- ) : ( -
    - {filtered.map(item => { - const isBuy = item.signalType === 'BUY'; - const detailRows = buildSignalDetailRows(item); - return ( -
  • - - -
  • - ); - })} -
- )} +
+ {rightTab === 'trade' ? ( + + handleSelectMarket(m)} + showSymbolField + paperTradingEnabled={paperTradingEnabled} + paperAutoTradeEnabled={paperAutoTradeEnabled} + onPaperOrderFilled={() => void refreshPaperData()} + /> +
+ )} + bottom={( +
+ handleSelectMarket(m)} + showSymbolField={false} + paperTradingEnabled={paperTradingEnabled} + paperAutoTradeEnabled={paperAutoTradeEnabled} + onPaperOrderFilled={() => void refreshPaperData()} + /> +
+ )} + /> + ) : ( +
+
+
실시간 호가
+
+ +
+
+
+ )} +
+
); diff --git a/frontend/src/components/TradeSignalSnackbar.tsx b/frontend/src/components/TradeSignalSnackbar.tsx index 1c43bfc..1f9293c 100644 --- a/frontend/src/components/TradeSignalSnackbar.tsx +++ b/frontend/src/components/TradeSignalSnackbar.tsx @@ -45,6 +45,7 @@ export const TradeSignalSnackbar: React.FC = ({ toastNotifications, dismissToast, dismissToasts, + dismissAllToasts, openDetail, } = useTradeNotification(); @@ -94,10 +95,10 @@ export const TradeSignalSnackbar: React.FC = ({ const dismissAll = useCallback(() => { if (toastNotifications.length === 0) return; - dismissToasts(toastNotifications.map(n => n.id)); + dismissAllToasts(); setPageIndex(0); positionsRef.current = {}; - }, [dismissToasts, toastNotifications]); + }, [dismissAllToasts, toastNotifications.length]); const dismissCurrentPage = useCallback(() => { const ids = pageItems.map(i => i.id); diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index f4216c3..7ed393c 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -122,6 +122,8 @@ export const TradeNotificationProvider: React.FC = ({ }) => { const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId; const [readIds, setReadIds] = useState>(() => loadReadIds()); + const readIdsRef = useRef(readIds); + readIdsRef.current = readIds; const [toastNotifications, setToastNotifications] = useState([]); const toastNotificationsRef = useRef(toastNotifications); toastNotificationsRef.current = toastNotifications; @@ -137,38 +139,45 @@ export const TradeNotificationProvider: React.FC = ({ const refreshHistory = useCallback(async () => { try { const dtos = await loadTradeSignals(); - const items = dtos.map(d => dtoToItem(d, readIds)); + /** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */ + const read = loadReadIds(); + const items = dtos.map(d => dtoToItem(d, read)); + + let newlyArrived: TradeNotificationItem[] = []; setAllNotifications(prev => { - const isBoot = prev.length === 0; - const newlyArrived = items.filter( + newlyArrived = items.filter( item => !item.isRead && !prev.some(p => p.id === item.id), ); - if (!isBoot && popupEnabled && newlyArrived.length > 0) { - setToastNotifications(tprev => { - const map = new Map(tprev.map(t => [t.id, t])); - for (const n of newlyArrived) map.set(n.id, n); - return [...map.values()].slice(0, MAX_TOAST_QUEUE); - }); - if (soundEnabled) { - playTradeAlertSound(alertSoundId); - } - } - const map = new Map(); - for (const n of items) map.set(n.id, n); + for (const n of items) { + const wasRead = read.has(n.id) || prev.find(p => p.id === n.id)?.isRead; + map.set(n.id, wasRead ? { ...n, isRead: true } : n); + } for (const n of prev) { if (!map.has(n.id)) map.set(n.id, n); + else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true }); } return [...map.values()] .sort((a, b) => b.receivedAt - a.receivedAt) .slice(0, MAX_HISTORY); }); + + if (popupEnabled && newlyArrived.length > 0) { + setToastNotifications(tprev => { + const map = new Map(tprev.map(t => [t.id, t])); + for (const n of newlyArrived) map.set(n.id, n); + return [...map.values()].slice(0, MAX_TOAST_QUEUE); + }); + if (soundEnabled) { + playTradeAlertSound(alertSoundId); + } + } } catch (e) { console.warn('[TradeNotification] 이력 로드 실패:', e); } - }, [readIds, popupEnabled, soundEnabled, alertSoundId]); + }, [popupEnabled, soundEnabled, alertSoundId]); useEffect(() => { void refreshHistory(); @@ -179,6 +188,7 @@ export const TradeNotificationProvider: React.FC = ({ const next = new Set(prev); next.add(id); saveReadIds(next); + readIdsRef.current = next; return next; }); setToastNotifications(prev => prev.filter(n => n.id !== id)); @@ -195,6 +205,7 @@ export const TradeNotificationProvider: React.FC = ({ const next = new Set(prev); next.delete(id); saveReadIds(next); + readIdsRef.current = next; return next; }); const item: TradeNotificationItem = { @@ -238,16 +249,30 @@ export const TradeNotificationProvider: React.FC = ({ const dismissToasts = useCallback((ids: string[]) => { if (ids.length === 0) return; const idSet = new Set(ids); + const toastSnapshot = toastNotificationsRef.current; setReadIds(r => { const next = new Set(r); ids.forEach(i => next.add(i)); saveReadIds(next); + readIdsRef.current = next; return next; }); setToastNotifications(prev => prev.filter(n => !idSet.has(n.id))); - setAllNotifications(all => - all.map(n => (idSet.has(n.id) ? { ...n, isRead: true } : n)), - ); + setAllNotifications(all => { + const byId = new Map(all.map(n => [n.id, n])); + for (const id of ids) { + const existing = byId.get(id); + if (existing) { + byId.set(id, { ...existing, isRead: true }); + } else { + const fromToast = toastSnapshot.find(n => n.id === id); + if (fromToast) byId.set(id, { ...fromToast, isRead: true }); + } + } + return [...byId.values()] + .sort((a, b) => b.receivedAt - a.receivedAt) + .slice(0, MAX_HISTORY); + }); }, []); const dismissAllToasts = useCallback(() => { @@ -259,24 +284,33 @@ export const TradeNotificationProvider: React.FC = ({ const next = new Set(r); idSet.forEach(i => next.add(i)); saveReadIds(next); + readIdsRef.current = next; return next; }); setToastNotifications([]); - setAllNotifications(all => - all.map(n => (idSet.has(n.id) ? { ...n, isRead: true } : n)), - ); + setAllNotifications(all => { + const byId = new Map(all.map(n => [n.id, n])); + for (const n of snapshot) { + const existing = byId.get(n.id); + byId.set(n.id, existing ? { ...existing, isRead: true } : { ...n, isRead: true }); + } + return [...byId.values()] + .sort((a, b) => b.receivedAt - a.receivedAt) + .slice(0, MAX_HISTORY); + }); }, []); const markAllAsRead = useCallback(() => { setAllNotifications(prev => { - const nextRead = new Set(readIds); + const nextRead = new Set(loadReadIds()); prev.forEach(n => nextRead.add(n.id)); saveReadIds(nextRead); + readIdsRef.current = nextRead; setReadIds(nextRead); return prev.map(n => ({ ...n, isRead: true })); }); setToastNotifications([]); - }, [readIds]); + }, []); const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => { setDetailCentered(options?.centered ?? false); diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 2279134..636fdf4 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1129,6 +1129,11 @@ export async function loadStrategyTimeframes(strategyId: number): Promise { + await request(`/strategies/${strategyId}/repair-timeframes`, { method: 'POST' }); +} + /** 차트 실시간 파이프라인 pin (STOMP warm-up) */ export async function pinCandleWatch(market: string, candleType: string): Promise { try { diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts index 7f8e3ae..82cd2ef 100644 --- a/frontend/src/utils/strategyConditionSerde.ts +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -1,4 +1,4 @@ -import type { LogicNode } from './strategyTypes'; +import type { LogicNode, ConditionDSL } from './strategyTypes'; import { generateNodeId } from './strategyTypes'; import { subtreeToFlatOrphans } from './strategyFlowLayout'; import { @@ -82,16 +82,79 @@ export function updateBranchRoot( }; } +/** START 분봉 변경 시 조건 노드의 left/rightCandleType 동기화 */ +function syncSubtreeCandleTypes(node: LogicNode | null, candleType: string): LogicNode | null { + if (!node) return null; + const ct = normalizeStartCandleType(candleType); + + if (node.type === 'CONDITION' && node.condition) { + const cond = node.condition as ConditionDSL; + return { + ...node, + condition: { + ...cond, + leftCandleType: ct, + rightCandleType: ct, + }, + }; + } + + if (node.children?.length) { + return { + ...node, + children: node.children.map(c => syncSubtreeCandleTypes(c, ct)).filter(Boolean) as LogicNode[], + }; + } + return node; +} + +function collectExplicitCandleTypesFromTree(node: LogicNode | null, out: Set): void { + if (!node) return; + if (node.type === 'TIMEFRAME' && node.candleType) { + out.add(normalizeStartCandleType(node.candleType)); + } + if (node.type === 'CONDITION' && node.condition) { + const c = node.condition as ConditionDSL; + if (c.leftCandleType) out.add(normalizeStartCandleType(c.leftCandleType)); + if (c.rightCandleType) out.add(normalizeStartCandleType(c.rightCandleType)); + } + node.children?.forEach(ch => collectExplicitCandleTypesFromTree(ch, out)); +} + +function inferPrimaryCandleFromTree(root: LogicNode | null): string { + const set = new Set(); + collectExplicitCandleTypesFromTree(root, set); + const non1m = [...set].filter(ct => ct !== '1m'); + if (non1m.length >= 1) return non1m[0]; + if (set.size >= 1) return [...set][0]; + return DEFAULT_START_CANDLE; +} + export function updateStartCandleType( state: EditorConditionState, startId: string, candleType: string, ): EditorConditionState { + const ct = normalizeStartCandleType(candleType); + const nextMeta = { + ...state.startMeta, + [startId]: { candleType: ct }, + }; + + if (startId === START_NODE_ID) { + return { + ...state, + startMeta: nextMeta, + root: syncSubtreeCandleTypes(state.root, ct), + }; + } + return { ...state, - startMeta: { - ...state.startMeta, - [startId]: { candleType: normalizeStartCandleType(candleType) }, + startMeta: nextMeta, + extraRoots: { + ...state.extraRoots, + [startId]: syncSubtreeCandleTypes(state.extraRoots[startId] ?? null, ct), }, }; } @@ -225,9 +288,12 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition }; } + const inferred = inferPrimaryCandleFromTree(dsl); return { root: dsl, - startMeta: defaultStartMeta(), + startMeta: { + [START_NODE_ID]: { candleType: inferred }, + }, extraStartIds: [], extraRoots: {}, startCombineOp: DEFAULT_START_COMBINE_OP, diff --git a/frontend/src/utils/strategyTimeframePin.ts b/frontend/src/utils/strategyTimeframePin.ts index c18ed02..c3ce162 100644 --- a/frontend/src/utils/strategyTimeframePin.ts +++ b/frontend/src/utils/strategyTimeframePin.ts @@ -1,4 +1,4 @@ -import { loadStrategyTimeframes, pinCandleWatch } from './backendApi'; +import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes } from './backendApi'; import { normalizeStartCandleType } from './strategyStartNodes'; /** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */ @@ -6,6 +6,11 @@ export async function pinStrategyEvaluationTimeframes( market: string, strategyId: number, ): Promise { + try { + await repairStrategyTimeframes(strategyId); + } catch { + /* 서버 미배포·권한 등 — timeframes 조회만 진행 */ + } const raw = await loadStrategyTimeframes(strategyId); const timeframes = [...new Set(raw.map(tf => normalizeStartCandleType(tf)))]; if (timeframes.length === 0) timeframes.push('1m');