diff --git a/docs/TRADING_REPORT_UI_DESIGN.pdf b/docs/TRADING_REPORT_UI_DESIGN.pdf index ef037ba..dcbd265 100644 Binary files a/docs/TRADING_REPORT_UI_DESIGN.pdf and b/docs/TRADING_REPORT_UI_DESIGN.pdf differ diff --git a/docs/TradingReport_UI_Design.pdf b/docs/TradingReport_UI_Design.pdf index 6f19d11..5639327 100644 Binary files a/docs/TradingReport_UI_Design.pdf and b/docs/TradingReport_UI_Design.pdf differ diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index cd7fc03..f8e1dfa 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -34,6 +34,7 @@ import { ensurePaperChartOverlays, resolveStrategyPrimaryTimeframe, } from '../../utils/strategyToChartIndicators'; +import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility'; interface Props { symbol: string; @@ -60,6 +61,11 @@ interface Props { const noop = () => {}; +/** PaperOverlayVisibility → ChartManager 동기화용 (candle 항상 true) */ +function paperVisToChartVis(pv: PaperOverlayVisibility) { + return { ma: pv.ma, bollinger: pv.bollinger, ichimoku: pv.ichimoku, candle: true }; +} + let _indSeq = 0; function newIndId() { _indSeq += 1; @@ -116,10 +122,14 @@ const BacktestAnalysisChart: React.FC = ({ const [error, setError] = useState(null); const [chartType] = useState('candlestick'); const [strategy, setStrategy] = useState(); + /** 개별 지표 숨김 ID 집합 (실시간 차트와 동일한 per-indicator 숨김 지원) */ + const [hiddenIndicatorIds, setHiddenIndicatorIds] = useState>(new Set()); const managerRef = useRef(null); + const overlayVisibilityRef = useRef(overlayVisibility); const logicalRangeUnsubRef = useRef<(() => void) | null>(null); const viewportUnsubRef = useRef<(() => void) | null>(null); const loadMoreRefRef = useRef void>>({ current: () => {} }); + overlayVisibilityRef.current = overlayVisibility; const signalsRef = useRef(signals); signalsRef.current = signals; const onHistoryBarsLoadedRef = useRef(onHistoryBarsLoaded); @@ -159,15 +169,55 @@ const BacktestAnalysisChart: React.FC = ({ }, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility]); const indicators = useMemo(() => { - if (!overlayVisibility) return baseIndicators; - return applyPaperOverlayVisibility(baseIndicators, overlayVisibility); - }, [baseIndicators, overlayVisibility]); + let inds = overlayVisibility + ? applyPaperOverlayVisibility(baseIndicators, overlayVisibility) + : baseIndicators; + // 개별 지표 숨김 상태 반영 (per-indicator hidden toggle) + if (hiddenIndicatorIds.size > 0) { + inds = inds.map(ind => + hiddenIndicatorIds.has(ind.id) ? { ...ind, hidden: true } : ind, + ); + } + return inds; + }, [baseIndicators, overlayVisibility, hiddenIndicatorIds]); const candleOverlayToggles = useMemo(() => { if (!overlayVisibility || !onToggleOverlay) return undefined; return listPaperOverlayToggleItems(baseIndicators, overlayVisibility); }, [baseIndicators, overlayVisibility, onToggleOverlay]); + /** + * 캔들 오버레이 그룹 토글 — 실시간 차트와 동일하게 ChartManager 를 즉시 호출한 뒤 + * 부모 state 도 업데이트한다. + */ + const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { + const pKey = key as PaperOverlayToggleKey; + const prev = overlayVisibilityRef.current; + if (prev && pKey in prev) { + const newVisible = !prev[pKey]; + managerRef.current?.setChartOverlayGroupVisible(key, newVisible); + } + onToggleOverlay?.(pKey); + }, [onToggleOverlay]); + + /** + * 개별 보조지표 숨김/표시 — 실시간 차트와 동일한 per-indicator 토글 + */ + const handleToggleIndicatorHidden = useCallback((id: string) => { + setHiddenIndicatorIds(prev => { + const next = new Set(prev); + const willHide = !next.has(id); + if (willHide) { + next.add(id); + } else { + next.delete(id); + } + // ChartManager 즉시 반영 + managerRef.current?.toggleIndicatorVisible(id, !willHide); + return next; + }); + }, []); + const auxPaneCount = useMemo( () => countNonOverlayIndicatorPanes(indicators, isOverlayType), [indicators], @@ -284,6 +334,10 @@ const BacktestAnalysisChart: React.FC = ({ const onManagerReady = useCallback((mgr: ChartManager) => { managerRef.current = mgr; + // 실시간 차트와 동일하게: 마운트 시 오버레이 가시성 ChartManager 에 동기화 + if (overlayVisibilityRef.current) { + mgr.setChartOverlayVisibility(paperVisToChartVis(overlayVisibilityRef.current)); + } setupHistoryScroll(mgr); setLoadedBarCount(mgr.getRawBarsLength()); applyPaneRatio(mgr); @@ -393,9 +447,8 @@ const BacktestAnalysisChart: React.FC = ({ seriesPriceLabelsEnabled={priceLabels} showHoverToolbar candleOverlayToggles={candleOverlayToggles} - onToggleCandleOverlay={onToggleOverlay - ? (key) => onToggleOverlay(key as PaperOverlayToggleKey) - : undefined} + onToggleCandleOverlay={onToggleOverlay ? handleToggleCandleOverlay : undefined} + onToggleIndicatorHidden={handleToggleIndicatorHidden} /> )} {!loading && !error && bars.length === 0 && ( diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index 32bfc5d..3843168 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -160,8 +160,12 @@ export const TradeNotificationProvider: React.FC = ({ const refreshHistory = useCallback(async (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => { try { const dtos = await loadTradeSignals(); - /** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */ - const read = opts?.rawServerList ? new Set() : loadReadIds(); + /** + * 항상 DB 캐시의 최신 readIds 사용. + * rawServerList=true 시 빈 Set을 쓰던 기존 코드는 새로고침 후 모든 알림이 + * 미읽음으로 복원되는 버그를 유발했으므로 제거. + */ + const read = loadReadIds(); const hidden = opts?.rawServerList ? new Set() : loadHiddenIds(); const items = dtos .map(d => dtoToItem(d, read)) @@ -170,9 +174,16 @@ export const TradeNotificationProvider: React.FC = ({ let newlyArrived: TradeNotificationItem[] = []; setAllNotifications(prev => { - newlyArrived = items.filter( - item => !item.isRead && !prev.some(p => p.id === item.id), - ); + /** + * rawServerList=true 는 앱 초기 로드(새로고침·로그인 직후)이므로 + * 이미 존재하는 서버 이력을 실시간 신규 알림으로 취급하지 않는다. + * 실제 새 알림 판단은 STOMP addNotification 과 일반 refreshHistory 에서만 한다. + */ + if (!opts?.rawServerList) { + newlyArrived = items.filter( + item => !item.isRead && !prev.some(p => p.id === item.id), + ); + } if (opts?.serverOnly || opts?.rawServerList) { return items @@ -234,15 +245,12 @@ export const TradeNotificationProvider: React.FC = ({ const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => { const id = makeId(signal); - // 실시간 재수신 시 이전에 읽음 처리된 동일 키도 다시 미확인으로 - setReadIds(prev => { - if (!prev.has(id)) return prev; - const next = new Set(prev); - next.delete(id); - saveReadIds(next); - readIdsRef.current = next; - return next; - }); + /** + * 이전에 읽음 처리(닫기)한 동일 키를 readIds 에서 제거하던 로직을 삭제. + * STOMP 재연결 시 동일 신호가 재수신되어도 사용자가 명시적으로 닫은 알림은 + * 다시 열리지 않아야 한다. 새로운 candleTime 을 가진 신호는 키가 달라 + * readIds 에 없으므로 정상적으로 미읽음 처리된다. + */ const item: TradeNotificationItem = { ...signal, id, @@ -311,28 +319,23 @@ export const TradeNotificationProvider: React.FC = ({ }, []); const dismissAllToasts = useCallback(() => { - const snapshot = toastNotificationsRef.current; - if (snapshot.length === 0) return; - const idSet = new Set(snapshot.map(n => n.id)); - - setReadIds(r => { - const next = new Set(r); - idSet.forEach(i => next.add(i)); - saveReadIds(next); - readIdsRef.current = next; - return next; + /** + * 전체 닫기 = 현재 토스트 + allNotifications 의 모든 미읽음 항목을 읽음 처리. + * 기존에는 toastNotifications 에 있는 항목만 처리해서, 토스트에 없는 미읽음 항목은 + * 다음 refreshHistory 폴링 시 다시 팝업으로 나타나는 문제가 있었다. + */ + setAllNotifications(prev => { + const nextRead = new Set(readIdsRef.current); + // 토스트 항목 + toastNotificationsRef.current.forEach(n => nextRead.add(n.id)); + // allNotifications 의 미읽음 항목 전체 + prev.forEach(n => nextRead.add(n.id)); + saveReadIds(nextRead); + readIdsRef.current = nextRead; + setReadIds(nextRead); + return prev.map(n => ({ ...n, isRead: true })); }); setToastNotifications([]); - 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(() => {