가상매매 시간봉 제거
This commit is contained in:
@@ -183,7 +183,6 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
void add(row.market, {
|
||||
koreanName,
|
||||
englishName,
|
||||
candleType: filtersRef.current.timeframe,
|
||||
});
|
||||
}, [add]);
|
||||
|
||||
|
||||
@@ -43,9 +43,7 @@ import {
|
||||
type VirtualSessionConfig,
|
||||
type VirtualTargetItem,
|
||||
type VirtualCardViewMode,
|
||||
resolveTargetCandleType,
|
||||
} from '../utils/virtualTradingStorage';
|
||||
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
|
||||
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
import {
|
||||
@@ -179,7 +177,11 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
})),
|
||||
[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,
|
||||
@@ -341,59 +343,6 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
}
|
||||
}, [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,
|
||||
});
|
||||
if (strategyId) {
|
||||
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 {
|
||||
@@ -483,9 +432,9 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
targets={targets}
|
||||
strategies={strategies}
|
||||
snapshots={snapshots}
|
||||
snapshotLoadingByMarket={snapshotLoadingByMarket}
|
||||
session={session}
|
||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||
onTargetCandleTypeChange={(market, ct) => void handleTargetCandleTypeChange(market, ct)}
|
||||
liveStatusByMarket={mergedLiveStatus}
|
||||
lastTickAtByMarket={lastTickAtByMarket}
|
||||
selectedMarket={selectedMarket}
|
||||
|
||||
@@ -5,8 +5,7 @@ import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSn
|
||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
||||
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import { normalizeStartCandleType } from '../../utils/strategyStartNodes';
|
||||
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import type { Timeframe } from '../../types';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
@@ -27,9 +26,8 @@ interface Props {
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
candleType: string;
|
||||
onCandleTypeChange?: (candleType: string) => void;
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
signalLoading?: boolean;
|
||||
running: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
lastTickAt?: number;
|
||||
@@ -54,9 +52,8 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
onStrategyChange,
|
||||
candleType,
|
||||
onCandleTypeChange,
|
||||
snapshot,
|
||||
signalLoading = false,
|
||||
running,
|
||||
liveStatus = 'idle',
|
||||
lastTickAt,
|
||||
@@ -83,8 +80,10 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
|
||||
const isDetail = viewMode === 'detail';
|
||||
const isChart = displayMode === 'chart';
|
||||
const evalCandleType = normalizeStartCandleType(candleType);
|
||||
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
|
||||
const chartTimeframe = useMemo(
|
||||
() => resolveStrategyPrimaryTimeframe(strategy) as Timeframe,
|
||||
[strategy],
|
||||
);
|
||||
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!running) return null;
|
||||
@@ -184,6 +183,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
snapshot={snapshot}
|
||||
viewMode={viewMode}
|
||||
receiving={highlightReceiving}
|
||||
loading={signalLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -192,9 +192,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
strategies={strategies}
|
||||
strategyId={strategyId}
|
||||
globalStrategyId={globalStrategyId}
|
||||
candleType={evalCandleType}
|
||||
onStrategyChange={onStrategyChange}
|
||||
onCandleTypeChange={onCandleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ interface Props {
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
/** 전체보기 분할 pane — 캔버스 높이 100% */
|
||||
fillHeight?: boolean;
|
||||
/** 카드 푸터 평가 분봉과 동기화 */
|
||||
/** 미지정 시 전략 DSL 대표 분봉 사용 */
|
||||
chartTimeframe?: Timeframe;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import { formatUpdatedTime } from '../../utils/virtualSignalMetrics';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
|
||||
import {
|
||||
defaultStrategyOptionLabel,
|
||||
parseTargetStrategySelectValue,
|
||||
@@ -14,20 +13,16 @@ interface Props {
|
||||
strategies: StrategyDto[];
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
candleType: string;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
onCandleTypeChange?: (candleType: string) => void;
|
||||
}
|
||||
|
||||
/** 카드 하단 — 갱신 시각(좌) · 전략·시간봉 드롭다운(우) */
|
||||
/** 카드 하단 — 갱신 시각(좌) · 전략 드롭다운(우) */
|
||||
const VirtualTargetCardFoot: React.FC<Props> = ({
|
||||
snapshot,
|
||||
strategies,
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
candleType,
|
||||
onStrategyChange,
|
||||
onCandleTypeChange,
|
||||
}) => (
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
@@ -49,18 +44,6 @@ const VirtualTargetCardFoot: React.FC<Props> = ({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="vtd-card-foot-field">
|
||||
<select
|
||||
className="vtd-card-foot-select vtd-card-foot-select--tf"
|
||||
aria-label="시간봉"
|
||||
value={candleType}
|
||||
onChange={e => onCandleTypeChange?.(e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,7 @@ import VirtualTargetQuote from './VirtualTargetQuote';
|
||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||
import VirtualTargetCardFoot from './VirtualTargetCardFoot';
|
||||
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import { normalizeStartCandleType } from '../../utils/strategyStartNodes';
|
||||
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import type { Timeframe } from '../../types';
|
||||
|
||||
interface Props {
|
||||
@@ -24,9 +23,8 @@ interface Props {
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
candleType: string;
|
||||
onCandleTypeChange?: (candleType: string) => void;
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
signalLoading?: boolean;
|
||||
running: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
lastTickAt?: number;
|
||||
@@ -48,9 +46,8 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
onStrategyChange,
|
||||
candleType,
|
||||
onCandleTypeChange,
|
||||
snapshot,
|
||||
signalLoading = false,
|
||||
running,
|
||||
liveStatus = 'idle',
|
||||
lastTickAt,
|
||||
@@ -72,8 +69,10 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||
const evalCandleType = normalizeStartCandleType(candleType);
|
||||
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
|
||||
const chartTimeframe = useMemo(
|
||||
() => resolveStrategyPrimaryTimeframe(strategy) as Timeframe,
|
||||
[strategy],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`vtd-focus-wrap${highlightReceiving ? ' vtd-focus-wrap--receiving' : ''}`}>
|
||||
@@ -129,6 +128,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
snapshot={snapshot}
|
||||
viewMode={viewMode}
|
||||
receiving={highlightReceiving}
|
||||
loading={signalLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -139,9 +139,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
strategies={strategies}
|
||||
strategyId={strategyId}
|
||||
globalStrategyId={globalStrategyId}
|
||||
candleType={evalCandleType}
|
||||
onStrategyChange={onStrategyChange}
|
||||
onCandleTypeChange={onCandleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type VirtualTargetItem,
|
||||
type VirtualCardViewMode,
|
||||
type VirtualSessionConfig,
|
||||
resolveTargetCandleType,
|
||||
} from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
@@ -19,9 +18,9 @@ interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
strategies: StrategyDto[];
|
||||
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||
snapshotLoadingByMarket?: Record<string, boolean>;
|
||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||
onTargetCandleTypeChange: (market: string, candleType: string) => void;
|
||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||
lastTickAtByMarket?: Record<string, number>;
|
||||
selectedMarket?: string;
|
||||
@@ -38,9 +37,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const VirtualTargetGrid: React.FC<Props> = ({
|
||||
targets, strategies, snapshots, session,
|
||||
targets, strategies, snapshots, snapshotLoadingByMarket = {}, session,
|
||||
onTargetStrategyChange,
|
||||
onTargetCandleTypeChange,
|
||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
||||
onSelectMarket,
|
||||
theme = 'dark',
|
||||
@@ -119,9 +117,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
strategyId={focusTarget.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)}
|
||||
candleType={resolveTargetCandleType(focusTarget, snapshots[focusTarget.market]?.timeframe)}
|
||||
onCandleTypeChange={ct => onTargetCandleTypeChange(focusTarget.market, ct)}
|
||||
snapshot={snapshots[focusTarget.market]}
|
||||
signalLoading={snapshotLoadingByMarket[focusTarget.market]}
|
||||
running={session.running}
|
||||
liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||
lastTickAt={lastTickAtByMarket[focusTarget.market]}
|
||||
@@ -147,9 +144,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
strategyId={t.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
onStrategyChange={id => onTargetStrategyChange(t.market, id)}
|
||||
candleType={resolveTargetCandleType(t, snapshots[t.market]?.timeframe)}
|
||||
onCandleTypeChange={ct => onTargetCandleTypeChange(t.market, ct)}
|
||||
snapshot={snapshots[t.market]}
|
||||
signalLoading={snapshotLoadingByMarket[t.market]}
|
||||
running={session.running}
|
||||
liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||
lastTickAt={lastTickAtByMarket[t.market]}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface Props {
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
viewMode?: VirtualCardViewMode;
|
||||
receiving?: boolean;
|
||||
/** 업비트 캔들 warm-up · live-conditions 수집 중 */
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +24,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
snapshot,
|
||||
viewMode = 'summary',
|
||||
receiving = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const rows = snapshot?.rows ?? [];
|
||||
@@ -36,8 +39,10 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className={`vtd-muted vtd-card-empty${className ? ` ${className}` : ''}`}>
|
||||
전략 조건·지표 데이터 없음
|
||||
<p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}>
|
||||
{loading
|
||||
? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)'
|
||||
: '전략 조건·지표 데이터 없음'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function autoAddTrendSearchTargets(
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName);
|
||||
targets = await addVirtualTarget(
|
||||
row.market,
|
||||
{ koreanName, englishName, candleType: timeframe },
|
||||
{ koreanName, englishName },
|
||||
{ showLimitAlert: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
isVirtualTargetAddAllowed,
|
||||
virtualTargetLimitMessage,
|
||||
} from '../utils/virtualTargetLimits';
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync';
|
||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||
import {
|
||||
@@ -18,8 +17,6 @@ import {
|
||||
export interface VirtualTargetMeta {
|
||||
koreanName?: string;
|
||||
englishName?: string;
|
||||
/** 추세검색 캔들 주기 → 투자대상 평가 분봉 기본값 */
|
||||
candleType?: string;
|
||||
}
|
||||
|
||||
export interface AddVirtualTargetOptions {
|
||||
@@ -51,7 +48,6 @@ export async function addVirtualTarget(
|
||||
strategyId: null,
|
||||
koreanName: meta.koreanName,
|
||||
englishName: en,
|
||||
candleType: meta.candleType ? normalizeStartCandleType(meta.candleType) : undefined,
|
||||
};
|
||||
|
||||
const next = [...targets, item];
|
||||
@@ -64,7 +60,6 @@ export async function addVirtualTarget(
|
||||
isPinned: false,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
candleType: item.candleType ?? '1m',
|
||||
skipWatchlistSync: true,
|
||||
skipGlobalTemplate: true,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ function fromLiveSettings(
|
||||
return {
|
||||
market: s.market,
|
||||
strategyId: s.strategyId ?? globalStrategyId,
|
||||
candleType: s.candleType,
|
||||
koreanName,
|
||||
englishName,
|
||||
pinned: !!s.isPinned,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
@@ -7,8 +6,6 @@ import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
export interface VirtualTargetItem {
|
||||
market: string;
|
||||
strategyId: number | null;
|
||||
/** 종목별 전략 평가 분봉 (gc_live_strategy_settings.candle_type) */
|
||||
candleType?: string;
|
||||
koreanName?: string;
|
||||
englishName?: string;
|
||||
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */
|
||||
@@ -35,6 +32,7 @@ function defaultSession(): VirtualSessionConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/** 레거시 ui_preferences.virtual.targets.candleType 제거 */
|
||||
function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
||||
return items.map(t => {
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||
@@ -42,7 +40,13 @@ function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
||||
t.koreanName,
|
||||
t.englishName,
|
||||
);
|
||||
return { ...t, koreanName, englishName };
|
||||
return {
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? null,
|
||||
koreanName,
|
||||
englishName,
|
||||
pinned: t.pinned,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +57,7 @@ export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
patchUiPreferences({ virtual: { targets: items } });
|
||||
patchUiPreferences({ virtual: { targets: normalizeTargets(items) } });
|
||||
notifyVirtualSessionChanged();
|
||||
}
|
||||
|
||||
@@ -87,14 +91,3 @@ export function loadVirtualCardViewMode(): VirtualCardViewMode {
|
||||
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||
patchUiPreferences({ virtual: { cardViewMode: mode } });
|
||||
}
|
||||
|
||||
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
|
||||
export function resolveTargetCandleType(
|
||||
item: Pick<VirtualTargetItem, 'candleType'>,
|
||||
snapshotTimeframe?: string | null,
|
||||
): string {
|
||||
if (item.candleType) return normalizeStartCandleType(item.candleType);
|
||||
const raw = snapshotTimeframe?.split(',')[0]?.trim();
|
||||
if (raw) return normalizeStartCandleType(raw);
|
||||
return '1m';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user