diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 698f984..423abaa 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -75,9 +75,9 @@ public class LiveConditionStatusService { for (PendingCond p : pending) { String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); timeframes.add(tf); - subscriptionManager.ensureMarketPinned(market, tf); + subscriptionManager.ensureBarsReadySync(market, tf, 60); } - subscriptionManager.ensureMarketPinned(market, "1m"); + subscriptionManager.ensureBarsReadySync(market, "1m", 60); List rows = new ArrayList<>(); int met = 0; @@ -239,6 +239,13 @@ public class LiveConditionStatusService { if (seen.add(key)) { out.add(new PendingCond(id, cond, timeframe, side)); } + return; + } + + // START·구버전 DSL 등 — 자식 노드 재귀 (프론트 extractVirtualConditions 와 동일) + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode c : children) walk(c, timeframe, side, out, seen); } } diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index dd951a4..c8af6f4 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -11,7 +11,9 @@ import { loadStrategy, runBacktest, type PaperSummaryDto, + type StrategyDto, } from '../utils/backendApi'; +import { prefetchNotificationStrategies } from '../utils/resolveNotificationStrategy'; import { loadAnalysisCandles } from '../utils/analysisChartData'; import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; import { candleTypeToTimeframe } from '../utils/strategyToChartIndicators'; @@ -145,6 +147,7 @@ export const TradeNotificationListPage: React.FC = ({ const [gridPreset, setGridPreset] = useState( () => loadTradeNotificationGridPreset(), ); + const [strategies, setStrategies] = useState([]); const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [selectedId, setSelectedId] = useState(null); const [reportOpen, setReportOpen] = useState(false); @@ -177,6 +180,14 @@ export const TradeNotificationListPage: React.FC = ({ void refreshPaperData(); }, [refreshPaperData]); + useEffect(() => { + let cancelled = false; + void prefetchNotificationStrategies().then(list => { + if (!cancelled) setStrategies(list); + }); + return () => { cancelled = true; }; + }, []); + const tradePrice = useMemo( () => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice), [tickers, selectedMarket], @@ -433,6 +444,7 @@ export const TradeNotificationListPage: React.FC = ({ theme={theme} layoutMode={listLayout} itemTag="div" + prefetchedStrategies={strategies} isSelected={selectedId === item.id} chartsEnabled={isListView} chartLiveReceiveHighlight={chartLiveReceiveHighlight} @@ -465,6 +477,7 @@ export const TradeNotificationListPage: React.FC = ({ handleReportFromAlert, reportLoadingId, tickers, + strategies, ]); return ( diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index 7760322..84e6e60 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -7,7 +7,7 @@ import type { TickerData } from '../../hooks/useMarketTicker'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot'; import type { StrategyDto } from '../../utils/backendApi'; -import { isStrategyMissingOnServer, loadStrategies, loadStrategy } from '../../utils/backendApi'; +import { resolveNotificationStrategy } from '../../utils/resolveNotificationStrategy'; import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext'; import { buildSignalDetailRows, @@ -68,6 +68,8 @@ interface Props { reportLoading?: boolean; /** 가상 스크롤 목록에서는 div + role=listitem */ itemTag?: 'li' | 'div'; + /** 목록 화면에서 선로드한 전략 목록 (ID·이름 fallback) */ + prefetchedStrategies?: StrategyDto[]; } const MAX_INDICATOR_CARDS = 8; @@ -88,6 +90,7 @@ const TradeNotificationListRow: React.FC = ({ onReport, reportLoading = false, itemTag = 'li', + prefetchedStrategies = [], }) => { useTradeAlertTimeFormat(); const isBuy = item.signalType === 'BUY'; @@ -137,17 +140,20 @@ const TradeNotificationListRow: React.FC = ({ return; } let cancelled = false; - void loadStrategies().then(list => { - if (!cancelled) setStrategies(list ?? []); - }); - if (!item.strategyId || isStrategyMissingOnServer(item.strategyId)) { + setStrategies(prefetchedStrategies); + if (!item.strategyId && !item.strategyName?.trim()) { return () => { cancelled = true; }; } - void loadStrategy(item.strategyId).then(s => { + void resolveNotificationStrategy( + item.strategyId, + item.strategyName, + prefetchedStrategies, + ).then(({ strategy: s, strategyId: sid }) => { if (!cancelled && s) setStrategy(s); + else if (!cancelled && !s && sid == null) setStrategy(undefined); }); return () => { cancelled = true; }; - }, [item.strategyId, panelActive, isListLayout]); + }, [item.strategyId, item.strategyName, panelActive, isListLayout, prefetchedStrategies]); useEffect(() => { setExpandedChartKey(null); @@ -181,7 +187,9 @@ const TradeNotificationListRow: React.FC = ({ item.market, item.strategyId, strategy, + item.strategyName, signalSnapshotEnabled, + prefetchedStrategies, ); const [chartActivitySeq, setChartActivitySeq] = useState(0); diff --git a/frontend/src/components/tradeNotification/TradeNotificationSignalSummary.tsx b/frontend/src/components/tradeNotification/TradeNotificationSignalSummary.tsx index 30d4c56..6ea20bc 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationSignalSummary.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationSignalSummary.tsx @@ -52,7 +52,9 @@ const TradeNotificationSignalSummary: React.FC = ({ market, strategyId, strategy, + strategyLabel, active && snapshotProp === undefined, + strategies, ); const snapshot = snapshotProp ?? internal.snapshot; const loading = signalLoadingProp ?? internal.loading; diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index e37ea72..cced43b 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -26,6 +26,7 @@ import { playTradeAlertSound, type TradeAlertSoundId, } from '../utils/tradeAlertSound'; +import { normalizeStrategyId } from '../utils/resolveNotificationStrategy'; import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb'; import { useAppSettings } from '../hooks/useAppSettings'; @@ -87,7 +88,7 @@ function dtoToItem(dto: TradeSignalDto, readIds: Set): TradeNotification price: dto.price, candleTime: dto.candleTime, strategyName: dto.strategyName, - strategyId: dto.strategyId, + strategyId: normalizeStrategyId(dto.strategyId) ?? undefined, executionType: dto.executionType, candleType: dto.candleType, isRead: readIds.has(id), diff --git a/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts index 59f53bc..076cd30 100644 --- a/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts +++ b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts @@ -2,14 +2,20 @@ * 매매 시그널 알림 목록 행 — 종목×전략 실시간 신호 스냅샷 (가상매매 카드 요약형과 동일 소스) */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { isStrategyMissingOnServer, loadStrategy, type StrategyDto } from '../utils/backendApi'; +import type { StrategyDto } from '../utils/backendApi'; import { extractVirtualConditions } from '../utils/virtualStrategyConditions'; import { normalizeConditionRow } from '../utils/virtualSignalMetrics'; +import { + resolveNotificationStrategy, + normalizeStrategyId, +} from '../utils/resolveNotificationStrategy'; import { fetchLiveSnapshot, type VirtualIndicatorSnapshot, } from './useVirtualIndicatorSnapshots'; +const MAX_EMPTY_RETRIES = 10; + function staticFallback( market: string, strategyId: number, @@ -31,7 +37,9 @@ export function useTradeNotificationSignalSnapshot( market: string, strategyId: number | null | undefined, strategy: StrategyDto | undefined, + strategyName: string | null | undefined, enabled: boolean, + prefetchedStrategies?: StrategyDto[], pollMs = 3000, ): { snapshot: VirtualIndicatorSnapshot | undefined; loading: boolean } { const [snapshot, setSnapshot] = useState(); @@ -39,13 +47,15 @@ export function useTradeNotificationSignalSnapshot( const emptyRetryRef = useRef(0); const genRef = useRef(0); const refreshInFlightRef = useRef(false); - /** strategy prop 이 undefined 일 때 직접 로드한 전략 캐시 */ - const fallbackStrategyRef = useRef(null); + const resolvedStrategyRef = useRef(); + const effectiveStrategyIdRef = useRef(null); const refresh = useCallback(async () => { - if (!enabled || strategyId == null) { + const rawId = normalizeStrategyId(strategyId); + if (!enabled || rawId == null) { setSnapshot(undefined); setLoading(false); + emptyRetryRef.current = 0; return; } if (refreshInFlightRef.current) return; @@ -55,28 +65,32 @@ export function useTradeNotificationSignalSnapshot( setLoading(true); try { - let effectiveStrategy = strategy; - if (!effectiveStrategy) { - if (fallbackStrategyRef.current) { - effectiveStrategy = fallbackStrategyRef.current; - } else if (!isStrategyMissingOnServer(strategyId)) { - try { - const loaded = await loadStrategy(strategyId); - if (loaded) { - fallbackStrategyRef.current = loaded; - effectiveStrategy = loaded; - } - } catch { /* ignore */ } - if (gen !== genRef.current) return; + let effectiveStrategy = strategy ?? resolvedStrategyRef.current; + let effectiveId = effectiveStrategy?.id ?? effectiveStrategyIdRef.current ?? rawId; + + if (!effectiveStrategy || effectiveStrategy.id !== rawId) { + const resolved = await resolveNotificationStrategy( + rawId, + strategyName, + prefetchedStrategies, + ); + if (gen !== genRef.current) return; + if (resolved.strategy) { + effectiveStrategy = resolved.strategy; + resolvedStrategyRef.current = resolved.strategy; + } + if (resolved.strategyId != null) { + effectiveId = resolved.strategyId; + effectiveStrategyIdRef.current = resolved.strategyId; } } const pinFirst = emptyRetryRef.current === 0; - let snap = await fetchLiveSnapshot(market, strategyId, effectiveStrategy, pinFirst); + let snap = await fetchLiveSnapshot(market, effectiveId, effectiveStrategy, pinFirst); if (gen !== genRef.current) return; if (!snap && effectiveStrategy) { - snap = staticFallback(market, strategyId, effectiveStrategy) ?? null; + snap = staticFallback(market, effectiveId, effectiveStrategy) ?? null; } if (snap && snap.rows.length > 0) { @@ -87,24 +101,28 @@ export function useTradeNotificationSignalSnapshot( if (gen === genRef.current) { setSnapshot(snap ?? undefined); + const stillCollecting = (!snap || snap.rows.length === 0) + && emptyRetryRef.current < MAX_EMPTY_RETRIES; + setLoading(stillCollecting); } } finally { refreshInFlightRef.current = false; - if (gen === genRef.current) { - setLoading(false); - } } - }, [market, strategyId, strategy, enabled]); + }, [market, strategyId, strategy, strategyName, enabled, prefetchedStrategies]); useEffect(() => { - if (!enabled || strategyId == null) { + const rawId = normalizeStrategyId(strategyId); + if (!enabled || rawId == null) { setSnapshot(undefined); setLoading(false); emptyRetryRef.current = 0; + resolvedStrategyRef.current = undefined; + effectiveStrategyIdRef.current = null; return; } emptyRetryRef.current = 0; - fallbackStrategyRef.current = null; + resolvedStrategyRef.current = strategy; + effectiveStrategyIdRef.current = strategy?.id ?? rawId; setLoading(true); void refresh(); const id = window.setInterval(() => void refresh(), pollMs); @@ -113,7 +131,7 @@ export function useTradeNotificationSignalSnapshot( genRef.current += 1; refreshInFlightRef.current = false; }; - }, [market, strategyId, enabled, pollMs, refresh]); + }, [market, strategyId, strategyName, enabled, pollMs, refresh, strategy?.id]); return { snapshot, loading }; } diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index 9a0c895..77e96e9 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -17,6 +17,7 @@ import { import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry'; import { coerceFiniteNumber } from '../utils/safeFormat'; +import { normalizeStrategyId } from '../utils/resolveNotificationStrategy'; import { extractVirtualConditions, type VirtualConditionRow, @@ -111,10 +112,11 @@ function staticSnapshot( }; } -function statusMatches(status: { market?: string; strategyId?: number }, market: string, strategyId: number): boolean { +function statusMatches(status: { market?: string; strategyId?: unknown }, market: string, strategyId: number): boolean { if (!status?.market || status.market !== market) return false; - const sid = coerceFiniteNumber(status.strategyId); - return sid === strategyId; + const sid = normalizeStrategyId(status.strategyId); + const expected = normalizeStrategyId(strategyId); + return sid != null && expected != null && sid === expected; } /** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */ diff --git a/frontend/src/utils/resolveNotificationStrategy.ts b/frontend/src/utils/resolveNotificationStrategy.ts new file mode 100644 index 0000000..90008a2 --- /dev/null +++ b/frontend/src/utils/resolveNotificationStrategy.ts @@ -0,0 +1,76 @@ +/** + * 매매 시그널 알림 — strategyId·전략명으로 StrategyDto 해석 + * (서버 DB와 알림 이력의 strategyId 불일치 시 전략명 fallback) + */ +import { + isStrategyMissingOnServer, + loadStrategies, + loadStrategy, + type StrategyDto, +} from './backendApi'; +import { reconcileStrategyMissingWithValidIds } from './strategyMissingCache'; +import { coerceFiniteNumber } from './safeFormat'; + +let strategiesCache: StrategyDto[] | null = null; +let strategiesCacheAt = 0; +const CACHE_MS = 30_000; + +export function normalizeStrategyId(id: unknown): number | null { + const n = coerceFiniteNumber(id); + return n != null && n > 0 ? Math.trunc(n) : null; +} + +async function getStrategiesList(force = false): Promise { + const now = Date.now(); + if (!force && strategiesCache && now - strategiesCacheAt < CACHE_MS) { + return strategiesCache; + } + const list = await loadStrategies(); + strategiesCache = list ?? []; + strategiesCacheAt = now; + reconcileStrategyMissingWithValidIds( + strategiesCache.map(s => s.id).filter((id): id is number => id != null && id > 0), + ); + return strategiesCache; +} + +export interface ResolvedNotificationStrategy { + strategy: StrategyDto | undefined; + /** live-conditions·차트에 사용할 유효 strategyId */ + strategyId: number | null; +} + +export async function resolveNotificationStrategy( + strategyId: unknown, + strategyName?: string | null, + prefetched?: StrategyDto[], +): Promise { + const normalizedId = normalizeStrategyId(strategyId); + const list = prefetched ?? await getStrategiesList(); + + if (normalizedId != null && !isStrategyMissingOnServer(normalizedId)) { + const fromList = list.find(s => s.id === normalizedId); + if (fromList) { + return { strategy: fromList, strategyId: normalizedId }; + } + const loaded = await loadStrategy(normalizedId); + if (loaded) { + return { strategy: loaded, strategyId: normalizedId }; + } + } + + const name = strategyName?.trim(); + if (name) { + const byName = list.find(s => s.name?.trim() === name); + if (byName?.id != null && byName.id > 0) { + return { strategy: byName, strategyId: byName.id }; + } + } + + return { strategy: undefined, strategyId: normalizedId }; +} + +/** 알림 목록 진입 시 전략 목록 선로드 */ +export async function prefetchNotificationStrategies(): Promise { + return getStrategiesList(true); +}