가상투자 화면 추가

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
@@ -2,31 +2,56 @@ import React, { useMemo } from 'react';
import { getKoreanName } from '../../utils/marketNameCache';
import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import {
buildConditionMetrics,
computeMatchRate,
formatUpdatedTime,
getTrafficLightState,
} from '../../utils/virtualSignalMetrics';
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
import VirtualLiveBadge from './VirtualLiveBadge';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
import VirtualTargetCardChart from './VirtualTargetCardChart';
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
import type { Theme } from '../../types';
export type VirtualCardDisplayMode = 'signal' | 'chart';
interface Props {
market: string;
strategy: StrategyDto | undefined;
strategies: StrategyDto[];
strategyId: number | null;
globalStrategyId: number | null;
onStrategyChange?: (strategyId: number | null) => void;
snapshot: VirtualIndicatorSnapshot | undefined;
running: boolean;
liveStatus?: VirtualLiveStatus;
lastTickAt?: number;
viewMode?: VirtualCardViewMode;
onOpenChart?: () => void;
displayMode?: VirtualCardDisplayMode;
onDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
onEnterFocus?: () => void;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
}
const VirtualTargetCard: React.FC<Props> = ({
market, strategy, snapshot, running, liveStatus = 'idle', viewMode = 'summary', onOpenChart,
market,
strategy,
strategies,
strategyId,
globalStrategyId,
onStrategyChange,
snapshot,
running,
liveStatus = 'idle',
lastTickAt,
viewMode = 'summary',
displayMode = 'signal',
onDisplayModeChange,
onEnterFocus,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
}) => {
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
@@ -34,65 +59,113 @@ const VirtualTargetCard: React.FC<Props> = ({
const rows = snapshot?.rows ?? [];
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
const matchRate = useMemo(
() => computeMatchRate(metrics, snapshot?.matchRate),
[metrics, snapshot?.matchRate],
);
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
const metCount = metrics.filter(m => m.status === 'match').length;
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
const isDetail = viewMode === 'detail';
const isChart = displayMode === 'chart';
const receiveSignal = useMemo(() => {
if (!running || isChart) return null;
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
}, [running, lastTickAt, snapshot?.updatedAt, isChart]);
const receiving = useLiveReceiveFlash(receiveSignal, running && !isChart);
return (
<div className={`vtd-card vtd-card--signal${running ? ' vtd-card--live' : ''}${isDetail ? ' vtd-card--detail' : ' vtd-card--summary'}`}>
<div
className={[
'vtd-card',
'vtd-card--signal',
running ? 'vtd-card--live' : '',
receiving ? 'vtd-card--receiving' : '',
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
isChart ? 'vtd-card--chart-mode' : '',
].filter(Boolean).join(' ')}
>
<div className="vtd-card-head">
<div className="vtd-card-title">
<span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span>
<div className="vtd-card-head-main">
<div className="vtd-card-title">
<span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span>
</div>
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
<span className="vtd-card-strategy-label"></span>
<select
className="vtd-card-strategy-select"
value={strategyId ?? globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
onStrategyChange?.(v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
</div>
<div className="vtd-card-head-actions">
<button
type="button"
className="vtd-card-chart-btn"
title="차트 보기"
aria-label="차트 보기"
onClick={onOpenChart}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="4" width="18" height="14" rx="2"/>
<path d="M7 14l3-3 3 2 4-5"/>
</svg>
</button>
<span className="vtd-card-strat">{strategy?.name ?? '전략 미지정'}</span>
{onEnterFocus && (
<button
type="button"
className="vtd-card-focus-btn"
onClick={e => { e.stopPropagation(); onEnterFocus(); }}
title="전체화면 보기 (차트 + 신호)"
aria-label="전체화면 보기"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="5,1 1,1 1,5" />
<polyline points="9,13 13,13 13,9" />
<line x1="1" y1="1" x2="6" y2="6" />
<line x1="13" y1="13" x2="8" y2="8" />
</svg>
</button>
)}
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시">
<button
type="button"
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
onClick={() => onDisplayModeChange?.('signal')}
title="이 종목만 신호 보기"
>
</button>
<button
type="button"
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
onClick={() => onDisplayModeChange?.('chart')}
title="이 종목만 차트 보기"
>
</button>
</div>
</div>
</div>
<div className="vtd-card-meta">
<span className="vtd-card-tf"> {tf}</span>
{isDetail && evalCount > 0 && (
{!isChart && isDetail && evalCount > 0 && (
<span className="vtd-card-met-count">{metCount}/{evalCount} </span>
)}
{running && <VirtualLiveBadge status={liveStatus} />}
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
</div>
{rows.length === 0 ? (
<p className="vtd-muted vtd-card-empty"> · </p>
{isChart ? (
<VirtualTargetCardChart
market={market}
strategy={strategy}
theme={theme}
running={running}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
/>
) : (
<>
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="vtd-sig-visual">
<VirtualSignalEqualizer matchRate={matchRate} />
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
</div>
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {formatUpdatedTime(snapshot?.updatedAt)}</span>
<span className="vtd-card-match-summary"> {matchRate}%</span>
</div>
</>
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode={viewMode}
receiving={receiving}
/>
)}
</div>
);