/** * 가상매매 공통 상태·동기화 (웹 VirtualTradingPage · 모바일 앱 공유) */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { TickerData } from './useMarketTicker'; import { loadLiveStrategySettings, loadPaperSummary, loadPaperTrades, loadStrategies, saveLiveStrategySettings, type PaperSummaryDto, type PaperTradeDto, type StrategyDto, } from '../utils/backendApi'; import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots'; import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus'; import { loadVirtualSession, loadVirtualTargets, saveVirtualSession, saveVirtualTargets, loadVirtualCardViewMode, saveVirtualCardViewMode, type VirtualSessionConfig, type VirtualTargetItem, type VirtualCardViewMode, } from '../utils/virtualTradingStorage'; import { syncVirtualTargetsToBackend, stopVirtualLiveOnBackend, } from '../utils/virtualLiveStrategySync'; import { syncStrategyTimeframesFromLayoutIfNeeded, warnStrategyTimeframeMismatch, } from '../utils/strategyTimeframeSync'; import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { freezeTargetsBeforeGlobalStrategyChange, resolveVirtualTargetStrategyId, } from '../utils/virtualTargetStrategy'; import { isVirtualTargetAddAllowed, virtualTargetLimitMessage, } from '../utils/virtualTargetLimits'; import { resolveVirtualTargetNames } from '../utils/virtualTargetNames'; import { useAppSettings, resolveAppDefaults, subscribeAppSettings, reloadAppSettingsCache, } from './useAppSettings'; import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate'; import { mergeTargetsWithLiveStrategySettings, mergeVirtualSessionWithAppLive, resolveVirtualTargetStrategyIdWithApp, } from '../utils/virtualTargetHydrate'; export interface UseVirtualTradingCoreOptions { defaultMarket?: string; tickers?: Map; paperTradingEnabled?: boolean; paperAutoTradeEnabled?: boolean; paperAutoTradeBudgetPct?: number; onPaperOrderFilled?: () => void; /** useAppSettings sessionKey (로그인 userId 등) */ settingsSessionKey?: number; } export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}) { const { defaultMarket = 'KRW-BTC', tickers, paperTradingEnabled: paperTradingEnabledProp, paperAutoTradeEnabled: paperAutoTradeEnabledProp, paperAutoTradeBudgetPct: paperAutoTradeBudgetPctProp, onPaperOrderFilled, settingsSessionKey = 0, } = options; const { settings: appSettings, isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey); const appChartDefaults = resolveAppDefaults(appSettings); const paperTradingEnabled = paperTradingEnabledProp ?? appChartDefaults.paperTradingEnabled; const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled; const paperAutoTradeBudgetPct = paperAutoTradeBudgetPctProp ?? appChartDefaults.paperAutoTradeBudgetPct; const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount; const appLiveStrategyId = appChartDefaults.liveStrategyId ?? null; const [targets, setTargets] = useState(() => loadVirtualTargets()); const [session, setSession] = useState(() => loadVirtualSession()); const [strategies, setStrategies] = useState([]); const [summary, setSummary] = useState(null); const [trades, setTrades] = useState([]); const [loading, setLoading] = useState(true); const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode()); /** 서버 ui_preferences 반영 전 빈 목록을 DB에 저장하지 않음 */ const preferencesHydratedRef = useRef(false); const reloadFromUiPreferences = useCallback(() => { setTargets(loadVirtualTargets()); setSession(mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId)); }, [appLiveStrategyId]); useEffect(() => { if (!settingsLoaded) return; let cancelled = false; preferencesHydratedRef.current = false; setLoading(true); void (async () => { reloadFromUiPreferences(); let nextTargets = loadVirtualTargets(); if (nextTargets.length === 0) { nextTargets = await hydrateVirtualTargetsIfEmpty(); if (!cancelled && nextTargets.length > 0) { setTargets(nextTargets); saveVirtualTargets(nextTargets); } } const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId); if (!cancelled) { setSession(sessionFromDb); } if (nextTargets.length > 0) { const withLive = await mergeTargetsWithLiveStrategySettings( nextTargets, sessionFromDb, appLiveStrategyId, ); if (!cancelled && withLive !== nextTargets) { setTargets(withLive); } } if (!cancelled) { preferencesHydratedRef.current = true; } await Promise.all([ loadStrategies().then(s => { if (cancelled) return; setStrategies(s); const validIds = new Set( s.map(x => x.id).filter((id): id is number => id != null && id > 0), ); setSession(prev => { const next = prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) ? { ...prev, globalStrategyId: null } : prev; return mergeVirtualSessionWithAppLive(next, appLiveStrategyId); }); setTargets(prev => prev.map(t => t.strategyId != null && !validIds.has(t.strategyId) ? { ...t, strategyId: null } : t, ), ); }).catch(() => { if (!cancelled) setStrategies([]); }), loadPaperSummary().then(s => { if (!cancelled) setSummary(s); }), loadPaperTrades().then(t => { if (!cancelled) setTrades(t); }), ]); if (!cancelled) setLoading(false); })(); return () => { cancelled = true; }; }, [settingsSessionKey, settingsLoaded, appLiveStrategyId, reloadFromUiPreferences]); /** DB ui_preferences 반영(웹·모바일 동일 계정) */ useEffect(() => { if (!settingsLoaded) return; return subscribeAppSettings(() => { reloadFromUiPreferences(); }); }, [settingsLoaded, reloadFromUiPreferences]); /** DB is_pinned·strategyId → uiPreferences.virtual.targets 동기화 */ useEffect(() => { if (!settingsLoaded) return; let cancelled = false; const initial = loadVirtualTargets(); if (initial.length === 0) return; const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId); void (async () => { const merged = await Promise.all( initial.map(async t => { try { const s = await loadLiveStrategySettings(t.market); let next = t; if (!!t.pinned !== !!s.isPinned) { next = { ...next, pinned: !!s.isPinned }; } if (next.strategyId == null && s.strategyId != null && s.strategyId > 0 && s.isLiveCheck) { next = { ...next, strategyId: s.strategyId }; } return next; } catch { return t; } }), ); const withLive = await mergeTargetsWithLiveStrategySettings( merged, sessionFromDb, appLiveStrategyId, ); if (!cancelled) setTargets(withLive); })(); return () => { cancelled = true; }; }, [settingsLoaded, appLiveStrategyId]); useEffect(() => { if (!preferencesHydratedRef.current) return; saveVirtualTargets(targets); }, [targets]); useEffect(() => { if (!preferencesHydratedRef.current) return; saveVirtualSession(session); }, [session]); useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); const strategyNames = useMemo( () => Object.fromEntries(strategies.map(s => [s.id, s.name])), [strategies], ); const refreshPaperData = useCallback(async () => { try { const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]); setSummary(sum); setTrades(tr); } catch { /* ignore */ } onPaperOrderFilled?.(); }, [onPaperOrderFilled]); const targetRefs = useMemo( () => targets.map(t => ({ market: t.market, strategyId: resolveVirtualTargetStrategyIdWithApp(t, session, appLiveStrategyId), })), [targets, session, appLiveStrategyId], ); const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots( targetRefs, strategies, session.running, ); const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus( targetRefs, session.running, ); const mergedLiveStatus = useMemo(() => { const merged = { ...liveStatusByMarket }; const now = Date.now(); const SNAP_LIVE_MS = 12_000; for (const [market, snap] of Object.entries(snapshots)) { if (snap?.updatedAt && now - snap.updatedAt < SNAP_LIVE_MS) { merged[market] = 'live'; } } return merged; }, [liveStatusByMarket, snapshots]); const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => { if (items.length > virtualTargetMaxCount) { window.alert(virtualTargetLimitMessage(virtualTargetMaxCount)); return; } setTargets(items); if (items.length > 0 && !items.some(t => t.market === selectedMarket)) { setSelectedMarket(items[0].market); } }, [selectedMarket, virtualTargetMaxCount]); const addTarget = useCallback(( market: string, meta?: { koreanName?: string; englishName?: string }, ) => { if (targets.some(t => t.market === market)) return false; if (!isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount)) { window.alert(virtualTargetLimitMessage(virtualTargetMaxCount)); return false; } const names = resolveVirtualTargetNames(market, meta?.koreanName, meta?.englishName); setTargets(prev => [...prev, { market, strategyId: session.globalStrategyId, koreanName: names.koreanName, englishName: names.englishName, }]); setSelectedMarket(market); return true; }, [targets, session.globalStrategyId, virtualTargetMaxCount]); const handleRemoveTarget = useCallback((market: string) => { setTargets(prev => prev.filter(x => x.market !== market)); }, []); const handleTogglePin = useCallback((market: string) => { setTargets(prev => { const item = prev.find(t => t.market === market); if (!item) return prev; const pinned = !item.pinned; void persistVirtualTargetPinned(market, pinned).catch(() => { window.alert('고정 설정 저장에 실패했습니다.'); }); return prev.map(t => (t.market === market ? { ...t, pinned } : t)); }); }, []); /** 상단 기본전략 — 신규 추가 종목 템플릿만 변경, 기존 종목 전략은 유지 */ const handleGlobalStrategyChange = useCallback((strategyId: number | null) => { setTargets(prev => freezeTargetsBeforeGlobalStrategyChange(prev, session.globalStrategyId)); setSession(prev => ({ ...prev, globalStrategyId: strategyId })); }, [session.globalStrategyId]); const handleStart = useCallback(async () => { if (targets.length === 0) { window.alert('투자대상 종목을 먼저 추가하세요.'); return; } if (!session.globalStrategyId) { window.alert('투자전략을 선택하세요.'); return; } const globalStrat = strategies.find(s => s.id === session.globalStrategyId); const synced = await syncStrategyTimeframesFromLayoutIfNeeded( session.globalStrategyId, globalStrat, ); if (!synced) return; const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat); if (!ok) return; try { await syncVirtualTargetsToBackend(targets, session, true); setSession(s => ({ ...s, running: true })); } catch { window.alert('가상투자 시작 설정 저장에 실패했습니다.'); } }, [targets, session, strategies]); const handleStop = useCallback(async () => { try { await stopVirtualLiveOnBackend(targets, session); } catch { /* ignore */ } setSession(s => ({ ...s, running: false })); }, [targets, session]); const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => { setTargets(prev => prev.map(t => t.market === market ? { ...t, strategyId } : t, )); if (!session.running) return; const effectiveId = resolveVirtualTargetStrategyId({ strategyId }, session.globalStrategyId); if (effectiveId == null) return; try { await saveLiveStrategySettings({ market, strategyId: effectiveId, isLiveCheck: true, executionType: session.executionType, positionMode: session.positionMode, skipWatchlistSync: true, skipGlobalTemplate: true, }); await pinStrategyEvaluationTimeframes(market, effectiveId); } catch { window.alert('종목 전략 변경 저장에 실패했습니다.'); } }, [session]); const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => { if (!session.running || targets.length === 0) return; try { await syncVirtualTargetsToBackend(targets, nextSession, true); } catch { window.alert('가상투자 설정 동기화에 실패했습니다.'); } }, [session.running, targets]); const handleExecutionTypeChange = useCallback((executionType: VirtualSessionConfig['executionType']) => { const next = { ...session, executionType }; setSession(next); void resyncRunningSession(next); }, [session, resyncRunningSession]); const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => { const next = { ...session, positionMode }; setSession(next); void resyncRunningSession(next); }, [session, resyncRunningSession]); /** 서버·DB에서 targets 다시 로드 (↻ 동기화) */ const reloadTargetsFromStorage = useCallback(() => { setLoading(true); preferencesHydratedRef.current = false; void reloadAppSettingsCache() .then(async () => { reloadFromUiPreferences(); let next = loadVirtualTargets(); if (next.length === 0) { next = await hydrateVirtualTargetsIfEmpty(); if (next.length > 0) { setTargets(next); saveVirtualTargets(next); } } preferencesHydratedRef.current = true; }) .catch(() => reloadFromUiPreferences()) .finally(() => setLoading(false)); }, [reloadFromUiPreferences]); return { targets, setTargets, handleTargetsChange, addTarget, session, setSession, strategies, summary, trades, loading, selectedMarket, setSelectedMarket, viewMode, setViewMode, refreshPaperData, snapshots, snapshotLoadingByMarket, liveStatusByMarket: mergedLiveStatus, lastTickAtByMarket, appLiveStrategyId, strategyNames, virtualTargetMaxCount, handleStart, handleStop, handleRemoveTarget, handleTogglePin, handleGlobalStrategyChange, handleExecutionTypeChange, handlePositionModeChange, handleTargetStrategyChange, reloadTargetsFromStorage, appChartDefaults, }; }