가상투자 메뉴 기능 구현
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 & 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;
|
||||
Reference in New Issue
Block a user