가상투자 화면 목록 선택 동작
This commit is contained in:
@@ -25,6 +25,8 @@ const PERIODS: { label: string; tf: Timeframe }[] = [
|
||||
{ label: '4h', tf: '4h' },
|
||||
{ label: '1h', tf: '1h' },
|
||||
{ label: '30m',tf: '30m' },
|
||||
{ label: '15m',tf: '15m' },
|
||||
{ label: '10m',tf: '10m' },
|
||||
{ label: '5m', tf: '5m' },
|
||||
{ label: '3m', tf: '3m' },
|
||||
{ label: '1m', tf: '1m' },
|
||||
|
||||
@@ -37,7 +37,7 @@ import type {
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
|
||||
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
||||
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
|
||||
// ── Ws 배지 ───────────────────────────────────────────────────────────────
|
||||
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const ALL_TIMEFRAMES: { tf: Timeframe; label: string }[] = [
|
||||
{ tf: '1m', label: '1분' },
|
||||
{ tf: '3m', label: '3분' },
|
||||
{ tf: '5m', label: '5분' },
|
||||
{ tf: '10m', label: '10분' },
|
||||
{ tf: '15m', label: '15분' },
|
||||
{ tf: '30m', label: '30분' },
|
||||
{ tf: '1h', label: '1시간' },
|
||||
|
||||
@@ -286,7 +286,7 @@ const ChartIcon: Record<ChartType, React.ReactNode> = {
|
||||
baseline: <IcLine />,
|
||||
};
|
||||
|
||||
const TIMEFRAMES: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
const TIMEFRAMES: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
const CHART_TYPES: { value: ChartType; label: string }[] = [
|
||||
{ value: 'candlestick', label: '캔들스틱' },
|
||||
{ value: 'bar', label: '바' },
|
||||
|
||||
@@ -110,12 +110,23 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
|
||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||
|
||||
const handleSelectMarket = useCallback((market: string) => {
|
||||
setSelectedMarket(market);
|
||||
const price = tickers?.get(market)?.tradePrice;
|
||||
if (price != null && price > 0) {
|
||||
const seq = Date.now();
|
||||
setFillBuy({ market, price, side: 'buy', seq });
|
||||
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
|
||||
}
|
||||
setRightTab('trade');
|
||||
}, [tickers]);
|
||||
|
||||
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
||||
setTargets(items);
|
||||
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
|
||||
setSelectedMarket(items[0].market);
|
||||
handleSelectMarket(items[0].market);
|
||||
}
|
||||
}, [selectedMarket]);
|
||||
}, [selectedMarket, handleSelectMarket]);
|
||||
|
||||
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
||||
setSession(s => ({ ...s, globalStrategyId: strategyId }));
|
||||
@@ -227,7 +238,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
selectedMarket={selectedMarket}
|
||||
tickers={tickers}
|
||||
onTargetsChange={handleTargetsChange}
|
||||
onSelectMarket={setSelectedMarket}
|
||||
onSelectMarket={handleSelectMarket}
|
||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||
/>
|
||||
)}
|
||||
@@ -245,6 +256,8 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||
liveStatusByMarket={mergedLiveStatus}
|
||||
lastTickAtByMarket={lastTickAtByMarket}
|
||||
selectedMarket={selectedMarket}
|
||||
onSelectMarket={handleSelectMarket}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
@@ -267,7 +280,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
availableKrw={summary?.cashBalance ?? 0}
|
||||
fillRequest={fillBuy}
|
||||
searchAnchorRef={orderAnchorRef}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
onMarketSelect={handleSelectMarket}
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
@@ -283,7 +296,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
onMarketSelect={handleSelectMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type T
|
||||
import type { Theme, Timeframe } from '../../types';
|
||||
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
||||
|
||||
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1D'];
|
||||
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '10m', '15m', '1h', '4h', '1D'];
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
||||
import { buildConditionMetrics, resolveVirtualTradeTiming } from '../../utils/virtualSignalMetrics';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
@@ -29,6 +29,8 @@ interface Props {
|
||||
displayMode?: VirtualCardDisplayMode;
|
||||
onDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
||||
onEnterFocus?: () => void;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
@@ -49,6 +51,8 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
displayMode = 'signal',
|
||||
onDisplayModeChange,
|
||||
onEnterFocus,
|
||||
selected = false,
|
||||
onSelect,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
@@ -59,6 +63,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
const rows = snapshot?.rows ?? [];
|
||||
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
const tradeTiming = useMemo(() => resolveVirtualTradeTiming(metrics), [metrics]);
|
||||
const metCount = metrics.filter(m => m.status === 'match').length;
|
||||
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
|
||||
|
||||
@@ -80,7 +85,20 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
receiving ? 'vtd-card--receiving' : '',
|
||||
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
||||
isChart ? 'vtd-card--chart-mode' : '',
|
||||
selected ? 'vtd-card--selected' : '',
|
||||
tradeTiming === 'buy' ? 'vtd-card--timing-buy' : '',
|
||||
tradeTiming === 'sell' ? 'vtd-card--timing-sell' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onSelect}
|
||||
onKeyDown={e => {
|
||||
if (onSelect && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
role={onSelect ? 'button' : undefined}
|
||||
tabIndex={onSelect ? 0 : undefined}
|
||||
aria-pressed={onSelect ? selected : undefined}
|
||||
>
|
||||
<div className="vtd-card-head">
|
||||
<div className="vtd-card-head-main">
|
||||
@@ -122,7 +140,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시">
|
||||
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface Props {
|
||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||
lastTickAtByMarket?: Record<string, number>;
|
||||
selectedMarket?: string;
|
||||
onSelectMarket?: (market: string) => void;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
@@ -32,7 +34,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
targets, strategies, snapshots, session,
|
||||
onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop,
|
||||
onTargetStrategyChange,
|
||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, theme = 'dark',
|
||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
||||
onSelectMarket,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
}) => {
|
||||
@@ -155,6 +159,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
displayMode={resolveCardDisplayMode(t.market)}
|
||||
onDisplayModeChange={mode => handleCardDisplayMode(t.market, mode)}
|
||||
onEnterFocus={() => setFocusMarket(t.market)}
|
||||
selected={t.market === selectedMarket}
|
||||
onSelect={onSelectMarket ? () => onSelectMarket(t.market) : undefined}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
|
||||
@@ -798,6 +798,39 @@
|
||||
);
|
||||
}
|
||||
|
||||
.vtd-card[role='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtd-card--selected {
|
||||
outline: 2px solid var(--accent, #3f7ef5);
|
||||
outline-offset: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* TradeAlertModal: 매수 #3f7ef5, 매도 #ef5350 */
|
||||
.vtd-card--timing-buy {
|
||||
border-color: #3f7ef5 !important;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(63, 126, 245, 0.45),
|
||||
0 0 16px rgba(63, 126, 245, 0.15);
|
||||
}
|
||||
|
||||
.vtd-card--timing-sell {
|
||||
border-color: #ef5350 !important;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(239, 83, 80, 0.45),
|
||||
0 0 16px rgba(239, 83, 80, 0.15);
|
||||
}
|
||||
|
||||
.vtd-card--selected.vtd-card--timing-buy {
|
||||
outline-color: #3f7ef5;
|
||||
}
|
||||
|
||||
.vtd-card--selected.vtd-card--timing-sell {
|
||||
outline-color: #ef5350;
|
||||
}
|
||||
|
||||
.vtd-card--live {
|
||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 40%, var(--se-border));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Timeframe = '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1D' | '1W' | '1M';
|
||||
export type Timeframe = '1m' | '3m' | '5m' | '10m' | '15m' | '30m' | '1h' | '4h' | '1D' | '1W' | '1M';
|
||||
export type ChartType = 'candlestick' | 'bar' | 'line' | 'area' | 'baseline';
|
||||
export type Theme = 'dark' | 'light' | 'blue';
|
||||
export type ChartMode = 'chart' | 'trading'; // 차트뷰 / 트레이딩뷰
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Timeframe } from '../types';
|
||||
/** 차트 Timeframe → 백엔드 candleType */
|
||||
export function timeframeToCandleType(tf: Timeframe): string {
|
||||
switch (tf) {
|
||||
case '1m': case '3m': case '5m': case '15m': case '30m': case '1h': case '4h':
|
||||
case '1m': case '3m': case '5m': case '10m': case '15m': case '30m': case '1h': case '4h':
|
||||
return tf;
|
||||
case '1D': return '1d';
|
||||
case '1W': case '1M': return '1d';
|
||||
|
||||
@@ -10,11 +10,11 @@ export const SYMBOLS = [
|
||||
];
|
||||
|
||||
const TF_SECONDS: Record<Timeframe, number> = {
|
||||
'1m':60,'3m':180,'5m':300,'15m':900,'30m':1800,'1h':3600,'4h':14400,'1D':86400,'1W':604800,'1M':2592000,
|
||||
'1m':60,'3m':180,'5m':300,'10m':600,'15m':900,'30m':1800,'1h':3600,'4h':14400,'1D':86400,'1W':604800,'1M':2592000,
|
||||
};
|
||||
|
||||
const TF_BARS: Record<Timeframe, number> = {
|
||||
'1m':300,'3m':300,'5m':300,'15m':300,'30m':300,'1h':500,'4h':500,'1D':500,'1W':300,'1M':120,
|
||||
'1m':300,'3m':300,'5m':300,'10m':300,'15m':300,'30m':300,'1h':500,'4h':500,'1D':500,'1W':300,'1M':120,
|
||||
};
|
||||
|
||||
const SEED_MAP: Record<string, number> = {
|
||||
|
||||
@@ -47,6 +47,7 @@ const CANDLE_KO: Record<string, string> = {
|
||||
'1m': '1분봉',
|
||||
'3m': '3분봉',
|
||||
'5m': '5분봉',
|
||||
'10m': '10분봉',
|
||||
'15m': '15분봉',
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
|
||||
@@ -4,7 +4,7 @@ export const START_NODE_ID = '__strategy_start__';
|
||||
export const DEFAULT_START_CANDLE = '1m';
|
||||
|
||||
export const STRATEGY_CANDLE_TYPES = [
|
||||
'1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d',
|
||||
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d',
|
||||
] as const;
|
||||
|
||||
export type StrategyCandleType = (typeof STRATEGY_CANDLE_TYPES)[number];
|
||||
|
||||
@@ -72,7 +72,7 @@ export function candleTypeToTimeframe(candleType: string): Timeframe {
|
||||
if (c === '1d') return '1D';
|
||||
if (c === '1h') return '1h';
|
||||
if (c === '4h') return '4h';
|
||||
if (['1m', '3m', '5m', '15m', '30m'].includes(c)) return c as Timeframe;
|
||||
if (['1m', '3m', '5m', '10m', '15m', '30m'].includes(c)) return c as Timeframe;
|
||||
return '1m';
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function resolveStrategyTimeframes(strategy: StrategyDto | undefined): Ti
|
||||
}
|
||||
if (set.size === 0) set.add('1m');
|
||||
return [...set].sort((a, b) => {
|
||||
const order: Timeframe[] = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1D', '1W', '1M'];
|
||||
const order: Timeframe[] = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1D', '1W', '1M'];
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const CANDLE_TYPE_KO: Record<string, string> = {
|
||||
'1m': '1분봉',
|
||||
'3m': '3분봉',
|
||||
'5m': '5분봉',
|
||||
'10m': '10분봉',
|
||||
'15m': '15분봉',
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
|
||||
@@ -77,6 +77,7 @@ function candleEndpoint(
|
||||
case '1m': return `${base}/minutes/1?market=${market}&count=${count}${toParam}`;
|
||||
case '3m': return `${base}/minutes/3?market=${market}&count=${count}${toParam}`;
|
||||
case '5m': return `${base}/minutes/5?market=${market}&count=${count}${toParam}`;
|
||||
case '10m': return `${base}/minutes/10?market=${market}&count=${count}${toParam}`;
|
||||
case '15m': return `${base}/minutes/15?market=${market}&count=${count}${toParam}`;
|
||||
case '30m': return `${base}/minutes/30?market=${market}&count=${count}${toParam}`;
|
||||
case '1h': return `${base}/minutes/60?market=${market}&count=${count}${toParam}`;
|
||||
@@ -318,7 +319,7 @@ export class UpbitWebSocketClient {
|
||||
|
||||
// ─── 타임프레임 초 단위 ───────────────────────────────────────────────────
|
||||
export const TF_SECONDS: Record<Timeframe, number> = {
|
||||
'1m': 60, '3m': 180, '5m': 300, '15m': 900, '30m': 1800,
|
||||
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
||||
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
||||
};
|
||||
|
||||
|
||||
@@ -143,6 +143,29 @@ export function getTrafficLightState(matchRate: number, metrics: ConditionMetric
|
||||
return 'red';
|
||||
}
|
||||
|
||||
/** 매수·매도 조건 충족률 기준 타이밍 (TradeAlertModal: 매수=블루, 매도=레드) */
|
||||
export type VirtualTradeTiming = 'buy' | 'sell' | 'neutral';
|
||||
|
||||
function sideMatchRate(metrics: ConditionMetric[], side: 'buy' | 'sell'): number {
|
||||
const rows = metrics.filter(m => m.row.side === side && m.status !== 'unknown');
|
||||
if (rows.length === 0) return 0;
|
||||
const met = rows.filter(m => m.status === 'match').length;
|
||||
return Math.round((met / rows.length) * 100);
|
||||
}
|
||||
|
||||
export function resolveVirtualTradeTiming(metrics: ConditionMetric[]): VirtualTradeTiming {
|
||||
const buyRate = sideMatchRate(metrics, 'buy');
|
||||
const sellRate = sideMatchRate(metrics, 'sell');
|
||||
if (buyRate === 0 && sellRate === 0) return 'neutral';
|
||||
if (buyRate >= 100 && buyRate > sellRate) return 'buy';
|
||||
if (sellRate >= 100 && sellRate > buyRate) return 'sell';
|
||||
if (buyRate >= 100 && sellRate < 100) return 'buy';
|
||||
if (sellRate >= 100 && buyRate < 100) return 'sell';
|
||||
if (buyRate > sellRate && buyRate >= 50) return 'buy';
|
||||
if (sellRate > buyRate && sellRate >= 50) return 'sell';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function formatUpdatedTime(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleTimeString('ko-KR', {
|
||||
|
||||
Reference in New Issue
Block a user