가상투자 화면 추가

This commit is contained in:
Macbook
2026-05-25 13:14:36 +09:00
parent 1fe3e404fa
commit 339e2355eb
23 changed files with 2230 additions and 457 deletions
+29 -1
View File
@@ -96,6 +96,12 @@ interface TradingChartProps {
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void; onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void;
/** 표시 시간대 (IANA) */ /** 표시 시간대 (IANA) */
displayTimezone?: string; displayTimezone?: string;
/** 거래량 pane 표시 (기본 true) */
volumeVisible?: boolean;
/** 하단 호버 줌·스크롤 툴바 (기본 true) */
showHoverToolbar?: boolean;
/** 보조지표 우측 가격축 라벨·설명 (차트 설정, 기본 true) */
seriesPriceLabelsEnabled?: boolean;
} }
const TradingChart: React.FC<TradingChartProps> = ({ const TradingChart: React.FC<TradingChartProps> = ({
@@ -122,6 +128,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
onRestoreIndicators, onRestoreIndicators,
onTradeOrderRequest, onTradeOrderRequest,
displayTimezone = DEFAULT_DISPLAY_TIMEZONE, displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
volumeVisible = true,
showHoverToolbar = true,
seriesPriceLabelsEnabled = true,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼 const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
@@ -225,6 +234,20 @@ const TradingChart: React.FC<TradingChartProps> = ({
} }
}, [mode, drawingTool]); }, [mode, drawingTool]);
const volumeVisibleRef = useRef(volumeVisible);
useEffect(() => { volumeVisibleRef.current = volumeVisible; }, [volumeVisible]);
useEffect(() => {
const mgr = managerRef.current;
if (!mgr) return;
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
mgr.setVolumeVisible(volumeVisible, wrapperH > 0 ? wrapperH : undefined);
}, [volumeVisible]);
useEffect(() => {
managerRef.current?.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
}, [seriesPriceLabelsEnabled]);
/** /**
* pane 높이 재배분 + 스크롤 컨테이너 확장 * pane 높이 재배분 + 스크롤 컨테이너 확장
* *
@@ -509,6 +532,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
const mgr = new ChartManager(containerRef.current, theme); const mgr = new ChartManager(containerRef.current, theme);
managerRef.current = mgr; managerRef.current = mgr;
setChartMgr(mgr); setChartMgr(mgr);
if (!volumeVisibleRef.current) {
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined);
}
mgr.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
onManagerReady(mgr); onManagerReady(mgr);
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => { const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
@@ -1123,7 +1151,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
)} )}
{/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */} {/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */}
{chartMgr && ( {chartMgr && showHoverToolbar && (
<ChartHoverToolbar <ChartHoverToolbar
onZoomOut={() => managerRef.current?.zoomOut()} onZoomOut={() => managerRef.current?.zoomOut()}
onZoomIn={() => managerRef.current?.zoomIn()} onZoomIn={() => managerRef.current?.zoomIn()}
+54 -62
View File
@@ -10,6 +10,7 @@ import {
type StrategyDto, type StrategyDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types'; import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker';
import TradeOrderPanel from './TradeOrderPanel'; import TradeOrderPanel from './TradeOrderPanel';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook'; import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import PaperSplitPanel from './paper/PaperSplitPanel'; import PaperSplitPanel from './paper/PaperSplitPanel';
@@ -18,6 +19,7 @@ import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
import VirtualTargetGrid from './virtual/VirtualTargetGrid'; import VirtualTargetGrid from './virtual/VirtualTargetGrid';
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots'; import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
import { import {
loadVirtualSession, loadVirtualSession,
loadVirtualTargets, loadVirtualTargets,
@@ -26,13 +28,14 @@ import {
type VirtualSessionConfig, type VirtualSessionConfig,
type VirtualTargetItem, type VirtualTargetItem,
} from '../utils/virtualTradingStorage'; } from '../utils/virtualTradingStorage';
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
import '../styles/virtualTradingDashboard.css'; import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook'; type RightTab = 'trade' | 'orderbook';
interface Props { interface Props {
theme?: Theme; theme?: Theme;
tickers?: Map<string, { tradePrice: number | null }>; tickers?: Map<string, TickerData>;
defaultMarket?: string; defaultMarket?: string;
paperTradingEnabled?: boolean; paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean; paperAutoTradeEnabled?: boolean;
@@ -61,6 +64,11 @@ const VirtualTradingPage: React.FC<Props> = ({
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null); const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null); const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null); const orderAnchorRef = useRef<HTMLDivElement>(null);
const { settings: appSettings } = useAppSettings();
const appChartDefaults = resolveAppDefaults(appSettings);
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
?? 'BACKEND_STOMP') as ChartRealtimeSource;
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
useEffect(() => { useEffect(() => {
loadStrategies().then(setStrategies).catch(() => setStrategies([])); loadStrategies().then(setStrategies).catch(() => setStrategies([]));
@@ -78,7 +86,10 @@ const VirtualTradingPage: React.FC<Props> = ({
[targets, session.globalStrategyId]); [targets, session.globalStrategyId]);
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running); const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
const liveStatusByMarket = useVirtualTargetLiveStatus(targetRefs, session.running); const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
targetRefs,
session.running,
);
const mergedLiveStatus = useMemo(() => { const mergedLiveStatus = useMemo(() => {
if (!session.running) return liveStatusByMarket; if (!session.running) return liveStatusByMarket;
@@ -164,63 +175,31 @@ const VirtualTradingPage: React.FC<Props> = ({
setRightTab('trade'); setRightTab('trade');
}, [selectedMarket]); }, [selectedMarket]);
const headerControls = ( const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => {
<div className="vtd-header-controls"> setTargets(prev => prev.map(t =>
<label className="vtd-header-field"> t.market === market ? { ...t, strategyId } : t,
<span></span> ));
<select if (!session.running || !strategyId) return;
className="vtd-header-select" try {
value={session.globalStrategyId ?? ''} await saveLiveStrategySettings({
onChange={e => { market,
const v = e.target.value; strategyId,
handleGlobalStrategyChange(v ? Number(v) : null); isLiveCheck: true,
}} executionType: session.executionType,
> positionMode: session.positionMode,
<option value=""> </option> });
{strategies.map(s => ( } catch {
<option key={s.id} value={s.id}>{s.name}</option> window.alert('종목 전략 변경 저장에 실패했습니다.');
))} }
</select> }, [session]);
</label>
<label className="vtd-header-field"> const handleExecutionTypeChange = useCallback((executionType: VirtualSessionConfig['executionType']) => {
<span></span> setSession(s => ({ ...s, executionType }));
<select }, []);
className="vtd-header-select"
value={session.executionType} const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => {
onChange={e => setSession(s => ({ setSession(s => ({ ...s, positionMode }));
...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 = ( const rightTabs = (
<div className="bps-right-tabs"> <div className="bps-right-tabs">
@@ -240,15 +219,16 @@ const VirtualTradingPage: React.FC<Props> = ({
leftDefaultWidth={380} leftDefaultWidth={380}
rightStorageKey="vtd-right-width" rightStorageKey="vtd-right-width"
rightDefaultWidth={380} rightDefaultWidth={380}
headerActions={headerControls}
left={( left={(
<VirtualLeftTargetPanel <VirtualLeftTargetPanel
targets={targets} targets={targets}
globalStrategyId={session.globalStrategyId} globalStrategyId={session.globalStrategyId}
strategies={strategies} strategies={strategies}
selectedMarket={selectedMarket} selectedMarket={selectedMarket}
tickers={tickers}
onTargetsChange={handleTargetsChange} onTargetsChange={handleTargetsChange}
onSelectMarket={setSelectedMarket} onSelectMarket={setSelectedMarket}
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
/> />
)} )}
center={( center={(
@@ -256,10 +236,18 @@ const VirtualTradingPage: React.FC<Props> = ({
targets={targets} targets={targets}
strategies={strategies} strategies={strategies}
snapshots={snapshots} snapshots={snapshots}
running={session.running} session={session}
globalStrategyId={session.globalStrategyId} onStrategyChange={handleGlobalStrategyChange}
onExecutionTypeChange={handleExecutionTypeChange}
onPositionModeChange={handlePositionModeChange}
onStart={() => void handleStart()}
onStop={() => void handleStop()}
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
liveStatusByMarket={mergedLiveStatus} liveStatusByMarket={mergedLiveStatus}
lastTickAtByMarket={lastTickAtByMarket}
theme={theme} theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
/> />
)} )}
rightTabs={rightTabs} rightTabs={rightTabs}
@@ -271,6 +259,7 @@ const VirtualTradingPage: React.FC<Props> = ({
topTitle="매수" topTitle="매수"
bottomTitle="매도" bottomTitle="매도"
top={( top={(
<div className="ptd-order-card">
<TradeOrderPanel <TradeOrderPanel
side="buy" side="buy"
market={selectedMarket} market={selectedMarket}
@@ -284,8 +273,10 @@ const VirtualTradingPage: React.FC<Props> = ({
paperAutoTradeEnabled={paperAutoTradeEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled} onPaperOrderFilled={onPaperOrderFilled}
/> />
</div>
)} )}
bottom={( bottom={(
<div className="ptd-order-card">
<TradeOrderPanel <TradeOrderPanel
side="sell" side="sell"
market={selectedMarket} market={selectedMarket}
@@ -298,6 +289,7 @@ const VirtualTradingPage: React.FC<Props> = ({
paperAutoTradeEnabled={paperAutoTradeEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled} onPaperOrderFilled={onPaperOrderFilled}
/> />
</div>
)} )}
/> />
) : ( ) : (
@@ -0,0 +1,50 @@
import React from 'react';
import type { ConditionStatus } from '../../utils/virtualSignalMetrics';
interface Props {
status: ConditionStatus;
progress?: number | null;
compact?: boolean;
}
const LABELS: Record<ConditionStatus, string> = {
match: 'Match',
pending: 'Pending',
nomatch: 'Mismatch',
unknown: '—',
};
const ConditionStatusSignal: React.FC<Props> = ({ status, progress, compact = false }) => {
if (status === 'unknown') {
return <span className="vtd-cond-signal vtd-cond-signal--unknown"></span>;
}
const pct = progress != null && Number.isFinite(progress)
? `${Math.round(progress)}%`
: null;
return (
<span className={`vtd-cond-signal vtd-cond-signal--${status}${compact ? ' vtd-cond-signal--compact' : ''}`}>
{pct != null && <span className="vtd-cond-signal-pct">{pct}</span>}
{status === 'match' && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
{status === 'pending' && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="M12 9v4"/><line x1="12" y1="17" x2="12.01" y2="17"/>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
)}
{status === 'nomatch' && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
)}
<span className="vtd-cond-signal-label">{LABELS[status]}</span>
</span>
);
};
export default ConditionStatusSignal;
@@ -0,0 +1,28 @@
import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import ConditionStatusSignal from './ConditionStatusSignal';
interface Props {
metrics: ConditionMetric[];
}
const VirtualConditionList: React.FC<Props> = ({ metrics }) => {
if (metrics.length === 0) {
return <p className="vtd-cond-list-empty vtd-muted"> </p>;
}
return (
<ul className="vtd-cond-list" aria-label="전략 조건 목록">
{metrics.map(({ row, status, progress }) => (
<li key={row.id} className="vtd-cond-list-item">
<span className="vtd-cond-list-name" title={row.displayName}>
{row.displayName}
</span>
<ConditionStatusSignal status={status} progress={progress} compact />
</li>
))}
</ul>
);
};
export default VirtualConditionList;
@@ -0,0 +1,181 @@
import React from 'react';
import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import type { VirtualCardDisplayMode } from './VirtualTargetCard';
interface Props {
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
strategies: StrategyDto[];
onStrategyChange: (strategyId: number | null) => void;
onExecutionTypeChange: (type: VirtualSessionConfig['executionType']) => void;
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
onStart: () => void;
onStop: () => void;
viewMode?: VirtualCardViewMode;
onViewModeChange?: (mode: VirtualCardViewMode) => void;
globalDisplayMode?: VirtualCardDisplayMode;
onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
/** 우측 뷰 토글: full=신호·차트+요약·상세, display-only=요약·상세만, hidden=숨김 */
viewControls?: 'full' | 'display-only' | 'hidden';
}
const VirtualGridUnifiedHeader: React.FC<Props> = ({
session,
strategies,
onStrategyChange,
onExecutionTypeChange,
onPositionModeChange,
onStart,
onStop,
viewMode = 'summary',
onViewModeChange,
globalDisplayMode = 'signal',
onGlobalDisplayModeChange,
viewControls = 'full',
}) => (
<div className="vtd-unified-head">
<span className="vtd-unified-head-title"> </span>
<div className="vtd-unified-head-center">
<div className="vtd-session-field">
<span className="vtd-session-label"></span>
<select
className="vtd-session-select"
value={session.globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
onStrategyChange(v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="vtd-session-field">
<span className="vtd-session-label"></span>
<div className="vtd-view-toggle" role="group" aria-label="실행방식">
<button
type="button"
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${session.executionType === 'REALTIME_TICK' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('REALTIME_TICK')}
>
</button>
</div>
</div>
<div className="vtd-session-field">
<span className="vtd-session-label"> </span>
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드">
<button
type="button"
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('LONG_ONLY')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${session.positionMode === 'SIGNAL_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('SIGNAL_ONLY')}
>
</button>
</div>
</div>
<div className="vtd-session-actions">
<button
type="button"
className="bps-btn vtd-session-btn vtd-session-btn--start"
disabled={session.running}
onClick={onStart}
>
</button>
<button
type="button"
className="bps-btn bps-btn--danger vtd-session-btn"
disabled={!session.running}
onClick={onStop}
>
</button>
</div>
</div>
{viewControls !== 'hidden' && onViewModeChange && (
<div className="vtd-unified-head-right">
{viewControls === 'full' && onGlobalDisplayModeChange && (
<>
<div className="vtd-grid-head-group" role="group" aria-label="전체 종목 신호·차트">
<div className="vtd-view-toggle">
<button
type="button"
className={`vtd-view-toggle-btn${globalDisplayMode === 'signal' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onGlobalDisplayModeChange('signal')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${globalDisplayMode === 'chart' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onGlobalDisplayModeChange('chart')}
>
</button>
</div>
</div>
<span className="vtd-grid-head-divider" aria-hidden="true" />
</>
)}
<div className="vtd-grid-head-group" role="group" aria-label="카드 표시 방식">
<div className="vtd-view-toggle">
<button
type="button"
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onViewModeChange('summary')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onViewModeChange('detail')}
>
</button>
</div>
</div>
{viewMode === 'detail' && (
<>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<div className="vtd-grid-head-legend">
<span className="vtd-legend vtd-legend--match"> </span>
<span className="vtd-legend vtd-legend--pending"> </span>
<span className="vtd-legend vtd-legend--nomatch"> </span>
</div>
</>
)}
{session.running && (
<>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<span className="vtd-grid-live"> 3</span>
</>
)}
</div>
)}
</div>
);
export default VirtualGridUnifiedHeader;
@@ -2,46 +2,12 @@ import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics'; import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics'; import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics';
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions'; import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
import ConditionStatusSignal from './ConditionStatusSignal';
interface Props { interface Props {
metrics: ConditionMetric[]; 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 }) => ( const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
<div className="vtd-sig-table-wrap"> <div className="vtd-sig-table-wrap">
<div className="vtd-sig-table-head">REAL-TIME INDICATOR COMPARISON</div> <div className="vtd-sig-table-head">REAL-TIME INDICATOR COMPARISON</div>
@@ -73,7 +39,7 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
</div> </div>
<span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span> <span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span>
</td> </td>
<td><StatusBadge status={status} /></td> <td><ConditionStatusSignal status={status} progress={progress} /></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { MarketSearchPanel } from '../MarketSearchPanel'; import { MarketSearchPanel } from '../MarketSearchPanel';
import { getKoreanName } from '../../utils/marketNameCache'; import { getKoreanName } from '../../utils/marketNameCache';
import type { StrategyDto } from '../../utils/backendApi'; import type { StrategyDto } from '../../utils/backendApi';
import type { TickerData } from '../../hooks/useMarketTicker';
import VirtualTargetQuote from './VirtualTargetQuote';
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage'; import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
interface Props { interface Props {
@@ -9,8 +11,10 @@ interface Props {
globalStrategyId: number | null; globalStrategyId: number | null;
strategies: StrategyDto[]; strategies: StrategyDto[];
selectedMarket: string; selectedMarket: string;
tickers?: Map<string, TickerData>;
onTargetsChange: (items: VirtualTargetItem[]) => void; onTargetsChange: (items: VirtualTargetItem[]) => void;
onSelectMarket: (market: string) => void; onSelectMarket: (market: string) => void;
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
} }
const VirtualLeftTargetPanel: React.FC<Props> = ({ const VirtualLeftTargetPanel: React.FC<Props> = ({
@@ -18,8 +22,10 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
globalStrategyId, globalStrategyId,
strategies, strategies,
selectedMarket, selectedMarket,
tickers,
onTargetsChange, onTargetsChange,
onSelectMarket, onSelectMarket,
onTargetStrategyChange,
}) => { }) => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
@@ -66,12 +72,6 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
onTargetsChange(targets.filter(t => t.market !== market)); onTargetsChange(targets.filter(t => t.market !== market));
}, [targets, onTargetsChange]); }, [targets, onTargetsChange]);
const handleStrategyChange = useCallback((market: string, strategyId: number | null) => {
onTargetsChange(targets.map(t =>
t.market === market ? { ...t, strategyId } : t,
));
}, [targets, onTargetsChange]);
return ( return (
<div className={`vtd-left${showDropdown ? ' vtd-left--dropdown-open' : ''}`}> <div className={`vtd-left${showDropdown ? ' vtd-left--dropdown-open' : ''}`}>
<section className="vtd-search-section" ref={searchRef}> <section className="vtd-search-section" ref={searchRef}>
@@ -138,21 +138,32 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
</button> </button>
</div> </div>
<label className="vtd-target-strat" onClick={e => e.stopPropagation()}> <VirtualTargetQuote
market={item.market}
ticker={tickers?.get(item.market)}
/>
<div
className="vtd-target-strategy-field"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
role="group"
aria-label="투자전략"
>
<span className="vtd-target-strategy-label"></span>
<select <select
className="vtd-left-select" className="vtd-target-strategy-select"
value={item.strategyId ?? globalStrategyId ?? ''} value={item.strategyId ?? globalStrategyId ?? ''}
onChange={e => { onChange={e => {
const v = e.target.value; const v = e.target.value;
handleStrategyChange(item.market, v ? Number(v) : null); onTargetStrategyChange(item.market, v ? Number(v) : null);
}} }}
> >
<option value=""> </option> <option value=""> </option>
{strategies.map(s => ( {strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
</label> </div>
</div> </div>
); );
})} })}
@@ -3,6 +3,8 @@ import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
interface Props { interface Props {
status: VirtualLiveStatus; status: VirtualLiveStatus;
/** 데이터 수신 직후 형광 연두 glow (실시간 차트 ws-dot receiving) */
receiving?: boolean;
} }
const LABEL: Record<VirtualLiveStatus, string | null> = { const LABEL: Record<VirtualLiveStatus, string | null> = {
@@ -12,13 +14,13 @@ const LABEL: Record<VirtualLiveStatus, string | null> = {
disconnected: '연결 끊김', disconnected: '연결 끊김',
}; };
const VirtualLiveBadge: React.FC<Props> = ({ status }) => { const VirtualLiveBadge: React.FC<Props> = ({ status, receiving = false }) => {
const label = LABEL[status]; const label = LABEL[status];
if (!label) return null; if (!label) return null;
return ( return (
<span className={`vtd-live-badge vtd-live-badge--${status}`}> <span className={`vtd-live-badge vtd-live-badge--${status}${receiving ? ' vtd-live-badge--receiving' : ''}`}>
<span className="vtd-live-badge-dot" aria-hidden /> <span className={`vtd-live-badge-dot${receiving ? ' receiving' : ''}`} aria-hidden />
<span className="vtd-live-badge-text">{label}</span> <span className="vtd-live-badge-text">{label}</span>
</span> </span>
); );
@@ -0,0 +1,103 @@
import React from 'react';
import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualSessionConfig } from '../../utils/virtualTradingStorage';
interface Props {
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
strategies: StrategyDto[];
onStrategyChange: (strategyId: number | null) => void;
onExecutionTypeChange: (type: VirtualSessionConfig['executionType']) => void;
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
onStart: () => void;
onStop: () => void;
}
const VirtualSessionToolbar: React.FC<Props> = ({
session,
strategies,
onStrategyChange,
onExecutionTypeChange,
onPositionModeChange,
onStart,
onStop,
}) => (
<div className="vtd-session-toolbar">
<div className="vtd-session-field">
<span className="vtd-session-label"></span>
<select
className="vtd-session-select"
value={session.globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
onStrategyChange(v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="vtd-session-field">
<span className="vtd-session-label"></span>
<div className="vtd-view-toggle" role="group" aria-label="실행방식">
<button
type="button"
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${session.executionType === 'REALTIME_TICK' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('REALTIME_TICK')}
>
</button>
</div>
</div>
<div className="vtd-session-field">
<span className="vtd-session-label"> </span>
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드">
<button
type="button"
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('LONG_ONLY')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${session.positionMode === 'SIGNAL_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('SIGNAL_ONLY')}
>
</button>
</div>
</div>
<div className="vtd-session-actions">
<button
type="button"
className="bps-btn vtd-session-btn vtd-session-btn--start"
disabled={session.running}
onClick={onStart}
>
</button>
<button
type="button"
className="bps-btn bps-btn--danger vtd-session-btn"
disabled={!session.running}
onClick={onStop}
>
</button>
</div>
</div>
);
export default VirtualSessionToolbar;
@@ -13,13 +13,13 @@ const LABELS: Record<TrafficLightState, string> = {
}; };
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => ( const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
<div className="vtd-sig-light"> <div className="vtd-sig-light vtd-sig-light--card-right">
<div className="vtd-sig-light-housing"> <div className="vtd-sig-light-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--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--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 className={`vtd-sig-light-bulb vtd-sig-light-bulb--blue${state === 'blue' ? ' vtd-sig-light-bulb--on' : ''}`} />
</div> </div>
<div className="vtd-sig-light-info"> <div className="vtd-sig-light-info vtd-sig-light-info--below">
<span className="vtd-sig-light-rate">{matchRate}%</span> <span className="vtd-sig-light-rate">{matchRate}%</span>
<span className="vtd-sig-light-label">{LABELS[state]}</span> <span className="vtd-sig-light-label">{LABELS[state]}</span>
</div> </div>
@@ -1,135 +1,34 @@
/** /**
* 가상투자 — 종목×전략 차트 팝업 (캔들 + 전략 보조지표) * 가상투자 — 종목×전략 차트 팝업 (레거시·외부 호출용)
*/ */
import React, { import React from 'react';
useCallback, useEffect, useMemo, useRef, useState,
} from 'react';
import AppPopup from '../AppPopup'; import AppPopup from '../AppPopup';
import TradingChart from '../TradingChart';
import type { StrategyDto } from '../../utils/backendApi'; import type { StrategyDto } from '../../utils/backendApi';
import { pinCandleWatch } from '../../utils/backendApi'; import type { Theme } from '../../types';
import type {
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
} from '../../types';
import { getKoreanName } from '../../utils/marketNameCache'; import { getKoreanName } from '../../utils/marketNameCache';
import { isUpbitMarket } from '../../utils/upbitApi'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { useChartRealtimeData, type WsStatus } from '../../hooks/useChartRealtimeData'; import VirtualTargetCardChart from './VirtualTargetCardChart';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
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 { interface Props {
market: string; market: string;
strategy: StrategyDto | undefined; strategy: StrategyDto | undefined;
theme?: Theme; theme?: Theme;
running?: boolean; running?: boolean;
chartRealtimeSource?: ChartRealtimeSource;
onClose: () => void; 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> = ({ const VirtualStrategyChartPopup: React.FC<Props> = ({
market, market,
strategy, strategy,
theme = 'dark', theme = 'dark',
running = false, running = false,
chartRealtimeSource = 'BACKEND_STOMP',
onClose, onClose,
}) => { }) => {
const { getParams, getVisualConfig } = useIndicatorSettings(); const { settings: appSettings } = useAppSettings();
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]); const chartSeriesPriceLabels = resolveAppDefaults(appSettings).chartSeriesPriceLabels;
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 ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, ''); const sym = market.replace(/^KRW-/, '');
@@ -147,58 +46,18 @@ const VirtualStrategyChartPopup: React.FC<Props> = ({
backdrop backdrop
closeOnBackdrop closeOnBackdrop
bodyClassName="app-popup-body vtd-chart-popup-body" bodyClassName="app-popup-body vtd-chart-popup-body"
headerExtra={running ? <WsBadge status={wsStatus} /> : undefined}
> >
<div className="vtd-chart-popup-meta"> <div className="vtd-chart-popup-meta">
<span className="vtd-chart-popup-strat">{stratName}</span> <span className="vtd-chart-popup-strat">{stratName}</span>
{indicators.length > 0 && (
<span className="vtd-chart-popup-ind-count"> {indicators.length}</span>
)}
</div> </div>
<VirtualTargetCardChart
<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} market={market}
timeframe={timeframe} strategy={strategy}
chartType={'candlestick' as ChartType}
theme={theme} theme={theme}
mode={'chart' as ChartMode} running={running}
indicators={indicators} chartRealtimeSource={chartRealtimeSource}
drawingTool="cursor" chartSeriesPriceLabels={chartSeriesPriceLabels}
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> </AppPopup>
); );
}; };
@@ -2,31 +2,56 @@ import React, { useMemo } from 'react';
import { getKoreanName } from '../../utils/marketNameCache'; import { getKoreanName } from '../../utils/marketNameCache';
import type { StrategyDto } from '../../utils/backendApi'; import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
buildConditionMetrics, import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
computeMatchRate, import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
formatUpdatedTime,
getTrafficLightState,
} from '../../utils/virtualSignalMetrics';
import VirtualLiveBadge from './VirtualLiveBadge'; import VirtualLiveBadge from './VirtualLiveBadge';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage'; import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable'; import VirtualTargetCardChart from './VirtualTargetCardChart';
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
import type { Theme } from '../../types';
export type VirtualCardDisplayMode = 'signal' | 'chart';
interface Props { interface Props {
market: string; market: string;
strategy: StrategyDto | undefined; strategy: StrategyDto | undefined;
strategies: StrategyDto[];
strategyId: number | null;
globalStrategyId: number | null;
onStrategyChange?: (strategyId: number | null) => void;
snapshot: VirtualIndicatorSnapshot | undefined; snapshot: VirtualIndicatorSnapshot | undefined;
running: boolean; running: boolean;
liveStatus?: VirtualLiveStatus; liveStatus?: VirtualLiveStatus;
lastTickAt?: number;
viewMode?: VirtualCardViewMode; viewMode?: VirtualCardViewMode;
onOpenChart?: () => void; displayMode?: VirtualCardDisplayMode;
onDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
onEnterFocus?: () => void;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
} }
const VirtualTargetCard: React.FC<Props> = ({ const VirtualTargetCard: React.FC<Props> = ({
market, strategy, snapshot, running, liveStatus = 'idle', viewMode = 'summary', onOpenChart, market,
strategy,
strategies,
strategyId,
globalStrategyId,
onStrategyChange,
snapshot,
running,
liveStatus = 'idle',
lastTickAt,
viewMode = 'summary',
displayMode = 'signal',
onDisplayModeChange,
onEnterFocus,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
}) => { }) => {
const ko = getKoreanName(market); const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, ''); const sym = market.replace(/^KRW-/, '');
@@ -34,65 +59,113 @@ const VirtualTargetCard: React.FC<Props> = ({
const rows = snapshot?.rows ?? []; const rows = snapshot?.rows ?? [];
const metrics = useMemo(() => buildConditionMetrics(rows), [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 metCount = metrics.filter(m => m.status === 'match').length;
const evalCount = metrics.filter(m => m.status !== 'unknown').length; const evalCount = metrics.filter(m => m.status !== 'unknown').length;
const isDetail = viewMode === 'detail'; const isDetail = viewMode === 'detail';
const isChart = displayMode === 'chart';
const receiveSignal = useMemo(() => {
if (!running || isChart) return null;
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
}, [running, lastTickAt, snapshot?.updatedAt, isChart]);
const receiving = useLiveReceiveFlash(receiveSignal, running && !isChart);
return ( return (
<div className={`vtd-card vtd-card--signal${running ? ' vtd-card--live' : ''}${isDetail ? ' vtd-card--detail' : ' vtd-card--summary'}`}> <div
className={[
'vtd-card',
'vtd-card--signal',
running ? 'vtd-card--live' : '',
receiving ? 'vtd-card--receiving' : '',
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
isChart ? 'vtd-card--chart-mode' : '',
].filter(Boolean).join(' ')}
>
<div className="vtd-card-head"> <div className="vtd-card-head">
<div className="vtd-card-head-main">
<div className="vtd-card-title"> <div className="vtd-card-title">
<span className="vtd-card-ko">{ko}</span> <span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span> <span className="vtd-card-sym">{sym}</span>
</div> </div>
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
<span className="vtd-card-strategy-label"></span>
<select
className="vtd-card-strategy-select"
value={strategyId ?? globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
onStrategyChange?.(v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
</div>
<div className="vtd-card-head-actions"> <div className="vtd-card-head-actions">
{onEnterFocus && (
<button <button
type="button" type="button"
className="vtd-card-chart-btn" className="vtd-card-focus-btn"
title="차트 보기" onClick={e => { e.stopPropagation(); onEnterFocus(); }}
aria-label="차트 보기" title="전체화면 보기 (차트 + 신호)"
onClick={onOpenChart} aria-label="전체화면 보기"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden> <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="4" width="18" height="14" rx="2"/> <polyline points="5,1 1,1 1,5" />
<path d="M7 14l3-3 3 2 4-5"/> <polyline points="9,13 13,13 13,9" />
<line x1="1" y1="1" x2="6" y2="6" />
<line x1="13" y1="13" x2="8" y2="8" />
</svg> </svg>
</button> </button>
<span className="vtd-card-strat">{strategy?.name ?? '전략 미지정'}</span> )}
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시">
<button
type="button"
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
onClick={() => onDisplayModeChange?.('signal')}
title="이 종목만 신호 보기"
>
</button>
<button
type="button"
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
onClick={() => onDisplayModeChange?.('chart')}
title="이 종목만 차트 보기"
>
</button>
</div>
</div> </div>
</div> </div>
<div className="vtd-card-meta"> <div className="vtd-card-meta">
<span className="vtd-card-tf"> {tf}</span> <span className="vtd-card-tf"> {tf}</span>
{isDetail && evalCount > 0 && ( {!isChart && isDetail && evalCount > 0 && (
<span className="vtd-card-met-count">{metCount}/{evalCount} </span> <span className="vtd-card-met-count">{metCount}/{evalCount} </span>
)} )}
{running && <VirtualLiveBadge status={liveStatus} />} {running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
</div> </div>
{rows.length === 0 ? ( {isChart ? (
<p className="vtd-muted vtd-card-empty"> · </p> <VirtualTargetCardChart
market={market}
strategy={strategy}
theme={theme}
running={running}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
/>
) : ( ) : (
<> <VirtualTargetSignalPanel
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}> snapshot={snapshot}
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div> viewMode={viewMode}
<div className="vtd-sig-visual"> receiving={receiving}
<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> </div>
); );
@@ -0,0 +1,221 @@
/**
* 가상투자 카드 — 인라인 캔들 + 전략 보조지표
*/
import React, {
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from 'react';
import TradingChart from '../TradingChart';
import type { StrategyDto } from '../../utils/backendApi';
import { pinCandleWatch } from '../../utils/backendApi';
import type {
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
} from '../../types';
import { isUpbitMarket } from '../../utils/upbitApi';
import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import type { ChartManager } from '../../utils/ChartManager';
import {
buildVirtualTradingChartIndicators,
resolveStrategyPrimaryTimeframe,
resolveStrategyTimeframes,
} from '../../utils/strategyToChartIndicators';
import { timeframeToCandleType } from '../../utils/chartCandleType';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
interface Props {
market: string;
strategy: StrategyDto | undefined;
theme?: Theme;
running?: boolean;
chartRealtimeSource?: ChartRealtimeSource;
/** 차트 설정 — 지표 가격축 라벨·설명 */
chartSeriesPriceLabels?: boolean;
/** 전체보기 분할 pane — 캔버스 높이 100% */
fillHeight?: boolean;
}
const noop = () => {};
const VirtualTargetCardChart: React.FC<Props> = ({
market,
strategy,
theme = 'dark',
running = false,
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
fillHeight = false,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy));
useEffect(() => {
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
}, [strategy, market]);
const indicators = useMemo(
() => buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig),
[strategy, getParams, getVisualConfig],
);
const effectiveIndicators = useMemo(
() => indicators.filter(ind => ind.timeframeVisibility?.[timeframe] !== false),
[indicators, timeframe],
);
const managerRef = useRef<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0);
const chartReloadTriggeredRef = useRef(false);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const chartLiveReadyRef = useRef(false);
const marketRef = useRef(market);
marketRef.current = market;
const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true;
const pending = pendingBarRef.current;
const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
pendingBarRef.current = null;
mgr.updateBar(pending);
}, []);
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
if (barsMarketRef.current !== marketRef.current) return;
if (!chartLiveReadyRef.current || !managerRef.current) {
pendingBarRef.current = bar;
return;
}
if (append) managerRef.current.appendBar(bar);
else managerRef.current.updateBar(bar);
}, []);
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, false);
}, [applyRealtimeBar]);
const handleNewCandle = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, true);
}, [applyRealtimeBar]);
const useUpbit = isUpbitMarket(market);
const { bars, barsMarket, isLoading } = useChartRealtimeData(
market,
timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
);
const { isLoadingMore } = useHistoryLoader({
symbol: market,
timeframe,
isUpbit: useUpbit,
managerRef,
});
barsMarketRef.current = barsMarket;
useLayoutEffect(() => {
chartLiveReadyRef.current = false;
pendingBarRef.current = null;
chartReloadTriggeredRef.current = false;
}, [market, timeframe]);
useEffect(() => {
const timers = [100, 200, 500, 1000, 2000].map(delay =>
setTimeout(() => {
const wrap = canvasWrapRef.current;
const mgr = managerRef.current;
if (!wrap) return;
const h = wrap.clientHeight;
if (h <= 0) return;
if (mgr?.hasMainSeries()) {
mgr.resetPaneHeights(h);
} else if (!chartReloadTriggeredRef.current && delay >= 1000 && bars.length > 0) {
chartReloadTriggeredRef.current = true;
setChartReloadTick(t => t + 1);
}
}, delay),
);
return () => timers.forEach(clearTimeout);
}, [market, timeframe, bars.length]);
useEffect(() => {
if (!useUpbit) return;
void pinCandleWatch(market, timeframeToCandleType(timeframe));
if (timeframeToCandleType(timeframe) !== '1m') {
void pinCandleWatch(market, '1m');
}
}, [market, timeframe, useUpbit, running]);
return (
<div className={`vtd-card-chart-panel${fillHeight ? ' vtd-card-chart-panel--fill' : ''}`}>
{tfOptions.length > 1 && (
<div className="vtd-card-chart-tf-row">
{tfOptions.map(tf => (
<button
key={tf}
type="button"
className={`vtd-card-chart-tf${timeframe === tf ? ' vtd-card-chart-tf--on' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
)}
<div
ref={canvasWrapRef}
className={`vtd-card-chart-canvas slot-chart-wrap${fillHeight ? ' vtd-card-chart-canvas--fill' : ''}`}
>
{isLoading && <div className="vtd-card-chart-loading"> </div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> </span>
</div>
)}
<TradingChart
key={`${market}-${timeframe}-${chartReloadTick}`}
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={timeframe}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={effectiveIndicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
volumeVisible={false}
showHoverToolbar={false}
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
/>
</div>
{effectiveIndicators.length === 0 && strategy && (
<p className="vtd-muted vtd-card-chart-empty"> .</p>
)}
</div>
);
};
export default VirtualTargetCardChart;
@@ -0,0 +1,133 @@
import React, { useMemo } from 'react';
import { getKoreanName } from '../../utils/marketNameCache';
import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import type { Theme } from '../../types';
import VirtualLiveBadge from './VirtualLiveBadge';
import VirtualTargetCardChart from './VirtualTargetCardChart';
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
interface Props {
market: string;
strategy: StrategyDto | undefined;
strategies: StrategyDto[];
strategyId: number | null;
globalStrategyId: number | null;
onStrategyChange?: (strategyId: number | null) => void;
snapshot: VirtualIndicatorSnapshot | undefined;
running: boolean;
liveStatus?: VirtualLiveStatus;
lastTickAt?: number;
viewMode?: VirtualCardViewMode;
onExit: () => void;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
}
/** 전체보기 — 좌 차트 · 우 신호 */
const VirtualTargetFocusView: React.FC<Props> = ({
market,
strategy,
strategies,
strategyId,
globalStrategyId,
onStrategyChange,
snapshot,
running,
liveStatus = 'idle',
lastTickAt,
viewMode = 'summary',
onExit,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
}) => {
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
const tf = snapshot?.timeframe ?? '—';
const receiveSignal = useMemo(() => {
if (!running) return null;
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
}, [running, lastTickAt, snapshot?.updatedAt]);
const receiving = useLiveReceiveFlash(receiveSignal, running);
return (
<div className={`vtd-focus-wrap${receiving ? ' vtd-focus-wrap--receiving' : ''}`}>
<div className="vtd-focus-head">
<div className="vtd-focus-head-main">
<div className="vtd-focus-title">
<span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span>
</div>
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
<span className="vtd-card-strategy-label"></span>
<select
className="vtd-card-strategy-select"
value={strategyId ?? globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
onStrategyChange?.(v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<span className="vtd-card-tf"> {tf}</span>
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
</div>
<button
type="button"
className="vtd-focus-exit-btn"
onClick={onExit}
title="전체 종목 그리드로 돌아가기"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="1.5" y="1.5" width="5" height="5" rx="1" />
<rect x="9.5" y="1.5" width="5" height="5" rx="1" />
<rect x="1.5" y="9.5" width="5" height="5" rx="1" />
<rect x="9.5" y="9.5" width="5" height="5" rx="1" />
</svg>
<span> </span>
</button>
</div>
<div className="vtd-focus-split">
<section className="vtd-focus-pane vtd-focus-pane--chart" aria-label="차트 보기">
<div className="vtd-focus-pane-head"></div>
<div className="vtd-focus-pane-body">
<VirtualTargetCardChart
market={market}
strategy={strategy}
theme={theme}
running={running}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
fillHeight
/>
</div>
</section>
<section className="vtd-focus-pane vtd-focus-pane--signal" aria-label="신호 보기">
<div className="vtd-focus-pane-head"></div>
<div className="vtd-focus-pane-body">
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode={viewMode}
receiving={receiving}
/>
</div>
</section>
</div>
</div>
);
};
export default VirtualTargetFocusView;
@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import VirtualTargetCard from './VirtualTargetCard'; import VirtualTargetCard, { type VirtualCardDisplayMode } from './VirtualTargetCard';
import VirtualStrategyChartPopup from './VirtualStrategyChartPopup'; import VirtualGridUnifiedHeader from './VirtualGridUnifiedHeader';
import VirtualTargetFocusView from './VirtualTargetFocusView';
import type { StrategyDto } from '../../utils/backendApi'; import type { StrategyDto } from '../../utils/backendApi';
import type { Theme } from '../../types'; import type { Theme } from '../../types';
import type { VirtualTargetItem, VirtualCardViewMode } from '../../utils/virtualTradingStorage'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } from '../../utils/virtualTradingStorage';
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage'; import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
@@ -12,90 +14,156 @@ interface Props {
targets: VirtualTargetItem[]; targets: VirtualTargetItem[];
strategies: StrategyDto[]; strategies: StrategyDto[];
snapshots: Record<string, VirtualIndicatorSnapshot>; snapshots: Record<string, VirtualIndicatorSnapshot>;
running: boolean; session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
globalStrategyId: number | null; onStrategyChange: (strategyId: number | null) => void;
onExecutionTypeChange: (type: VirtualSessionConfig['executionType']) => void;
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
onStart: () => void;
onStop: () => void;
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
liveStatusByMarket?: Record<string, VirtualLiveStatus>; liveStatusByMarket?: Record<string, VirtualLiveStatus>;
lastTickAtByMarket?: Record<string, number>;
theme?: Theme; theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
} }
const VirtualTargetGrid: React.FC<Props> = ({ const VirtualTargetGrid: React.FC<Props> = ({
targets, strategies, snapshots, running, globalStrategyId, liveStatusByMarket = {}, theme = 'dark', targets, strategies, snapshots, session,
onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop,
onTargetStrategyChange,
liveStatusByMarket = {}, lastTickAtByMarket = {}, theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
}) => { }) => {
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode()); const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
const [chartTarget, setChartTarget] = useState<{ market: string; strategy: StrategyDto | undefined } | null>(null); const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
const [focusMarket, setFocusMarket] = useState<string | null>(null);
const activeMarkets = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
useEffect(() => {
if (focusMarket && !activeMarkets.has(focusMarket)) {
setFocusMarket(null);
}
}, [activeMarkets, focusMarket]);
useEffect(() => { useEffect(() => {
saveVirtualCardViewMode(viewMode); saveVirtualCardViewMode(viewMode);
}, [viewMode]); }, [viewMode]);
/** 제거된 종목 override 정리 */
useEffect(() => {
setCardDisplayOverrides(prev => {
const next: Record<string, VirtualCardDisplayMode> = {};
let changed = false;
for (const [market, mode] of Object.entries(prev)) {
if (activeMarkets.has(market)) next[market] = mode;
else changed = true;
}
return changed ? next : prev;
});
}, [activeMarkets]);
const handleGlobalDisplayMode = useCallback((mode: VirtualCardDisplayMode) => {
setGlobalDisplayMode(mode);
setCardDisplayOverrides({});
}, []);
const handleCardDisplayMode = useCallback((market: string, mode: VirtualCardDisplayMode) => {
setCardDisplayOverrides(prev => ({ ...prev, [market]: mode }));
}, []);
const resolveCardDisplayMode = useCallback(
(market: string) => cardDisplayOverrides[market] ?? globalDisplayMode,
[cardDisplayOverrides, globalDisplayMode],
);
const focusTarget = useMemo(
() => (focusMarket ? targets.find(t => t.market === focusMarket) : undefined),
[focusMarket, targets],
);
const renderUnifiedHeader = (viewControls: 'full' | 'display-only' | 'hidden') => (
<VirtualGridUnifiedHeader
session={session}
strategies={strategies}
onStrategyChange={onStrategyChange}
onExecutionTypeChange={onExecutionTypeChange}
onPositionModeChange={onPositionModeChange}
onStart={onStart}
onStop={onStop}
viewMode={viewMode}
onViewModeChange={setViewMode}
globalDisplayMode={globalDisplayMode}
onGlobalDisplayModeChange={handleGlobalDisplayMode}
viewControls={viewControls}
/>
);
if (targets.length === 0) { if (targets.length === 0) {
return ( return (
<div className="vtd-grid-wrap">
{renderUnifiedHeader('hidden')}
<div className="vtd-grid-empty"> <div className="vtd-grid-empty">
<p className="vtd-muted"> .</p> <p className="vtd-muted"> .</p>
</div> </div>
</div>
); );
} }
return ( return (
<> <div className={`vtd-grid-wrap${focusMarket ? ' vtd-grid-wrap--focus' : ''}`}>
<div className="vtd-grid-wrap"> {renderUnifiedHeader(focusMarket ? 'display-only' : 'full')}
<div className="vtd-grid-head"> {focusMarket && focusTarget ? (
<span className="vtd-grid-head-title"> </span> <VirtualTargetFocusView
<div className="vtd-grid-head-right"> market={focusTarget.market}
<div className="vtd-view-toggle" role="group" aria-label="카드 표시 방식"> strategy={strategies.find(s => s.id === (focusTarget.strategyId ?? session.globalStrategyId))}
<button strategies={strategies}
type="button" strategyId={focusTarget.strategyId}
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`} globalStrategyId={session.globalStrategyId}
onClick={() => setViewMode('summary')} onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)}
> snapshot={snapshots[focusTarget.market]}
running={session.running}
</button> liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')}
<button lastTickAt={lastTickAtByMarket[focusTarget.market]}
type="button" viewMode={viewMode}
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`} onExit={() => setFocusMarket(null)}
onClick={() => setViewMode('detail')} theme={theme}
> chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
</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"> <div className="vtd-grid">
{targets.map(t => { {targets.map(t => {
const strat = strategies.find(s => s.id === (t.strategyId ?? globalStrategyId)); const strat = strategies.find(s => s.id === (t.strategyId ?? session.globalStrategyId));
return ( return (
<VirtualTargetCard <VirtualTargetCard
key={t.market} key={t.market}
market={t.market} market={t.market}
strategy={strat} strategy={strat}
strategies={strategies}
strategyId={t.strategyId}
globalStrategyId={session.globalStrategyId}
onStrategyChange={id => onTargetStrategyChange(t.market, id)}
snapshot={snapshots[t.market]} snapshot={snapshots[t.market]}
running={running} running={session.running}
liveStatus={liveStatusByMarket[t.market] ?? (running ? 'connecting' : 'idle')} liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
lastTickAt={lastTickAtByMarket[t.market]}
viewMode={viewMode} viewMode={viewMode}
onOpenChart={() => setChartTarget({ market: t.market, strategy: strat })} displayMode={resolveCardDisplayMode(t.market)}
onDisplayModeChange={mode => handleCardDisplayMode(t.market, mode)}
onEnterFocus={() => setFocusMarket(t.market)}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
/> />
); );
})} })}
</div> </div>
</div>
{chartTarget && (
<VirtualStrategyChartPopup
market={chartTarget.market}
strategy={chartTarget.strategy}
theme={theme}
running={running}
onClose={() => setChartTarget(null)}
/>
)} )}
</> </div>
); );
}; };
@@ -0,0 +1,70 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import type { ChangeDir, TickerData } from '../../hooks/useMarketTicker';
function fmtKrw(price: number | null | undefined): string {
if (price == null || !Number.isFinite(price)) return '-';
if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
if (price >= 0.01) return price.toFixed(4);
return price.toFixed(8);
}
function fmtPct(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate)) return '-';
const sign = rate >= 0 ? '+' : '';
return `${sign}${(rate * 100).toFixed(2)}%`;
}
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
if (change === 'RISE') return <span className="vtd-target-arrow vtd-target-arrow--rise"></span>;
if (change === 'FALL') return <span className="vtd-target-arrow vtd-target-arrow--fall"></span>;
return <span className="vtd-target-arrow vtd-target-arrow--even"></span>;
});
interface Props {
market: string;
ticker?: TickerData;
}
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker }) => {
const code = market.replace(/^KRW-/, '');
const change: ChangeDir = ticker?.change ?? 'EVEN';
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
const [flashClass, setFlashClass] = useState('');
const prevPriceRef = useRef<number | null>(null);
useEffect(() => {
const price = ticker?.tradePrice ?? null;
if (price === null) return;
const prev = prevPriceRef.current;
if (prev !== null && prev !== price) {
const cls = price > prev ? 'vtd-target-quote--flash-rise' : 'vtd-target-quote--flash-fall';
setFlashClass(cls);
const t = setTimeout(() => setFlashClass(''), 700);
prevPriceRef.current = price;
return () => clearTimeout(t);
}
prevPriceRef.current = price;
}, [ticker?.tradePrice]);
return (
<div className={`vtd-target-quote ${flashClass}`.trim()} aria-label="현재가 및 전일 대비">
<div className="vtd-target-quote-left">
<ChangeArrow change={change} />
<span className="vtd-target-quote-pair">{code}/KRW</span>
</div>
<div className="vtd-target-quote-right">
<span className={`vtd-target-price ${colorCls}`}>
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
</span>
<span className={`vtd-target-change ${colorCls}`}>
{ticker ? fmtPct(ticker.changeRate) : '-'}
</span>
</div>
</div>
);
};
export default VirtualTargetQuote;
@@ -0,0 +1,81 @@
import React, { useMemo } from 'react';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import {
buildConditionMetrics,
computeMatchRate,
formatUpdatedTime,
getTrafficLightState,
} from '../../utils/virtualSignalMetrics';
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
import VirtualConditionList from './VirtualConditionList';
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
interface Props {
snapshot: VirtualIndicatorSnapshot | undefined;
viewMode?: VirtualCardViewMode;
receiving?: boolean;
className?: string;
}
const VirtualTargetSignalPanel: React.FC<Props> = ({
snapshot,
viewMode = 'summary',
receiving = false,
className = '',
}) => {
const rows = snapshot?.rows ?? [];
const isDetail = viewMode === 'detail';
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
const matchRate = useMemo(
() => computeMatchRate(metrics, snapshot?.matchRate),
[metrics, snapshot?.matchRate],
);
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
const metCount = metrics.filter(m => m.status === 'match').length;
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
if (rows.length === 0) {
return (
<p className={`vtd-muted vtd-card-empty${className ? ` ${className}` : ''}`}>
·
</p>
);
}
return (
<div
className={[
'vtd-sig-panel-wrap',
isDetail ? '' : 'vtd-sig-panel-wrap--summary',
receiving ? 'vtd-sig-panel-wrap--receiving' : '',
className,
].filter(Boolean).join(' ')}
>
{isDetail && evalCount > 0 && (
<div className="vtd-focus-sig-meta">
<span className="vtd-card-met-count">{metCount}/{evalCount} </span>
</div>
)}
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="vtd-sig-visual">
<div className="vtd-sig-visual-eq">
<VirtualSignalEqualizer matchRate={matchRate} />
</div>
<VirtualConditionList metrics={metrics} />
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
</div>
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {formatUpdatedTime(snapshot?.updatedAt)}</span>
<span className="vtd-card-match-summary"> {matchRate}%</span>
</div>
</div>
);
};
export default VirtualTargetSignalPanel;
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef, useState } from 'react';
/** 실시간 차트 ws-dot receiving 과 동일 — 데이터 수신 시 짧은 형광 플래시 */
export function useLiveReceiveFlash(
signal: number | string | null | undefined,
enabled: boolean,
durationMs = 600,
): boolean {
const [flashing, setFlashing] = useState(false);
const prevRef = useRef<number | string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!enabled || signal == null) {
prevRef.current = null;
return;
}
const prev = prevRef.current;
prevRef.current = signal;
if (prev == null || prev === signal) return;
setFlashing(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setFlashing(false);
timerRef.current = null;
}, durationMs);
}, [signal, enabled, durationMs]);
useEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
}, []);
return flashing;
}
@@ -30,11 +30,18 @@ function topicFor(market: string): string {
return `/sub/charts/${market}/${TICK_CANDLE}`; return `/sub/charts/${market}/${TICK_CANDLE}`;
} }
export interface VirtualTargetLiveState {
statusByMarket: Record<string, VirtualLiveStatus>;
/** STOMP 틱 수신 시각 (ms) — 카드 형광 플래시 트리거 */
lastTickAtByMarket: Record<string, number>;
}
export function useVirtualTargetLiveStatus( export function useVirtualTargetLiveStatus(
targets: TargetRef[], targets: TargetRef[],
running: boolean, running: boolean,
): Record<string, VirtualLiveStatus> { ): VirtualTargetLiveState {
const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({}); const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({});
const [lastTickAtByMarket, setLastTickAtByMarket] = useState<Record<string, number>>({});
const stateRef = useRef<Record<string, MarketLiveState>>({}); const stateRef = useRef<Record<string, MarketLiveState>>({});
const stompConnectedRef = useRef(isStompChartConnected()); const stompConnectedRef = useRef(isStompChartConnected());
@@ -43,6 +50,9 @@ export function useVirtualTargetLiveStatus(
const next: MarketLiveState = { ...prev, ...patch }; const next: MarketLiveState = { ...prev, ...patch };
stateRef.current[market] = next; stateRef.current[market] = next;
setByMarket(cur => ({ ...cur, [market]: next.status })); setByMarket(cur => ({ ...cur, [market]: next.status }));
if (patch.lastTickAt != null) {
setLastTickAtByMarket(cur => ({ ...cur, [market]: patch.lastTickAt! }));
}
}; };
const recomputeStatus = (market: string) => { const recomputeStatus = (market: string) => {
@@ -64,6 +74,7 @@ export function useVirtualTargetLiveStatus(
if (!running) { if (!running) {
stateRef.current = {}; stateRef.current = {};
setByMarket({}); setByMarket({});
setLastTickAtByMarket({});
return; return;
} }
@@ -87,6 +98,11 @@ export function useVirtualTargetLiveStatus(
delete next[key]; delete next[key];
return next; return next;
}); });
setLastTickAtByMarket(cur => {
const next = { ...cur };
delete next[key];
return next;
});
} }
} }
@@ -115,5 +131,5 @@ export function useVirtualTargetLiveStatus(
}; };
}, [targets, running]); }, [targets, running]);
return byMarket; return { statusByMarket: byMarket, lastTickAtByMarket };
} }
+2 -2
View File
@@ -254,10 +254,10 @@
} }
.bps-right-body { .bps-right-body {
flex: 1; flex: 1 1 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
padding: 10px; padding: 8px 10px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+137 -17
View File
@@ -245,8 +245,9 @@
} }
.bps-right-body .ptd-right-body { .bps-right-body .ptd-right-body {
flex: 1; flex: 1 1 0;
min-height: 0; min-height: 0;
height: 100%;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -254,15 +255,132 @@
padding: 0; padding: 0;
} }
.bps-right-body .ptd-right-body > * {
flex: 1;
min-height: 0;
}
.bps-right-body .ptd-right-body > .ptd-split-panel { .bps-right-body .ptd-right-body > .ptd-split-panel {
gap: 8px; gap: 8px;
} }
.bps-right-body .ptd-right-body > .ptd-split-panel--right {
flex: 1 1 0;
min-height: 0;
height: 100%;
}
.ptd-split-panel--right .ptd-split-card-body {
overflow: hidden;
padding: 4px 8px 6px;
}
.ptd-split-panel--right .ptd-split-card-body > .ptd-order-card,
.ptd-split-panel--right .ptd-split-card-body > .top-panel {
flex: 1 1 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ptd-split-panel--right .ptd-order-card > .top-panel {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: clamp(3px, 1.4cqh, 10px);
overflow: hidden;
overscroll-behavior: contain;
padding: 2px 2px 0;
}
.ptd-split-panel--right .ptd-order-card .top-paper-hint {
flex-shrink: 0;
margin: 0;
padding: 5px 7px;
font-size: clamp(10px, 1.8cqh, 11px);
}
.ptd-split-panel--right .ptd-order-card .top-field,
.ptd-split-panel--right .ptd-order-card .top-row--balance,
.ptd-split-panel--right .ptd-order-card .top-meta {
flex-shrink: 0;
margin-bottom: 0;
margin-top: 0;
}
.ptd-split-panel--right .ptd-order-card .top-label {
margin-bottom: clamp(2px, 0.6cqh, 5px);
font-size: clamp(10px, 1.7cqh, 11px);
}
.ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-evenly;
gap: clamp(2px, 1.2cqh, 8px);
margin: 0;
}
.ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-field,
.ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-pct-row--block {
margin: 0;
flex-shrink: 0;
}
.ptd-split-panel--right .ptd-order-card .top-input,
.ptd-split-panel--right .ptd-order-card .top-input--full {
padding: clamp(5px, 1.4cqh, 8px) clamp(8px, 1.8cqw, 10px);
font-size: clamp(11px, 2cqh, 13px);
}
.ptd-split-panel--right .ptd-order-card .top-kind-btn {
padding: clamp(4px, 1.2cqh, 6px) 3px;
font-size: clamp(9px, 1.8cqh, 11px);
}
.ptd-split-panel--right .ptd-order-card .top-pct-btn {
padding: clamp(3px, 1cqh, 5px) 2px;
font-size: clamp(9px, 1.6cqh, 10px);
}
.ptd-split-panel--right .ptd-order-card .top-step {
width: clamp(26px, 6cqw, 32px);
font-size: clamp(12px, 2.2cqh, 14px);
}
.ptd-split-panel--right .ptd-order-card .top-balance {
font-size: clamp(11px, 2cqh, 13px);
}
.ptd-split-panel--right .ptd-order-card .top-meta {
font-size: clamp(9px, 1.6cqh, 10px);
}
.ptd-split-panel--right .ptd-order-card .top-actions {
flex-shrink: 0;
margin-top: 0;
gap: clamp(6px, 1.4cqh, 8px);
}
.ptd-split-panel--right .ptd-order-card .top-reset,
.ptd-split-panel--right .ptd-order-card .top-submit {
padding: clamp(6px, 1.6cqh, 10px) clamp(10px, 2cqw, 12px);
font-size: clamp(12px, 2.2cqh, 14px);
}
.ptd-order-card {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: none;
background: transparent;
container-type: size;
container-name: trade-card;
}
.bps-footer .ptd-metrics-grid { .bps-footer .ptd-metrics-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -521,29 +639,31 @@
border-radius: 0; border-radius: 0;
} }
.ptd-order-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
border: none;
background: transparent;
container-type: size;
container-name: trade-card;
}
.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; flex: 1; } .ptd-order-card .ptd-order-stack { padding: 0 4px 4px; flex: 1; }
.ptd-order-card .top-panel { padding: 4px 2px; background: transparent; } .ptd-order-card .top-panel { padding: 4px 2px; background: transparent; }
.ptd-order-card .top-field { margin-bottom: 4px; } .ptd-order-card .top-field { margin-bottom: 4px; }
.ptd-order-card .top-price-qty-block .top-field { margin-bottom: 0; } .ptd-order-card .top-price-qty-block .top-field { margin-bottom: 0; }
.ptd-order-card .top-price-qty-block .top-pct-row--block { margin-bottom: 4px; } .ptd-order-card .top-price-qty-block .top-pct-row--block { margin-bottom: 4px; }
@container trade-card (min-height: 420px) {
.ptd-split-panel--right .ptd-order-card > .top-panel {
gap: clamp(6px, 2cqh, 14px);
}
.ptd-split-panel--right .ptd-order-card .top-price-qty-block {
gap: clamp(4px, 1.8cqh, 12px);
}
}
@container trade-card (max-height: 360px) { @container trade-card (max-height: 360px) {
.ptd-split-panel--right .ptd-order-card > .top-panel {
gap: 3px;
}
.ptd-order-card .top-price-qty-block { .ptd-order-card .top-price-qty-block {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 8px; column-gap: 8px;
row-gap: 4px; row-gap: 4px;
align-content: space-evenly;
} }
.ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; } .ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; }
.ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; } .ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; }
+748 -31
View File
@@ -1,5 +1,119 @@
/* 가상투자 대시보드 */ /* 가상투자 대시보드 */
.vtd-unified-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px 14px;
padding: 8px 12px;
border-bottom: 1px solid var(--se-border);
flex-shrink: 0;
background: var(--se-center-bg, var(--se-panel-card-bg));
}
.vtd-unified-head-title {
font-size: 12px;
font-weight: 700;
color: var(--se-text);
white-space: nowrap;
flex-shrink: 0;
}
.vtd-unified-head-center {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px 12px;
min-width: 0;
}
.vtd-unified-head-right {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0;
flex-shrink: 0;
}
.vtd-unified-head .vtd-session-actions {
margin-left: 0;
}
.vtd-unified-head .vtd-session-select {
min-width: 120px;
max-width: 160px;
}
@media (max-width: 1280px) {
.vtd-unified-head {
grid-template-columns: 1fr;
gap: 8px;
}
.vtd-unified-head-center {
justify-content: flex-start;
}
.vtd-unified-head-right {
justify-content: flex-start;
}
}
.vtd-session-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 8px 12px;
border-bottom: 1px solid var(--se-border);
flex-shrink: 0;
background: var(--se-center-bg, var(--se-panel-card-bg));
}
.vtd-session-field {
display: flex;
align-items: center;
gap: 8px;
}
.vtd-session-label {
font-size: 11px;
font-weight: 600;
color: var(--se-text-muted);
white-space: nowrap;
}
.vtd-session-select {
min-width: 140px;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--se-border);
background: var(--se-panel-card-bg);
color: var(--se-text);
font-size: 12px;
}
.vtd-session-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.vtd-session-btn {
padding: 5px 14px;
font-size: 0.82rem;
}
.vtd-session-btn--start {
background: var(--se-btn-gold-bg);
border-color: var(--se-btn-gold-border);
color: var(--se-btn-gold-text);
font-weight: 700;
}
.vtd-header-controls { .vtd-header-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -207,6 +321,85 @@
color: var(--se-text-muted); color: var(--se-text-muted);
} }
/* 현재가·전일대비 (실시간 차트 마켓 패널과 동일 스타일) */
.vtd-target-quote {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
padding: 6px 4px;
border-radius: 6px;
transition: background 0.15s ease;
}
.vtd-target-quote-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.vtd-target-quote-pair {
font-size: 10px;
color: var(--se-text-muted);
white-space: nowrap;
}
.vtd-target-quote-right {
display: flex;
align-items: baseline;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.vtd-target-price {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.vtd-target-change {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.vtd-target-up { color: var(--up, #ef5350); }
.vtd-target-dn { color: var(--down, #26a69a); }
.vtd-target-even { color: var(--se-text-muted); }
.vtd-target-arrow {
font-size: 9px;
line-height: 1;
flex-shrink: 0;
}
.vtd-target-arrow--rise { color: var(--up, #ef5350); }
.vtd-target-arrow--fall { color: var(--down, #26a69a); }
.vtd-target-arrow--even { color: var(--se-text-muted); font-size: 8px; }
@keyframes vtd-target-flash-rise {
0% { background: color-mix(in srgb, var(--up, #ef5350) 28%, transparent); }
100% { background: transparent; }
}
@keyframes vtd-target-flash-fall {
0% { background: color-mix(in srgb, var(--down, #26a69a) 28%, transparent); }
100% { background: transparent; }
}
.vtd-target-quote--flash-rise {
animation: vtd-target-flash-rise 0.7s ease-out forwards;
}
.vtd-target-quote--flash-fall {
animation: vtd-target-flash-fall 0.7s ease-out forwards;
}
.vtd-target-remove { .vtd-target-remove {
flex-shrink: 0; flex-shrink: 0;
width: 22px; width: 22px;
@@ -224,13 +417,31 @@
color: var(--down, #ef5350); color: var(--down, #ef5350);
} }
.vtd-target-strat { .vtd-target-strategy-field {
display: block; display: flex;
align-items: center;
gap: 8px;
margin-top: 8px; margin-top: 8px;
width: 100%;
} }
.vtd-target-strat select { .vtd-target-strategy-label {
width: 100%; font-size: 11px;
font-weight: 600;
color: var(--se-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.vtd-target-strategy-select {
flex: 1;
min-width: 0;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--se-border);
background: var(--se-panel-card-bg);
color: var(--se-text);
font-size: 12px;
} }
.vtd-muted { .vtd-muted {
@@ -260,6 +471,183 @@
overflow: hidden; overflow: hidden;
} }
.vtd-grid-wrap--focus {
gap: 6px;
}
/* 카드 — 전체화면(분할) 진입 버튼 */
.vtd-card-focus-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border));
border-radius: 8px;
background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, transparent);
color: var(--accent, #3f7ef5);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.vtd-card-focus-btn:hover {
background: color-mix(in srgb, var(--accent, #3f7ef5) 20%, transparent);
border-color: var(--accent, #3f7ef5);
}
/* 종목 전체보기 — 좌 차트 · 우 신호 */
.vtd-focus-wrap {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 8px;
border: 1px solid color-mix(in srgb, #c9a227 35%, var(--se-border));
border-radius: 12px;
background: var(--se-panel-card-bg);
padding: 10px 12px 12px;
overflow: hidden;
}
.vtd-focus-wrap--receiving {
border-color: #69f0ae !important;
box-shadow:
0 0 0 1px #69f0ae,
0 0 10px 4px #69f0ae66,
inset 0 0 32px color-mix(in srgb, #b9f6ca 12%, transparent);
}
.vtd-focus-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
flex-wrap: wrap;
}
.vtd-focus-head-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 14px;
flex: 1;
min-width: 0;
}
.vtd-focus-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.vtd-focus-exit-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--se-border);
border-radius: 8px;
background: var(--se-btn-bg, var(--se-panel-card-bg));
color: var(--se-text);
font-size: 11px;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s, background 0.15s;
}
.vtd-focus-exit-btn:hover {
border-color: var(--accent, #3f7ef5);
background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, transparent);
color: var(--accent, #3f7ef5);
}
.vtd-focus-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
}
@media (max-width: 1024px) {
.vtd-focus-split {
grid-template-columns: 1fr;
overflow-y: auto;
}
}
.vtd-focus-pane {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid var(--se-border);
border-radius: 10px;
background: color-mix(in srgb, var(--se-center-bg, #131722) 60%, var(--se-panel-card-bg));
overflow: hidden;
}
.vtd-focus-pane-head {
flex-shrink: 0;
padding: 6px 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--se-text-muted);
border-bottom: 1px solid var(--se-border);
text-transform: uppercase;
}
.vtd-focus-pane-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 8px;
overflow: hidden;
}
.vtd-focus-pane--signal .vtd-focus-pane-body {
overflow-y: auto;
}
.vtd-sig-panel-wrap {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
}
.vtd-sig-panel-wrap--receiving .vtd-sig-panel {
box-shadow: inset 0 0 24px color-mix(in srgb, #b9f6ca 8%, transparent);
}
.vtd-focus-sig-meta {
flex-shrink: 0;
}
.vtd-card-chart-panel--fill {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.vtd-card-chart-canvas--fill {
flex: 1;
min-height: 280px;
height: auto !important;
}
.vtd-focus-pane--chart .vtd-card-chart-panel--fill {
height: 100%;
}
.vtd-grid-head { .vtd-grid-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -282,10 +670,26 @@
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 0;
min-width: 0; min-width: 0;
} }
.vtd-grid-head-group {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
}
.vtd-grid-head-divider {
width: 1px;
height: 22px;
margin: 0 10px;
background: color-mix(in srgb, var(--se-border) 90%, var(--se-text-muted));
flex-shrink: 0;
align-self: center;
}
.vtd-view-toggle { .vtd-view-toggle {
display: inline-flex; display: inline-flex;
align-items: stretch; align-items: stretch;
@@ -343,6 +747,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
flex: 1;
min-height: 0;
} }
.vtd-grid { .vtd-grid {
@@ -397,13 +803,68 @@
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent);
} }
/* 실시간 데이터 수신 — 실시간 차트 ws-dot.receiving 과 동일 형광 연두 음영 */
.vtd-card--receiving {
border-color: #69f0ae !important;
box-shadow:
0 0 0 1px #69f0ae,
0 0 10px 4px #69f0ae66,
0 0 22px 8px #b9f6ca33,
inset 0 0 48px color-mix(in srgb, #b9f6ca 14%, transparent);
background: linear-gradient(
165deg,
color-mix(in srgb, #b9f6ca 18%, #1a2035) 0%,
color-mix(in srgb, #69f0ae 6%, var(--se-panel-card-bg)) 45%,
var(--se-panel-card-bg) 70%
);
transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out, background 0.15s ease-out;
}
.vtd-card--receiving .vtd-sig-panel {
box-shadow: inset 0 0 24px color-mix(in srgb, #b9f6ca 8%, transparent);
}
.vtd-card-head { .vtd-card-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
gap: 8px; gap: 8px;
} }
.vtd-card-head-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.vtd-card-strategy-field {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 1;
min-width: 0;
}
.vtd-card-strategy-label {
font-size: 10px;
font-weight: 600;
color: var(--se-text-muted);
white-space: nowrap;
}
.vtd-card-strategy-select {
min-width: 100px;
max-width: 180px;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid var(--se-border);
background: var(--se-panel-card-bg);
color: var(--se-text);
font-size: 11px;
}
.vtd-card-head-actions { .vtd-card-head-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -411,6 +872,116 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* 카드 내 신호 / 차트 전환 */
.vtd-card-mode-toggle {
display: inline-flex;
border: 1px solid var(--se-border);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.vtd-card-mode-btn {
padding: 4px 10px;
border: none;
background: transparent;
color: var(--se-text-muted);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.vtd-card-mode-btn + .vtd-card-mode-btn {
border-left: 1px solid var(--se-border);
}
.vtd-card-mode-btn:hover:not(.vtd-card-mode-btn--on) {
background: color-mix(in srgb, var(--se-text-muted) 8%, transparent);
color: var(--se-text);
}
.vtd-card-mode-btn--on {
background: color-mix(in srgb, var(--accent, #3f7ef5) 22%, transparent);
color: var(--accent, #3f7ef5);
}
.vtd-card--chart-mode {
min-height: 420px;
}
.vtd-card-chart-panel {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-height: 0;
}
.vtd-card-chart-tf-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vtd-card-chart-tf {
padding: 3px 8px;
border-radius: 5px;
border: 1px solid var(--se-border);
background: transparent;
color: var(--se-text-muted);
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.vtd-card-chart-tf--on {
border-color: var(--accent, #3f7ef5);
background: color-mix(in srgb, var(--accent, #3f7ef5) 15%, transparent);
color: var(--accent, #3f7ef5);
}
.vtd-card-chart-canvas {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 340px;
height: min(42vh, 400px);
border: 1px solid var(--se-border);
border-radius: 10px;
overflow: hidden;
background: var(--se-center-bg, #131722);
}
.vtd-card-chart-canvas .chart-hover-toolbar {
display: none !important;
}
.vtd-card-chart-canvas > .tv-chart-wrap {
flex: 1;
min-height: 0;
width: 100%;
}
.vtd-card-chart-loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--se-center-bg, #131722) 75%, transparent);
font-size: 11px;
color: var(--se-text-muted);
}
.vtd-card-chart-empty {
margin: 4px 0 0;
text-align: center;
font-size: 10px;
}
.vtd-card-chart-btn { .vtd-card-chart-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -451,16 +1022,6 @@
color: var(--se-text-muted); color: var(--se-text-muted);
} }
.vtd-card-strat {
font-size: 11px;
color: var(--accent, #3f7ef5);
text-align: right;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vtd-card-meta { .vtd-card-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -497,15 +1058,30 @@
} }
.vtd-live-badge--live { .vtd-live-badge--live {
color: #26a69a; color: #69f0ae;
} }
.vtd-live-badge--live .vtd-live-badge-dot { .vtd-live-badge--live .vtd-live-badge-dot {
background: #26a69a; background: #69f0ae;
box-shadow: 0 0 6px 2px color-mix(in srgb, #26a69a 75%, transparent); box-shadow: 0 0 4px 1px #69f0ae55;
animation: vtd-live-pulse 2s ease-in-out infinite; animation: vtd-live-pulse 2s ease-in-out infinite;
} }
/* 수신 순간: 실시간 차트 ws-dot.receiving 과 동일 형광 연두 */
.vtd-live-badge-dot.receiving {
animation: none !important;
background: #b9f6ca !important;
transform: scale(1.35);
box-shadow:
0 0 6px 3px #69f0ae,
0 0 14px 6px #69f0ae66,
0 0 24px 10px #b9f6ca22;
}
.vtd-live-badge--receiving {
color: #b9f6ca;
}
.vtd-live-badge--connecting { .vtd-live-badge--connecting {
color: #787b86; color: #787b86;
} }
@@ -525,8 +1101,8 @@
} }
@keyframes vtd-live-pulse { @keyframes vtd-live-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 6px 2px color-mix(in srgb, #26a69a 75%, transparent); } 0%, 100% { opacity: 1; box-shadow: 0 0 4px 1px #69f0ae55; }
50% { opacity: 0.85; box-shadow: 0 0 10px 4px color-mix(in srgb, #26a69a 90%, transparent); } 50% { opacity: 0.75; box-shadow: 0 0 8px 3px #69f0ae88; }
} }
@keyframes vtd-live-blink { @keyframes vtd-live-blink {
@@ -539,7 +1115,11 @@
} }
.vtd-sig-panel--summary .vtd-sig-visual { .vtd-sig-panel--summary .vtd-sig-visual {
padding: 10px 16px 12px; padding: 8px 10px 10px;
}
.vtd-sig-panel--summary .vtd-cond-list {
max-height: 160px;
} }
.vtd-card--summary .vtd-card-foot { .vtd-card--summary .vtd-card-foot {
@@ -584,23 +1164,28 @@
} }
.vtd-sig-visual { .vtd-sig-visual {
display: flex; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch; align-items: stretch;
justify-content: center; gap: 10px 12px;
gap: 20px; padding: 8px 10px;
padding: 8px 12px;
border: 1px solid color-mix(in srgb, #c9a227 30%, var(--se-border)); border: 1px solid color-mix(in srgb, #c9a227 30%, var(--se-border));
border-radius: 10px; border-radius: 10px;
background: color-mix(in srgb, #0d111f 60%, transparent); background: color-mix(in srgb, #0d111f 60%, transparent);
} }
.vtd-sig-visual-eq {
display: flex;
align-items: stretch;
flex-shrink: 0;
}
/* 세그먼트 이퀄라이저 */ /* 세그먼트 이퀄라이저 */
.vtd-sig-eq { .vtd-sig-eq {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: stretch; align-items: stretch;
flex: 1; flex-shrink: 0;
max-width: 120px;
} }
.vtd-sig-eq-scale { .vtd-sig-eq-scale {
@@ -665,7 +1250,100 @@
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent); text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
} }
/* 신호등 */ /* 조건 목록 (이퀄라이저 ↔ 신호등 사이) */
.vtd-cond-list {
list-style: none;
margin: 0;
padding: 2px 4px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 188px;
overflow-y: auto;
min-width: 0;
}
.vtd-cond-list::-webkit-scrollbar {
width: 4px;
}
.vtd-cond-list::-webkit-scrollbar-thumb {
background: var(--se-border);
border-radius: 2px;
}
.vtd-cond-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--se-panel-card-bg) 70%, #000);
border: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent);
}
.vtd-cond-list-name {
flex: 1;
min-width: 0;
font-size: 10px;
font-weight: 600;
color: var(--se-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vtd-cond-list-empty {
margin: 0;
padding: 8px;
font-size: 10px;
text-align: center;
align-self: center;
}
/* 조건 충족 신호 (Match / Pending / No Match) */
.vtd-cond-signal {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 9px;
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
}
.vtd-cond-signal-pct {
font-variant-numeric: tabular-nums;
min-width: 2.2em;
text-align: right;
}
.vtd-cond-signal-label {
letter-spacing: 0.02em;
}
.vtd-cond-signal--match {
color: var(--down, #ef5350);
}
.vtd-cond-signal--pending {
color: #ffb300;
}
.vtd-cond-signal--nomatch {
color: var(--up, #26a69a);
}
.vtd-cond-signal--unknown {
color: var(--se-text-muted);
}
.vtd-cond-signal--compact .vtd-cond-signal-label {
font-size: 8px;
}
/* 신호등 — 카드 우측, 텍스트는 신호등 아래 */
.vtd-sig-light { .vtd-sig-light {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -674,6 +1352,15 @@
justify-content: center; justify-content: center;
} }
.vtd-sig-light--card-right {
flex: 0 0 auto;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 4px 0 2px;
}
.vtd-sig-light-housing { .vtd-sig-light-housing {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -718,6 +1405,12 @@
gap: 4px; gap: 4px;
} }
.vtd-sig-light-info--below {
align-items: center;
text-align: center;
max-width: 100px;
}
.vtd-sig-light-rate { .vtd-sig-light-rate {
font-size: 22px; font-size: 22px;
font-weight: 800; font-weight: 800;
@@ -725,6 +1418,10 @@
line-height: 1; line-height: 1;
} }
.vtd-sig-light-info--below .vtd-sig-light-rate {
font-size: 18px;
}
.vtd-sig-light-label { .vtd-sig-light-label {
font-size: 10px; font-size: 10px;
color: var(--se-text-muted); color: var(--se-text-muted);
@@ -732,6 +1429,11 @@
line-height: 1.3; line-height: 1.3;
} }
.vtd-sig-light-info--below .vtd-sig-light-label {
max-width: 96px;
font-size: 9px;
}
/* 지표 비교 테이블 */ /* 지표 비교 테이블 */
.vtd-sig-table-wrap { .vtd-sig-table-wrap {
border: 1px solid color-mix(in srgb, #c9a227 35%, var(--se-border)); border: 1px solid color-mix(in srgb, #c9a227 35%, var(--se-border));
@@ -962,6 +1664,14 @@
/* 차트 팝업 */ /* 차트 팝업 */
.vtd-chart-popup-body { .vtd-chart-popup-body {
padding: 10px 14px 14px !important; padding: 10px 14px 14px !important;
display: flex;
flex-direction: column;
max-height: min(88vh, 680px);
overflow: hidden;
}
.vtd-chart-popup-body .vtd-chart-popup-canvas-wrap {
flex: 1;
} }
.vtd-chart-popup-meta { .vtd-chart-popup-meta {
@@ -1007,6 +1717,10 @@
.vtd-chart-popup-canvas-wrap { .vtd-chart-popup-canvas-wrap {
position: relative; position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: min(58vh, 520px); height: min(58vh, 520px);
min-height: 360px; min-height: 360px;
border: 1px solid var(--se-border); border: 1px solid var(--se-border);
@@ -1015,8 +1729,11 @@
background: var(--se-center-bg, #131722); background: var(--se-center-bg, #131722);
} }
.vtd-chart-popup-canvas-wrap .chart-container { /* TradingChart tv-chart-wrap — slot-chart-wrap 과 동일하게 가용 높이 확보 */
height: 100% !important; .vtd-chart-popup-canvas-wrap > .tv-chart-wrap {
flex: 1;
min-height: 0;
width: 100%;
} }
.vtd-chart-popup-loading { .vtd-chart-popup-loading {
@@ -244,3 +244,22 @@ export function buildStrategyChartIndicators(
} }
return result; return result;
} }
/** 가상투자 차트 — 전략 지표 + 기본 일목균형표 */
export function buildVirtualTradingChartIndicators(
strategy: StrategyDto | undefined,
getParams: GetParams,
getVisual: GetVisual,
): IndicatorConfig[] {
const strategyIndicators = buildStrategyChartIndicators(strategy, getParams, getVisual);
if (strategyIndicators.some(ind => ind.type === 'IchimokuCloud')) {
return strategyIndicators;
}
const defaultIchimoku = buildOneIndicator(
{ dslType: 'ICHIMOKU', registryType: 'IchimokuCloud' },
getParams,
getVisual,
);
if (!defaultIchimoku) return strategyIndicators;
return [defaultIchimoku, ...strategyIndicators];
}