/** * 가상투자 카드 — 종목×전략별 지표·조건 스냅샷 * running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립) */ import { useCallback, useEffect, useRef, useState } from 'react'; import type { StrategyDto } from '../utils/backendApi'; import { fetchLiveConditionStatus, pinCandleWatch, type LiveConditionRowDto, } from '../utils/backendApi'; import { extractVirtualConditions, type VirtualConditionRow, } from '../utils/virtualStrategyConditions'; export interface VirtualIndicatorSnapshot { market: string; strategyId: number; timeframe: string; rows: Array; updatedAt: number; /** 백엔드 Ta4j 집계 일치율 (0~100) */ matchRate?: number; } function rowFallbackKey(row: Pick): string { return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`; } function liveFallbackKey(r: LiveConditionRowDto): string { return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${r.indicatorType}`; } function mergeRows( base: VirtualConditionRow[], live: LiveConditionRowDto[], ): Array { const liveById = new Map(); const liveByKey = new Map(); for (const r of live) { liveById.set(r.id, r); liveByKey.set(liveFallbackKey(r), r); } if (base.length === 0) { return live.map(r => ({ id: r.id, indicatorType: r.indicatorType, displayName: r.displayName, conditionType: r.conditionType, conditionLabel: r.conditionLabel, targetValue: r.targetValue, timeframe: r.timeframe, side: r.side, plotKey: r.indicatorType, satisfied: r.satisfied, thresholdLabel: r.thresholdLabel, currentValue: r.currentValue, })); } return base.map(row => { const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row)); if (!liveRow) { return { ...row, currentValue: null as number | null }; } return { ...row, satisfied: liveRow.satisfied, thresholdLabel: liveRow.thresholdLabel, currentValue: liveRow.currentValue, targetValue: liveRow.targetValue ?? row.targetValue, }; }); } async function buildSnapshot( market: string, strategyId: number, strategy: StrategyDto | undefined, running: boolean, ): Promise { const baseRows = strategy ? extractVirtualConditions(strategy) : []; if (!running) { if (baseRows.length === 0) return null; return { market, strategyId, timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), rows: baseRows.map(r => ({ ...r, currentValue: null })), updatedAt: Date.now(), matchRate: 0, }; } const status = await fetchLiveConditionStatus(market, strategyId); if (!status || status.market !== market) { if (baseRows.length === 0) return null; return { market, strategyId, timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), rows: baseRows.map(r => ({ ...r, currentValue: null })), updatedAt: Date.now(), matchRate: 0, }; } return { market, strategyId, timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '), rows: mergeRows(baseRows, status.rows), updatedAt: status.updatedAt || Date.now(), matchRate: status.matchRate, }; } interface TargetRef { market: string; strategyId: number | null; } export function useVirtualIndicatorSnapshots( targets: TargetRef[], strategies: StrategyDto[], running: boolean, pollMs = 3000, ): Record { const [snapshots, setSnapshots] = useState>({}); const strategiesRef = useRef(strategies); strategiesRef.current = strategies; const pinnedRef = useRef>(new Set()); const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|'); const pinTargets = useCallback(async () => { const strats = strategiesRef.current; const pins = new Set(); for (const t of targets) { if (t.strategyId == null) continue; const strat = strats.find(s => s.id === t.strategyId); const conditions = strat ? extractVirtualConditions(strat) : []; const tfs = conditions.length > 0 ? [...new Set(conditions.map(c => c.timeframe))] : ['1m']; for (const tf of tfs) { const key = `${t.market}:${tf}`; pins.add(key); if (!pinnedRef.current.has(key)) { await pinCandleWatch(t.market, tf); pinnedRef.current.add(key); } } const k1 = `${t.market}:1m`; if (!pins.has(k1) || !tfs.includes('1m')) { if (!pinnedRef.current.has(k1)) { await pinCandleWatch(t.market, '1m'); pinnedRef.current.add(k1); } } } for (const k of pinnedRef.current) { if (!pins.has(k)) pinnedRef.current.delete(k); } }, [targets]); const refresh = useCallback(async () => { const strats = strategiesRef.current; if (running) await pinTargets(); const jobs = targets .filter(t => t.strategyId != null) .map(async t => { const strat = strats.find(s => s.id === t.strategyId); const snap = await buildSnapshot(t.market, t.strategyId!, strat, running); if (snap) { setSnapshots(prev => ({ ...prev, [snap.market]: snap })); } return snap; }); await Promise.all(jobs); // 제거된 종목 스냅샷 정리 const activeMarkets = new Set(targets.map(t => t.market)); setSnapshots(prev => { const next = { ...prev }; let changed = false; for (const key of Object.keys(next)) { if (!activeMarkets.has(key)) { delete next[key]; changed = true; } } return changed ? next : prev; }); }, [targets, running, pinTargets]); useEffect(() => { if (targets.length === 0) { setSnapshots({}); pinnedRef.current.clear(); return; } void refresh(); if (!running) return; const id = window.setInterval(() => void refresh(), pollMs); return () => clearInterval(id); }, [targetsKey, running, pollMs, refresh]); return snapshots; }