서버 가상매매 목록 지표없음 문제 개선
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,59 +118,127 @@ 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;
|
||||||
loadStrategies()
|
let cancelled = false;
|
||||||
.then(list => {
|
preferencesHydratedRef.current = false;
|
||||||
setStrategies(list);
|
setLoading(true);
|
||||||
const validIds = new Set(
|
void (async () => {
|
||||||
list.map(s => s.id).filter((id): id is number => id != null && id > 0),
|
reloadFromUiPreferences();
|
||||||
);
|
let nextTargets = loadVirtualTargets();
|
||||||
setSession(prev =>
|
if (nextTargets.length === 0) {
|
||||||
prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId)
|
nextTargets = await hydrateVirtualTargetsIfEmpty();
|
||||||
? { ...prev, globalStrategyId: null }
|
if (!cancelled && nextTargets.length > 0) {
|
||||||
: prev,
|
setTargets(nextTargets);
|
||||||
);
|
saveVirtualTargets(nextTargets);
|
||||||
setTargets(prev =>
|
}
|
||||||
prev.map(t =>
|
}
|
||||||
t.strategyId != null && !validIds.has(t.strategyId)
|
const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId);
|
||||||
? { ...t, strategyId: null }
|
if (!cancelled) {
|
||||||
: t,
|
setSession(sessionFromDb);
|
||||||
),
|
}
|
||||||
);
|
if (nextTargets.length > 0) {
|
||||||
})
|
const withLive = await mergeTargetsWithLiveStrategySettings(
|
||||||
.catch(() => setStrategies([])),
|
nextTargets,
|
||||||
loadPaperSummary().then(setSummary),
|
sessionFromDb,
|
||||||
loadPaperTrades().then(setTrades),
|
appLiveStrategyId,
|
||||||
]).finally(() => setLoading(false));
|
);
|
||||||
}, []);
|
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(() => {
|
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,7 +52,9 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 로 고정 */
|
||||||
|
|||||||
Reference in New Issue
Block a user