Files
goldenChart/frontend/src/hooks/useVirtualTradingCore.ts
T

450 lines
16 KiB
TypeScript

/**
* 가상매매 공통 상태·동기화 (웹 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<string, TickerData>;
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<VirtualTargetItem[]>(() => loadVirtualTargets());
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => 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,
};
}