가상투자 메뉴 기능 구현

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
+134 -66
View File
@@ -42,16 +42,35 @@ interface MarketSearchPanelProps {
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
*/
anchorRect?: DOMRect | null;
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
variant?: 'overlay' | 'embedded';
/** 외부 입력과 검색어 동기화 */
initialQuery?: string;
/** 제어형 검색어 (embedded 등 외부 input 사용 시) */
query?: string;
onQueryChange?: (query: string) => void;
/** favorite=별표, add=투자대상 추가(+) */
actionMode?: 'favorite' | 'add';
/** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
/** 이미 추가된 종목 (add 모드에서 + 비활성) */
addedMarkets?: Set<string>;
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
variant = 'overlay', query: controlledQuery, onQueryChange,
actionMode = 'favorite', onAdd, addedMarkets,
}) => {
const embedded = variant === 'embedded';
const [markets, setMarkets] = useState<MarketInfo[]>([]);
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
const [query, setQuery] = useState(initialQuery);
const [internalQuery, setInternalQuery] = useState(initialQuery);
const query = controlledQuery ?? internalQuery;
const setQuery = useCallback((next: string) => {
if (controlledQuery === undefined) setInternalQuery(next);
onQueryChange?.(next);
}, [controlledQuery, onQueryChange]);
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [loading, setLoading] = useState(true);
@@ -84,13 +103,13 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
})
.catch(() => setLoading(false));
setTimeout(() => inputRef.current?.focus(), 50);
if (!embedded) setTimeout(() => inputRef.current?.focus(), 50);
refreshFavs();
}, [refreshFavs]);
}, [refreshFavs, embedded]);
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
if (controlledQuery === undefined) setInternalQuery(initialQuery);
}, [initialQuery, controlledQuery]);
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
useEffect(() => {
@@ -145,6 +164,30 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
}
}, [markets]);
const tryAddMarket = useCallback((market: string) => {
if (actionMode === 'add') {
onSelect(market);
if (addedMarkets?.has(market)) {
if (embedded) onClose();
return;
}
const row = markets.find(m => m.market === market);
onAdd?.(market, {
koreanName: row?.korean_name ?? market,
englishName: row?.english_name ?? market,
});
if (embedded) onClose();
return;
}
onSelect(market);
if (!embedded) onClose();
}, [actionMode, onSelect, onAdd, addedMarkets, markets, embedded, onClose]);
const handleAddClick = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
tryAddMarket(market);
}, [tryAddMarket]);
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
const q = query.trim();
const filtered = markets.filter(m => {
@@ -176,21 +219,23 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
const PANEL_Z = 99999;
const panelStyle: React.CSSProperties | undefined = anchorRect
? {
position: 'fixed',
top: anchorRect.top + HEADER_H,
left: anchorRect.left,
width: Math.max(anchorRect.width, MIN_W),
height: anchorRect.height - HEADER_H,
maxWidth: anchorRect.width,
zIndex: PANEL_Z,
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
background: 'var(--bg2, #24283b)',
color: 'var(--text, #c0caf5)',
border: '1px solid var(--border, rgba(122,162,247,0.15))',
}
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
const panelStyle: React.CSSProperties | undefined = embedded
? undefined
: anchorRect
? {
position: 'fixed',
top: anchorRect.top + HEADER_H,
left: anchorRect.left,
width: Math.max(anchorRect.width, MIN_W),
height: anchorRect.height - HEADER_H,
maxWidth: anchorRect.width,
zIndex: PANEL_Z,
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
background: 'var(--bg2, #24283b)',
color: 'var(--text, #c0caf5)',
border: '1px solid var(--border, rgba(122,162,247,0.15))',
}
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
const backdropStyle: React.CSSProperties | undefined = anchorRect
@@ -206,22 +251,26 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
}
: undefined;
const handleRowSelect = useCallback((market: string) => {
tryAddMarket(market);
}, [tryAddMarket]);
return (
<>
{/* 배경 오버레이 (클릭 시 닫기) */}
<div
className="msp-backdrop"
style={backdropStyle}
onClick={onClose}
/>
{!embedded && (
<div
className="msp-backdrop"
style={backdropStyle}
onClick={onClose}
/>
)}
{/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
<div
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}`}
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}${embedded ? ' msp-panel--embedded' : ''}`}
style={panelStyle}
>
{/* 검색 입력 */}
{!embedded && (
<div className="msp-search-row">
<span className="msp-search-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -236,10 +285,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter' && sorted.length > 0) {
onSelect(sorted[0].market);
onClose();
}
if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market);
}}
/>
{query && (
@@ -255,14 +301,18 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
</svg>
</button>
</div>
)}
{/* 컬럼 헤더 */}
<div className="msp-header">
<span className="msp-col msp-col-fav" />
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}`}>
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
<span className="msp-col msp-col-name"></span>
<span className="msp-col msp-col-price"></span>
<span className="msp-col msp-col-change"></span>
<span className="msp-col msp-col-vol"></span>
{!embedded && (
<>
<span className="msp-col msp-col-price"></span>
<span className="msp-col msp-col-change"></span>
<span className="msp-col msp-col-vol"></span>
</>
)}
</div>
{/* 목록 */}
@@ -276,6 +326,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
{sorted.map(m => {
const tk = tickers[m.market];
const isFav = favs.has(m.market);
const isAdded = addedMarkets?.has(m.market) ?? false;
const isActive = m.market === currentMarket;
const rate = tk ? tk.signed_change_rate * 100 : null;
const isUp = rate !== null && rate >= 0;
@@ -283,40 +334,57 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
return (
<div
key={m.market}
className={`msp-item${isActive ? ' active' : ''}`}
onClick={() => { onSelect(m.market); onClose(); }}
className={`msp-item${embedded ? ' msp-item--embedded' : ''}${isActive ? ' active' : ''}`}
onClick={() => handleRowSelect(m.market)}
>
{/* 즐겨찾기 */}
<span
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
onClick={e => toggleFav(m.market, e)}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
{isFav ? '' : ''}
</span>
{actionMode === 'add' ? (
<button
type="button"
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
onClick={e => handleAddClick(m.market, e)}
disabled={isAdded}
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
>
{isAdded ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
)}
</button>
) : (
<span
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
onClick={e => toggleFav(m.market, e)}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
{isFav ? '★' : '☆'}
</span>
)}
{/* 이름 */}
<span className="msp-col msp-col-name">
<span className="msp-kor">{m.korean_name}</span>
<span className="msp-sym">{m.symbol}</span>
</span>
{/* 현재가 */}
<span className="msp-col msp-col-price">
{tk ? tk.trade_price.toLocaleString() : '-'}
</span>
{/* 전일대비 */}
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
</span>
{/* 거래대금 */}
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
{!embedded && (
<>
<span className="msp-col msp-col-price">
{tk ? tk.trade_price.toLocaleString() : '-'}
</span>
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
</span>
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
</>
)}
</div>
);
})}
+10 -1
View File
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
interface TopMenuBarProps {
activePage: MenuPage;
@@ -129,10 +129,19 @@ const IcStrategyEditor = () => (
</svg>
);
const IcVirtual = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="12" height="12" rx="1.5"/>
<path d="M5 8h6M8 5v6"/>
<circle cx="11.5" cy="4.5" r="1.2" fill="currentColor" stroke="none"/>
</svg>
);
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'virtual', label: '가상투자', icon: <IcVirtual /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> },
@@ -0,0 +1,336 @@
/**
* 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadStrategies,
saveLiveStrategySettings,
type PaperSummaryDto,
type StrategyDto,
} from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types';
import TradeOrderPanel from './TradeOrderPanel';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import PaperSplitPanel from './paper/PaperSplitPanel';
import BuilderPageShell from './layout/BuilderPageShell';
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import {
loadVirtualSession,
loadVirtualTargets,
saveVirtualSession,
saveVirtualTargets,
type VirtualSessionConfig,
type VirtualTargetItem,
} from '../utils/virtualTradingStorage';
import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook';
interface Props {
theme?: Theme;
tickers?: Map<string, { tradePrice: number | null }>;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
}
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
const VirtualTradingPage: React.FC<Props> = ({
theme = 'dark',
tickers,
defaultMarket = 'KRW-BTC',
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
}) => {
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [loading, setLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
loadPaperSummary().then(setSummary).finally(() => setLoading(false));
}, []);
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
useEffect(() => { saveVirtualSession(session); }, [session]);
const targetRefs = useMemo(() =>
targets.map(t => ({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
})),
[targets, session.globalStrategyId]);
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
const liveStatusByMarket = useVirtualTargetLiveStatus(targetRefs, session.running);
const mergedLiveStatus = useMemo(() => {
if (!session.running) return liveStatusByMarket;
const merged = { ...liveStatusByMarket };
const now = Date.now();
for (const [market, snap] of Object.entries(snapshots)) {
if (snap?.updatedAt && now - snap.updatedAt < 8000) {
merged[market] = 'live';
}
}
return merged;
}, [liveStatusByMarket, snapshots, session.running]);
const posQty = useMemo(() => {
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
return p?.quantity ?? 0;
}, [summary?.positions, selectedMarket]);
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
setTargets(items);
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
setSelectedMarket(items[0].market);
}
}, [selectedMarket]);
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
setSession(s => ({ ...s, globalStrategyId: strategyId }));
}, []);
const handleStart = useCallback(async () => {
if (targets.length === 0) {
window.alert('투자대상 종목을 먼저 추가하세요.');
return;
}
if (!session.globalStrategyId) {
window.alert('투자전략을 선택하세요.');
return;
}
const template = {
isLiveCheck: true,
executionType: session.executionType,
positionMode: session.positionMode,
};
try {
await Promise.all(targets.map(t =>
saveLiveStrategySettings({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
...template,
}),
));
setSession(s => ({ ...s, running: true }));
} catch {
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
}
}, [targets, session]);
const handleStop = useCallback(async () => {
if (targets.length === 0) {
setSession(s => ({ ...s, running: false }));
return;
}
try {
await Promise.all(targets.map(t =>
saveLiveStrategySettings({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
isLiveCheck: false,
executionType: session.executionType,
positionMode: session.positionMode,
}),
));
} catch { /* ignore */ }
setSession(s => ({ ...s, running: false }));
}, [targets, session]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
const side = rowType === 'ask' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req); else setFillSell(req);
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 rightTabs = (
<div className="bps-right-tabs">
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}></button>
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}></button>
</div>
);
return (
<BuilderPageShell
theme={theme}
title="가상투자"
subtitle="Virtual Trading"
loading={loading}
loadingText="가상투자 대시보드 로딩…"
leftStorageKey="vtd-left-width"
leftDefaultWidth={380}
rightStorageKey="vtd-right-width"
rightDefaultWidth={380}
headerActions={headerControls}
left={(
<VirtualLeftTargetPanel
targets={targets}
globalStrategyId={session.globalStrategyId}
strategies={strategies}
selectedMarket={selectedMarket}
onTargetsChange={handleTargetsChange}
onSelectMarket={setSelectedMarket}
/>
)}
center={(
<VirtualTargetGrid
targets={targets}
strategies={strategies}
snapshots={snapshots}
running={session.running}
globalStrategyId={session.globalStrategyId}
liveStatusByMarket={mergedLiveStatus}
theme={theme}
/>
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
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}
/>
)}
bottom={(
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={setSelectedMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
/>
) : (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매도 호가"
bottomTitle="매수 호가"
top={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="asks"
fillHeight
hideHeader
depth={10}
/>
)}
bottom={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="bids"
fillHeight
hideHeader
depth={10}
/>
)}
/>
)}
</div>
)}
/>
);
};
export default VirtualTradingPage;
@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { Theme } from '../../types';
import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize';
import '../../styles/strategyEditorTheme.css';
@@ -7,6 +7,9 @@ import '../../styles/builderPageShell.css';
const LEFT_MIN = 220;
const LEFT_MAX = 520;
const LEFT_DEFAULT = 280;
const RIGHT_MIN = 260;
const RIGHT_MAX = 520;
const RIGHT_DEFAULT = 280;
const FOOTER_MIN = 88;
const FOOTER_MAX = 420;
const FOOTER_DEFAULT = 140;
@@ -29,6 +32,9 @@ export interface BuilderPageShellProps {
footerLabel?: string;
footer?: React.ReactNode;
leftStorageKey?: string;
leftDefaultWidth?: number;
rightStorageKey?: string;
rightDefaultWidth?: number;
footerStorageKey?: string;
loading?: boolean;
loadingText?: string;
@@ -52,15 +58,21 @@ export default function BuilderPageShell({
footerLabel,
footer,
leftStorageKey = 'bps-left-width',
leftDefaultWidth = LEFT_DEFAULT,
rightStorageKey = 'bps-right-width',
rightDefaultWidth = RIGHT_DEFAULT,
footerStorageKey = 'bps-footer-height',
loading = false,
loadingText = '로딩 중…',
}: BuilderPageShellProps) {
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, LEFT_DEFAULT));
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, leftDefaultWidth));
const [rightWidth, setRightWidth] = useState(() => readStoredSize(rightStorageKey, rightDefaultWidth));
const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const footerHeightRef = useRef(footerHeight);
leftWidthRef.current = leftWidth;
rightWidthRef.current = rightWidth;
footerHeightRef.current = footerHeight;
const onLeftSplitter = usePanelResize(
@@ -72,6 +84,37 @@ export default function BuilderPageShell({
v => storeSize(leftStorageKey, v),
);
const handleRightSplitter = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const startX = e.clientX;
const start = rightWidthRef.current;
const cursor = 'col-resize';
document.body.style.cursor = cursor;
document.body.style.userSelect = 'none';
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('se-splitter--active');
const splitter = e.currentTarget;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
const next = Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, start - delta));
setRightWidth(next);
};
const onUp = (ev: PointerEvent) => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
splitter.releasePointerCapture(ev.pointerId);
splitter.classList.remove('se-splitter--active');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
storeSize(rightStorageKey, rightWidthRef.current);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}, [rightStorageKey]);
const onFooterSplitter = usePanelResize(
'horizontal',
setFooterHeight,
@@ -165,14 +208,26 @@ export default function BuilderPageShell({
</main>
{right && (
<aside className="bps-right">
{rightTabs ?? (rightTitle ? (
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
<h2 className="bps-panel-title">{rightTitle}</h2>
</div>
) : null)}
<div className="bps-right-body">{right}</div>
</aside>
<>
<div
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비 조절"
onPointerDown={handleRightSplitter}
/>
<aside
className="bps-right"
style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}
>
{rightTabs ?? (rightTitle ? (
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
<h2 className="bps-panel-title">{rightTitle}</h2>
</div>
) : null)}
<div className="bps-right-body">{right}</div>
</aside>
</>
)}
</div>
</div>
@@ -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;