가상매매 시간봉 제거

This commit is contained in:
Macbook
2026-05-28 20:48:28 +09:00
parent 7e3644cb62
commit 15160f7d2c
45 changed files with 996 additions and 405 deletions
+39 -6
View File
@@ -54,6 +54,8 @@ import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrat
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
let _cache: AppSettingsDto | null = null;
let _loadPromise: Promise<AppSettingsDto> | null = null;
/** invalidate 이후 완료된 로드만 캐시에 반영 */
let _loadGeneration = 0;
type AppSettingsListener = () => void;
const _listeners = new Set<AppSettingsListener>();
@@ -74,20 +76,31 @@ export function subscribeAppSettings(listener: AppSettingsListener): () => void
function ensureLoaded(): Promise<AppSettingsDto> {
if (_cache !== null) return Promise.resolve(_cache);
if (_loadPromise) return _loadPromise;
const generation = _loadGeneration;
_loadPromise = loadAppSettings().then(async data => {
if (generation !== _loadGeneration) {
_loadPromise = null;
return _cache ?? {};
}
_cache = data ?? {};
const migrated = migrateLocalStorageToUiPreferences(_cache);
if (migrated?.changed) {
try {
const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences });
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
if (generation === _loadGeneration) {
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
}
} catch (e) {
console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e);
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
if (generation === _loadGeneration) {
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
}
}
}
_loadPromise = null;
notifyAppSettingsListeners();
if (generation === _loadGeneration) {
notifyAppSettingsListeners();
}
return _cache;
});
return _loadPromise;
@@ -96,6 +109,7 @@ function ensureLoaded(): Promise<AppSettingsDto> {
export function invalidateAppSettingsCache() {
_cache = null;
_loadPromise = null;
_loadGeneration += 1;
}
/** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */
@@ -167,13 +181,32 @@ export function resolveAppDefaults(s: AppSettingsDto) {
/** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */
export function useAppSettings(sessionKey = 0) {
const [settings, setSettings] = useState<AppSettingsDto>(_cache ?? {});
const [isLoaded, setIsLoaded] = useState(false);
const [isLoaded, setIsLoaded] = useState(_cache !== null);
const mountedRef = useRef(true);
const prevSessionKeyRef = useRef(sessionKey);
useEffect(() => {
mountedRef.current = true;
setIsLoaded(false);
invalidateAppSettingsCache();
const sessionChanged = prevSessionKeyRef.current !== sessionKey;
prevSessionKeyRef.current = sessionKey;
if (sessionChanged) {
if (_cache !== null) {
setSettings(_cache);
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
setIsLoaded(true);
} else {
setIsLoaded(false);
invalidateAppSettingsCache();
}
} else if (_cache !== null) {
setSettings(_cache);
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
setIsLoaded(true);
} else {
setIsLoaded(false);
}
ensureLoaded().then(data => {
if (mountedRef.current) {
setSettings(data);
@@ -1,6 +1,7 @@
/**
* 가상투자 카드 — 종목×전략별 지표·조건 스냅샷
* running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립)
* 전략 지정 시: candles/watch pin(업비트 warm-up) + live-conditions API
* running 시 3초 폴링, 미수집 시 재시도
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { StrategyDto } from '../utils/backendApi';
@@ -9,6 +10,7 @@ import {
pinCandleWatch,
type LiveConditionRowDto,
} from '../utils/backendApi';
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry';
import { coerceFiniteNumber } from '../utils/safeFormat';
import {
@@ -27,6 +29,12 @@ export interface VirtualIndicatorSnapshot {
matchRate?: number;
}
export interface VirtualIndicatorSnapshotsState {
snapshots: Record<string, VirtualIndicatorSnapshot>;
/** 업비트 캔들·live-conditions 수집 중 */
loadingByMarket: Record<string, boolean>;
}
function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'indicatorType' | 'conditionType' | 'targetValue' | 'plotKey'>): string {
return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`;
}
@@ -84,24 +92,38 @@ function mergeRows(
});
}
async function buildSnapshot(
function staticSnapshot(
market: string,
strategyId: number,
baseRows: VirtualConditionRow[],
): VirtualIndicatorSnapshot {
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })),
updatedAt: Date.now(),
matchRate: 0,
};
}
/** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */
async function fetchLiveSnapshot(
market: string,
strategyId: number,
strategy: StrategyDto | undefined,
running: boolean,
pinFirst: boolean,
): Promise<VirtualIndicatorSnapshot | null> {
const baseRows = strategy ? extractVirtualConditions(strategy) : [];
if (!running) {
if (baseRows.length === 0) return null;
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })),
updatedAt: Date.now(),
matchRate: 0,
};
if (pinFirst) {
try {
await pinStrategyEvaluationTimeframes(market, strategyId);
} catch {
try {
await pinCandleWatch(market, '1m');
} catch { /* ignore */ }
}
}
let status: Awaited<ReturnType<typeof fetchLiveConditionStatus>> = null;
@@ -110,23 +132,20 @@ async function buildSnapshot(
} catch {
status = null;
}
if (!status || status.market !== market || status.strategyId !== strategyId) {
if (baseRows.length === 0) return null;
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })),
updatedAt: Date.now(),
matchRate: 0,
};
return staticSnapshot(market, strategyId, baseRows);
}
const rows = mergeRows(baseRows, status.rows);
if (rows.length === 0) return null;
return {
market,
strategyId,
timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: mergeRows(baseRows, status.rows),
rows,
updatedAt: status.updatedAt || Date.now(),
matchRate: coerceFiniteNumber(status.matchRate) ?? 0,
};
@@ -142,12 +161,14 @@ export function useVirtualIndicatorSnapshots(
strategies: StrategyDto[],
running: boolean,
pollMs = 3000,
): Record<string, VirtualIndicatorSnapshot> {
): VirtualIndicatorSnapshotsState {
const [snapshots, setSnapshots] = useState<Record<string, VirtualIndicatorSnapshot>>({});
const [loadingByMarket, setLoadingByMarket] = useState<Record<string, boolean>>({});
const strategiesRef = useRef(strategies);
strategiesRef.current = strategies;
const pinnedRef = useRef<Set<string>>(new Set());
const refreshGenRef = useRef<Record<string, number>>({});
const emptyRetryRef = useRef<Record<string, number>>({});
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
const pinTargets = useCallback(async () => {
@@ -155,21 +176,25 @@ export function useVirtualIndicatorSnapshots(
const pins = new Set<string>();
for (const t of targets) {
if (t.strategyId == null) continue;
const strat = strats.find(s => s.id === t.strategyId);
const conditions = strat ? extractVirtualConditions(strat) : [];
const tfs = conditions.length > 0
? [...new Set(conditions.map(c => c.timeframe))]
: ['1m'];
for (const tf of tfs) {
const key = `${t.market}:${tf}`;
pins.add(key);
if (!pinnedRef.current.has(key)) {
await pinCandleWatch(t.market, tf);
pinnedRef.current.add(key);
try {
const tfs = await pinStrategyEvaluationTimeframes(t.market, t.strategyId);
for (const tf of tfs) pins.add(`${t.market}:${tf}`);
} catch {
const strat = strats.find(s => s.id === t.strategyId);
const conditions = strat ? extractVirtualConditions(strat) : [];
const tfs = conditions.length > 0
? [...new Set(conditions.map(c => c.timeframe))]
: ['1m'];
for (const tf of tfs) {
const key = `${t.market}:${tf}`;
pins.add(key);
if (!pinnedRef.current.has(key)) {
await pinCandleWatch(t.market, tf);
pinnedRef.current.add(key);
}
}
}
const k1 = `${t.market}:1m`;
if (!pins.has(k1) || !tfs.includes('1m')) {
const k1 = `${t.market}:1m`;
pins.add(k1);
if (!pinnedRef.current.has(k1)) {
await pinCandleWatch(t.market, '1m');
pinnedRef.current.add(k1);
@@ -183,20 +208,50 @@ export function useVirtualIndicatorSnapshots(
const refresh = useCallback(async () => {
const strats = strategiesRef.current;
if (running) await pinTargets();
const activeTargets = targets.filter(t => t.strategyId != null);
if (activeTargets.length === 0) {
setSnapshots({});
setLoadingByMarket({});
return;
}
const jobs = targets
.filter(t => t.strategyId != null)
.map(async t => {
const gen = (refreshGenRef.current[t.market] ?? 0) + 1;
refreshGenRef.current[t.market] = gen;
const strat = strats.find(s => s.id === t.strategyId);
const snap = await buildSnapshot(t.market, t.strategyId!, strat, running);
if (!snap || refreshGenRef.current[t.market] !== gen) return snap;
if (snap.strategyId !== t.strategyId) return snap;
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
return snap;
});
setLoadingByMarket(prev => {
const next = { ...prev };
for (const t of activeTargets) next[t.market] = true;
return next;
});
await pinTargets();
const jobs = activeTargets.map(async t => {
const gen = (refreshGenRef.current[t.market] ?? 0) + 1;
refreshGenRef.current[t.market] = gen;
const strat = strats.find(s => s.id === t.strategyId);
const baseRows = strat ? extractVirtualConditions(strat) : [];
const needsLiveApi = running || baseRows.length === 0;
let snap: VirtualIndicatorSnapshot | null = null;
if (needsLiveApi) {
const retry = emptyRetryRef.current[t.market] ?? 0;
snap = await fetchLiveSnapshot(t.market, t.strategyId!, strat, retry === 0);
} else {
snap = staticSnapshot(t.market, t.strategyId!, baseRows);
}
if (!snap || refreshGenRef.current[t.market] !== gen) return snap;
if (snap.strategyId !== t.strategyId) return snap;
if (snap.rows.length === 0) {
const n = (emptyRetryRef.current[t.market] ?? 0) + 1;
emptyRetryRef.current[t.market] = n;
} else {
emptyRetryRef.current[t.market] = 0;
}
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
setLoadingByMarket(prev => ({ ...prev, [t.market]: false }));
return snap;
});
await Promise.all(jobs);
const activeMarkets = new Set(targets.map(t => t.market));
@@ -211,20 +266,36 @@ export function useVirtualIndicatorSnapshots(
}
return changed ? next : prev;
});
setLoadingByMarket(prev => {
const next = { ...prev };
let changed = false;
for (const key of Object.keys(next)) {
if (!activeMarkets.has(key)) {
delete next[key];
changed = true;
}
}
return changed ? next : prev;
});
}, [targets, running, pinTargets]);
const hasStrategyTargets = targets.some(t => t.strategyId != null);
useEffect(() => {
if (targets.length === 0) {
setSnapshots({});
setLoadingByMarket({});
pinnedRef.current.clear();
refreshGenRef.current = {};
emptyRetryRef.current = {};
return;
}
void refresh();
if (!running) return;
const id = window.setInterval(() => void refresh(), pollMs);
if (!running && !hasStrategyTargets) return;
const intervalMs = running ? pollMs : 5000;
const id = window.setInterval(() => void refresh(), intervalMs);
return () => clearInterval(id);
}, [targetsKey, running, pollMs, refresh]);
}, [targetsKey, running, hasStrategyTargets, pollMs, refresh]);
return snapshots;
return { snapshots, loadingByMarket };
}
@@ -39,6 +39,8 @@ export interface VirtualTargetLiveState {
export function useVirtualTargetLiveStatus(
targets: TargetRef[],
running: boolean,
/** 전략 지정만 된 상태에서도 STOMP·pin으로 업비트 틱 수신 (지표 warm-up 보조) */
eagerConnect = true,
): VirtualTargetLiveState {
const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({});
const [lastTickAtByMarket, setLastTickAtByMarket] = useState<Record<string, number>>({});
@@ -57,7 +59,7 @@ export function useVirtualTargetLiveStatus(
const recomputeStatus = (market: string) => {
const s = stateRef.current[market];
if (!s || !running) return;
if (!s) return;
const now = Date.now();
if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) {
if (s.status !== 'live') patchMarket(market, { status: 'live' });
@@ -71,17 +73,18 @@ export function useVirtualTargetLiveStatus(
};
useEffect(() => {
if (!running) {
const activeMarkets = targets
.filter(t => t.strategyId != null)
.map(t => t.market);
const active = running || (eagerConnect && activeMarkets.length > 0);
if (!active) {
stateRef.current = {};
setByMarket({});
setLastTickAtByMarket({});
return;
}
const activeMarkets = targets
.filter(t => t.strategyId != null)
.map(t => t.market);
for (const market of activeMarkets) {
if (!stateRef.current[market]) {
stateRef.current[market] = { status: 'connecting', lastTickAt: null };
@@ -129,7 +132,7 @@ export function useVirtualTargetLiveStatus(
offConn();
clearInterval(staleId);
};
}, [targets, running]);
}, [targets, running, eagerConnect]);
return { statusByMarket: byMarket, lastTickAtByMarket };
}
+6 -54
View File
@@ -27,7 +27,6 @@ import {
type VirtualTargetItem,
type VirtualCardViewMode,
} from '../utils/virtualTradingStorage';
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
import {
syncVirtualTargetsToBackend,
stopVirtualLiveOnBackend,
@@ -197,7 +196,11 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
[targets, session.globalStrategyId],
);
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots(
targetRefs,
strategies,
session.running,
);
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
targetRefs,
session.running,
@@ -338,57 +341,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
}
}, [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 {
@@ -448,6 +400,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
setViewMode,
refreshPaperData,
snapshots,
snapshotLoadingByMarket,
liveStatusByMarket: mergedLiveStatus,
lastTickAtByMarket,
strategyNames,
@@ -460,7 +413,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
handleExecutionTypeChange,
handlePositionModeChange,
handleTargetStrategyChange,
handleTargetCandleTypeChange,
reloadTargetsFromStorage,
appChartDefaults,
};