450 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|