가상투자 화면 추가
This commit is contained in:
@@ -96,6 +96,12 @@ interface TradingChartProps {
|
||||
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void;
|
||||
/** 표시 시간대 (IANA) */
|
||||
displayTimezone?: string;
|
||||
/** 거래량 pane 표시 (기본 true) */
|
||||
volumeVisible?: boolean;
|
||||
/** 하단 호버 줌·스크롤 툴바 (기본 true) */
|
||||
showHoverToolbar?: boolean;
|
||||
/** 보조지표 우측 가격축 라벨·설명 (차트 설정, 기본 true) */
|
||||
seriesPriceLabelsEnabled?: boolean;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -122,6 +128,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onRestoreIndicators,
|
||||
onTradeOrderRequest,
|
||||
displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
|
||||
volumeVisible = true,
|
||||
showHoverToolbar = true,
|
||||
seriesPriceLabelsEnabled = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -225,6 +234,20 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
}
|
||||
}, [mode, drawingTool]);
|
||||
|
||||
const volumeVisibleRef = useRef(volumeVisible);
|
||||
useEffect(() => { volumeVisibleRef.current = volumeVisible; }, [volumeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
|
||||
mgr.setVolumeVisible(volumeVisible, wrapperH > 0 ? wrapperH : undefined);
|
||||
}, [volumeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current?.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
|
||||
}, [seriesPriceLabelsEnabled]);
|
||||
|
||||
/**
|
||||
* pane 높이 재배분 + 스크롤 컨테이너 확장
|
||||
*
|
||||
@@ -509,6 +532,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const mgr = new ChartManager(containerRef.current, theme);
|
||||
managerRef.current = mgr;
|
||||
setChartMgr(mgr);
|
||||
if (!volumeVisibleRef.current) {
|
||||
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
|
||||
mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined);
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
|
||||
onManagerReady(mgr);
|
||||
|
||||
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
||||
@@ -1123,7 +1151,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
)}
|
||||
|
||||
{/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */}
|
||||
{chartMgr && (
|
||||
{chartMgr && showHoverToolbar && (
|
||||
<ChartHoverToolbar
|
||||
onZoomOut={() => managerRef.current?.zoomOut()}
|
||||
onZoomIn={() => managerRef.current?.zoomIn()}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type StrategyDto,
|
||||
} from '../utils/backendApi';
|
||||
import type { Theme, TradeOrderFillRequest } from '../types';
|
||||
import type { TickerData } from '../hooks/useMarketTicker';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||
@@ -18,6 +19,7 @@ import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
|
||||
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
|
||||
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
|
||||
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
||||
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||
import {
|
||||
loadVirtualSession,
|
||||
loadVirtualTargets,
|
||||
@@ -26,13 +28,14 @@ import {
|
||||
type VirtualSessionConfig,
|
||||
type VirtualTargetItem,
|
||||
} from '../utils/virtualTradingStorage';
|
||||
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
type RightTab = 'trade' | 'orderbook';
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
tickers?: Map<string, { tradePrice: number | null }>;
|
||||
tickers?: Map<string, TickerData>;
|
||||
defaultMarket?: string;
|
||||
paperTradingEnabled?: boolean;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
@@ -61,6 +64,11 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const appChartDefaults = resolveAppDefaults(appSettings);
|
||||
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
|
||||
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
||||
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
||||
|
||||
useEffect(() => {
|
||||
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
|
||||
@@ -78,7 +86,10 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
[targets, session.globalStrategyId]);
|
||||
|
||||
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
|
||||
const liveStatusByMarket = useVirtualTargetLiveStatus(targetRefs, session.running);
|
||||
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
||||
targetRefs,
|
||||
session.running,
|
||||
);
|
||||
|
||||
const mergedLiveStatus = useMemo(() => {
|
||||
if (!session.running) return liveStatusByMarket;
|
||||
@@ -164,63 +175,31 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
setRightTab('trade');
|
||||
}, [selectedMarket]);
|
||||
|
||||
const headerControls = (
|
||||
<div className="vtd-header-controls">
|
||||
<label className="vtd-header-field">
|
||||
<span>투자전략</span>
|
||||
<select
|
||||
className="vtd-header-select"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
handleGlobalStrategyChange(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="vtd-header-field">
|
||||
<span>실행방식</span>
|
||||
<select
|
||||
className="vtd-header-select"
|
||||
value={session.executionType}
|
||||
onChange={e => setSession(s => ({
|
||||
...s,
|
||||
executionType: e.target.value === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
}))}
|
||||
>
|
||||
<option value="CANDLE_CLOSE">봉 마감 직후</option>
|
||||
<option value="REALTIME_TICK">실시간 틱</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="vtd-header-field">
|
||||
<span>시그널 모드</span>
|
||||
<select
|
||||
className="vtd-header-select"
|
||||
value={session.positionMode}
|
||||
onChange={e => setSession(s => ({
|
||||
...s,
|
||||
positionMode: e.target.value === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
}))}
|
||||
>
|
||||
<option value="LONG_ONLY">보유 자산 기준</option>
|
||||
<option value="SIGNAL_ONLY">순수 지표 기준</option>
|
||||
</select>
|
||||
</label>
|
||||
{!session.running ? (
|
||||
<button type="button" className="bps-btn bps-btn--primary" onClick={() => void handleStart()}>
|
||||
시작
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="bps-btn bps-btn--danger" onClick={() => void handleStop()}>
|
||||
종료
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => {
|
||||
setTargets(prev => prev.map(t =>
|
||||
t.market === market ? { ...t, strategyId } : t,
|
||||
));
|
||||
if (!session.running || !strategyId) return;
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId,
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
});
|
||||
} catch {
|
||||
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const handleExecutionTypeChange = useCallback((executionType: VirtualSessionConfig['executionType']) => {
|
||||
setSession(s => ({ ...s, executionType }));
|
||||
}, []);
|
||||
|
||||
const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => {
|
||||
setSession(s => ({ ...s, positionMode }));
|
||||
}, []);
|
||||
|
||||
const rightTabs = (
|
||||
<div className="bps-right-tabs">
|
||||
@@ -240,15 +219,16 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
leftDefaultWidth={380}
|
||||
rightStorageKey="vtd-right-width"
|
||||
rightDefaultWidth={380}
|
||||
headerActions={headerControls}
|
||||
left={(
|
||||
<VirtualLeftTargetPanel
|
||||
targets={targets}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
strategies={strategies}
|
||||
selectedMarket={selectedMarket}
|
||||
tickers={tickers}
|
||||
onTargetsChange={handleTargetsChange}
|
||||
onSelectMarket={setSelectedMarket}
|
||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||
/>
|
||||
)}
|
||||
center={(
|
||||
@@ -256,10 +236,18 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
targets={targets}
|
||||
strategies={strategies}
|
||||
snapshots={snapshots}
|
||||
running={session.running}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
session={session}
|
||||
onStrategyChange={handleGlobalStrategyChange}
|
||||
onExecutionTypeChange={handleExecutionTypeChange}
|
||||
onPositionModeChange={handlePositionModeChange}
|
||||
onStart={() => void handleStart()}
|
||||
onStop={() => void handleStop()}
|
||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||
liveStatusByMarket={mergedLiveStatus}
|
||||
lastTickAtByMarket={lastTickAtByMarket}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
/>
|
||||
)}
|
||||
rightTabs={rightTabs}
|
||||
@@ -271,33 +259,37 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
topTitle="매수"
|
||||
bottomTitle="매도"
|
||||
top={(
|
||||
<TradeOrderPanel
|
||||
side="buy"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={summary?.cashBalance ?? 0}
|
||||
fillRequest={fillBuy}
|
||||
searchAnchorRef={orderAnchorRef}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
/>
|
||||
<div className="ptd-order-card">
|
||||
<TradeOrderPanel
|
||||
side="buy"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={summary?.cashBalance ?? 0}
|
||||
fillRequest={fillBuy}
|
||||
searchAnchorRef={orderAnchorRef}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
bottom={(
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
/>
|
||||
<div className="ptd-order-card">
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import type { ConditionStatus } from '../../utils/virtualSignalMetrics';
|
||||
|
||||
interface Props {
|
||||
status: ConditionStatus;
|
||||
progress?: number | null;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const LABELS: Record<ConditionStatus, string> = {
|
||||
match: 'Match',
|
||||
pending: 'Pending',
|
||||
nomatch: 'Mismatch',
|
||||
unknown: '—',
|
||||
};
|
||||
|
||||
const ConditionStatusSignal: React.FC<Props> = ({ status, progress, compact = false }) => {
|
||||
if (status === 'unknown') {
|
||||
return <span className="vtd-cond-signal vtd-cond-signal--unknown">—</span>;
|
||||
}
|
||||
|
||||
const pct = progress != null && Number.isFinite(progress)
|
||||
? `${Math.round(progress)}%`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<span className={`vtd-cond-signal vtd-cond-signal--${status}${compact ? ' vtd-cond-signal--compact' : ''}`}>
|
||||
{pct != null && <span className="vtd-cond-signal-pct">{pct}</span>}
|
||||
{status === 'match' && (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
{status === 'pending' && (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
||||
<path d="M12 9v4"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
)}
|
||||
{status === 'nomatch' && (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="vtd-cond-signal-label">{LABELS[status]}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionStatusSignal;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
|
||||
import ConditionStatusSignal from './ConditionStatusSignal';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
}
|
||||
|
||||
const VirtualConditionList: React.FC<Props> = ({ metrics }) => {
|
||||
if (metrics.length === 0) {
|
||||
return <p className="vtd-cond-list-empty vtd-muted">조건 없음</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="vtd-cond-list" aria-label="전략 조건 목록">
|
||||
{metrics.map(({ row, status, progress }) => (
|
||||
<li key={row.id} className="vtd-cond-list-item">
|
||||
<span className="vtd-cond-list-name" title={row.displayName}>
|
||||
{row.displayName}
|
||||
</span>
|
||||
<ConditionStatusSignal status={status} progress={progress} compact />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualConditionList;
|
||||
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualCardDisplayMode } from './VirtualTargetCard';
|
||||
|
||||
interface Props {
|
||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
strategies: StrategyDto[];
|
||||
onStrategyChange: (strategyId: number | null) => void;
|
||||
onExecutionTypeChange: (type: VirtualSessionConfig['executionType']) => void;
|
||||
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
viewMode?: VirtualCardViewMode;
|
||||
onViewModeChange?: (mode: VirtualCardViewMode) => void;
|
||||
globalDisplayMode?: VirtualCardDisplayMode;
|
||||
onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
||||
/** 우측 뷰 토글: full=신호·차트+요약·상세, display-only=요약·상세만, hidden=숨김 */
|
||||
viewControls?: 'full' | 'display-only' | 'hidden';
|
||||
}
|
||||
|
||||
const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
||||
session,
|
||||
strategies,
|
||||
onStrategyChange,
|
||||
onExecutionTypeChange,
|
||||
onPositionModeChange,
|
||||
onStart,
|
||||
onStop,
|
||||
viewMode = 'summary',
|
||||
onViewModeChange,
|
||||
globalDisplayMode = 'signal',
|
||||
onGlobalDisplayModeChange,
|
||||
viewControls = 'full',
|
||||
}) => (
|
||||
<div className="vtd-unified-head">
|
||||
<span className="vtd-unified-head-title">종목별 매매시그널 일치 현황</span>
|
||||
|
||||
<div className="vtd-unified-head-center">
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-session-select"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">실행방식</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="실행방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
|
||||
>
|
||||
봉 마감 직후
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'REALTIME_TICK' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('REALTIME_TICK')}
|
||||
>
|
||||
실시간 틱
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">시그널 모드</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onPositionModeChange('LONG_ONLY')}
|
||||
>
|
||||
보유 자산 기준
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.positionMode === 'SIGNAL_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onPositionModeChange('SIGNAL_ONLY')}
|
||||
>
|
||||
순수 지표 기준
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vtd-session-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn vtd-session-btn vtd-session-btn--start"
|
||||
disabled={session.running}
|
||||
onClick={onStart}
|
||||
>
|
||||
시작
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn bps-btn--danger vtd-session-btn"
|
||||
disabled={!session.running}
|
||||
onClick={onStop}
|
||||
>
|
||||
종료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewControls !== 'hidden' && onViewModeChange && (
|
||||
<div className="vtd-unified-head-right">
|
||||
{viewControls === 'full' && onGlobalDisplayModeChange && (
|
||||
<>
|
||||
<div className="vtd-grid-head-group" role="group" aria-label="전체 종목 신호·차트">
|
||||
<div className="vtd-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${globalDisplayMode === 'signal' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onGlobalDisplayModeChange('signal')}
|
||||
>
|
||||
신호
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${globalDisplayMode === 'chart' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onGlobalDisplayModeChange('chart')}
|
||||
>
|
||||
차트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<div className="vtd-grid-head-group" role="group" aria-label="카드 표시 방식">
|
||||
<div className="vtd-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onViewModeChange('summary')}
|
||||
>
|
||||
요약표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onViewModeChange('detail')}
|
||||
>
|
||||
상세표시
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'detail' && (
|
||||
<>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
<div className="vtd-grid-head-legend">
|
||||
<span className="vtd-legend vtd-legend--match">● 충족</span>
|
||||
<span className="vtd-legend vtd-legend--pending">● 대기</span>
|
||||
<span className="vtd-legend vtd-legend--nomatch">● 미충족</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{session.running && (
|
||||
<>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
<span className="vtd-grid-live">실시간 갱신 3초</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default VirtualGridUnifiedHeader;
|
||||
@@ -2,46 +2,12 @@ import React from 'react';
|
||||
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
|
||||
import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics';
|
||||
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
|
||||
import ConditionStatusSignal from './ConditionStatusSignal';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ConditionMetric['status'] }) {
|
||||
switch (status) {
|
||||
case 'match':
|
||||
return (
|
||||
<span className="vtd-sig-status vtd-sig-status--match">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Match
|
||||
</span>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="vtd-sig-status vtd-sig-status--pending">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 9v4"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case 'nomatch':
|
||||
return (
|
||||
<span className="vtd-sig-status vtd-sig-status--nomatch">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
No Match
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return <span className="vtd-sig-status vtd-sig-status--unknown">—</span>;
|
||||
}
|
||||
}
|
||||
|
||||
const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
<div className="vtd-sig-table-wrap">
|
||||
<div className="vtd-sig-table-head">REAL-TIME INDICATOR COMPARISON</div>
|
||||
@@ -73,7 +39,7 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
</div>
|
||||
<span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span>
|
||||
</td>
|
||||
<td><StatusBadge status={status} /></td>
|
||||
<td><ConditionStatusSignal status={status} progress={progress} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MarketSearchPanel } from '../MarketSearchPanel';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import VirtualTargetQuote from './VirtualTargetQuote';
|
||||
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
|
||||
|
||||
interface Props {
|
||||
@@ -9,8 +11,10 @@ interface Props {
|
||||
globalStrategyId: number | null;
|
||||
strategies: StrategyDto[];
|
||||
selectedMarket: string;
|
||||
tickers?: Map<string, TickerData>;
|
||||
onTargetsChange: (items: VirtualTargetItem[]) => void;
|
||||
onSelectMarket: (market: string) => void;
|
||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||
}
|
||||
|
||||
const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
@@ -18,8 +22,10 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
globalStrategyId,
|
||||
strategies,
|
||||
selectedMarket,
|
||||
tickers,
|
||||
onTargetsChange,
|
||||
onSelectMarket,
|
||||
onTargetStrategyChange,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
@@ -66,12 +72,6 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
onTargetsChange(targets.filter(t => t.market !== market));
|
||||
}, [targets, onTargetsChange]);
|
||||
|
||||
const handleStrategyChange = useCallback((market: string, strategyId: number | null) => {
|
||||
onTargetsChange(targets.map(t =>
|
||||
t.market === market ? { ...t, strategyId } : t,
|
||||
));
|
||||
}, [targets, onTargetsChange]);
|
||||
|
||||
return (
|
||||
<div className={`vtd-left${showDropdown ? ' vtd-left--dropdown-open' : ''}`}>
|
||||
<section className="vtd-search-section" ref={searchRef}>
|
||||
@@ -138,21 +138,32 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<label className="vtd-target-strat" onClick={e => e.stopPropagation()}>
|
||||
<VirtualTargetQuote
|
||||
market={item.market}
|
||||
ticker={tickers?.get(item.market)}
|
||||
/>
|
||||
<div
|
||||
className="vtd-target-strategy-field"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
role="group"
|
||||
aria-label="투자전략"
|
||||
>
|
||||
<span className="vtd-target-strategy-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-left-select"
|
||||
className="vtd-target-strategy-select"
|
||||
value={item.strategyId ?? globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
handleStrategyChange(item.market, v ? Number(v) : null);
|
||||
onTargetStrategyChange(item.market, v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 전략 —</option>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
|
||||
interface Props {
|
||||
status: VirtualLiveStatus;
|
||||
/** 데이터 수신 직후 형광 연두 glow (실시간 차트 ws-dot receiving) */
|
||||
receiving?: boolean;
|
||||
}
|
||||
|
||||
const LABEL: Record<VirtualLiveStatus, string | null> = {
|
||||
@@ -12,13 +14,13 @@ const LABEL: Record<VirtualLiveStatus, string | null> = {
|
||||
disconnected: '연결 끊김',
|
||||
};
|
||||
|
||||
const VirtualLiveBadge: React.FC<Props> = ({ status }) => {
|
||||
const VirtualLiveBadge: React.FC<Props> = ({ status, receiving = false }) => {
|
||||
const label = LABEL[status];
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<span className={`vtd-live-badge vtd-live-badge--${status}`}>
|
||||
<span className="vtd-live-badge-dot" aria-hidden />
|
||||
<span className={`vtd-live-badge vtd-live-badge--${status}${receiving ? ' vtd-live-badge--receiving' : ''}`}>
|
||||
<span className={`vtd-live-badge-dot${receiving ? ' receiving' : ''}`} aria-hidden />
|
||||
<span className="vtd-live-badge-text">{label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualSessionConfig } from '../../utils/virtualTradingStorage';
|
||||
|
||||
interface Props {
|
||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
strategies: StrategyDto[];
|
||||
onStrategyChange: (strategyId: number | null) => void;
|
||||
onExecutionTypeChange: (type: VirtualSessionConfig['executionType']) => void;
|
||||
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
const VirtualSessionToolbar: React.FC<Props> = ({
|
||||
session,
|
||||
strategies,
|
||||
onStrategyChange,
|
||||
onExecutionTypeChange,
|
||||
onPositionModeChange,
|
||||
onStart,
|
||||
onStop,
|
||||
}) => (
|
||||
<div className="vtd-session-toolbar">
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-session-select"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">실행방식</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="실행방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
|
||||
>
|
||||
봉 마감 직후
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'REALTIME_TICK' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('REALTIME_TICK')}
|
||||
>
|
||||
실시간 틱
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">시그널 모드</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onPositionModeChange('LONG_ONLY')}
|
||||
>
|
||||
보유 자산 기준
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.positionMode === 'SIGNAL_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onPositionModeChange('SIGNAL_ONLY')}
|
||||
>
|
||||
순수 지표 기준
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vtd-session-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn vtd-session-btn vtd-session-btn--start"
|
||||
disabled={session.running}
|
||||
onClick={onStart}
|
||||
>
|
||||
시작
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn bps-btn--danger vtd-session-btn"
|
||||
disabled={!session.running}
|
||||
onClick={onStop}
|
||||
>
|
||||
종료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default VirtualSessionToolbar;
|
||||
@@ -13,13 +13,13 @@ const LABELS: Record<TrafficLightState, string> = {
|
||||
};
|
||||
|
||||
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
|
||||
<div className="vtd-sig-light">
|
||||
<div className="vtd-sig-light vtd-sig-light--card-right">
|
||||
<div className="vtd-sig-light-housing">
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--red${state === 'red' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--yellow${state === 'yellow' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--blue${state === 'blue' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
</div>
|
||||
<div className="vtd-sig-light-info">
|
||||
<div className="vtd-sig-light-info vtd-sig-light-info--below">
|
||||
<span className="vtd-sig-light-rate">{matchRate}%</span>
|
||||
<span className="vtd-sig-light-label">{LABELS[state]}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,135 +1,34 @@
|
||||
/**
|
||||
* 가상투자 — 종목×전략 차트 팝업 (캔들 + 전략 보조지표)
|
||||
* 가상투자 — 종목×전략 차트 팝업 (레거시·외부 호출용)
|
||||
*/
|
||||
import React, {
|
||||
useCallback, useEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import React from 'react';
|
||||
import AppPopup from '../AppPopup';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { pinCandleWatch } from '../../utils/backendApi';
|
||||
import type {
|
||||
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
|
||||
} from '../../types';
|
||||
import type { Theme } from '../../types';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { useChartRealtimeData, type WsStatus } from '../../hooks/useChartRealtimeData';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import {
|
||||
buildStrategyChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
resolveStrategyTimeframes,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import { timeframeToCandleType } from '../../utils/chartCandleType';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategy: StrategyDto | undefined;
|
||||
theme?: Theme;
|
||||
running?: boolean;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function WsBadge({ status }: { status: WsStatus }) {
|
||||
const map: Record<WsStatus, { cls: string; label: string }> = {
|
||||
connecting: { cls: 'vtd-chart-ws--connecting', label: '연결 중' },
|
||||
connected: { cls: 'vtd-chart-ws--live', label: '실시간' },
|
||||
disconnected: { cls: 'vtd-chart-ws--off', label: '연결 끊김' },
|
||||
error: { cls: 'vtd-chart-ws--off', label: '오류' },
|
||||
};
|
||||
const { cls, label } = map[status];
|
||||
return (
|
||||
<span className={`vtd-chart-ws ${cls}`}>
|
||||
<span className="vtd-chart-ws-dot" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const VirtualStrategyChartPopup: React.FC<Props> = ({
|
||||
market,
|
||||
strategy,
|
||||
theme = 'dark',
|
||||
running = false,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
onClose,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
|
||||
}, [strategy, market]);
|
||||
|
||||
const indicators = useMemo(
|
||||
() => buildStrategyChartIndicators(strategy, getParams, getVisualConfig),
|
||||
[strategy, getParams, getVisualConfig],
|
||||
);
|
||||
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||
const barsMarketRef = useRef<string | null>(null);
|
||||
const chartLiveReadyRef = useRef(false);
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
const handleCandlesReady = useCallback(() => {
|
||||
chartLiveReadyRef.current = true;
|
||||
const pending = pendingBarRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
|
||||
pendingBarRef.current = null;
|
||||
mgr.updateBar(pending);
|
||||
}, []);
|
||||
|
||||
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
|
||||
if (barsMarketRef.current !== marketRef.current) return;
|
||||
if (!chartLiveReadyRef.current || !managerRef.current) {
|
||||
pendingBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
if (append) managerRef.current.appendBar(bar);
|
||||
else managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, false);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, true);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
const { bars, barsMarket, wsStatus, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
timeframe,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit]),
|
||||
);
|
||||
|
||||
barsMarketRef.current = barsMarket;
|
||||
|
||||
useEffect(() => {
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingBarRef.current = null;
|
||||
}, [market, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(timeframe));
|
||||
if (timeframeToCandleType(timeframe) !== '1m') {
|
||||
void pinCandleWatch(market, '1m');
|
||||
}
|
||||
}, [market, timeframe, useUpbit, running]);
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const chartSeriesPriceLabels = resolveAppDefaults(appSettings).chartSeriesPriceLabels;
|
||||
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
@@ -147,58 +46,18 @@ const VirtualStrategyChartPopup: React.FC<Props> = ({
|
||||
backdrop
|
||||
closeOnBackdrop
|
||||
bodyClassName="app-popup-body vtd-chart-popup-body"
|
||||
headerExtra={running ? <WsBadge status={wsStatus} /> : undefined}
|
||||
>
|
||||
<div className="vtd-chart-popup-meta">
|
||||
<span className="vtd-chart-popup-strat">{stratName}</span>
|
||||
{indicators.length > 0 && (
|
||||
<span className="vtd-chart-popup-ind-count">보조지표 {indicators.length}개</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="vtd-chart-popup-tf-row">
|
||||
{tfOptions.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`vtd-chart-popup-tf${timeframe === tf ? ' vtd-chart-popup-tf--on' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vtd-chart-popup-canvas-wrap">
|
||||
{isLoading && <div className="vtd-chart-popup-loading">차트 로딩…</div>}
|
||||
<TradingChart
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
timeframe={timeframe}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode={'chart' as ChartMode}
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[] as Drawing[]}
|
||||
logScale={false}
|
||||
drawingsLocked
|
||||
drawingsVisible={false}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
onCrosshair={noop as (d: LegendData | null) => void}
|
||||
onManagerReady={mgr => { managerRef.current = mgr; }}
|
||||
onAddDrawing={noop as (d: Drawing) => void}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
magnifierEnabled={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{indicators.length === 0 && (
|
||||
<p className="vtd-muted vtd-chart-popup-empty">
|
||||
전략에 차트로 표시할 보조지표가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
<VirtualTargetCardChart
|
||||
market={market}
|
||||
strategy={strategy}
|
||||
theme={theme}
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
/>
|
||||
</AppPopup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,31 +2,56 @@ import React, { useMemo } from 'react';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
computeMatchRate,
|
||||
formatUpdatedTime,
|
||||
getTrafficLightState,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
||||
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
|
||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||
import type { Theme } from '../../types';
|
||||
|
||||
export type VirtualCardDisplayMode = 'signal' | 'chart';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategy: StrategyDto | undefined;
|
||||
strategies: StrategyDto[];
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
running: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
lastTickAt?: number;
|
||||
viewMode?: VirtualCardViewMode;
|
||||
onOpenChart?: () => void;
|
||||
displayMode?: VirtualCardDisplayMode;
|
||||
onDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
||||
onEnterFocus?: () => void;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
}
|
||||
|
||||
const VirtualTargetCard: React.FC<Props> = ({
|
||||
market, strategy, snapshot, running, liveStatus = 'idle', viewMode = 'summary', onOpenChart,
|
||||
market,
|
||||
strategy,
|
||||
strategies,
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
onStrategyChange,
|
||||
snapshot,
|
||||
running,
|
||||
liveStatus = 'idle',
|
||||
lastTickAt,
|
||||
viewMode = 'summary',
|
||||
displayMode = 'signal',
|
||||
onDisplayModeChange,
|
||||
onEnterFocus,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
}) => {
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
@@ -34,65 +59,113 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
const rows = snapshot?.rows ?? [];
|
||||
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
const matchRate = useMemo(
|
||||
() => computeMatchRate(metrics, snapshot?.matchRate),
|
||||
[metrics, snapshot?.matchRate],
|
||||
);
|
||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||
const metCount = metrics.filter(m => m.status === 'match').length;
|
||||
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
|
||||
|
||||
const isDetail = viewMode === 'detail';
|
||||
const isChart = displayMode === 'chart';
|
||||
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!running || isChart) return null;
|
||||
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
|
||||
}, [running, lastTickAt, snapshot?.updatedAt, isChart]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, running && !isChart);
|
||||
|
||||
return (
|
||||
<div className={`vtd-card vtd-card--signal${running ? ' vtd-card--live' : ''}${isDetail ? ' vtd-card--detail' : ' vtd-card--summary'}`}>
|
||||
<div
|
||||
className={[
|
||||
'vtd-card',
|
||||
'vtd-card--signal',
|
||||
running ? 'vtd-card--live' : '',
|
||||
receiving ? 'vtd-card--receiving' : '',
|
||||
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
||||
isChart ? 'vtd-card--chart-mode' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className="vtd-card-head">
|
||||
<div className="vtd-card-title">
|
||||
<span className="vtd-card-ko">{ko}</span>
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
<div className="vtd-card-head-main">
|
||||
<div className="vtd-card-title">
|
||||
<span className="vtd-card-ko">{ko}</span>
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
</div>
|
||||
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
|
||||
<span className="vtd-card-strategy-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-card-strategy-select"
|
||||
value={strategyId ?? globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange?.(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vtd-card-head-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-card-chart-btn"
|
||||
title="차트 보기"
|
||||
aria-label="차트 보기"
|
||||
onClick={onOpenChart}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<rect x="3" y="4" width="18" height="14" rx="2"/>
|
||||
<path d="M7 14l3-3 3 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className="vtd-card-strat">{strategy?.name ?? '전략 미지정'}</span>
|
||||
{onEnterFocus && (
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-card-focus-btn"
|
||||
onClick={e => { e.stopPropagation(); onEnterFocus(); }}
|
||||
title="전체화면 보기 (차트 + 신호)"
|
||||
aria-label="전체화면 보기"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="5,1 1,1 1,5" />
|
||||
<polyline points="9,13 13,13 13,9" />
|
||||
<line x1="1" y1="1" x2="6" y2="6" />
|
||||
<line x1="13" y1="13" x2="8" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange?.('signal')}
|
||||
title="이 종목만 신호 보기"
|
||||
>
|
||||
신호
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange?.('chart')}
|
||||
title="이 종목만 차트 보기"
|
||||
>
|
||||
차트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vtd-card-meta">
|
||||
<span className="vtd-card-tf">시간봉 {tf}</span>
|
||||
{isDetail && evalCount > 0 && (
|
||||
{!isChart && isDetail && evalCount > 0 && (
|
||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||
)}
|
||||
{running && <VirtualLiveBadge status={liveStatus} />}
|
||||
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="vtd-muted vtd-card-empty">전략 조건·지표 데이터 없음</p>
|
||||
{isChart ? (
|
||||
<VirtualTargetCardChart
|
||||
market={market}
|
||||
strategy={strategy}
|
||||
theme={theme}
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual">
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
|
||||
</div>
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<span className="vtd-card-match-summary">매매조건 일치율 {matchRate}%</span>
|
||||
</div>
|
||||
</>
|
||||
<VirtualTargetSignalPanel
|
||||
snapshot={snapshot}
|
||||
viewMode={viewMode}
|
||||
receiving={receiving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 가상투자 카드 — 인라인 캔들 + 전략 보조지표
|
||||
*/
|
||||
import React, {
|
||||
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { pinCandleWatch } from '../../utils/backendApi';
|
||||
import type {
|
||||
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
|
||||
} from '../../types';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import {
|
||||
buildVirtualTradingChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
resolveStrategyTimeframes,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import { timeframeToCandleType } from '../../utils/chartCandleType';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategy: StrategyDto | undefined;
|
||||
theme?: Theme;
|
||||
running?: boolean;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
/** 차트 설정 — 지표 가격축 라벨·설명 */
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
/** 전체보기 분할 pane — 캔버스 높이 100% */
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
market,
|
||||
strategy,
|
||||
theme = 'dark',
|
||||
running = false,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
fillHeight = false,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
|
||||
}, [strategy, market]);
|
||||
|
||||
const indicators = useMemo(
|
||||
() => buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig),
|
||||
[strategy, getParams, getVisualConfig],
|
||||
);
|
||||
|
||||
const effectiveIndicators = useMemo(
|
||||
() => indicators.filter(ind => ind.timeframeVisibility?.[timeframe] !== false),
|
||||
[indicators, timeframe],
|
||||
);
|
||||
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartReloadTick, setChartReloadTick] = useState(0);
|
||||
const chartReloadTriggeredRef = useRef(false);
|
||||
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||
const barsMarketRef = useRef<string | null>(null);
|
||||
const chartLiveReadyRef = useRef(false);
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
const handleCandlesReady = useCallback(() => {
|
||||
chartLiveReadyRef.current = true;
|
||||
const pending = pendingBarRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
|
||||
pendingBarRef.current = null;
|
||||
mgr.updateBar(pending);
|
||||
}, []);
|
||||
|
||||
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
|
||||
if (barsMarketRef.current !== marketRef.current) return;
|
||||
if (!chartLiveReadyRef.current || !managerRef.current) {
|
||||
pendingBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
if (append) managerRef.current.appendBar(bar);
|
||||
else managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, false);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, true);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
const { bars, barsMarket, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
timeframe,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const { isLoadingMore } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: useUpbit,
|
||||
managerRef,
|
||||
});
|
||||
|
||||
barsMarketRef.current = barsMarket;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingBarRef.current = null;
|
||||
chartReloadTriggeredRef.current = false;
|
||||
}, [market, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = [100, 200, 500, 1000, 2000].map(delay =>
|
||||
setTimeout(() => {
|
||||
const wrap = canvasWrapRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!wrap) return;
|
||||
const h = wrap.clientHeight;
|
||||
if (h <= 0) return;
|
||||
if (mgr?.hasMainSeries()) {
|
||||
mgr.resetPaneHeights(h);
|
||||
} else if (!chartReloadTriggeredRef.current && delay >= 1000 && bars.length > 0) {
|
||||
chartReloadTriggeredRef.current = true;
|
||||
setChartReloadTick(t => t + 1);
|
||||
}
|
||||
}, delay),
|
||||
);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [market, timeframe, bars.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(timeframe));
|
||||
if (timeframeToCandleType(timeframe) !== '1m') {
|
||||
void pinCandleWatch(market, '1m');
|
||||
}
|
||||
}, [market, timeframe, useUpbit, running]);
|
||||
|
||||
return (
|
||||
<div className={`vtd-card-chart-panel${fillHeight ? ' vtd-card-chart-panel--fill' : ''}`}>
|
||||
{tfOptions.length > 1 && (
|
||||
<div className="vtd-card-chart-tf-row">
|
||||
{tfOptions.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`vtd-card-chart-tf${timeframe === tf ? ' vtd-card-chart-tf--on' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={canvasWrapRef}
|
||||
className={`vtd-card-chart-canvas slot-chart-wrap${fillHeight ? ' vtd-card-chart-canvas--fill' : ''}`}
|
||||
>
|
||||
{isLoading && <div className="vtd-card-chart-loading">차트 로딩…</div>}
|
||||
{isLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
||||
<span>과거 데이터 로딩…</span>
|
||||
</div>
|
||||
)}
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${chartReloadTick}`}
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
timeframe={timeframe}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode={'chart' as ChartMode}
|
||||
indicators={effectiveIndicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[] as Drawing[]}
|
||||
logScale={false}
|
||||
drawingsLocked
|
||||
drawingsVisible={false}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
onCrosshair={noop as (d: LegendData | null) => void}
|
||||
onManagerReady={mgr => { managerRef.current = mgr; }}
|
||||
onAddDrawing={noop as (d: Drawing) => void}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
magnifierEnabled={false}
|
||||
volumeVisible={false}
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{effectiveIndicators.length === 0 && strategy && (
|
||||
<p className="vtd-muted vtd-card-chart-empty">전략에 차트로 표시할 보조지표가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetCardChart;
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { Theme } from '../../types';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategy: StrategyDto | undefined;
|
||||
strategies: StrategyDto[];
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
running: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
lastTickAt?: number;
|
||||
viewMode?: VirtualCardViewMode;
|
||||
onExit: () => void;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
}
|
||||
|
||||
/** 전체보기 — 좌 차트 · 우 신호 */
|
||||
const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
market,
|
||||
strategy,
|
||||
strategies,
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
onStrategyChange,
|
||||
snapshot,
|
||||
running,
|
||||
liveStatus = 'idle',
|
||||
lastTickAt,
|
||||
viewMode = 'summary',
|
||||
onExit,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
}) => {
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
const tf = snapshot?.timeframe ?? '—';
|
||||
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!running) return null;
|
||||
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
|
||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||
|
||||
return (
|
||||
<div className={`vtd-focus-wrap${receiving ? ' vtd-focus-wrap--receiving' : ''}`}>
|
||||
<div className="vtd-focus-head">
|
||||
<div className="vtd-focus-head-main">
|
||||
<div className="vtd-focus-title">
|
||||
<span className="vtd-card-ko">{ko}</span>
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
</div>
|
||||
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
|
||||
<span className="vtd-card-strategy-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-card-strategy-select"
|
||||
value={strategyId ?? globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange?.(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="vtd-card-tf">시간봉 {tf}</span>
|
||||
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-focus-exit-btn"
|
||||
onClick={onExit}
|
||||
title="전체 종목 그리드로 돌아가기"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="1.5" y="1.5" width="5" height="5" rx="1" />
|
||||
<rect x="9.5" y="1.5" width="5" height="5" rx="1" />
|
||||
<rect x="1.5" y="9.5" width="5" height="5" rx="1" />
|
||||
<rect x="9.5" y="9.5" width="5" height="5" rx="1" />
|
||||
</svg>
|
||||
<span>전체 종목</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="vtd-focus-split">
|
||||
<section className="vtd-focus-pane vtd-focus-pane--chart" aria-label="차트 보기">
|
||||
<div className="vtd-focus-pane-head">차트</div>
|
||||
<div className="vtd-focus-pane-body">
|
||||
<VirtualTargetCardChart
|
||||
market={market}
|
||||
strategy={strategy}
|
||||
theme={theme}
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
fillHeight
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="vtd-focus-pane vtd-focus-pane--signal" aria-label="신호 보기">
|
||||
<div className="vtd-focus-pane-head">신호</div>
|
||||
<div className="vtd-focus-pane-body">
|
||||
<VirtualTargetSignalPanel
|
||||
snapshot={snapshot}
|
||||
viewMode={viewMode}
|
||||
receiving={receiving}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetFocusView;
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import VirtualTargetCard from './VirtualTargetCard';
|
||||
import VirtualStrategyChartPopup from './VirtualStrategyChartPopup';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import VirtualTargetCard, { type VirtualCardDisplayMode } from './VirtualTargetCard';
|
||||
import VirtualGridUnifiedHeader from './VirtualGridUnifiedHeader';
|
||||
import VirtualTargetFocusView from './VirtualTargetFocusView';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { Theme } from '../../types';
|
||||
import type { VirtualTargetItem, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } from '../../utils/virtualTradingStorage';
|
||||
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
@@ -12,90 +14,156 @@ interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
strategies: StrategyDto[];
|
||||
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||
running: boolean;
|
||||
globalStrategyId: number | null;
|
||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
onStrategyChange: (strategyId: number | null) => void;
|
||||
onExecutionTypeChange: (type: VirtualSessionConfig['executionType']) => void;
|
||||
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||
lastTickAtByMarket?: Record<string, number>;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
}
|
||||
|
||||
const VirtualTargetGrid: React.FC<Props> = ({
|
||||
targets, strategies, snapshots, running, globalStrategyId, liveStatusByMarket = {}, theme = 'dark',
|
||||
targets, strategies, snapshots, session,
|
||||
onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop,
|
||||
onTargetStrategyChange,
|
||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||
const [chartTarget, setChartTarget] = useState<{ market: string; strategy: StrategyDto | undefined } | null>(null);
|
||||
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
|
||||
const activeMarkets = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusMarket && !activeMarkets.has(focusMarket)) {
|
||||
setFocusMarket(null);
|
||||
}
|
||||
}, [activeMarkets, focusMarket]);
|
||||
|
||||
useEffect(() => {
|
||||
saveVirtualCardViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
/** 제거된 종목 override 정리 */
|
||||
useEffect(() => {
|
||||
setCardDisplayOverrides(prev => {
|
||||
const next: Record<string, VirtualCardDisplayMode> = {};
|
||||
let changed = false;
|
||||
for (const [market, mode] of Object.entries(prev)) {
|
||||
if (activeMarkets.has(market)) next[market] = mode;
|
||||
else changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [activeMarkets]);
|
||||
|
||||
const handleGlobalDisplayMode = useCallback((mode: VirtualCardDisplayMode) => {
|
||||
setGlobalDisplayMode(mode);
|
||||
setCardDisplayOverrides({});
|
||||
}, []);
|
||||
|
||||
const handleCardDisplayMode = useCallback((market: string, mode: VirtualCardDisplayMode) => {
|
||||
setCardDisplayOverrides(prev => ({ ...prev, [market]: mode }));
|
||||
}, []);
|
||||
|
||||
const resolveCardDisplayMode = useCallback(
|
||||
(market: string) => cardDisplayOverrides[market] ?? globalDisplayMode,
|
||||
[cardDisplayOverrides, globalDisplayMode],
|
||||
);
|
||||
|
||||
const focusTarget = useMemo(
|
||||
() => (focusMarket ? targets.find(t => t.market === focusMarket) : undefined),
|
||||
[focusMarket, targets],
|
||||
);
|
||||
|
||||
const renderUnifiedHeader = (viewControls: 'full' | 'display-only' | 'hidden') => (
|
||||
<VirtualGridUnifiedHeader
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
onStrategyChange={onStrategyChange}
|
||||
onExecutionTypeChange={onExecutionTypeChange}
|
||||
onPositionModeChange={onPositionModeChange}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
globalDisplayMode={globalDisplayMode}
|
||||
onGlobalDisplayModeChange={handleGlobalDisplayMode}
|
||||
viewControls={viewControls}
|
||||
/>
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return (
|
||||
<div className="vtd-grid-empty">
|
||||
<p className="vtd-muted">좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.</p>
|
||||
<div className="vtd-grid-wrap">
|
||||
{renderUnifiedHeader('hidden')}
|
||||
<div className="vtd-grid-empty">
|
||||
<p className="vtd-muted">좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vtd-grid-wrap">
|
||||
<div className="vtd-grid-head">
|
||||
<span className="vtd-grid-head-title">종목별 매매시그널 일치 현황</span>
|
||||
<div className="vtd-grid-head-right">
|
||||
<div className="vtd-view-toggle" role="group" aria-label="카드 표시 방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => setViewMode('summary')}
|
||||
>
|
||||
요약표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => setViewMode('detail')}
|
||||
>
|
||||
상세표시
|
||||
</button>
|
||||
</div>
|
||||
{viewMode === 'detail' && (
|
||||
<div className="vtd-grid-head-legend">
|
||||
<span className="vtd-legend vtd-legend--match">● 충족</span>
|
||||
<span className="vtd-legend vtd-legend--pending">● 대기</span>
|
||||
<span className="vtd-legend vtd-legend--nomatch">● 미충족</span>
|
||||
</div>
|
||||
)}
|
||||
{running && <span className="vtd-grid-live">실시간 갱신 3초</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`vtd-grid-wrap${focusMarket ? ' vtd-grid-wrap--focus' : ''}`}>
|
||||
{renderUnifiedHeader(focusMarket ? 'display-only' : 'full')}
|
||||
{focusMarket && focusTarget ? (
|
||||
<VirtualTargetFocusView
|
||||
market={focusTarget.market}
|
||||
strategy={strategies.find(s => s.id === (focusTarget.strategyId ?? session.globalStrategyId))}
|
||||
strategies={strategies}
|
||||
strategyId={focusTarget.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)}
|
||||
snapshot={snapshots[focusTarget.market]}
|
||||
running={session.running}
|
||||
liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||
lastTickAt={lastTickAtByMarket[focusTarget.market]}
|
||||
viewMode={viewMode}
|
||||
onExit={() => setFocusMarket(null)}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
/>
|
||||
) : (
|
||||
<div className="vtd-grid">
|
||||
{targets.map(t => {
|
||||
const strat = strategies.find(s => s.id === (t.strategyId ?? globalStrategyId));
|
||||
const strat = strategies.find(s => s.id === (t.strategyId ?? session.globalStrategyId));
|
||||
return (
|
||||
<VirtualTargetCard
|
||||
key={t.market}
|
||||
market={t.market}
|
||||
strategy={strat}
|
||||
strategies={strategies}
|
||||
strategyId={t.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
onStrategyChange={id => onTargetStrategyChange(t.market, id)}
|
||||
snapshot={snapshots[t.market]}
|
||||
running={running}
|
||||
liveStatus={liveStatusByMarket[t.market] ?? (running ? 'connecting' : 'idle')}
|
||||
running={session.running}
|
||||
liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||
lastTickAt={lastTickAtByMarket[t.market]}
|
||||
viewMode={viewMode}
|
||||
onOpenChart={() => setChartTarget({ market: t.market, strategy: strat })}
|
||||
displayMode={resolveCardDisplayMode(t.market)}
|
||||
onDisplayModeChange={mode => handleCardDisplayMode(t.market, mode)}
|
||||
onEnterFocus={() => setFocusMarket(t.market)}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{chartTarget && (
|
||||
<VirtualStrategyChartPopup
|
||||
market={chartTarget.market}
|
||||
strategy={chartTarget.strategy}
|
||||
theme={theme}
|
||||
running={running}
|
||||
onClose={() => setChartTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import type { ChangeDir, TickerData } from '../../hooks/useMarketTicker';
|
||||
|
||||
function fmtKrw(price: number | null | undefined): string {
|
||||
if (price == null || !Number.isFinite(price)) return '-';
|
||||
if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
if (price >= 0.01) return price.toFixed(4);
|
||||
return price.toFixed(8);
|
||||
}
|
||||
|
||||
function fmtPct(rate: number | null | undefined): string {
|
||||
if (rate == null || !Number.isFinite(rate)) return '-';
|
||||
const sign = rate >= 0 ? '+' : '';
|
||||
return `${sign}${(rate * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
|
||||
if (change === 'RISE') return <span className="vtd-target-arrow vtd-target-arrow--rise">▲</span>;
|
||||
if (change === 'FALL') return <span className="vtd-target-arrow vtd-target-arrow--fall">▼</span>;
|
||||
return <span className="vtd-target-arrow vtd-target-arrow--even">─</span>;
|
||||
});
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
ticker?: TickerData;
|
||||
}
|
||||
|
||||
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker }) => {
|
||||
const code = market.replace(/^KRW-/, '');
|
||||
const change: ChangeDir = ticker?.change ?? 'EVEN';
|
||||
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
|
||||
|
||||
const [flashClass, setFlashClass] = useState('');
|
||||
const prevPriceRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const price = ticker?.tradePrice ?? null;
|
||||
if (price === null) return;
|
||||
const prev = prevPriceRef.current;
|
||||
if (prev !== null && prev !== price) {
|
||||
const cls = price > prev ? 'vtd-target-quote--flash-rise' : 'vtd-target-quote--flash-fall';
|
||||
setFlashClass(cls);
|
||||
const t = setTimeout(() => setFlashClass(''), 700);
|
||||
prevPriceRef.current = price;
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
prevPriceRef.current = price;
|
||||
}, [ticker?.tradePrice]);
|
||||
|
||||
return (
|
||||
<div className={`vtd-target-quote ${flashClass}`.trim()} aria-label="현재가 및 전일 대비">
|
||||
<div className="vtd-target-quote-left">
|
||||
<ChangeArrow change={change} />
|
||||
<span className="vtd-target-quote-pair">{code}/KRW</span>
|
||||
</div>
|
||||
<div className="vtd-target-quote-right">
|
||||
<span className={`vtd-target-price ${colorCls}`}>
|
||||
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
|
||||
</span>
|
||||
<span className={`vtd-target-change ${colorCls}`}>
|
||||
{ticker ? fmtPct(ticker.changeRate) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetQuote;
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
computeMatchRate,
|
||||
formatUpdatedTime,
|
||||
getTrafficLightState,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
||||
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
|
||||
import VirtualConditionList from './VirtualConditionList';
|
||||
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
|
||||
interface Props {
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
viewMode?: VirtualCardViewMode;
|
||||
receiving?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
snapshot,
|
||||
viewMode = 'summary',
|
||||
receiving = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const rows = snapshot?.rows ?? [];
|
||||
const isDetail = viewMode === 'detail';
|
||||
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
const matchRate = useMemo(
|
||||
() => computeMatchRate(metrics, snapshot?.matchRate),
|
||||
[metrics, snapshot?.matchRate],
|
||||
);
|
||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||
const metCount = metrics.filter(m => m.status === 'match').length;
|
||||
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className={`vtd-muted vtd-card-empty${className ? ` ${className}` : ''}`}>
|
||||
전략 조건·지표 데이터 없음
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'vtd-sig-panel-wrap',
|
||||
isDetail ? '' : 'vtd-sig-panel-wrap--summary',
|
||||
receiving ? 'vtd-sig-panel-wrap--receiving' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{isDetail && evalCount > 0 && (
|
||||
<div className="vtd-focus-sig-meta">
|
||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual">
|
||||
<div className="vtd-sig-visual-eq">
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
</div>
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
|
||||
</div>
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<span className="vtd-card-match-summary">매매조건 일치율 {matchRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetSignalPanel;
|
||||
Reference in New Issue
Block a user