가상투자 설정 수정
This commit is contained in:
@@ -20,6 +20,7 @@ import BacktestAnalysisChart from './backtest/BacktestAnalysisChart';
|
||||
import BacktestKpiPanel from './backtest/BacktestKpiPanel';
|
||||
import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard';
|
||||
import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
|
||||
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
|
||||
import {
|
||||
readStoredSize,
|
||||
storeSize,
|
||||
@@ -91,6 +92,18 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}, []);
|
||||
|
||||
const refreshLiveTrades = useCallback(async () => {
|
||||
const [trades, summary, strategies] = await Promise.all([
|
||||
loadPaperTrades(),
|
||||
loadPaperSummary(),
|
||||
loadStrategies(),
|
||||
]);
|
||||
const strategyNames = Object.fromEntries(strategies.map(s => [s.id, s.name]));
|
||||
const live = buildLiveExecutionItems(trades, summary, strategyNames);
|
||||
setLiveItems(live);
|
||||
setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null));
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [list, trades, summary, strategies] = await Promise.all([
|
||||
@@ -110,6 +123,12 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||
|
||||
useEffect(() => { void fetchAll(); }, [fetchAll]);
|
||||
|
||||
useEffect(() => {
|
||||
const onTradesChanged = () => { void refreshLiveTrades(); };
|
||||
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
|
||||
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
|
||||
}, [refreshLiveTrades]);
|
||||
|
||||
const deleteAll = useCallback(async () => {
|
||||
if (records.length === 0) return;
|
||||
if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||||
|
||||
@@ -19,6 +19,8 @@ interface Props {
|
||||
strategyId: number | undefined;
|
||||
executionType: string;
|
||||
strategyName: string | null;
|
||||
strategyNameByMarket?: Record<string, string>;
|
||||
executionTypeByMarket?: Record<string, string>;
|
||||
onMarkersChange: (markers: LiveMarker[]) => void;
|
||||
/** 차트 화면일 때만 마커를 차트에 반영 */
|
||||
chartMarkersActive: boolean;
|
||||
@@ -32,6 +34,8 @@ export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(fu
|
||||
popupEnabled: _popupEnabled,
|
||||
strategyName,
|
||||
executionType,
|
||||
strategyNameByMarket,
|
||||
executionTypeByMarket,
|
||||
onMarkersChange,
|
||||
chartMarkersActive,
|
||||
}, ref) {
|
||||
@@ -59,8 +63,8 @@ export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(fu
|
||||
signalType: marker.signal,
|
||||
price: marker.price,
|
||||
candleTime: marker.time,
|
||||
strategyName,
|
||||
executionType,
|
||||
strategyName: strategyNameByMarket?.[marker.market] ?? strategyName,
|
||||
executionType: executionTypeByMarket?.[marker.market] ?? executionType,
|
||||
candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m',
|
||||
});
|
||||
// STOMP만 수신·DB 저장 누락 시 배지 동기화
|
||||
|
||||
@@ -26,6 +26,8 @@ interface LiveStrategyPanelProps {
|
||||
settings: LiveStrategySettingsDto;
|
||||
watchlistCount?: number;
|
||||
monitoredMarkets?: string[];
|
||||
/** 가상투자 세션에서 제어 — 차트 팝업은 읽기 전용 */
|
||||
virtualDriven?: boolean;
|
||||
onClearMarkers?: () => void;
|
||||
onClose?: () => void;
|
||||
onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
||||
@@ -47,7 +49,7 @@ const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
|
||||
|
||||
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
theme, market, strategies, settings,
|
||||
watchlistCount = 0, monitoredMarkets = [],
|
||||
watchlistCount = 0, monitoredMarkets = [], virtualDriven = false,
|
||||
onClearMarkers, onClose, onSettingsChange,
|
||||
}) => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -65,6 +67,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
});
|
||||
|
||||
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||||
if (virtualDriven) return;
|
||||
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
||||
const prev = settings;
|
||||
onSettingsChange?.(next);
|
||||
@@ -84,7 +87,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [settings, market, onClearMarkers, onSettingsChange]);
|
||||
}, [settings, market, onClearMarkers, onSettingsChange, virtualDriven]);
|
||||
|
||||
const isOn = settings.isLiveCheck;
|
||||
const execType = settings.executionType;
|
||||
@@ -190,6 +193,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOn}
|
||||
disabled={virtualDriven}
|
||||
onChange={e => persist({ isLiveCheck: e.target.checked })}
|
||||
/>
|
||||
<span className="lsp-toggle-slider" />
|
||||
@@ -200,7 +204,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
<span className="lsp-field-label">전략 선택</span>
|
||||
<select
|
||||
className="lsp-select"
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
value={stratId ?? ''}
|
||||
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
@@ -221,7 +225,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
name="execType"
|
||||
value="CANDLE_CLOSE"
|
||||
checked={execType === 'CANDLE_CLOSE'}
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
|
||||
/>
|
||||
<span className="lsp-option-text">
|
||||
@@ -235,7 +239,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
name="execType"
|
||||
value="REALTIME_TICK"
|
||||
checked={execType === 'REALTIME_TICK'}
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
|
||||
/>
|
||||
<span className="lsp-option-text">
|
||||
@@ -255,7 +259,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
name="lsp-posMode"
|
||||
value="LONG_ONLY"
|
||||
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
|
||||
/>
|
||||
<span className="lsp-option-text">
|
||||
@@ -269,7 +273,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
name="lsp-posMode"
|
||||
value="SIGNAL_ONLY"
|
||||
checked={settings.positionMode === 'SIGNAL_ONLY'}
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
|
||||
/>
|
||||
<span className="lsp-option-text">
|
||||
|
||||
@@ -72,6 +72,8 @@ interface SettingsPageProps {
|
||||
onLiveSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
||||
watchlistCount?: number;
|
||||
monitoredMarkets?: string[];
|
||||
/** 가상투자 세션 실행 중 — 전략 설정은 가상투자 화면에서 */
|
||||
virtualDriven?: boolean;
|
||||
/** Main 탭 보조지표 기본 파라미터 (DB) */
|
||||
getIndicatorParams?: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
||||
saveIndicatorParams?: (type: string, params: Record<string, number | string | boolean>) => void;
|
||||
@@ -219,7 +221,7 @@ const ALL_CATEGORIES: Category[] = [
|
||||
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
|
||||
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
|
||||
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
|
||||
{ id: 'paper', label: '모의투자', icon: <IcPaper />, desc: '가상 자본, 수수료, 자동매매 ON/OFF 등 모의투자 설정' },
|
||||
{ id: 'paper', label: '가상투자', icon: <IcPaper />, desc: '가상 자본, 수수료, 자동매매 ON/OFF 등 가상투자 설정' },
|
||||
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
|
||||
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
||||
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
||||
@@ -332,16 +334,16 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
||||
return (
|
||||
<>
|
||||
<SettingSection title="매매 운영 모드">
|
||||
<SettingRow label="실행 대상" desc="모의투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
|
||||
<SettingRow label="실행 대상" desc="가상투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
|
||||
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
|
||||
<option value="PAPER">모의투자만</option>
|
||||
<option value="PAPER">가상투자만</option>
|
||||
<option value="LIVE">실거래만</option>
|
||||
<option value="BOTH">모의 + 실거래 병행</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
<SettingSection title="모의투자 사용">
|
||||
<SettingRow label="모의투자 활성화" desc="켜면 차트 매수·매도와 전략 시그널이 가상 계좌로 체결됩니다.">
|
||||
<SettingSection title="가상투자 사용">
|
||||
<SettingRow label="가상투자 활성화" desc="켜면 가상투자·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
@@ -350,24 +352,16 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
||||
</SettingSection>
|
||||
<SettingSection title="자동매매">
|
||||
<SettingRow
|
||||
label="자동매매"
|
||||
desc={
|
||||
paperAutoTradeEnabled
|
||||
? 'ON: 실시간 전략이 켜진 상태에서 BUY/SELL 시그널 발생 시 아래 조건으로 모의 계좌에 자동 체결됩니다.'
|
||||
: 'OFF: 시그널은 알림·차트 표시만 하며, 매매는 차트 우측 매수·매도 패널에서 직접 주문합니다.'
|
||||
}
|
||||
label="자동매매 ON/OFF"
|
||||
desc="가상투자 화면 타이틀바에서 변경합니다. Match 충족 시 자동 매수·매도 여부를 제어합니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
<span className={`stg-badge ${paperAutoTradeEnabled ? 'stg-badge--on' : 'stg-badge--off'}`}>
|
||||
{paperAutoTradeEnabled ? 'ON' : 'OFF'}
|
||||
{paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="자동 매수 예산 (%)"
|
||||
desc="BUY 시그널 시 주문가능 현금 중 사용 비율. SELL 시그널은 해당 종목 보유 수량 전량 매도."
|
||||
desc="Match 매수 시 주문가능 현금 중 사용 비율. Match 매도는 해당 종목 보유 수량 전량 매도."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
@@ -381,7 +375,7 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
||||
/>
|
||||
</SettingRow>
|
||||
{!paperAutoTradeEnabled && (
|
||||
<p className="stg-hint">자동매매가 꺼져 있으면 전략 시그널로는 주문되지 않습니다. 수동 주문만 모의 계좌에 반영됩니다.</p>
|
||||
<p className="stg-hint">자동매매 ON/OFF는 가상투자 화면 타이틀바에서 변경하세요. OFF 상태에서는 Match·시그널로 주문되지 않습니다.</p>
|
||||
)}
|
||||
</SettingSection>
|
||||
<SettingSection title="계좌·비용">
|
||||
@@ -722,6 +716,7 @@ interface StrategyPanelProps {
|
||||
liveSettings?: LiveStrategySettingsDto;
|
||||
watchlistCount?: number;
|
||||
monitoredMarkets?: string[];
|
||||
virtualDriven?: boolean;
|
||||
onClearLiveMarkers?: () => void;
|
||||
tradeAlertPopup?: boolean;
|
||||
onTradeAlertPopup?: (v: boolean) => void;
|
||||
@@ -734,6 +729,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
liveSettings: liveSettingsProp,
|
||||
watchlistCount = 0,
|
||||
monitoredMarkets = [],
|
||||
virtualDriven = false,
|
||||
onClearLiveMarkers,
|
||||
tradeAlertPopup = true,
|
||||
onTradeAlertPopup,
|
||||
@@ -763,6 +759,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
}, [liveSettingsProp]);
|
||||
|
||||
const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||||
if (virtualDriven) return;
|
||||
const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket };
|
||||
setLiveSaving(true);
|
||||
try {
|
||||
@@ -775,7 +772,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
} finally {
|
||||
setLiveSaving(false);
|
||||
}
|
||||
}, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange]);
|
||||
}, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven]);
|
||||
|
||||
const isOn = liveSettings.isLiveCheck;
|
||||
const execType = liveSettings.executionType;
|
||||
@@ -786,9 +783,16 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
<>
|
||||
{/* ── 실시간 전략 체크 ─────────────────────────────────────────────── */}
|
||||
<SettingSection title="실시간 전략 체크">
|
||||
{virtualDriven && (
|
||||
<p className="stg-hint" style={{ marginBottom: 12 }}>
|
||||
가상투자 세션이 실행 중입니다. 전략·실행방식·시그널 모드는 <strong>가상투자</strong> 화면에서 변경하세요.
|
||||
</p>
|
||||
)}
|
||||
<SettingRow
|
||||
label="모니터링 현황"
|
||||
desc="★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다. 실시간 체크를 켜고 전략을 선택하세요."
|
||||
desc={virtualDriven
|
||||
? "가상투자 투자대상 종목이 백엔드에서 실시간 전략 체크 대상입니다."
|
||||
: "★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다. 실시간 체크를 켜고 전략을 선택하세요."}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
@@ -810,6 +814,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOn}
|
||||
disabled={virtualDriven}
|
||||
onChange={e => persistLive({ isLiveCheck: e.target.checked })}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
@@ -830,7 +835,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
>
|
||||
<select
|
||||
className="stg-select"
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
value={stratId ?? ''}
|
||||
onChange={e => persistLive({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
||||
style={{ opacity: isOn ? 1 : 0.45 }}
|
||||
@@ -854,7 +859,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
name="stg-exec-type"
|
||||
value="CANDLE_CLOSE"
|
||||
checked={execType === 'CANDLE_CLOSE'}
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
onChange={() => persistLive({ executionType: 'CANDLE_CLOSE' })}
|
||||
/>
|
||||
<span className="stg-radio-option-text">
|
||||
@@ -868,7 +873,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
name="stg-exec-type"
|
||||
value="REALTIME_TICK"
|
||||
checked={execType === 'REALTIME_TICK'}
|
||||
disabled={!isOn}
|
||||
disabled={virtualDriven || !isOn}
|
||||
onChange={() => persistLive({ executionType: 'REALTIME_TICK' })}
|
||||
/>
|
||||
<span className="stg-radio-option-text">
|
||||
@@ -1334,6 +1339,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onLiveSettingsChange,
|
||||
watchlistCount = 0,
|
||||
monitoredMarkets = [],
|
||||
virtualDriven = false,
|
||||
getIndicatorParams,
|
||||
saveIndicatorParams,
|
||||
getIndicatorVisual,
|
||||
@@ -1500,6 +1506,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
liveSettings={liveSettings}
|
||||
watchlistCount={watchlistCount}
|
||||
monitoredMarkets={monitoredMarkets}
|
||||
virtualDriven={virtualDriven}
|
||||
onClearLiveMarkers={onClearLiveMarkers}
|
||||
tradeAlertPopup={tradeAlertPopup}
|
||||
onTradeAlertPopup={onTradeAlertPopup}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
loadStrategies,
|
||||
saveLiveStrategySettings,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
type StrategyDto,
|
||||
} from '../utils/backendApi';
|
||||
import type { Theme, TradeOrderFillRequest } from '../types';
|
||||
@@ -14,10 +16,17 @@ import type { TickerData } from '../hooks/useMarketTicker';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
|
||||
import BuilderPageShell from './layout/BuilderPageShell';
|
||||
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
|
||||
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
|
||||
import {
|
||||
VirtualSessionHeaderControls,
|
||||
VirtualViewHeaderControls,
|
||||
} from './virtual/VirtualGridUnifiedHeader';
|
||||
import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard';
|
||||
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
|
||||
import { useVirtualAutoTrade } from '../hooks/useVirtualAutoTrade';
|
||||
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
||||
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||
import {
|
||||
@@ -25,14 +34,21 @@ import {
|
||||
loadVirtualTargets,
|
||||
saveVirtualSession,
|
||||
saveVirtualTargets,
|
||||
loadVirtualCardViewMode,
|
||||
saveVirtualCardViewMode,
|
||||
type VirtualSessionConfig,
|
||||
type VirtualTargetItem,
|
||||
type VirtualCardViewMode,
|
||||
} from '../utils/virtualTradingStorage';
|
||||
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
import {
|
||||
syncVirtualTargetsToBackend,
|
||||
stopVirtualLiveOnBackend,
|
||||
} from '../utils/virtualLiveStrategySync';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
type RightTab = 'trade' | 'orderbook';
|
||||
type RightTab = 'trade' | 'orderbook' | 'history';
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
@@ -40,30 +56,34 @@ interface Props {
|
||||
defaultMarket?: string;
|
||||
paperTradingEnabled?: boolean;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
paperAutoTradeBudgetPct?: number;
|
||||
onPaperAutoTradeEnabled?: (enabled: boolean) => void;
|
||||
onPaperOrderFilled?: () => void;
|
||||
}
|
||||
|
||||
function coinCode(symbol: string): string {
|
||||
return symbol.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
const VirtualTradingPage: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
tickers,
|
||||
defaultMarket = 'KRW-BTC',
|
||||
paperTradingEnabled = true,
|
||||
paperAutoTradeEnabled = false,
|
||||
paperAutoTradeBudgetPct = 95,
|
||||
onPaperAutoTradeEnabled,
|
||||
onPaperOrderFilled,
|
||||
}) => {
|
||||
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
|
||||
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const appChartDefaults = resolveAppDefaults(appSettings);
|
||||
@@ -72,12 +92,35 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
||||
|
||||
useEffect(() => {
|
||||
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
|
||||
loadPaperSummary().then(setSummary).finally(() => setLoading(false));
|
||||
void Promise.all([
|
||||
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
|
||||
loadPaperSummary().then(setSummary),
|
||||
loadPaperTrades().then(setTrades),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||
|
||||
const strategyNames = useMemo(
|
||||
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
||||
[strategies],
|
||||
);
|
||||
|
||||
const refreshPaperData = useCallback(async (opts?: { focusHistory?: boolean }) => {
|
||||
try {
|
||||
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||
setSummary(sum);
|
||||
setTrades(tr);
|
||||
} catch { /* ignore */ }
|
||||
onPaperOrderFilled?.();
|
||||
if (opts?.focusHistory) setRightTab('history');
|
||||
}, [onPaperOrderFilled]);
|
||||
|
||||
const handleGlobalDisplayModeChange = useCallback((mode: VirtualCardDisplayMode) => {
|
||||
setGlobalDisplayMode(mode);
|
||||
}, []);
|
||||
|
||||
const targetRefs = useMemo(() =>
|
||||
targets.map(t => ({
|
||||
@@ -92,6 +135,17 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
session.running,
|
||||
);
|
||||
|
||||
useVirtualAutoTrade({
|
||||
enabled: paperTradingEnabled && paperAutoTradeEnabled,
|
||||
session,
|
||||
targets,
|
||||
snapshots,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions: summary?.positions,
|
||||
onFilled: () => void refreshPaperData({ focusHistory: true }),
|
||||
});
|
||||
|
||||
const mergedLiveStatus = useMemo(() => {
|
||||
if (!session.running) return liveStatusByMarket;
|
||||
const merged = { ...liveStatusByMarket };
|
||||
@@ -133,8 +187,12 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
}, [selectedMarket, handleSelectMarket]);
|
||||
|
||||
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
||||
setSession(s => ({ ...s, globalStrategyId: strategyId }));
|
||||
}, []);
|
||||
const next = { ...session, globalStrategyId: strategyId };
|
||||
setSession(next);
|
||||
if (session.running && strategyId) {
|
||||
void syncVirtualTargetsToBackend(targets, next, true);
|
||||
}
|
||||
}, [session, targets]);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (targets.length === 0) {
|
||||
@@ -145,19 +203,8 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
window.alert('투자전략을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
const template = {
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
};
|
||||
try {
|
||||
await Promise.all(targets.map(t =>
|
||||
saveLiveStrategySettings({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
...template,
|
||||
}),
|
||||
));
|
||||
await syncVirtualTargetsToBackend(targets, session, true);
|
||||
setSession(s => ({ ...s, running: true }));
|
||||
} catch {
|
||||
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
|
||||
@@ -165,20 +212,8 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
}, [targets, session]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (targets.length === 0) {
|
||||
setSession(s => ({ ...s, running: false }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Promise.all(targets.map(t =>
|
||||
saveLiveStrategySettings({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
isLiveCheck: false,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
}),
|
||||
));
|
||||
await stopVirtualLiveOnBackend(targets, session);
|
||||
} catch { /* ignore */ }
|
||||
setSession(s => ({ ...s, running: false }));
|
||||
}, [targets, session]);
|
||||
@@ -202,24 +237,41 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
skipWatchlistSync: true,
|
||||
});
|
||||
} catch {
|
||||
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
||||
if (!session.running || targets.length === 0) return;
|
||||
try {
|
||||
await syncVirtualTargetsToBackend(targets, nextSession, true);
|
||||
} catch {
|
||||
window.alert('가상투자 설정 동기화에 실패했습니다.');
|
||||
}
|
||||
}, [session.running, targets]);
|
||||
|
||||
const handleExecutionTypeChange = useCallback((executionType: VirtualSessionConfig['executionType']) => {
|
||||
setSession(s => ({ ...s, executionType }));
|
||||
}, []);
|
||||
const next = { ...session, executionType };
|
||||
setSession(next);
|
||||
void resyncRunningSession(next);
|
||||
}, [session, resyncRunningSession]);
|
||||
|
||||
const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => {
|
||||
setSession(s => ({ ...s, positionMode }));
|
||||
}, []);
|
||||
const next = { ...session, positionMode };
|
||||
setSession(next);
|
||||
void resyncRunningSession(next);
|
||||
}, [session, resyncRunningSession]);
|
||||
|
||||
const rightTabs = (
|
||||
<div className="bps-right-tabs">
|
||||
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
||||
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>
|
||||
거래{trades.length > 0 ? ` (${trades.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -228,9 +280,34 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
title="가상투자"
|
||||
subtitle="Virtual Trading"
|
||||
pageClassName="bps-page--vtd"
|
||||
loading={loading}
|
||||
loadingText="가상투자 대시보드 로딩…"
|
||||
collapsiblePanels
|
||||
headerCenter={(
|
||||
<VirtualSessionHeaderControls
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
onStrategyChange={handleGlobalStrategyChange}
|
||||
onExecutionTypeChange={handleExecutionTypeChange}
|
||||
onPositionModeChange={handlePositionModeChange}
|
||||
onStart={() => void handleStart()}
|
||||
onStop={() => void handleStop()}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
/>
|
||||
)}
|
||||
headerActions={(
|
||||
<VirtualViewHeaderControls
|
||||
session={session}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
globalDisplayMode={globalDisplayMode}
|
||||
onGlobalDisplayModeChange={handleGlobalDisplayModeChange}
|
||||
showSignalChartToggle={!focusMarket}
|
||||
/>
|
||||
)}
|
||||
leftStorageKey="vtd-left-width"
|
||||
leftDefaultWidth={380}
|
||||
leftCollapsedStorageKey="vtd-left-open"
|
||||
@@ -255,11 +332,6 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
strategies={strategies}
|
||||
snapshots={snapshots}
|
||||
session={session}
|
||||
onStrategyChange={handleGlobalStrategyChange}
|
||||
onExecutionTypeChange={handleExecutionTypeChange}
|
||||
onPositionModeChange={handlePositionModeChange}
|
||||
onStart={() => void handleStart()}
|
||||
onStop={() => void handleStop()}
|
||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||
liveStatusByMarket={mergedLiveStatus}
|
||||
lastTickAtByMarket={lastTickAtByMarket}
|
||||
@@ -269,6 +341,9 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
tickers={tickers}
|
||||
viewMode={viewMode}
|
||||
globalDisplayMode={globalDisplayMode}
|
||||
onFocusMarketChange={setFocusMarket}
|
||||
/>
|
||||
)}
|
||||
rightTabs={rightTabs}
|
||||
@@ -292,7 +367,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -308,12 +383,12 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
) : rightTab === 'orderbook' ? (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--right"
|
||||
topTitle="매도 호가"
|
||||
@@ -339,6 +414,14 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<PaperTradeHistoryList
|
||||
trades={trades}
|
||||
strategyNames={strategyNames}
|
||||
onSelectMarket={handleSelectMarket}
|
||||
emptyText="수동·자동 매매 체결 내역이 없습니다."
|
||||
className="ptd-trade-history--fill"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface BuilderPageShellProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
headerActions?: React.ReactNode;
|
||||
/** 타이틀 우측·중앙 그리드 시작선 정렬 (가상투자 등) */
|
||||
headerCenter?: React.ReactNode;
|
||||
pageClassName?: string;
|
||||
banner?: React.ReactNode;
|
||||
leftTitle?: string;
|
||||
leftTabs?: React.ReactNode;
|
||||
@@ -99,6 +102,8 @@ export default function BuilderPageShell({
|
||||
title,
|
||||
subtitle,
|
||||
headerActions,
|
||||
headerCenter,
|
||||
pageClassName,
|
||||
banner,
|
||||
leftTitle,
|
||||
leftTabs,
|
||||
@@ -207,6 +212,14 @@ export default function BuilderPageShell({
|
||||
});
|
||||
}, [rightCollapsedStorageKey]);
|
||||
|
||||
const pageStyle = useMemo(() => ({
|
||||
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
||||
'--bps-right-width': rightOpen ? `${rightWidth}px` : '0px',
|
||||
'--bps-left-open': leftOpen ? '1' : '0',
|
||||
'--bps-left-handle': '16px',
|
||||
'--bps-splitter-w': '5px',
|
||||
}) as React.CSSProperties, [leftOpen, leftWidth, rightOpen, rightWidth]);
|
||||
|
||||
const bodyStyle = useMemo(() => ({
|
||||
'--bps-footer-height': `${footerHeight}px`,
|
||||
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
||||
@@ -235,13 +248,23 @@ export default function BuilderPageShell({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bps-page se-page se-page--${theme}`}>
|
||||
<header className="bps-header">
|
||||
<div
|
||||
className={`bps-page se-page se-page--${theme}${pageClassName ? ` ${pageClassName}` : ''}`}
|
||||
style={pageStyle}
|
||||
>
|
||||
<header className={`bps-header${headerCenter ? ' bps-header--vtd' : ''}`}>
|
||||
<div className="bps-header-left">
|
||||
<h1 className="bps-title">{title}</h1>
|
||||
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
||||
</div>
|
||||
{headerActions && <div className="bps-header-actions">{headerActions}</div>}
|
||||
{headerCenter ? (
|
||||
<>
|
||||
<div className="bps-header-center">{headerCenter}</div>
|
||||
{headerActions && <div className="bps-header-actions">{headerActions}</div>}
|
||||
</>
|
||||
) : (
|
||||
headerActions && <div className="bps-header-actions">{headerActions}</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{banner}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
loadStrategies,
|
||||
loadLiveStrategySettings,
|
||||
saveLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
type PaperSummaryDto,
|
||||
type StrategyDto,
|
||||
} from '../../utils/backendApi';
|
||||
import { loadVirtualSession } from '../../utils/virtualTradingStorage';
|
||||
import { fmtKrw } from '../TradeOrderPanel';
|
||||
import PaperSplitPanel from './PaperSplitPanel';
|
||||
|
||||
@@ -25,12 +25,22 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [settings, setSettings] = useState<LiveStrategySettingsDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [virtualRunning, setVirtualRunning] = useState(() => loadVirtualSession().running);
|
||||
|
||||
useEffect(() => {
|
||||
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshVirtual = () => setVirtualRunning(loadVirtualSession().running);
|
||||
window.addEventListener('gc_virtual_session_changed', refreshVirtual);
|
||||
window.addEventListener('storage', refreshVirtual);
|
||||
return () => {
|
||||
window.removeEventListener('gc_virtual_session_changed', refreshVirtual);
|
||||
window.removeEventListener('storage', refreshVirtual);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLiveStrategySettings(market)
|
||||
@@ -47,26 +57,10 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market]);
|
||||
|
||||
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||||
if (!settings) return;
|
||||
const next = { ...settings, ...patch, market };
|
||||
const prev = settings;
|
||||
setSettings(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveLiveStrategySettings(next);
|
||||
if (saved) setSettings(saved);
|
||||
else setSettings(prev);
|
||||
} catch {
|
||||
setSettings(prev);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [settings, market]);
|
||||
}, [market, virtualRunning]);
|
||||
|
||||
const selected = strategies.find(s => s.id === settings?.strategyId);
|
||||
const execLabel = settings?.executionType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
||||
|
||||
const top = (
|
||||
<>
|
||||
@@ -79,55 +73,20 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="ptd-left-section">
|
||||
<label className="ptd-left-row">
|
||||
<span>전략 체크</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.isLiveCheck ?? false}
|
||||
disabled={saving}
|
||||
onChange={e => void persist({ isLiveCheck: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="ptd-left-field">
|
||||
<span>전략 선택</span>
|
||||
<select
|
||||
className="ptd-left-select"
|
||||
value={settings?.strategyId ?? ''}
|
||||
disabled={saving}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
void persist({ strategyId: v ? Number(v) : null });
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selected && (
|
||||
<p className="ptd-left-hint">
|
||||
실시간 전략·체크 대상 종목은 <strong>가상투자</strong> 화면에서 설정합니다.
|
||||
{virtualRunning ? ' (세션 실행 중)' : ''}
|
||||
</p>
|
||||
{settings?.isLiveCheck && selected && (
|
||||
<p className="ptd-left-hint">
|
||||
적용: {selected.name}
|
||||
{settings?.strategyCandleTypes?.length
|
||||
? ` · ${settings.strategyCandleTypes.join(', ')}`
|
||||
: ''}
|
||||
{market.replace(/^KRW-/, '')}: {selected.name} · {execLabel}
|
||||
</p>
|
||||
)}
|
||||
<label className="ptd-left-field">
|
||||
<span>실행 방식</span>
|
||||
<select
|
||||
className="ptd-left-select"
|
||||
value={settings?.executionType ?? 'CANDLE_CLOSE'}
|
||||
disabled={saving}
|
||||
onChange={e => void persist({ executionType: e.target.value as LiveStrategySettingsDto['executionType'] })}
|
||||
>
|
||||
<option value="CANDLE_CLOSE">봉 마감 직후</option>
|
||||
<option value="REALTIME_TICK">실시간 틱 (3초)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
|
||||
{paperAutoTradeEnabled ? '자동매매 ON' : '자동매매 OFF — 설정 탭에서 변경'}
|
||||
{paperAutoTradeEnabled
|
||||
? '자동매매 ON — 가상투자 Match 시 가상 계좌 자동 체결'
|
||||
: '자동매매 OFF — 가상투자 타이틀바에서 변경'}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import type { PaperTradeDto } from '../../utils/backendApi';
|
||||
import { fmtKrw } from '../TradeOrderPanel';
|
||||
|
||||
function coinCode(symbol: string): string {
|
||||
return symbol.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function fmtTradeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 19).replace('T', ' ');
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function sourceLabel(source: string | null | undefined): string {
|
||||
if (source === 'STRATEGY') return '자동';
|
||||
return '수동';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trades: PaperTradeDto[];
|
||||
strategyNames?: Record<number, string>;
|
||||
emptyText?: string;
|
||||
className?: string;
|
||||
onSelectMarket?: (market: string) => void;
|
||||
}
|
||||
|
||||
const PaperTradeHistoryList: React.FC<Props> = ({
|
||||
trades,
|
||||
strategyNames = {},
|
||||
emptyText = '거래 내역이 없습니다.',
|
||||
className = '',
|
||||
onSelectMarket,
|
||||
}) => (
|
||||
<div className={`ptd-trade-history${className ? ` ${className}` : ''}`}>
|
||||
{trades.length === 0 ? (
|
||||
<p className="ptd-muted ptd-trade-history-empty">{emptyText}</p>
|
||||
) : (
|
||||
<div className="ptd-trade-history-scroll">
|
||||
<table className="ptd-table ptd-table--compact ptd-table--trades">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>유형</th>
|
||||
<th>구분</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map(t => {
|
||||
const strategyName = t.strategyId != null
|
||||
? (strategyNames[t.strategyId] ?? `#${t.strategyId}`)
|
||||
: null;
|
||||
return (
|
||||
<tr
|
||||
key={t.id}
|
||||
className={onSelectMarket ? 'ptd-row--click' : undefined}
|
||||
onClick={onSelectMarket ? () => onSelectMarket(t.symbol) : undefined}
|
||||
title={strategyName ? `전략: ${strategyName}` : undefined}
|
||||
>
|
||||
<td className="ptd-time">{fmtTradeTime(t.createdAt)}</td>
|
||||
<td>{coinCode(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>
|
||||
{t.side === 'BUY' ? '매수' : '매도'}
|
||||
</td>
|
||||
<td className={t.source === 'STRATEGY' ? 'ptd-source--auto' : 'ptd-source--manual'}>
|
||||
{sourceLabel(t.source)}
|
||||
</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td>{t.quantity}</td>
|
||||
<td>{fmtKrw(t.netAmount)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PaperTradeHistoryList;
|
||||
@@ -3,7 +3,7 @@ import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualCardDisplayMode } from './VirtualTargetCard';
|
||||
|
||||
interface Props {
|
||||
interface SessionProps {
|
||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
strategies: StrategyDto[];
|
||||
onStrategyChange: (strategyId: number | null) => void;
|
||||
@@ -11,15 +11,21 @@ interface Props {
|
||||
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';
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onPaperAutoTradeEnabled?: (enabled: boolean) => void;
|
||||
paperTradingEnabled?: boolean;
|
||||
}
|
||||
|
||||
const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
||||
interface ViewProps {
|
||||
session: Pick<VirtualSessionConfig, 'running'>;
|
||||
viewMode: VirtualCardViewMode;
|
||||
onViewModeChange: (mode: VirtualCardViewMode) => void;
|
||||
globalDisplayMode?: VirtualCardDisplayMode;
|
||||
onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
||||
showSignalChartToggle?: boolean;
|
||||
}
|
||||
|
||||
export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
|
||||
session,
|
||||
strategies,
|
||||
onStrategyChange,
|
||||
@@ -27,155 +33,187 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
||||
onPositionModeChange,
|
||||
onStart,
|
||||
onStop,
|
||||
viewMode = 'summary',
|
||||
onViewModeChange,
|
||||
globalDisplayMode = 'signal',
|
||||
onGlobalDisplayModeChange,
|
||||
viewControls = 'full',
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperAutoTradeEnabled,
|
||||
paperTradingEnabled = true,
|
||||
}) => (
|
||||
<div className="vtd-unified-head">
|
||||
<span className="vtd-unified-head-title">종목별 매매시그널 일치 현황</span>
|
||||
<div className="vtd-header-session">
|
||||
<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-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">
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">실행방식</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="실행방식">
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn vtd-session-btn vtd-session-btn--start"
|
||||
disabled={session.running}
|
||||
onClick={onStart}
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
|
||||
>
|
||||
시작
|
||||
봉 마감 직후
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn bps-btn--danger vtd-session-btn"
|
||||
disabled={!session.running}
|
||||
onClick={onStop}
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'REALTIME_TICK' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('REALTIME_TICK')}
|
||||
>
|
||||
종료
|
||||
실시간 틱
|
||||
</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 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-field">
|
||||
<span className="vtd-session-label">자동매매</span>
|
||||
<div
|
||||
className="vtd-view-toggle"
|
||||
role="group"
|
||||
aria-label="자동매매"
|
||||
title={
|
||||
!paperTradingEnabled
|
||||
? '설정 › 가상투자에서 가상투자를 활성화하세요'
|
||||
: paperAutoTradeEnabled
|
||||
? 'Match 충족 시 자동 매수·매도'
|
||||
: '수동 주문만 가능'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${paperAutoTradeEnabled ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
disabled={!paperTradingEnabled || !onPaperAutoTradeEnabled}
|
||||
onClick={() => onPaperAutoTradeEnabled?.(true)}
|
||||
>
|
||||
ON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${!paperAutoTradeEnabled ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
disabled={!paperTradingEnabled || !onPaperAutoTradeEnabled}
|
||||
onClick={() => onPaperAutoTradeEnabled?.(false)}
|
||||
>
|
||||
OFF
|
||||
</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 VirtualGridUnifiedHeader;
|
||||
export const VirtualViewHeaderControls: React.FC<ViewProps> = ({
|
||||
session,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
globalDisplayMode = 'signal',
|
||||
onGlobalDisplayModeChange,
|
||||
showSignalChartToggle = true,
|
||||
}) => (
|
||||
<div className="vtd-header-view">
|
||||
{showSignalChartToggle && 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>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import VirtualTargetCard, { type VirtualCardDisplayMode } from './VirtualTargetCard';
|
||||
import VirtualGridUnifiedHeader from './VirtualGridUnifiedHeader';
|
||||
import VirtualTargetFocusView from './VirtualTargetFocusView';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { Theme } from '../../types';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } from '../../utils/virtualTradingStorage';
|
||||
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
@@ -16,11 +14,6 @@ interface Props {
|
||||
strategies: StrategyDto[];
|
||||
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
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>;
|
||||
lastTickAtByMarket?: Record<string, number>;
|
||||
@@ -30,11 +23,13 @@ interface Props {
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
viewMode: VirtualCardViewMode;
|
||||
globalDisplayMode: VirtualCardDisplayMode;
|
||||
onFocusMarketChange?: (market: string | null) => void;
|
||||
}
|
||||
|
||||
const VirtualTargetGrid: React.FC<Props> = ({
|
||||
targets, strategies, snapshots, session,
|
||||
onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop,
|
||||
onTargetStrategyChange,
|
||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
||||
onSelectMarket,
|
||||
@@ -42,9 +37,10 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
tickers,
|
||||
viewMode,
|
||||
globalDisplayMode,
|
||||
onFocusMarketChange,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
|
||||
@@ -53,14 +49,14 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
useEffect(() => {
|
||||
if (focusMarket && !activeMarkets.has(focusMarket)) {
|
||||
setFocusMarket(null);
|
||||
onFocusMarketChange?.(null);
|
||||
}
|
||||
}, [activeMarkets, focusMarket]);
|
||||
}, [activeMarkets, focusMarket, onFocusMarketChange]);
|
||||
|
||||
useEffect(() => {
|
||||
saveVirtualCardViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
onFocusMarketChange?.(focusMarket);
|
||||
}, [focusMarket, onFocusMarketChange]);
|
||||
|
||||
/** 제거된 종목 override 정리 */
|
||||
useEffect(() => {
|
||||
setCardDisplayOverrides(prev => {
|
||||
const next: Record<string, VirtualCardDisplayMode> = {};
|
||||
@@ -73,10 +69,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
});
|
||||
}, [activeMarkets]);
|
||||
|
||||
const handleGlobalDisplayMode = useCallback((mode: VirtualCardDisplayMode) => {
|
||||
setGlobalDisplayMode(mode);
|
||||
useEffect(() => {
|
||||
setCardDisplayOverrides({});
|
||||
}, []);
|
||||
}, [globalDisplayMode]);
|
||||
|
||||
const handleCardDisplayMode = useCallback((market: string, mode: VirtualCardDisplayMode) => {
|
||||
setCardDisplayOverrides(prev => ({ ...prev, [market]: mode }));
|
||||
@@ -92,27 +87,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
[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) {
|
||||
return (
|
||||
<div className="vtd-grid-wrap">
|
||||
{renderUnifiedHeader('hidden')}
|
||||
<div className="vtd-grid-empty">
|
||||
<p className="vtd-muted">좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.</p>
|
||||
</div>
|
||||
@@ -122,7 +99,6 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className={`vtd-grid-wrap${focusMarket ? ' vtd-grid-wrap--focus' : ''}`}>
|
||||
{renderUnifiedHeader(focusMarket ? 'display-only' : 'full')}
|
||||
{focusMarket && focusTarget ? (
|
||||
<VirtualTargetFocusView
|
||||
market={focusTarget.market}
|
||||
|
||||
Reference in New Issue
Block a user