앱 수정
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 가상매매 공통 상태·동기화 (웹 VirtualTradingPage · 모바일 앱 공유)
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, 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 { useVirtualAutoTrade } from './useVirtualAutoTrade';
|
||||
import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus';
|
||||
import {
|
||||
loadVirtualSession,
|
||||
loadVirtualTargets,
|
||||
saveVirtualSession,
|
||||
saveVirtualTargets,
|
||||
loadVirtualCardViewMode,
|
||||
saveVirtualCardViewMode,
|
||||
type VirtualSessionConfig,
|
||||
type VirtualTargetItem,
|
||||
type VirtualCardViewMode,
|
||||
} from '../utils/virtualTradingStorage';
|
||||
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
|
||||
import {
|
||||
syncVirtualTargetsToBackend,
|
||||
stopVirtualLiveOnBackend,
|
||||
} from '../utils/virtualLiveStrategySync';
|
||||
import {
|
||||
syncStrategyTimeframesFromLayoutIfNeeded,
|
||||
warnStrategyTimeframeMismatch,
|
||||
} from '../utils/strategyTimeframeSync';
|
||||
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
||||
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
||||
import {
|
||||
coalesceTargetsToDefaultStrategy,
|
||||
resolveVirtualTargetStrategyId,
|
||||
} from '../utils/virtualTargetStrategy';
|
||||
import {
|
||||
isVirtualTargetAddAllowed,
|
||||
virtualTargetLimitMessage,
|
||||
} from '../utils/virtualTargetLimits';
|
||||
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
|
||||
import { useAppSettings, resolveAppDefaults } from './useAppSettings';
|
||||
|
||||
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 } = 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 [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());
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setTargets(loadVirtualTargets());
|
||||
setSession(loadVirtualSession());
|
||||
void Promise.all([
|
||||
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
|
||||
loadPaperSummary().then(setSummary),
|
||||
loadPaperTrades().then(setTrades),
|
||||
]).finally(() => setLoading(false));
|
||||
}, [settingsSessionKey]);
|
||||
|
||||
/** DB is_pinned → uiPreferences.virtual.targets 동기화 */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const initial = loadVirtualTargets();
|
||||
if (initial.length === 0) return;
|
||||
void (async () => {
|
||||
const merged = await Promise.all(
|
||||
initial.map(async t => {
|
||||
try {
|
||||
const s = await loadLiveStrategySettings(t.market);
|
||||
const pinned = !!s.isPinned;
|
||||
if (!!t.pinned === pinned) return t;
|
||||
return { ...t, pinned };
|
||||
} catch {
|
||||
return t;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cancelled) setTargets(merged);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.globalStrategyId == null) return;
|
||||
setTargets(prev => coalesceTargetsToDefaultStrategy(prev, session.globalStrategyId));
|
||||
}, [session.globalStrategyId]);
|
||||
|
||||
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: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||
})),
|
||||
[targets, session.globalStrategyId],
|
||||
);
|
||||
|
||||
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
|
||||
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
||||
targetRefs,
|
||||
session.running,
|
||||
);
|
||||
|
||||
useVirtualAutoTrade({
|
||||
enabled: paperTradingEnabled && paperAutoTradeEnabled,
|
||||
session,
|
||||
targets,
|
||||
snapshots,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions: summary?.positions,
|
||||
onFilled: () => void refreshPaperData(),
|
||||
});
|
||||
|
||||
const mergedLiveStatus = useMemo(() => {
|
||||
if (!session.running) return liveStatusByMarket;
|
||||
const merged = { ...liveStatusByMarket };
|
||||
const now = Date.now();
|
||||
for (const [market, snap] of Object.entries(snapshots)) {
|
||||
if (snap?.updatedAt && now - snap.updatedAt < 8000) {
|
||||
merged[market] = 'live';
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [liveStatusByMarket, snapshots, session.running]);
|
||||
|
||||
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: null,
|
||||
koreanName: names.koreanName,
|
||||
englishName: names.englishName,
|
||||
}]);
|
||||
setSelectedMarket(market);
|
||||
return true;
|
||||
}, [targets, virtualTargetMaxCount]);
|
||||
|
||||
const handleRemoveTarget = useCallback((market: string) => {
|
||||
const t = targets.find(x => x.market === market);
|
||||
if (t?.pinned) return;
|
||||
setTargets(prev => prev.filter(x => x.market !== market));
|
||||
}, [targets]);
|
||||
|
||||
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) => {
|
||||
const next = { ...session, globalStrategyId: strategyId };
|
||||
setSession(next);
|
||||
if (session.running && strategyId) {
|
||||
void syncVirtualTargetsToBackend(targets, next, true);
|
||||
}
|
||||
}, [session, targets]);
|
||||
|
||||
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 handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => {
|
||||
const normalized = normalizeStartCandleType(candleType);
|
||||
setTargets(prev => prev.map(t =>
|
||||
t.market === market ? { ...t, candleType: normalized } : t,
|
||||
));
|
||||
if (!session.running) return;
|
||||
const target = targets.find(t => t.market === market);
|
||||
const strategyId = target
|
||||
? resolveVirtualTargetStrategyId(target, session.globalStrategyId)
|
||||
: session.globalStrategyId;
|
||||
if (!strategyId) return;
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId,
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
skipWatchlistSync: true,
|
||||
skipGlobalTemplate: true,
|
||||
});
|
||||
await pinStrategyEvaluationTimeframes(market, strategyId);
|
||||
} catch {
|
||||
window.alert('종목 평가 분봉 변경 저장에 실패했습니다.');
|
||||
}
|
||||
}, [session, targets]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const missing = targets.filter(t => !t.candleType);
|
||||
if (missing.length === 0) return;
|
||||
void Promise.all(
|
||||
missing.map(async t => {
|
||||
try {
|
||||
const s = await loadLiveStrategySettings(t.market);
|
||||
return { market: t.market, candleType: s.candleType };
|
||||
} catch {
|
||||
return { market: t.market, candleType: undefined };
|
||||
}
|
||||
}),
|
||||
).then(rows => {
|
||||
if (cancelled) return;
|
||||
setTargets(prev => prev.map(t => {
|
||||
const row = rows.find(r => r.market === t.market);
|
||||
if (t.candleType || !row?.candleType) return t;
|
||||
return { ...t, candleType: normalizeStartCandleType(row.candleType) };
|
||||
}));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]);
|
||||
|
||||
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(() => {
|
||||
setTargets(loadVirtualTargets());
|
||||
setSession(loadVirtualSession());
|
||||
}, []);
|
||||
|
||||
return {
|
||||
targets,
|
||||
setTargets,
|
||||
handleTargetsChange,
|
||||
addTarget,
|
||||
session,
|
||||
setSession,
|
||||
strategies,
|
||||
summary,
|
||||
trades,
|
||||
loading,
|
||||
selectedMarket,
|
||||
setSelectedMarket,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
refreshPaperData,
|
||||
snapshots,
|
||||
liveStatusByMarket: mergedLiveStatus,
|
||||
lastTickAtByMarket,
|
||||
strategyNames,
|
||||
virtualTargetMaxCount,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleRemoveTarget,
|
||||
handleTogglePin,
|
||||
handleGlobalStrategyChange,
|
||||
handleExecutionTypeChange,
|
||||
handlePositionModeChange,
|
||||
handleTargetStrategyChange,
|
||||
handleTargetCandleTypeChange,
|
||||
reloadTargetsFromStorage,
|
||||
appChartDefaults,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user