From 2f3133d413efc31314ea081c3a16cfe247d7b661 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 7 Jun 2026 19:14:13 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B0=80=EC=83=81?= =?UTF-8?q?=EB=A7=A4=EB=A7=A4=20=EB=AA=A9=EB=A1=9D=20=EC=A7=80=ED=91=9C?= =?UTF-8?q?=EC=97=86=EC=9D=8C=20=EB=AC=B8=EC=A0=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/virtual/VirtualTradingScreen.tsx | 7 +- .../service/LiveStrategySettingsService.java | 31 +++- .../src/components/VirtualTradingPage.tsx | 150 +++++++++++++----- .../components/virtual/VirtualTargetCard.tsx | 10 ++ .../components/virtual/VirtualTargetGrid.tsx | 7 +- .../virtual/VirtualTargetSignalPanel.tsx | 7 +- frontend/src/hooks/useVirtualTradingCore.ts | 63 ++++++-- frontend/src/utils/backendApi.ts | 2 +- frontend/src/utils/virtualTargetHydrate.ts | 78 +++++++++ frontend/src/utils/virtualTargetStrategy.ts | 6 +- 10 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 frontend/src/utils/virtualTargetHydrate.ts diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx index dd682e0..38e1b90 100644 --- a/app/src/screens/virtual/VirtualTradingScreen.tsx +++ b/app/src/screens/virtual/VirtualTradingScreen.tsx @@ -36,6 +36,7 @@ export default function VirtualTradingScreen() { snapshots, snapshotLoadingByMarket, liveStatusByMarket, + appLiveStrategyId, handleStart, handleStop, handleRemoveTarget, @@ -184,7 +185,11 @@ export default function VirtualTradingScreen() { aria-label="투자 대상 목록" > {t => { - const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId); + const strategyId = resolveVirtualTargetStrategyId( + t, + session.globalStrategyId, + appLiveStrategyId, + ); const snapKey = `${t.market}:${strategyId ?? ''}`; const snap = snapshots[snapKey] ?? snapshots[t.market]; const signalLoading = snapshotLoadingByMarket[t.market]; diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index 177395a..5a13e2a 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -351,19 +351,42 @@ public class LiveStrategySettingsService { return dto; } - /** 앱 전역 실시간 체크 ON/OFF·전략 ID를 DTO에 반영 (종목별 행과 분리) */ + /** + * 앱 전역 실시간 템플릿 반영. + * 전역 liveStrategyCheck+ID 가 있으면 관심종목·차트 모드 우선. + * 없으면 가상투자 per-market 행(isLiveCheck + strategyId) 유지. + */ private LiveStrategySettingsDto mergeGlobalTemplate( LiveStrategySettingsDto dto, GcAppSettings app, String market) { dto.setMarket(market); - dto.setLiveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck())); - dto.setStrategyId(app.getLiveStrategyId()); + boolean globalCheck = Boolean.TRUE.equals(app.getLiveStrategyCheck()); + 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) { dto.setExecutionType(app.getLiveExecutionType()); } if (app.getLivePositionMode() != null) { dto.setPositionMode(app.getLivePositionMode()); } - return dto; } private LiveStrategySettingsDto defaultDtoFromApp(String market, GcAppSettings app) { diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 183a954..4040b4d 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -44,7 +44,7 @@ import { type VirtualTargetItem, type VirtualCardViewMode, } from '../utils/virtualTradingStorage'; -import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings'; +import { useAppSettings, resolveAppDefaults, subscribeAppSettings } from '../hooks/useAppSettings'; import { coerceFiniteNumber } from '../utils/safeFormat'; import type { TradeNotificationGridPresetId } from '../utils/tradeNotificationGridPresets'; import { @@ -62,6 +62,12 @@ import { resolveVirtualTargetStrategyId, } from '../utils/virtualTargetStrategy'; import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits'; +import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate'; +import { + mergeTargetsWithLiveStrategySettings, + mergeVirtualSessionWithAppLive, + resolveVirtualTargetStrategyIdWithApp, +} from '../utils/virtualTargetHydrate'; import '../styles/virtualTradingDashboard.css'; type RightTab = 'trade' | 'orderbook' | 'history'; @@ -102,7 +108,8 @@ const VirtualTradingPage: React.FC = ({ const [globalDisplayMode, setGlobalDisplayMode] = useState('signal'); const [focusMarket, setFocusMarket] = useState(null); const orderAnchorRef = useRef(null); - const { settings: appSettings } = useAppSettings(); + const preferencesHydratedRef = useRef(false); + const { settings: appSettings, isLoaded: settingsLoaded } = useAppSettings(); const appChartDefaults = resolveAppDefaults(appSettings); const chartRealtimeSource = (appChartDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource; @@ -111,59 +118,127 @@ const VirtualTradingPage: React.FC = ({ const chartPaneSeparator = appChartDefaults.chartPaneSeparator; const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight; const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount; + const appLiveStrategyId = appChartDefaults.liveStrategyId ?? null; + + const reloadFromUiPreferences = useCallback(() => { + const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId); + setSession(sessionFromDb); + setTargets(loadVirtualTargets()); + }, [appLiveStrategyId]); useEffect(() => { - void Promise.all([ - loadStrategies() - .then(list => { - setStrategies(list); - const validIds = new Set( - list.map(s => s.id).filter((id): id is number => id != null && id > 0), - ); - setSession(prev => - prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) - ? { ...prev, globalStrategyId: null } - : prev, - ); - setTargets(prev => - prev.map(t => - t.strategyId != null && !validIds.has(t.strategyId) - ? { ...t, strategyId: null } - : t, - ), - ); - }) - .catch(() => setStrategies([])), - loadPaperSummary().then(setSummary), - loadPaperTrades().then(setTrades), - ]).finally(() => setLoading(false)); - }, []); + 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(list => { + if (cancelled) return; + setStrategies(list); + const validIds = new Set( + list.map(s => s.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(sum => { if (!cancelled) setSummary(sum); }), + loadPaperTrades().then(tr => { if (!cancelled) setTrades(tr); }), + ]); + if (!cancelled) setLoading(false); + })(); + return () => { cancelled = true; }; + }, [settingsLoaded, appLiveStrategyId, reloadFromUiPreferences]); - /** DB is_pinned → localStorage 투자대상 목록 동기화 (최초 1회) */ useEffect(() => { + if (!settingsLoaded) return; + return subscribeAppSettings(() => { + reloadFromUiPreferences(); + }); + }, [settingsLoaded, reloadFromUiPreferences]); + + /** DB is_pinned·strategyId → ui_preferences 투자대상 동기화 */ + 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); - const pinned = !!s.isPinned; - if (!!t.pinned === pinned) return t; - return { ...t, pinned }; + 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; } }), ); - if (!cancelled) setTargets(merged); + const withLive = await mergeTargetsWithLiveStrategySettings( + merged, + sessionFromDb, + appLiveStrategyId, + ); + if (!cancelled) setTargets(withLive); })(); return () => { cancelled = true; }; - }, []); + }, [settingsLoaded, appLiveStrategyId]); - useEffect(() => { saveVirtualTargets(targets); }, [targets]); - useEffect(() => { saveVirtualSession(session); }, [session]); + useEffect(() => { + if (!preferencesHydratedRef.current) return; + saveVirtualTargets(targets); + }, [targets]); + useEffect(() => { + if (!preferencesHydratedRef.current) return; + saveVirtualSession(session); + }, [session]); useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]); @@ -194,9 +269,9 @@ const VirtualTradingPage: React.FC = ({ const targetRefs = useMemo(() => targets.map(t => ({ market: t.market, - strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId), + strategyId: resolveVirtualTargetStrategyIdWithApp(t, session, appLiveStrategyId), })), - [targets, session.globalStrategyId]); + [targets, session, appLiveStrategyId]); const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots( targetRefs, @@ -474,6 +549,7 @@ const VirtualTradingPage: React.FC = ({ globalDisplayMode={globalDisplayMode} onFocusMarketChange={setFocusMarket} gridPreset={gridPreset} + appLiveStrategyId={appLiveStrategyId} /> )} rightTabs={rightTabs} diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx index 58c1ee1..2c4aac4 100644 --- a/frontend/src/components/virtual/VirtualTargetCard.tsx +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -4,6 +4,7 @@ import { resolveVirtualTargetNames, resolveVirtualTargetStrategyDisplayName, } from '../../utils/virtualTargetNames'; +import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; @@ -57,6 +58,8 @@ interface Props { readOnlyStrategyLabel?: string; /** 헤더 우측 레이아웃 (알림 목록 신호 슬롯: inline-quote) */ headVariant?: VirtualCardHeadVariant; + /** gc_app_settings.liveStrategyId — ui 미동기화 시 지표 평가용 */ + appLiveStrategyId?: number | null; } const VirtualTargetCard: React.FC = ({ @@ -88,6 +91,7 @@ const VirtualTargetCard: React.FC = ({ englishName, readOnlyStrategyLabel, headVariant = 'default', + appLiveStrategyId = null, }) => { const inlineHeadQuote = headVariant === 'inline-quote'; const { koreanName: displayKo, englishName: displayEn } = resolveVirtualTargetNames( @@ -101,6 +105,11 @@ const VirtualTargetCard: React.FC = ({ readOnlyStrategyLabel, ); const rows = snapshot?.rows ?? []; + const effectiveStrategyId = resolveVirtualTargetStrategyId( + { strategyId }, + globalStrategyId, + appLiveStrategyId, + ); const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); const metCount = metrics.filter(m => m.status === 'match').length; @@ -232,6 +241,7 @@ const VirtualTargetCard: React.FC = ({ viewMode={viewMode} receiving={highlightReceiving} loading={signalLoading} + hasStrategy={effectiveStrategyId != null} /> )} diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index 3906672..fe7610f 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -44,6 +44,7 @@ interface Props { globalDisplayMode: VirtualCardDisplayMode; onFocusMarketChange?: (market: string | null) => void; gridPreset: TradeNotificationGridPresetId; + appLiveStrategyId?: number | null; } const VirtualTargetGrid: React.FC = ({ @@ -62,6 +63,7 @@ const VirtualTargetGrid: React.FC = ({ globalDisplayMode, onFocusMarketChange, gridPreset, + appLiveStrategyId = null, }) => { const gridCols = getTradeNotificationGridPreset(gridPreset).cols; @@ -146,7 +148,7 @@ const VirtualTargetGrid: React.FC = ({ running={session.running} liveStatus={resolveVirtualLiveStatusFallback( focusTarget.market, - resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId), + resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId, appLiveStrategyId), liveStatusByMarket, session.running, )} @@ -184,7 +186,7 @@ const VirtualTargetGrid: React.FC = ({ aria-label="가상매매 종목 그리드" > {t => { - const stratId = resolveVirtualTargetStrategyId(t, session.globalStrategyId); + const stratId = resolveVirtualTargetStrategyId(t, session.globalStrategyId, appLiveStrategyId); const strat = strategies.find(s => s.id === stratId); return ( = ({ ticker={tickers?.get(t.market)} koreanName={t.koreanName} englishName={t.englishName} + appLiveStrategyId={appLiveStrategyId} /> ); }} diff --git a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx index d62d95e..4062960 100644 --- a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx +++ b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx @@ -18,6 +18,8 @@ interface Props { receiving?: boolean; /** 업비트 캔들 warm-up · live-conditions 수집 중 */ loading?: boolean; + /** false — 전략 미지정 안내 */ + hasStrategy?: boolean; className?: string; } @@ -26,6 +28,7 @@ const VirtualTargetSignalPanel: React.FC = ({ viewMode = 'summary', receiving = false, loading = false, + hasStrategy = true, className = '', }) => { const rows = snapshot?.rows ?? []; @@ -49,7 +52,9 @@ const VirtualTargetSignalPanel: React.FC = ({

{loading ? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)' - : '전략 조건·지표 데이터 없음'} + : !hasStrategy + ? '투자전략을 선택하세요 (상단 또는 종목별 전략)' + : '전략 조건·지표 데이터 없음'}

); } diff --git a/frontend/src/hooks/useVirtualTradingCore.ts b/frontend/src/hooks/useVirtualTradingCore.ts index 73e9bdb..fbb26ca 100644 --- a/frontend/src/hooks/useVirtualTradingCore.ts +++ b/frontend/src/hooks/useVirtualTradingCore.ts @@ -52,6 +52,11 @@ import { reloadAppSettingsCache, } from './useAppSettings'; import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate'; +import { + mergeTargetsWithLiveStrategySettings, + mergeVirtualSessionWithAppLive, + resolveVirtualTargetStrategyIdWithApp, +} from '../utils/virtualTargetHydrate'; export interface UseVirtualTradingCoreOptions { defaultMarket?: string; @@ -81,6 +86,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled; const paperAutoTradeBudgetPct = paperAutoTradeBudgetPctProp ?? appChartDefaults.paperAutoTradeBudgetPct; const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount; + const appLiveStrategyId = appChartDefaults.liveStrategyId ?? null; const [targets, setTargets] = useState(() => loadVirtualTargets()); const [session, setSession] = useState(() => loadVirtualSession()); @@ -95,8 +101,8 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} const reloadFromUiPreferences = useCallback(() => { setTargets(loadVirtualTargets()); - setSession(loadVirtualSession()); - }, []); + setSession(mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId)); + }, [appLiveStrategyId]); useEffect(() => { if (!settingsLoaded) return; @@ -113,6 +119,20 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} 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; } @@ -123,11 +143,12 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} const validIds = new Set( s.map(x => x.id).filter((id): id is number => id != null && id > 0), ); - setSession(prev => - prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) + setSession(prev => { + const next = prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) ? { ...prev, globalStrategyId: null } - : prev, - ); + : prev; + return mergeVirtualSessionWithAppLive(next, appLiveStrategyId); + }); setTargets(prev => prev.map(t => t.strategyId != null && !validIds.has(t.strategyId) @@ -142,7 +163,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} if (!cancelled) setLoading(false); })(); return () => { cancelled = true; }; - }, [settingsSessionKey, settingsLoaded, reloadFromUiPreferences]); + }, [settingsSessionKey, settingsLoaded, appLiveStrategyId, reloadFromUiPreferences]); /** DB ui_preferences 반영(웹·모바일 동일 계정) */ useEffect(() => { @@ -152,29 +173,40 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} }); }, [settingsLoaded, reloadFromUiPreferences]); - /** DB is_pinned → uiPreferences.virtual.targets 동기화 */ + /** 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); - const pinned = !!s.isPinned; - if (!!t.pinned === pinned) return t; - return { ...t, pinned }; + 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; } }), ); - if (!cancelled) setTargets(merged); + const withLive = await mergeTargetsWithLiveStrategySettings( + merged, + sessionFromDb, + appLiveStrategyId, + ); + if (!cancelled) setTargets(withLive); })(); return () => { cancelled = true; }; - }, [settingsLoaded]); + }, [settingsLoaded, appLiveStrategyId]); useEffect(() => { if (!preferencesHydratedRef.current) return; @@ -203,9 +235,9 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} const targetRefs = useMemo( () => targets.map(t => ({ market: t.market, - strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId), + strategyId: resolveVirtualTargetStrategyIdWithApp(t, session, appLiveStrategyId), })), - [targets, session.globalStrategyId], + [targets, session, appLiveStrategyId], ); const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots( @@ -400,6 +432,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} snapshotLoadingByMarket, liveStatusByMarket: mergedLiveStatus, lastTickAtByMarket, + appLiveStrategyId, strategyNames, virtualTargetMaxCount, handleStart, diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 04a30dc..44bcea5 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1472,7 +1472,7 @@ export async function pinCandleWatch(market: string, candleType: string): Promis try { await fetch( `${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, - { method: 'POST' }, + { method: 'POST', headers: authHeaders() }, ); } catch { /* pin 실패해도 평가 API는 시도 */ diff --git a/frontend/src/utils/virtualTargetHydrate.ts b/frontend/src/utils/virtualTargetHydrate.ts new file mode 100644 index 0000000..c9b04a3 --- /dev/null +++ b/frontend/src/utils/virtualTargetHydrate.ts @@ -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, + session: Pick, + 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 { + 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; +} diff --git a/frontend/src/utils/virtualTargetStrategy.ts b/frontend/src/utils/virtualTargetStrategy.ts index ef6566a..991dc18 100644 --- a/frontend/src/utils/virtualTargetStrategy.ts +++ b/frontend/src/utils/virtualTargetStrategy.ts @@ -11,9 +11,13 @@ export const VIRTUAL_DEFAULT_STRATEGY_VALUE = '__default__'; export function resolveVirtualTargetStrategyId( target: Pick, globalStrategyId: number | null, + /** gc_app_settings.liveStrategyId — ui_preferences.globalStrategyId 미동기화 시 폴백 */ + appLiveStrategyId: number | null = null, ): number | null { 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 로 고정 */