가상투자 메뉴 기능 구현

This commit is contained in:
Macbook
2026-05-25 06:41:45 +09:00
parent 0cfe7fc84c
commit 8b373b11e3
33 changed files with 3897 additions and 99 deletions
@@ -0,0 +1,84 @@
import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics';
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
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>
<table className="vtd-sig-table">
<thead>
<tr>
<th>TECHNICAL INDICATOR</th>
<th>CURRENT VALUE</th>
<th>STRATEGY THRESHOLD</th>
<th>PROGRESS</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{metrics.map(({ row, status, progress }) => (
<tr key={row.id}>
<td className="vtd-sig-table-ind">{row.displayName}</td>
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
<td className="vtd-sig-table-threshold">
{row.thresholdLabel
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
</td>
<td className="vtd-sig-table-progress">
<div className="vtd-sig-row-bar">
<div
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${status}`}
style={{ width: `${progress ?? 0}%` }}
/>
</div>
<span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span>
</td>
<td><StatusBadge status={status} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
export default VirtualIndicatorCompareTable;
@@ -0,0 +1,165 @@
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 { VirtualTargetItem } from '../../utils/virtualTradingStorage';
interface Props {
targets: VirtualTargetItem[];
globalStrategyId: number | null;
strategies: StrategyDto[];
selectedMarket: string;
onTargetsChange: (items: VirtualTargetItem[]) => void;
onSelectMarket: (market: string) => void;
}
const VirtualLeftTargetPanel: React.FC<Props> = ({
targets,
globalStrategyId,
strategies,
selectedMarket,
onTargetsChange,
onSelectMarket,
}) => {
const [query, setQuery] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
const closeDropdown = useCallback(() => {
setShowDropdown(false);
}, []);
const openDropdown = useCallback(() => {
setShowDropdown(true);
}, []);
useEffect(() => {
if (!showDropdown) return;
const onDocMouseDown = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', onDocMouseDown);
return () => document.removeEventListener('mousedown', onDocMouseDown);
}, [showDropdown]);
const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => {
if (addedSet.has(market)) return;
onTargetsChange([
...targets,
{
market,
strategyId: globalStrategyId,
koreanName: meta.koreanName,
englishName: meta.englishName,
},
]);
onSelectMarket(market);
setQuery('');
setShowDropdown(false);
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket]);
const handleRemove = useCallback((market: string) => {
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}>
<div className="vtd-search-label"> ( )</div>
<input
className={`vtd-search-input${showDropdown ? ' vtd-search-input--open' : ''}`}
placeholder="종목명 또는 심볼 검색"
value={query}
onChange={e => {
setQuery(e.target.value);
setShowDropdown(true);
}}
onFocus={openDropdown}
onClick={openDropdown}
/>
{showDropdown && (
<div className="vtd-search-dropdown">
<MarketSearchPanel
variant="embedded"
currentMarket={selectedMarket}
query={query}
onQueryChange={setQuery}
actionMode="add"
addedMarkets={addedSet}
onAdd={handleAdd}
onSelect={onSelectMarket}
onClose={closeDropdown}
/>
</div>
)}
</section>
<section className="vtd-target-section">
<div className="vtd-target-list-head">
<span></span>
<span className="vtd-target-count">{targets.length}</span>
</div>
<div className="vtd-target-list">
{targets.length === 0 && (
<p className="vtd-muted"> + .</p>
)}
{targets.map(item => {
const ko = item.koreanName ?? getKoreanName(item.market);
const en = item.englishName ?? item.market.replace(/^KRW-/, '');
const active = item.market === selectedMarket;
return (
<div
key={item.market}
className={`vtd-target-item${active ? ' vtd-target-item--active' : ''}`}
onClick={() => onSelectMarket(item.market)}
>
<div className="vtd-target-item-main">
<div className="vtd-target-names">
<span className="vtd-target-ko">{ko}</span>
<span className="vtd-target-en">{en}</span>
</div>
<button
type="button"
className="vtd-target-remove"
title="목록에서 제거"
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
>
</button>
</div>
<label className="vtd-target-strat" onClick={e => e.stopPropagation()}>
<select
className="vtd-left-select"
value={item.strategyId ?? globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
handleStrategyChange(item.market, v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
</div>
);
})}
</div>
</section>
</div>
);
};
export default VirtualLeftTargetPanel;
@@ -0,0 +1,27 @@
import React from 'react';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
interface Props {
status: VirtualLiveStatus;
}
const LABEL: Record<VirtualLiveStatus, string | null> = {
idle: null,
connecting: '연결 중',
live: '실시간',
disconnected: '연결 끊김',
};
const VirtualLiveBadge: React.FC<Props> = ({ status }) => {
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-text">{label}</span>
</span>
);
};
export default VirtualLiveBadge;
@@ -0,0 +1,64 @@
import React, { useMemo } from 'react';
const SEGMENTS = 25;
interface Props {
matchRate: number;
}
const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
const litCount = useMemo(
() => Math.round((Math.min(100, Math.max(0, matchRate)) / 100) * SEGMENTS),
[matchRate],
);
const segments = useMemo(() => {
const items: Array<{ lit: boolean; tone: 'blue' | 'gold' | 'peak' }> = [];
for (let i = 0; i < SEGMENTS; i++) {
const lit = i < litCount;
if (!lit) {
items.push({ lit: false, tone: 'blue' });
continue;
}
if (matchRate >= 100 && i === SEGMENTS - 1) {
items.push({ lit: true, tone: 'peak' });
} else if (i >= Math.floor(SEGMENTS * 0.55)) {
items.push({ lit: true, tone: 'gold' });
} else {
items.push({ lit: true, tone: 'blue' });
}
}
return items;
}, [litCount, matchRate]);
const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
return (
<div className="vtd-sig-eq">
<div className="vtd-sig-eq-scale">
{ticks.map(t => (
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
))}
</div>
<div className="vtd-sig-eq-bar-wrap">
<div className="vtd-sig-eq-bar">
{segments.map((seg, i) => (
<div
key={i}
className={[
'vtd-sig-eq-seg',
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
].filter(Boolean).join(' ')}
/>
))}
</div>
{matchRate >= 100 && (
<div className="vtd-sig-eq-badge">100% MATCH</div>
)}
</div>
</div>
);
};
export default VirtualSignalEqualizer;
@@ -0,0 +1,29 @@
import React from 'react';
import type { TrafficLightState } from '../../utils/virtualSignalMetrics';
interface Props {
state: TrafficLightState;
matchRate: number;
}
const LABELS: Record<TrafficLightState, string> = {
red: '조건 미충족',
yellow: '부분 일치 / 대기',
blue: '시그널 일치 (100%)',
};
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
<div className="vtd-sig-light">
<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">
<span className="vtd-sig-light-rate">{matchRate}%</span>
<span className="vtd-sig-light-label">{LABELS[state]}</span>
</div>
</div>
);
export default VirtualSignalTrafficLight;
@@ -0,0 +1,206 @@
/**
* 가상투자 — 종목×전략 차트 팝업 (캔들 + 전략 보조지표)
*/
import React, {
useCallback, useEffect, useMemo, useRef, useState,
} 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 { 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';
interface Props {
market: string;
strategy: StrategyDto | undefined;
theme?: Theme;
running?: boolean;
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,
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 ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
const stratName = strategy?.name ?? '전략 미지정';
return (
<AppPopup
onClose={onClose}
title={sym}
titleKo={ko}
badge="CHART"
width={920}
maxWidth="96vw"
centered
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>
)}
</AppPopup>
);
};
export default VirtualStrategyChartPopup;
@@ -0,0 +1,101 @@
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 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';
interface Props {
market: string;
strategy: StrategyDto | undefined;
snapshot: VirtualIndicatorSnapshot | undefined;
running: boolean;
liveStatus?: VirtualLiveStatus;
viewMode?: VirtualCardViewMode;
onOpenChart?: () => void;
}
const VirtualTargetCard: React.FC<Props> = ({
market, strategy, snapshot, running, liveStatus = 'idle', viewMode = 'summary', onOpenChart,
}) => {
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
const tf = snapshot?.timeframe ?? '—';
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';
return (
<div className={`vtd-card vtd-card--signal${running ? ' vtd-card--live' : ''}${isDetail ? ' vtd-card--detail' : ' vtd-card--summary'}`}>
<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>
<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>
</div>
</div>
<div className="vtd-card-meta">
<span className="vtd-card-tf"> {tf}</span>
{isDetail && evalCount > 0 && (
<span className="vtd-card-met-count">{metCount}/{evalCount} </span>
)}
{running && <VirtualLiveBadge status={liveStatus} />}
</div>
{rows.length === 0 ? (
<p className="vtd-muted vtd-card-empty"> · </p>
) : (
<>
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; 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>
</>
)}
</div>
);
};
export default VirtualTargetCard;
@@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react';
import VirtualTargetCard from './VirtualTargetCard';
import VirtualStrategyChartPopup from './VirtualStrategyChartPopup';
import type { StrategyDto } from '../../utils/backendApi';
import type { Theme } from '../../types';
import type { VirtualTargetItem, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
interface Props {
targets: VirtualTargetItem[];
strategies: StrategyDto[];
snapshots: Record<string, VirtualIndicatorSnapshot>;
running: boolean;
globalStrategyId: number | null;
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
theme?: Theme;
}
const VirtualTargetGrid: React.FC<Props> = ({
targets, strategies, snapshots, running, globalStrategyId, liveStatusByMarket = {}, theme = 'dark',
}) => {
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
const [chartTarget, setChartTarget] = useState<{ market: string; strategy: StrategyDto | undefined } | null>(null);
useEffect(() => {
saveVirtualCardViewMode(viewMode);
}, [viewMode]);
if (targets.length === 0) {
return (
<div className="vtd-grid-empty">
<p className="vtd-muted"> .</p>
</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">
{targets.map(t => {
const strat = strategies.find(s => s.id === (t.strategyId ?? globalStrategyId));
return (
<VirtualTargetCard
key={t.market}
market={t.market}
strategy={strat}
snapshot={snapshots[t.market]}
running={running}
liveStatus={liveStatusByMarket[t.market] ?? (running ? 'connecting' : 'idle')}
viewMode={viewMode}
onOpenChart={() => setChartTarget({ market: t.market, strategy: strat })}
/>
);
})}
</div>
</div>
{chartTarget && (
<VirtualStrategyChartPopup
market={chartTarget.market}
strategy={chartTarget.strategy}
theme={theme}
running={running}
onClose={() => setChartTarget(null)}
/>
)}
</>
);
};
export default VirtualTargetGrid;