앱 수정

This commit is contained in:
Macbook
2026-05-28 15:37:02 +09:00
parent 3503ef33f5
commit f64dc1e983
21 changed files with 1434 additions and 755 deletions
+407
View File
@@ -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,
};
}