From 2f1a4d3bcad74ac3b8f2f7345837c8d133f5cc69 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 8 Jun 2026 01:31:02 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=A7=80=ED=91=9C=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TradeNotificationListRow.tsx | 3 +- .../useTradeNotificationSignalSnapshot.ts | 42 ++++++++--- .../src/hooks/useVirtualIndicatorSnapshots.ts | 37 +++++++--- frontend/src/utils/backendApi.ts | 9 +++ .../src/utils/resolveNotificationStrategy.ts | 71 ++++++++++++++++--- frontend/src/utils/strategyHydrate.ts | 63 ++++++++++++++++ frontend/src/utils/strategyMissingCache.ts | 5 ++ frontend/src/utils/strategyTimeframePin.ts | 31 +++++++- .../src/utils/virtualStrategyConditions.ts | 5 +- 9 files changed, 230 insertions(+), 36 deletions(-) create mode 100644 frontend/src/utils/strategyHydrate.ts diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index 84e6e60..2b40e37 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -148,12 +148,13 @@ const TradeNotificationListRow: React.FC = ({ item.strategyId, item.strategyName, prefetchedStrategies, + item.market, ).then(({ strategy: s, strategyId: sid }) => { if (!cancelled && s) setStrategy(s); else if (!cancelled && !s && sid == null) setStrategy(undefined); }); return () => { cancelled = true; }; - }, [item.strategyId, item.strategyName, panelActive, isListLayout, prefetchedStrategies]); + }, [item.strategyId, item.strategyName, item.market, panelActive, isListLayout, prefetchedStrategies]); useEffect(() => { setExpandedChartKey(null); diff --git a/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts index 076cd30..d77e2d3 100644 --- a/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts +++ b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts @@ -9,22 +9,24 @@ import { resolveNotificationStrategy, normalizeStrategyId, } from '../utils/resolveNotificationStrategy'; +import { hydrateStrategyDto, normalizeMarketCode } from '../utils/strategyHydrate'; import { fetchLiveSnapshot, type VirtualIndicatorSnapshot, } from './useVirtualIndicatorSnapshots'; -const MAX_EMPTY_RETRIES = 10; +const MAX_EMPTY_RETRIES = 15; function staticFallback( market: string, strategyId: number, strategy: StrategyDto, ): VirtualIndicatorSnapshot | undefined { - const baseRows = extractVirtualConditions(strategy); + const hydrated = hydrateStrategyDto(strategy); + const baseRows = extractVirtualConditions(hydrated); if (baseRows.length === 0) return undefined; return { - market, + market: normalizeMarketCode(market), strategyId, timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })), @@ -49,6 +51,7 @@ export function useTradeNotificationSignalSnapshot( const refreshInFlightRef = useRef(false); const resolvedStrategyRef = useRef(); const effectiveStrategyIdRef = useRef(null); + const normalizedMarket = normalizeMarketCode(market); const refresh = useCallback(async () => { const rawId = normalizeStrategyId(strategyId); @@ -65,14 +68,19 @@ export function useTradeNotificationSignalSnapshot( setLoading(true); try { - let effectiveStrategy = strategy ?? resolvedStrategyRef.current; + let effectiveStrategy = strategy ? hydrateStrategyDto(strategy) : resolvedStrategyRef.current; let effectiveId = effectiveStrategy?.id ?? effectiveStrategyIdRef.current ?? rawId; - if (!effectiveStrategy || effectiveStrategy.id !== rawId) { + const needsResolve = !effectiveStrategy + || extractVirtualConditions(effectiveStrategy).length === 0 + || effectiveStrategy.id !== rawId; + + if (needsResolve) { const resolved = await resolveNotificationStrategy( rawId, strategyName, prefetchedStrategies, + normalizedMarket, ); if (gen !== genRef.current) return; if (resolved.strategy) { @@ -86,11 +94,17 @@ export function useTradeNotificationSignalSnapshot( } const pinFirst = emptyRetryRef.current === 0; - let snap = await fetchLiveSnapshot(market, effectiveId, effectiveStrategy, pinFirst); + let snap = await fetchLiveSnapshot( + normalizedMarket, + effectiveId, + effectiveStrategy, + pinFirst, + { readOnlyPin: true }, + ); if (gen !== genRef.current) return; - if (!snap && effectiveStrategy) { - snap = staticFallback(market, effectiveId, effectiveStrategy) ?? null; + if ((!snap || snap.rows.length === 0) && effectiveStrategy) { + snap = staticFallback(normalizedMarket, effectiveId, effectiveStrategy) ?? snap; } if (snap && snap.rows.length > 0) { @@ -108,7 +122,15 @@ export function useTradeNotificationSignalSnapshot( } finally { refreshInFlightRef.current = false; } - }, [market, strategyId, strategy, strategyName, enabled, prefetchedStrategies]); + }, [ + market, + normalizedMarket, + strategyId, + strategy, + strategyName, + enabled, + prefetchedStrategies, + ]); useEffect(() => { const rawId = normalizeStrategyId(strategyId); @@ -121,7 +143,7 @@ export function useTradeNotificationSignalSnapshot( return; } emptyRetryRef.current = 0; - resolvedStrategyRef.current = strategy; + resolvedStrategyRef.current = strategy ? hydrateStrategyDto(strategy) : undefined; effectiveStrategyIdRef.current = strategy?.id ?? rawId; setLoading(true); void refresh(); diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index 77e96e9..58e5b24 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -14,10 +14,11 @@ import { pinCandleWatch, type LiveConditionRowDto, } from '../utils/backendApi'; -import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; +import { pinStrategyEvaluationTimeframes, pinReadOnlyStrategyTimeframes } from '../utils/strategyTimeframePin'; import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry'; import { coerceFiniteNumber } from '../utils/safeFormat'; import { normalizeStrategyId } from '../utils/resolveNotificationStrategy'; +import { hydrateStrategyDto, normalizeMarketCode } from '../utils/strategyHydrate'; import { extractVirtualConditions, type VirtualConditionRow, @@ -113,51 +114,67 @@ function staticSnapshot( } function statusMatches(status: { market?: string; strategyId?: unknown }, market: string, strategyId: number): boolean { - if (!status?.market || status.market !== market) return false; + if (!status?.market) return false; + if (normalizeMarketCode(status.market) !== normalizeMarketCode(market)) return false; const sid = normalizeStrategyId(status.strategyId); const expected = normalizeStrategyId(strategyId); return sid != null && expected != null && sid === expected; } +export interface FetchLiveSnapshotOptions { + /** true — syncStrategyTimeframes·repair 생략 (알림 목록) */ + readOnlyPin?: boolean; +} + /** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */ export async function fetchLiveSnapshot( market: string, strategyId: number, strategy: StrategyDto | undefined, pinFirst: boolean, + options?: FetchLiveSnapshotOptions, ): Promise { - const baseRows = strategy ? extractVirtualConditions(strategy) : []; + const normalizedMarket = normalizeMarketCode(market); + const hydratedStrategy = strategy ? hydrateStrategyDto(strategy) : undefined; + const baseRows = hydratedStrategy ? extractVirtualConditions(hydratedStrategy) : []; if (pinFirst) { try { - await pinStrategyEvaluationTimeframes(market, strategyId); + if (options?.readOnlyPin) { + await pinReadOnlyStrategyTimeframes(normalizedMarket, strategyId, hydratedStrategy); + } else { + await pinStrategyEvaluationTimeframes(normalizedMarket, strategyId); + } } catch { try { - await pinCandleWatch(market, '1m'); + await pinCandleWatch(normalizedMarket, '3m'); + } catch { /* ignore */ } + try { + await pinCandleWatch(normalizedMarket, '1m'); } catch { /* ignore */ } } } let status: Awaited> = null; try { - status = await fetchLiveConditionStatus(market, strategyId); + status = await fetchLiveConditionStatus(normalizedMarket, strategyId); } catch { status = null; } - if (!status || !statusMatches(status, market, strategyId)) { + if (!status || !statusMatches(status, normalizedMarket, strategyId)) { if (baseRows.length === 0) return null; - return staticSnapshot(market, strategyId, baseRows); + return staticSnapshot(normalizedMarket, strategyId, baseRows); } const rows = mergeRows(baseRows, status.rows ?? []); if (rows.length === 0) { if (baseRows.length === 0) return null; - return staticSnapshot(market, strategyId, baseRows); + return staticSnapshot(normalizedMarket, strategyId, baseRows); } return { - market, + market: normalizedMarket, strategyId, timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '), rows, diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 44bcea5..eb68c4f 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -9,6 +9,7 @@ import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage'; import { isStrategyMissingOnServer, markStrategyMissingFromPath, + unmarkStrategyMissingOnServer, } from './strategyMissingCache'; export { isStrategyMissingOnServer } from './strategyMissingCache'; @@ -1097,6 +1098,14 @@ export async function loadStrategy(id: number): Promise { return request(`/strategies/${id}`); } +/** 알림·지표 표시 — missing 캐시 무시, 성공 시 캐시 해제 */ +export async function loadStrategyForNotification(id: number): Promise { + if (!hasRegisteredUser() || id <= 0) return null; + const dto = await request(`/strategies/${id}`); + if (dto) unmarkStrategyMissingOnServer(id); + return dto; +} + /** 전략 저장 (id 없으면 신규, 있으면 수정) — 실패 시 Error throw */ export async function saveStrategy(dto: StrategyDto): Promise { const res = await fetch(`${API_BASE}/strategies`, { diff --git a/frontend/src/utils/resolveNotificationStrategy.ts b/frontend/src/utils/resolveNotificationStrategy.ts index 90008a2..04d376f 100644 --- a/frontend/src/utils/resolveNotificationStrategy.ts +++ b/frontend/src/utils/resolveNotificationStrategy.ts @@ -1,15 +1,16 @@ /** - * 매매 시그널 알림 — strategyId·전략명으로 StrategyDto 해석 - * (서버 DB와 알림 이력의 strategyId 불일치 시 전략명 fallback) + * 매매 시그널 알림 — strategyId·전략명·종목별 live 설정으로 StrategyDto 해석 */ import { - isStrategyMissingOnServer, + loadLiveStrategySettings, loadStrategies, - loadStrategy, + loadStrategyForNotification, type StrategyDto, } from './backendApi'; -import { reconcileStrategyMissingWithValidIds } from './strategyMissingCache'; +import { reconcileStrategyMissingWithValidIds, unmarkStrategyMissingOnServer } from './strategyMissingCache'; import { coerceFiniteNumber } from './safeFormat'; +import { hydrateStrategyDto, normalizeMarketCode } from './strategyHydrate'; +import { extractVirtualConditions } from './virtualStrategyConditions'; let strategiesCache: StrategyDto[] | null = null; let strategiesCacheAt = 0; @@ -34,6 +35,28 @@ async function getStrategiesList(force = false): Promise { return strategiesCache; } +function findStrategyByName(list: StrategyDto[], name: string): StrategyDto | undefined { + const trimmed = name.trim(); + if (!trimmed) return undefined; + const exact = list.find(s => s.name?.trim() === trimmed); + if (exact) return exact; + const lower = trimmed.toLowerCase(); + return list.find(s => { + const n = s.name?.trim().toLowerCase(); + return n === lower || n?.includes(lower) || lower.includes(n ?? ''); + }); +} + +function pickStrategy(dto: StrategyDto, strategyId: number): ResolvedNotificationStrategy { + const hydrated = hydrateStrategyDto(dto); + unmarkStrategyMissingOnServer(strategyId); + return { strategy: hydrated, strategyId }; +} + +function hasExtractableConditions(strategy: StrategyDto): boolean { + return extractVirtualConditions(hydrateStrategyDto(strategy)).length > 0; +} + export interface ResolvedNotificationStrategy { strategy: StrategyDto | undefined; /** live-conditions·차트에 사용할 유효 strategyId */ @@ -44,16 +67,20 @@ export async function resolveNotificationStrategy( strategyId: unknown, strategyName?: string | null, prefetched?: StrategyDto[], + market?: string | null, ): Promise { const normalizedId = normalizeStrategyId(strategyId); const list = prefetched ?? await getStrategiesList(); - if (normalizedId != null && !isStrategyMissingOnServer(normalizedId)) { + if (normalizedId != null) { const fromList = list.find(s => s.id === normalizedId); - if (fromList) { - return { strategy: fromList, strategyId: normalizedId }; + if (fromList?.id != null && hasExtractableConditions(fromList)) { + return pickStrategy(fromList, normalizedId); + } + const loaded = await loadStrategyForNotification(normalizedId); + if (loaded && extractVirtualConditions(loaded).length > 0) { + return { strategy: loaded, strategyId: normalizedId }; } - const loaded = await loadStrategy(normalizedId); if (loaded) { return { strategy: loaded, strategyId: normalizedId }; } @@ -61,9 +88,31 @@ export async function resolveNotificationStrategy( const name = strategyName?.trim(); if (name) { - const byName = list.find(s => s.name?.trim() === name); + const byName = findStrategyByName(list, name); if (byName?.id != null && byName.id > 0) { - return { strategy: byName, strategyId: byName.id }; + if (hasExtractableConditions(byName)) { + return pickStrategy(byName, byName.id); + } + const loaded = await loadStrategyForNotification(byName.id); + if (loaded) { + return { strategy: loaded, strategyId: byName.id }; + } + } + } + + const normalizedMarket = market ? normalizeMarketCode(market) : null; + if (normalizedMarket) { + try { + const live = await loadLiveStrategySettings(normalizedMarket); + const liveId = normalizeStrategyId(live.strategyId); + if (liveId != null) { + const loaded = await loadStrategyForNotification(liveId); + if (loaded) { + return { strategy: loaded, strategyId: liveId }; + } + } + } catch { + /* ignore */ } } diff --git a/frontend/src/utils/strategyHydrate.ts b/frontend/src/utils/strategyHydrate.ts new file mode 100644 index 0000000..e337405 --- /dev/null +++ b/frontend/src/utils/strategyHydrate.ts @@ -0,0 +1,63 @@ +/** + * 전략 DTO 정규화 — API·DB에서 문자열 JSON·flowLayout 보정 + */ +import type { LogicNode } from './strategyTypes'; +import type { StrategyDto } from './backendApi'; +import { extractVirtualConditions } from './virtualStrategyConditions'; +import { decodeConditionForEditor, encodeConditionForSave } from './strategyConditionSerde'; +import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage'; + +export function asLogicNode(raw: unknown): LogicNode | null { + if (!raw) return null; + if (typeof raw === 'string') { + const t = raw.trim(); + if (!t) return null; + try { + return JSON.parse(t) as LogicNode; + } catch { + return null; + } + } + if (typeof raw === 'object') return raw as LogicNode; + return null; +} + +export function normalizeMarketCode(market: string): string { + const s = market.trim().toUpperCase(); + if (!s) return 'KRW-BTC'; + return s.startsWith('KRW-') ? s : `KRW-${s}`; +} + +/** buy/sell DSL 파싱·flowLayout 재인코딩으로 조건 추출 가능 상태로 보정 */ +export function hydrateStrategyDto(strategy: StrategyDto): StrategyDto { + const buy = asLogicNode(strategy.buyCondition); + const sell = asLogicNode(strategy.sellCondition); + let next: StrategyDto = { + ...strategy, + buyCondition: buy ?? undefined, + sellCondition: sell ?? undefined, + }; + + if (extractVirtualConditions(next).length > 0) return next; + + const layout = strategy.flowLayout as StrategyFlowLayoutStore | null | undefined; + if (!layout) return next; + + try { + const buyState = decodeConditionForEditor(buy); + const sellState = decodeConditionForEditor(sell); + const encodedBuy = encodeConditionForSave(buyState); + const encodedSell = encodeConditionForSave(sellState); + if (encodedBuy || encodedSell) { + next = { + ...next, + buyCondition: encodedBuy ?? buy ?? undefined, + sellCondition: encodedSell ?? sell ?? undefined, + }; + } + } catch { + /* flowLayout 보정 실패 — 원본 유지 */ + } + + return next; +} diff --git a/frontend/src/utils/strategyMissingCache.ts b/frontend/src/utils/strategyMissingCache.ts index d1b4122..6329f8b 100644 --- a/frontend/src/utils/strategyMissingCache.ts +++ b/frontend/src/utils/strategyMissingCache.ts @@ -42,6 +42,11 @@ export function markStrategyMissingOnServer(strategyId: number): void { persistStrategyMissingCache(); } +export function unmarkStrategyMissingOnServer(strategyId: number): void { + if (strategyId <= 0) return; + if (strategyMissingIds.delete(strategyId)) persistStrategyMissingCache(); +} + /** 전략 목록에 다시 나타난 ID 는 missing 에서 제거 */ export function reconcileStrategyMissingWithValidIds(validIds: Iterable): void { const valid = new Set(validIds); diff --git a/frontend/src/utils/strategyTimeframePin.ts b/frontend/src/utils/strategyTimeframePin.ts index a0308e4..137d908 100644 --- a/frontend/src/utils/strategyTimeframePin.ts +++ b/frontend/src/utils/strategyTimeframePin.ts @@ -1,6 +1,6 @@ -import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes } from './backendApi'; +import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes, type StrategyDto } from './backendApi'; import { normalizeStartCandleType } from './strategyStartNodes'; -import { syncStrategyTimeframesFromLayoutIfNeeded } from './strategyTimeframeSync'; +import { collectUiEvaluationTimeframes, syncStrategyTimeframesFromLayoutIfNeeded } from './strategyTimeframeSync'; /** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */ export async function pinStrategyEvaluationTimeframes( @@ -22,3 +22,30 @@ export async function pinStrategyEvaluationTimeframes( } return timeframes; } + +/** 알림 목록 — DB sync/repair 없이 평가 분봉만 pin (서버 read-only 안전) */ +export async function pinReadOnlyStrategyTimeframes( + market: string, + strategyId: number, + strategy?: StrategyDto, +): Promise { + let timeframes: string[] = []; + try { + timeframes = collectUiEvaluationTimeframes(strategyId, strategy); + } catch { + /* ignore */ + } + if (timeframes.length === 0) { + try { + const raw = await loadStrategyTimeframes(strategyId); + timeframes = raw.length ? raw : ['1m']; + } catch { + timeframes = ['1m']; + } + } + const unique = [...new Set(timeframes.map(tf => normalizeStartCandleType(tf)))]; + if (!unique.includes('1m')) unique.unshift('1m'); + for (const tf of unique) { + await pinCandleWatch(market, tf); + } +} diff --git a/frontend/src/utils/virtualStrategyConditions.ts b/frontend/src/utils/virtualStrategyConditions.ts index 922d00d..19b83f2 100644 --- a/frontend/src/utils/virtualStrategyConditions.ts +++ b/frontend/src/utils/virtualStrategyConditions.ts @@ -6,6 +6,7 @@ import { CONDITION_LABEL } from './strategyTypes'; import type { StrategyDto } from './backendApi'; import { formatIndicatorDisplayLabel } from './indicatorRegistry'; import { coerceFiniteNumber, safeToFixed } from './safeFormat'; +import { asLogicNode } from './strategyHydrate'; export { coerceFiniteNumber } from './safeFormat'; @@ -79,8 +80,8 @@ export function extractVirtualConditions(strategy: StrategyDto | null | undefine if (!strategy) return []; const out: VirtualConditionRow[] = []; const seen = new Set(); - walk(strategy.buyCondition as LogicNode | null, '1m', 'buy', out, seen); - walk(strategy.sellCondition as LogicNode | null, '1m', 'sell', out, seen); + walk(asLogicNode(strategy.buyCondition), '1m', 'buy', out, seen); + walk(asLogicNode(strategy.sellCondition), '1m', 'sell', out, seen); return out; }