서버 가상매매 목록 지표없음 문제 개선

This commit is contained in:
Macbook
2026-06-07 19:14:13 +09:00
parent b294c2be5d
commit 2f3133d413
10 changed files with 299 additions and 62 deletions
@@ -36,6 +36,7 @@ export default function VirtualTradingScreen() {
snapshots, snapshots,
snapshotLoadingByMarket, snapshotLoadingByMarket,
liveStatusByMarket, liveStatusByMarket,
appLiveStrategyId,
handleStart, handleStart,
handleStop, handleStop,
handleRemoveTarget, handleRemoveTarget,
@@ -184,7 +185,11 @@ export default function VirtualTradingScreen() {
aria-label="투자 대상 목록" aria-label="투자 대상 목록"
> >
{t => { {t => {
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId); const strategyId = resolveVirtualTargetStrategyId(
t,
session.globalStrategyId,
appLiveStrategyId,
);
const snapKey = `${t.market}:${strategyId ?? ''}`; const snapKey = `${t.market}:${strategyId ?? ''}`;
const snap = snapshots[snapKey] ?? snapshots[t.market]; const snap = snapshots[snapKey] ?? snapshots[t.market];
const signalLoading = snapshotLoadingByMarket[t.market]; const signalLoading = snapshotLoadingByMarket[t.market];
@@ -351,19 +351,42 @@ public class LiveStrategySettingsService {
return dto; return dto;
} }
/** 앱 전역 실시간 체크 ON/OFF·전략 ID를 DTO에 반영 (종목별 행과 분리) */ /**
* 앱 전역 실시간 템플릿 반영.
* 전역 liveStrategyCheck+ID 가 있으면 관심종목·차트 모드 우선.
* 없으면 가상투자 per-market 행(isLiveCheck + strategyId) 유지.
*/
private LiveStrategySettingsDto mergeGlobalTemplate( private LiveStrategySettingsDto mergeGlobalTemplate(
LiveStrategySettingsDto dto, GcAppSettings app, String market) { LiveStrategySettingsDto dto, GcAppSettings app, String market) {
dto.setMarket(market); dto.setMarket(market);
dto.setLiveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck())); boolean globalCheck = Boolean.TRUE.equals(app.getLiveStrategyCheck());
dto.setStrategyId(app.getLiveStrategyId()); Long globalSid = app.getLiveStrategyId();
if (globalCheck && globalSid != null) {
dto.setLiveCheck(true);
dto.setStrategyId(globalSid);
applyGlobalExecutionFields(dto, app);
return dto;
}
if (dto.getStrategyId() != null && dto.isLiveCheck()) {
applyGlobalExecutionFields(dto, app);
return dto;
}
dto.setLiveCheck(globalCheck);
dto.setStrategyId(globalSid);
applyGlobalExecutionFields(dto, app);
return dto;
}
private void applyGlobalExecutionFields(LiveStrategySettingsDto dto, GcAppSettings app) {
if (app.getLiveExecutionType() != null) { if (app.getLiveExecutionType() != null) {
dto.setExecutionType(app.getLiveExecutionType()); dto.setExecutionType(app.getLiveExecutionType());
} }
if (app.getLivePositionMode() != null) { if (app.getLivePositionMode() != null) {
dto.setPositionMode(app.getLivePositionMode()); dto.setPositionMode(app.getLivePositionMode());
} }
return dto;
} }
private LiveStrategySettingsDto defaultDtoFromApp(String market, GcAppSettings app) { private LiveStrategySettingsDto defaultDtoFromApp(String market, GcAppSettings app) {
+98 -22
View File
@@ -44,7 +44,7 @@ import {
type VirtualTargetItem, type VirtualTargetItem,
type VirtualCardViewMode, type VirtualCardViewMode,
} from '../utils/virtualTradingStorage'; } from '../utils/virtualTradingStorage';
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings'; import { useAppSettings, resolveAppDefaults, subscribeAppSettings } from '../hooks/useAppSettings';
import { coerceFiniteNumber } from '../utils/safeFormat'; import { coerceFiniteNumber } from '../utils/safeFormat';
import type { TradeNotificationGridPresetId } from '../utils/tradeNotificationGridPresets'; import type { TradeNotificationGridPresetId } from '../utils/tradeNotificationGridPresets';
import { import {
@@ -62,6 +62,12 @@ import {
resolveVirtualTargetStrategyId, resolveVirtualTargetStrategyId,
} from '../utils/virtualTargetStrategy'; } from '../utils/virtualTargetStrategy';
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits'; import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate';
import {
mergeTargetsWithLiveStrategySettings,
mergeVirtualSessionWithAppLive,
resolveVirtualTargetStrategyIdWithApp,
} from '../utils/virtualTargetHydrate';
import '../styles/virtualTradingDashboard.css'; import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook' | 'history'; type RightTab = 'trade' | 'orderbook' | 'history';
@@ -102,7 +108,8 @@ const VirtualTradingPage: React.FC<Props> = ({
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal'); const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
const [focusMarket, setFocusMarket] = useState<string | null>(null); const [focusMarket, setFocusMarket] = useState<string | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null); const orderAnchorRef = useRef<HTMLDivElement>(null);
const { settings: appSettings } = useAppSettings(); const preferencesHydratedRef = useRef(false);
const { settings: appSettings, isLoaded: settingsLoaded } = useAppSettings();
const appChartDefaults = resolveAppDefaults(appSettings); const appChartDefaults = resolveAppDefaults(appSettings);
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
?? 'BACKEND_STOMP') as ChartRealtimeSource; ?? 'BACKEND_STOMP') as ChartRealtimeSource;
@@ -111,20 +118,60 @@ const VirtualTradingPage: React.FC<Props> = ({
const chartPaneSeparator = appChartDefaults.chartPaneSeparator; const chartPaneSeparator = appChartDefaults.chartPaneSeparator;
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight; const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount; const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
const appLiveStrategyId = appChartDefaults.liveStrategyId ?? null;
const reloadFromUiPreferences = useCallback(() => {
const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId);
setSession(sessionFromDb);
setTargets(loadVirtualTargets());
}, [appLiveStrategyId]);
useEffect(() => { useEffect(() => {
void Promise.all([ 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() loadStrategies()
.then(list => { .then(list => {
if (cancelled) return;
setStrategies(list); setStrategies(list);
const validIds = new Set( const validIds = new Set(
list.map(s => s.id).filter((id): id is number => id != null && id > 0), list.map(s => s.id).filter((id): id is number => id != null && id > 0),
); );
setSession(prev => setSession(prev => {
prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) const next = prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId)
? { ...prev, globalStrategyId: null } ? { ...prev, globalStrategyId: null }
: prev, : prev;
); return mergeVirtualSessionWithAppLive(next, appLiveStrategyId);
});
setTargets(prev => setTargets(prev =>
prev.map(t => prev.map(t =>
t.strategyId != null && !validIds.has(t.strategyId) t.strategyId != null && !validIds.has(t.strategyId)
@@ -133,37 +180,65 @@ const VirtualTradingPage: React.FC<Props> = ({
), ),
); );
}) })
.catch(() => setStrategies([])), .catch(() => { if (!cancelled) setStrategies([]); }),
loadPaperSummary().then(setSummary), loadPaperSummary().then(sum => { if (!cancelled) setSummary(sum); }),
loadPaperTrades().then(setTrades), loadPaperTrades().then(tr => { if (!cancelled) setTrades(tr); }),
]).finally(() => setLoading(false)); ]);
}, []); if (!cancelled) setLoading(false);
})();
return () => { cancelled = true; };
}, [settingsLoaded, appLiveStrategyId, reloadFromUiPreferences]);
/** DB is_pinned → localStorage 투자대상 목록 동기화 (최초 1회) */
useEffect(() => { useEffect(() => {
if (!settingsLoaded) return;
return subscribeAppSettings(() => {
reloadFromUiPreferences();
});
}, [settingsLoaded, reloadFromUiPreferences]);
/** DB is_pinned·strategyId → ui_preferences 투자대상 동기화 */
useEffect(() => {
if (!settingsLoaded) return;
let cancelled = false; let cancelled = false;
const initial = loadVirtualTargets(); const initial = loadVirtualTargets();
if (initial.length === 0) return; if (initial.length === 0) return;
const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId);
void (async () => { void (async () => {
const merged = await Promise.all( const merged = await Promise.all(
initial.map(async t => { initial.map(async t => {
try { try {
const s = await loadLiveStrategySettings(t.market); const s = await loadLiveStrategySettings(t.market);
const pinned = !!s.isPinned; let next = t;
if (!!t.pinned === pinned) return t; if (!!t.pinned !== !!s.isPinned) {
return { ...t, pinned }; 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 { } catch {
return t; return t;
} }
}), }),
); );
if (!cancelled) setTargets(merged); const withLive = await mergeTargetsWithLiveStrategySettings(
merged,
sessionFromDb,
appLiveStrategyId,
);
if (!cancelled) setTargets(withLive);
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [settingsLoaded, appLiveStrategyId]);
useEffect(() => { saveVirtualTargets(targets); }, [targets]); useEffect(() => {
useEffect(() => { saveVirtualSession(session); }, [session]); if (!preferencesHydratedRef.current) return;
saveVirtualTargets(targets);
}, [targets]);
useEffect(() => {
if (!preferencesHydratedRef.current) return;
saveVirtualSession(session);
}, [session]);
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]); useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]);
@@ -194,9 +269,9 @@ const VirtualTradingPage: React.FC<Props> = ({
const targetRefs = useMemo(() => const targetRefs = useMemo(() =>
targets.map(t => ({ targets.map(t => ({
market: t.market, market: t.market,
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId), strategyId: resolveVirtualTargetStrategyIdWithApp(t, session, appLiveStrategyId),
})), })),
[targets, session.globalStrategyId]); [targets, session, appLiveStrategyId]);
const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots( const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots(
targetRefs, targetRefs,
@@ -474,6 +549,7 @@ const VirtualTradingPage: React.FC<Props> = ({
globalDisplayMode={globalDisplayMode} globalDisplayMode={globalDisplayMode}
onFocusMarketChange={setFocusMarket} onFocusMarketChange={setFocusMarket}
gridPreset={gridPreset} gridPreset={gridPreset}
appLiveStrategyId={appLiveStrategyId}
/> />
)} )}
rightTabs={rightTabs} rightTabs={rightTabs}
@@ -4,6 +4,7 @@ import {
resolveVirtualTargetNames, resolveVirtualTargetNames,
resolveVirtualTargetStrategyDisplayName, resolveVirtualTargetStrategyDisplayName,
} from '../../utils/virtualTargetNames'; } from '../../utils/virtualTargetNames';
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
@@ -57,6 +58,8 @@ interface Props {
readOnlyStrategyLabel?: string; readOnlyStrategyLabel?: string;
/** 헤더 우측 레이아웃 (알림 목록 신호 슬롯: inline-quote) */ /** 헤더 우측 레이아웃 (알림 목록 신호 슬롯: inline-quote) */
headVariant?: VirtualCardHeadVariant; headVariant?: VirtualCardHeadVariant;
/** gc_app_settings.liveStrategyId — ui 미동기화 시 지표 평가용 */
appLiveStrategyId?: number | null;
} }
const VirtualTargetCard: React.FC<Props> = ({ const VirtualTargetCard: React.FC<Props> = ({
@@ -88,6 +91,7 @@ const VirtualTargetCard: React.FC<Props> = ({
englishName, englishName,
readOnlyStrategyLabel, readOnlyStrategyLabel,
headVariant = 'default', headVariant = 'default',
appLiveStrategyId = null,
}) => { }) => {
const inlineHeadQuote = headVariant === 'inline-quote'; const inlineHeadQuote = headVariant === 'inline-quote';
const { koreanName: displayKo, englishName: displayEn } = resolveVirtualTargetNames( const { koreanName: displayKo, englishName: displayEn } = resolveVirtualTargetNames(
@@ -101,6 +105,11 @@ const VirtualTargetCard: React.FC<Props> = ({
readOnlyStrategyLabel, readOnlyStrategyLabel,
); );
const rows = snapshot?.rows ?? []; const rows = snapshot?.rows ?? [];
const effectiveStrategyId = resolveVirtualTargetStrategyId(
{ strategyId },
globalStrategyId,
appLiveStrategyId,
);
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
const metCount = metrics.filter(m => m.status === 'match').length; const metCount = metrics.filter(m => m.status === 'match').length;
@@ -232,6 +241,7 @@ const VirtualTargetCard: React.FC<Props> = ({
viewMode={viewMode} viewMode={viewMode}
receiving={highlightReceiving} receiving={highlightReceiving}
loading={signalLoading} loading={signalLoading}
hasStrategy={effectiveStrategyId != null}
/> />
)} )}
@@ -44,6 +44,7 @@ interface Props {
globalDisplayMode: VirtualCardDisplayMode; globalDisplayMode: VirtualCardDisplayMode;
onFocusMarketChange?: (market: string | null) => void; onFocusMarketChange?: (market: string | null) => void;
gridPreset: TradeNotificationGridPresetId; gridPreset: TradeNotificationGridPresetId;
appLiveStrategyId?: number | null;
} }
const VirtualTargetGrid: React.FC<Props> = ({ const VirtualTargetGrid: React.FC<Props> = ({
@@ -62,6 +63,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
globalDisplayMode, globalDisplayMode,
onFocusMarketChange, onFocusMarketChange,
gridPreset, gridPreset,
appLiveStrategyId = null,
}) => { }) => {
const gridCols = getTradeNotificationGridPreset(gridPreset).cols; const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
@@ -146,7 +148,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
running={session.running} running={session.running}
liveStatus={resolveVirtualLiveStatusFallback( liveStatus={resolveVirtualLiveStatusFallback(
focusTarget.market, focusTarget.market,
resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId), resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId, appLiveStrategyId),
liveStatusByMarket, liveStatusByMarket,
session.running, session.running,
)} )}
@@ -184,7 +186,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
aria-label="가상매매 종목 그리드" aria-label="가상매매 종목 그리드"
> >
{t => { {t => {
const stratId = resolveVirtualTargetStrategyId(t, session.globalStrategyId); const stratId = resolveVirtualTargetStrategyId(t, session.globalStrategyId, appLiveStrategyId);
const strat = strategies.find(s => s.id === stratId); const strat = strategies.find(s => s.id === stratId);
return ( return (
<VirtualTargetCard <VirtualTargetCard
@@ -219,6 +221,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
ticker={tickers?.get(t.market)} ticker={tickers?.get(t.market)}
koreanName={t.koreanName} koreanName={t.koreanName}
englishName={t.englishName} englishName={t.englishName}
appLiveStrategyId={appLiveStrategyId}
/> />
); );
}} }}
@@ -18,6 +18,8 @@ interface Props {
receiving?: boolean; receiving?: boolean;
/** 업비트 캔들 warm-up · live-conditions 수집 중 */ /** 업비트 캔들 warm-up · live-conditions 수집 중 */
loading?: boolean; loading?: boolean;
/** false — 전략 미지정 안내 */
hasStrategy?: boolean;
className?: string; className?: string;
} }
@@ -26,6 +28,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
viewMode = 'summary', viewMode = 'summary',
receiving = false, receiving = false,
loading = false, loading = false,
hasStrategy = true,
className = '', className = '',
}) => { }) => {
const rows = snapshot?.rows ?? []; const rows = snapshot?.rows ?? [];
@@ -49,6 +52,8 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
<p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}> <p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}>
{loading {loading
? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)' ? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)'
: !hasStrategy
? '투자전략을 선택하세요 (상단 또는 종목별 전략)'
: '전략 조건·지표 데이터 없음'} : '전략 조건·지표 데이터 없음'}
</p> </p>
); );
+48 -15
View File
@@ -52,6 +52,11 @@ import {
reloadAppSettingsCache, reloadAppSettingsCache,
} from './useAppSettings'; } from './useAppSettings';
import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate'; import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate';
import {
mergeTargetsWithLiveStrategySettings,
mergeVirtualSessionWithAppLive,
resolveVirtualTargetStrategyIdWithApp,
} from '../utils/virtualTargetHydrate';
export interface UseVirtualTradingCoreOptions { export interface UseVirtualTradingCoreOptions {
defaultMarket?: string; defaultMarket?: string;
@@ -81,6 +86,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled; const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled;
const paperAutoTradeBudgetPct = paperAutoTradeBudgetPctProp ?? appChartDefaults.paperAutoTradeBudgetPct; const paperAutoTradeBudgetPct = paperAutoTradeBudgetPctProp ?? appChartDefaults.paperAutoTradeBudgetPct;
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount; const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
const appLiveStrategyId = appChartDefaults.liveStrategyId ?? null;
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets()); const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession()); const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
@@ -95,8 +101,8 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
const reloadFromUiPreferences = useCallback(() => { const reloadFromUiPreferences = useCallback(() => {
setTargets(loadVirtualTargets()); setTargets(loadVirtualTargets());
setSession(loadVirtualSession()); setSession(mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId));
}, []); }, [appLiveStrategyId]);
useEffect(() => { useEffect(() => {
if (!settingsLoaded) return; if (!settingsLoaded) return;
@@ -113,6 +119,20 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
saveVirtualTargets(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) { if (!cancelled) {
preferencesHydratedRef.current = true; preferencesHydratedRef.current = true;
} }
@@ -123,11 +143,12 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
const validIds = new Set( const validIds = new Set(
s.map(x => x.id).filter((id): id is number => id != null && id > 0), s.map(x => x.id).filter((id): id is number => id != null && id > 0),
); );
setSession(prev => setSession(prev => {
prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) const next = prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId)
? { ...prev, globalStrategyId: null } ? { ...prev, globalStrategyId: null }
: prev, : prev;
); return mergeVirtualSessionWithAppLive(next, appLiveStrategyId);
});
setTargets(prev => setTargets(prev =>
prev.map(t => prev.map(t =>
t.strategyId != null && !validIds.has(t.strategyId) t.strategyId != null && !validIds.has(t.strategyId)
@@ -142,7 +163,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [settingsSessionKey, settingsLoaded, reloadFromUiPreferences]); }, [settingsSessionKey, settingsLoaded, appLiveStrategyId, reloadFromUiPreferences]);
/** DB ui_preferences 반영(웹·모바일 동일 계정) */ /** DB ui_preferences 반영(웹·모바일 동일 계정) */
useEffect(() => { useEffect(() => {
@@ -152,29 +173,40 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
}); });
}, [settingsLoaded, reloadFromUiPreferences]); }, [settingsLoaded, reloadFromUiPreferences]);
/** DB is_pinned → uiPreferences.virtual.targets 동기화 */ /** DB is_pinned·strategyId → uiPreferences.virtual.targets 동기화 */
useEffect(() => { useEffect(() => {
if (!settingsLoaded) return; if (!settingsLoaded) return;
let cancelled = false; let cancelled = false;
const initial = loadVirtualTargets(); const initial = loadVirtualTargets();
if (initial.length === 0) return; if (initial.length === 0) return;
const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId);
void (async () => { void (async () => {
const merged = await Promise.all( const merged = await Promise.all(
initial.map(async t => { initial.map(async t => {
try { try {
const s = await loadLiveStrategySettings(t.market); const s = await loadLiveStrategySettings(t.market);
const pinned = !!s.isPinned; let next = t;
if (!!t.pinned === pinned) return t; if (!!t.pinned !== !!s.isPinned) {
return { ...t, pinned }; 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 { } catch {
return t; return t;
} }
}), }),
); );
if (!cancelled) setTargets(merged); const withLive = await mergeTargetsWithLiveStrategySettings(
merged,
sessionFromDb,
appLiveStrategyId,
);
if (!cancelled) setTargets(withLive);
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [settingsLoaded]); }, [settingsLoaded, appLiveStrategyId]);
useEffect(() => { useEffect(() => {
if (!preferencesHydratedRef.current) return; if (!preferencesHydratedRef.current) return;
@@ -203,9 +235,9 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
const targetRefs = useMemo( const targetRefs = useMemo(
() => targets.map(t => ({ () => targets.map(t => ({
market: t.market, market: t.market,
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId), strategyId: resolveVirtualTargetStrategyIdWithApp(t, session, appLiveStrategyId),
})), })),
[targets, session.globalStrategyId], [targets, session, appLiveStrategyId],
); );
const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots( const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots(
@@ -400,6 +432,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
snapshotLoadingByMarket, snapshotLoadingByMarket,
liveStatusByMarket: mergedLiveStatus, liveStatusByMarket: mergedLiveStatus,
lastTickAtByMarket, lastTickAtByMarket,
appLiveStrategyId,
strategyNames, strategyNames,
virtualTargetMaxCount, virtualTargetMaxCount,
handleStart, handleStart,
+1 -1
View File
@@ -1472,7 +1472,7 @@ export async function pinCandleWatch(market: string, candleType: string): Promis
try { try {
await fetch( await fetch(
`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, `${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`,
{ method: 'POST' }, { method: 'POST', headers: authHeaders() },
); );
} catch { } catch {
/* pin 실패해도 평가 API는 시도 */ /* pin 실패해도 평가 API는 시도 */
@@ -0,0 +1,78 @@
/**
* 가상투자 — ui_preferences·앱 설정·서버 live-strategy 행 간 전략 ID 정합
*/
import { loadLiveStrategySettings, type LiveStrategySettingsDto } from './backendApi';
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
/** 종목별 effective strategyId (ui → 세션 → 앱 전역 liveStrategyId) */
export function resolveVirtualTargetStrategyIdWithApp(
target: Pick<VirtualTargetItem, 'strategyId'>,
session: Pick<VirtualSessionConfig, 'globalStrategyId'>,
appLiveStrategyId: number | null | undefined,
): number | null {
return resolveVirtualTargetStrategyId(
target,
session.globalStrategyId,
appLiveStrategyId ?? null,
);
}
/** 세션 globalStrategyId — ui_preferences 비었을 때 앱 전역 liveStrategyId 로 복구 */
export function mergeVirtualSessionWithAppLive(
session: VirtualSessionConfig,
appLiveStrategyId: number | null | undefined,
): VirtualSessionConfig {
if (session.globalStrategyId != null) return session;
const sid = appLiveStrategyId;
if (sid == null || sid <= 0) return session;
return { ...session, globalStrategyId: sid };
}
function applyLiveRow(
target: VirtualTargetItem,
live: LiveStrategySettingsDto,
globalStrategyId: number | null,
appLiveStrategyId: number | null | undefined,
): VirtualTargetItem {
if (target.strategyId != null) return target;
const effective = resolveVirtualTargetStrategyIdWithApp(
target,
{ globalStrategyId },
appLiveStrategyId,
);
if (effective != null) return target;
const sid = live.strategyId;
if (sid == null || sid <= 0 || !live.isLiveCheck) return target;
return { ...target, strategyId: sid };
}
/** 서버 gc_live_strategy_settings — ui에 strategyId 없을 때 per-market 복구 */
export async function mergeTargetsWithLiveStrategySettings(
targets: VirtualTargetItem[],
session: VirtualSessionConfig,
appLiveStrategyId: number | null | undefined,
): Promise<VirtualTargetItem[]> {
if (targets.length === 0) return targets;
const globalId = session.globalStrategyId;
const merged = await Promise.all(
targets.map(async t => {
const effective = resolveVirtualTargetStrategyIdWithApp(
t,
session,
appLiveStrategyId,
);
if (effective != null) return t;
try {
const live = await loadLiveStrategySettings(t.market);
return applyLiveRow(t, live, globalId, appLiveStrategyId);
} catch {
return t;
}
}),
);
const changed = merged.some((t, i) => t.strategyId !== targets[i]?.strategyId);
return changed ? merged : targets;
}
+5 -1
View File
@@ -11,9 +11,13 @@ export const VIRTUAL_DEFAULT_STRATEGY_VALUE = '__default__';
export function resolveVirtualTargetStrategyId( export function resolveVirtualTargetStrategyId(
target: Pick<VirtualTargetItem, 'strategyId'>, target: Pick<VirtualTargetItem, 'strategyId'>,
globalStrategyId: number | null, globalStrategyId: number | null,
/** gc_app_settings.liveStrategyId — ui_preferences.globalStrategyId 미동기화 시 폴백 */
appLiveStrategyId: number | null = null,
): number | null { ): number | null {
if (target.strategyId != null) return target.strategyId; if (target.strategyId != null) return target.strategyId;
return globalStrategyId; if (globalStrategyId != null) return globalStrategyId;
if (appLiveStrategyId != null && appLiveStrategyId > 0) return appLiveStrategyId;
return null;
} }
/** 기본전략 변경 직전 — null(기본 따름) 종목을 이전 기본전략 ID 로 고정 */ /** 기본전략 변경 직전 — null(기본 따름) 종목을 이전 기본전략 ID 로 고정 */