가상투자 설정 수정

This commit is contained in:
Macbook
2026-05-25 22:30:54 +09:00
parent cae1c3a624
commit 1e950c7db4
24 changed files with 1015 additions and 362 deletions
+106 -32
View File
@@ -84,6 +84,9 @@ import {
type LiveStrategySettingsDto,
type BacktestSignal,
} from './utils/backendApi';
import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy';
import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage';
import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
import { useIsMobile } from './hooks/useMediaQuery';
import MobileChartDock from './components/MobileChartDock';
import LoginModal from './components/LoginModal';
@@ -583,6 +586,11 @@ function App() {
const [paperCoinQty, setPaperCoinQty] = useState(0);
const [paperRefreshKey, setPaperRefreshKey] = useState(0);
const handlePaperOrderFilled = useCallback(() => {
setPaperRefreshKey(k => k + 1);
notifyPaperTradesChanged();
}, []);
const refreshPaperAccount = useCallback(async (forMarket?: string) => {
const summary = await loadPaperSummary();
if (!summary) return;
@@ -698,55 +706,104 @@ function App() {
syncBacktestMarkersRef.current = syncBacktestMarkers;
// ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ─────────────────
// ── 실시간 전략 체크 (가상투자 세션 우선, 없으면 전역+관심종목) ─────────────
const [showLivePanel, setShowLivePanel] = useState(false);
const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]);
const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState<string[] | undefined>();
const [activeLiveSettings, setActiveLiveSettings] = useState<LiveStrategySettingsDto[]>([]);
const { session: virtualSession, isVirtualActive, monitoredMarkets: virtualMonitoredMarkets } = useVirtualLiveStrategy();
const refreshActiveLiveSettings = useCallback(() => {
loadActiveLiveStrategySettings()
.then(setActiveLiveSettings)
.catch(() => setActiveLiveSettings([]));
}, []);
useEffect(() => {
refreshActiveLiveSettings();
}, [refreshActiveLiveSettings, isVirtualActive, virtualMonitoredMarkets.join(','), virtualSession.executionType, virtualSession.positionMode]);
useEffect(() => {
const onVirtualChanged = () => refreshActiveLiveSettings();
window.addEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged);
return () => window.removeEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged);
}, [refreshActiveLiveSettings]);
useEffect(() => {
if (!appSettingsLoaded) return;
if (isVirtualActive) {
const active = activeLiveSettings.find(s => s.market === symbol);
setLiveStrategyCandleTypes(active?.strategyCandleTypes);
return;
}
loadLiveStrategySettings(symbol)
.then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes))
.catch(() => { /* optional */ });
}, [symbol, appSettingsLoaded, appDefaults.liveStrategyId]);
}, [symbol, appSettingsLoaded, appDefaults.liveStrategyId, isVirtualActive, activeLiveSettings]);
const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({
market: symbol,
strategyId: appDefaults.liveStrategyId ?? null,
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
strategyCandleTypes: liveStrategyCandleTypes,
}), [symbol, appDefaults, liveStrategyCandleTypes]);
const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => {
if (isVirtualActive) {
const active = activeLiveSettings.find(s => s.market === symbol);
return {
market: symbol,
strategyId: active?.strategyId ?? virtualSession.globalStrategyId,
isLiveCheck: true,
executionType: active?.executionType ?? virtualSession.executionType,
positionMode: active?.positionMode ?? virtualSession.positionMode,
strategyCandleTypes: active?.strategyCandleTypes ?? liveStrategyCandleTypes,
};
}
return {
market: symbol,
strategyId: appDefaults.liveStrategyId ?? null,
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
strategyCandleTypes: liveStrategyCandleTypes,
};
}, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes]);
/** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */
/** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */
const monitoredMarkets = useMemo(() => {
if (isVirtualActive) return virtualMonitoredMarkets;
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
const set = new Set(watchlist);
if (symbol) set.add(symbol);
return [...set];
}, [watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]);
}, [isVirtualActive, virtualMonitoredMarkets, watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]);
const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]);
/** STOMP 구독 목록 — API 로드 전에도 관심종목 × 1m 폴백 구독 */
const liveStompSubscriptions = useMemo(() => {
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
if (monitoredMarkets.length === 0) return [];
const filtered = activeLiveSettings.filter(s => monitoredMarkets.includes(s.market));
if (filtered.length > 0) return expandLiveStrategySubscriptions(filtered);
if (marketSubscriptions.length > 0) return marketSubscriptions;
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' }));
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]);
}, [monitoredMarkets, activeLiveSettings, marketSubscriptions]);
useEffect(() => {
if (monitoredMarkets.length === 0) {
setMarketSubscriptions([]);
return;
}
if (isVirtualActive) {
setMarketSubscriptions(expandLiveStrategySubscriptions(
activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)),
));
return;
}
if (!appDefaults.liveStrategyCheck) {
setMarketSubscriptions([]);
return;
}
loadActiveLiveStrategySettings()
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
.catch(() => { /* liveStompSubscriptions 가 monitoredMarkets 폴백 사용 */ });
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(',')]);
.catch(() => { /* liveStompSubscriptions 폴백 */ });
}, [isVirtualActive, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(','), activeLiveSettings]);
const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => {
if (isVirtualActive) return;
saveAppDef({
liveStrategyCheck: saved.isLiveCheck,
liveStrategyId: saved.strategyId ?? undefined,
@@ -757,11 +814,9 @@ function App() {
setLiveStrategyCandleTypes(saved.strategyCandleTypes);
}
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
loadActiveLiveStrategySettings()
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
.catch(() => { /* 폴백 유지 */ });
refreshActiveLiveSettings();
}
}, [saveAppDef, appDefaults.liveStrategyCheck]);
}, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings]);
const prevFavoritesRef = useRef<string[]>(getFavorites());
@@ -808,10 +863,21 @@ function App() {
return () => { cancelled = true; };
}, [dbLoaded]);
const liveStrategyName = useMemo(
() => liveStrategies.find(s => s.id === appDefaults.liveStrategyId)?.name ?? null,
[liveStrategies, appDefaults.liveStrategyId],
);
const liveStrategyName = useMemo(() => {
const sid = liveStrategySettings.strategyId;
return sid != null ? liveStrategies.find(s => s.id === sid)?.name ?? null : null;
}, [liveStrategies, liveStrategySettings.strategyId]);
const liveStrategyNameByMarket = useMemo(() => {
const map: Record<string, string> = {};
for (const s of activeLiveSettings) {
if (s.strategyId != null) {
const name = liveStrategies.find(st => st.id === s.strategyId)?.name;
if (name) map[s.market] = name;
}
}
return map;
}, [activeLiveSettings, liveStrategies]);
const goToMarketChart = useCallback((market: string) => {
if (layoutDef.count > 1) {
@@ -1477,9 +1543,13 @@ function App() {
activeMarket={symbol}
enabled={monitoredMarkets.length > 0}
popupEnabled={appDefaults.tradeAlertPopup ?? true}
strategyId={appDefaults.liveStrategyId ?? undefined}
executionType={appDefaults.liveExecutionType ?? 'CANDLE_CLOSE'}
strategyId={liveStrategySettings.strategyId ?? undefined}
executionType={liveStrategySettings.executionType ?? 'CANDLE_CLOSE'}
strategyName={liveStrategyName}
strategyNameByMarket={liveStrategyNameByMarket}
executionTypeByMarket={Object.fromEntries(
activeLiveSettings.map(s => [s.market, s.executionType ?? 'CANDLE_CLOSE']),
)}
chartMarkersActive={menuPage === 'chart'}
onMarkersChange={markers => {
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
@@ -1537,7 +1607,7 @@ function App() {
defaultMarket={symbol}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => setPaperRefreshKey(k => k + 1)}
onPaperOrderFilled={handlePaperOrderFilled}
onGoChart={m => {
goToMarketChart(m);
setMenuPage('chart');
@@ -1552,7 +1622,9 @@ function App() {
defaultMarket={symbol}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => setPaperRefreshKey(k => k + 1)}
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
onPaperOrderFilled={handlePaperOrderFilled}
/>
)}
@@ -1596,6 +1668,7 @@ function App() {
onLiveSettingsChange={handleLiveSettingsChange}
watchlistCount={watchlist.length}
monitoredMarkets={monitoredMarkets}
virtualDriven={isVirtualActive}
getIndicatorParams={getIndicatorParams}
saveIndicatorParams={saveIndicatorParams}
getIndicatorVisual={getIndicatorVisual}
@@ -2080,8 +2153,9 @@ function App() {
market={symbol}
strategies={liveStrategies}
settings={liveStrategySettings}
watchlistCount={watchlist.length}
watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length}
monitoredMarkets={monitoredMarkets}
virtualDriven={isVirtualActive}
onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }}
onSettingsChange={handleLiveSettingsChange}
onClose={() => setShowLivePanel(false)}
@@ -2303,7 +2377,7 @@ function App() {
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => {
refreshPaperAccount(symbol);
setPaperRefreshKey(k => k + 1);
handlePaperOrderFilled();
}}
/>
@@ -2407,7 +2481,7 @@ function App() {
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
onOrderDone={() => {
refreshPaperAccount(symbol);
setPaperRefreshKey(k => k + 1);
handlePaperOrderFilled();
}}
/>
@@ -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 저장 누락 시 배지 동기화
+11 -7
View File
@@ -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">
+30 -23
View File
@@ -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}
+129 -46
View File
@@ -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}
+117
View File
@@ -0,0 +1,117 @@
/**
* 가상투자 Match(전략 조건 100% 충족) 시 자동 모의 체결
* 설정 · 가상투자 · 자동매매 ON + 세션 running 일 때만 동작
*/
import { useEffect, useRef } from 'react';
import type { TickerData } from './useMarketTicker';
import type { VirtualIndicatorSnapshot } from './useVirtualIndicatorSnapshots';
import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
import { placePaperOrder, type PaperSummaryDto } from '../utils/backendApi';
import {
buildConditionMetrics,
resolveVirtualTradeTiming,
type VirtualTradeTiming,
} from '../utils/virtualSignalMetrics';
import { coerceFiniteNumber } from '../utils/safeFormat';
const EXEC_COOLDOWN_MS = 15_000;
interface Options {
enabled: boolean;
session: VirtualSessionConfig;
targets: VirtualTargetItem[];
snapshots: Record<string, VirtualIndicatorSnapshot | undefined>;
tickers?: Map<string, TickerData>;
paperAutoTradeBudgetPct: number;
positions?: PaperSummaryDto['positions'];
onFilled?: () => void;
}
export function useVirtualAutoTrade({
enabled,
session,
targets,
snapshots,
tickers,
paperAutoTradeBudgetPct,
positions = [],
onFilled,
}: Options): void {
const prevTimingRef = useRef<Record<string, VirtualTradeTiming>>({});
const inflightRef = useRef<Set<string>>(new Set());
const lastExecRef = useRef<Record<string, number>>({});
useEffect(() => {
if (!enabled || !session.running) {
prevTimingRef.current = {};
return;
}
for (const target of targets) {
const snap = snapshots[target.market];
if (!snap?.rows?.length) continue;
const metrics = buildConditionMetrics(snap.rows);
const timing = resolveVirtualTradeTiming(metrics);
const prev = prevTimingRef.current[target.market] ?? 'neutral';
prevTimingRef.current[target.market] = timing;
if (timing !== 'buy' && timing !== 'sell') continue;
if (timing === prev) continue;
const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice);
if (price == null || price <= 0) continue;
const heldQty = coerceFiniteNumber(
positions.find(p => p.symbol === target.market)?.quantity,
) ?? 0;
if (session.positionMode === 'LONG_ONLY') {
if (timing === 'buy' && heldQty > 0) continue;
if (timing === 'sell' && heldQty <= 0) continue;
}
const side = timing === 'buy' ? 'BUY' : 'SELL';
const execKey = `${target.market}:${side}`;
if (inflightRef.current.has(execKey)) continue;
if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) continue;
const strategyId = target.strategyId ?? session.globalStrategyId ?? null;
inflightRef.current.add(execKey);
void placePaperOrder({
market: target.market,
side,
orderKind: 'market',
price,
quantity: 0,
budgetPct: timing === 'buy' ? paperAutoTradeBudgetPct : 100,
source: 'STRATEGY',
strategyId,
})
.then(trade => {
if (trade) {
lastExecRef.current[execKey] = Date.now();
onFilled?.();
}
})
.catch(err => {
console.warn('[VirtualAutoTrade]', target.market, side, err);
})
.finally(() => {
inflightRef.current.delete(execKey);
});
}
}, [
enabled,
session.running,
session.globalStrategyId,
session.positionMode,
targets,
snapshots,
tickers,
paperAutoTradeBudgetPct,
positions,
onFilled,
]);
}
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
loadVirtualSession,
loadVirtualTargets,
VIRTUAL_SESSION_CHANGED_EVENT,
type VirtualSessionConfig,
type VirtualTargetItem,
} from '../utils/virtualTradingStorage';
export function useVirtualLiveStrategy() {
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
const refresh = useCallback(() => {
setSession(loadVirtualSession());
setTargets(loadVirtualTargets());
}, []);
useEffect(() => {
window.addEventListener(VIRTUAL_SESSION_CHANGED_EVENT, refresh);
window.addEventListener('storage', refresh);
return () => {
window.removeEventListener(VIRTUAL_SESSION_CHANGED_EVENT, refresh);
window.removeEventListener('storage', refresh);
};
}, [refresh]);
const isVirtualActive = useMemo(
() => session.running && targets.length > 0 && session.globalStrategyId != null,
[session, targets],
);
const monitoredMarkets = useMemo(
() => (isVirtualActive ? targets.map(t => t.market) : []),
[isVirtualActive, targets],
);
return { session, targets, isVirtualActive, monitoredMarkets, refresh };
}
+30
View File
@@ -38,6 +38,36 @@
.bps-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* 가상투자 — 타이틀바 3열 (좌측패널폭 | 세션설정 | 뷰옵션) */
.bps-header--vtd {
display: grid;
grid-template-columns:
calc(var(--bps-left-width, 380px) + var(--bps-left-handle, 16px) + calc(var(--bps-left-open, 1) * var(--bps-splitter-w, 5px)))
minmax(0, 1fr)
auto;
align-items: center;
gap: 8px 12px;
padding: 8px 18px;
min-height: 48px;
}
.bps-header--vtd .bps-header-left {
min-width: 0;
overflow: hidden;
}
.bps-header--vtd .bps-header-center {
display: flex;
align-items: center;
min-width: 0;
justify-self: stretch;
}
.bps-header--vtd .bps-header-actions {
justify-self: end;
flex-shrink: 0;
}
.bps-btn {
border: 1px solid var(--se-border);
background: var(--se-btn-bg);
+50
View File
@@ -827,6 +827,56 @@
.ptd-table--compact { font-size: 9px; }
.ptd-table--compact th,
.ptd-table--compact td { padding: 5px 6px; }
.ptd-trade-history {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.ptd-trade-history--fill {
flex: 1;
padding: 8px 10px;
box-sizing: border-box;
}
.ptd-trade-history-empty {
margin: 12px 0;
text-align: center;
}
.ptd-trade-history-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.ptd-table--trades th,
.ptd-table--trades td {
white-space: nowrap;
}
.ptd-row--click {
cursor: pointer;
}
.ptd-row--click:hover td {
background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, transparent);
}
.ptd-source--auto {
color: var(--accent, #3f7ef5);
font-weight: 600;
}
.ptd-source--manual {
color: var(--se-text-muted, var(--text3));
}
.bps-page--vtd .ptd-right-body > .ptd-trade-history--fill {
height: 100%;
}
.ptd-status {
display: inline-block;
padding: 2px 5px;
@@ -1,5 +1,57 @@
/* 가상투자 대시보드 */
/* 타이틀바 — 세션 설정 (중앙 그리드 시작선) */
.vtd-header-session {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
min-width: 0;
}
.vtd-header-session .vtd-session-select {
min-width: 120px;
max-width: 160px;
}
.vtd-header-session .vtd-session-actions {
margin-left: 0;
}
/* 타이틀바 — 뷰 옵션 (우측) */
.vtd-header-view {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0;
}
.bps-header--vtd .vtd-grid-live {
font-size: 11px;
white-space: nowrap;
}
.bps-header--vtd .vtd-grid-head-legend {
font-size: 10px;
}
@media (max-width: 1280px) {
.bps-header--vtd {
grid-template-columns: 1fr;
gap: 8px;
}
.bps-header--vtd .bps-header-center,
.bps-header--vtd .bps-header-actions {
justify-self: stretch;
}
.vtd-header-view {
justify-content: flex-start;
}
}
.vtd-unified-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -465,7 +517,7 @@
.vtd-grid-wrap {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0;
flex: 1;
min-height: 0;
overflow: hidden;
@@ -811,6 +863,11 @@
color: var(--accent, #3f7ef5);
}
.vtd-view-toggle-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vtd-grid-head-legend {
display: flex;
align-items: center;
+4
View File
@@ -1019,6 +1019,10 @@ export interface LiveStrategySettingsDto {
candleType?: string;
/** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */
strategyCandleTypes?: string[];
/** true면 관심종목 동기화 생략 (가상투자 per-market) */
skipWatchlistSync?: boolean;
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
skipGlobalTemplate?: boolean;
}
/** 실시간 전략 체크 설정 로드 */
+6
View File
@@ -0,0 +1,6 @@
/** 가상·모의 체결 후 백테스팅 실시간 매매 목록 등 갱신용 */
export const PAPER_TRADES_CHANGED_EVENT = 'gc_paper_trades_changed';
export function notifyPaperTradesChanged(): void {
window.dispatchEvent(new CustomEvent(PAPER_TRADES_CHANGED_EVENT));
}
+1 -1
View File
@@ -41,7 +41,7 @@ export const MENU_LABELS: Record<string, string> = {
settings_indicators: '설정 · 보조지표',
settings_backtest: '설정 · 백테스팅',
settings_strategy: '설정 · 전략',
settings_paper: '설정 · 모의투자',
settings_paper: '설정 · 가상투자',
settings_alert: '설정 · 알림',
settings_network: '설정 · 네트워크',
settings_admin: '설정 · 관리자',
@@ -0,0 +1,54 @@
import { saveLiveStrategySettings } from './backendApi';
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */
export async function syncVirtualTargetsToBackend(
targets: VirtualTargetItem[],
session: VirtualSessionConfig,
isLiveCheck: boolean,
): Promise<void> {
if (targets.length === 0) return;
const shared = {
isLiveCheck,
executionType: session.executionType,
positionMode: session.positionMode,
skipWatchlistSync: true,
} as const;
await Promise.all(
targets.map(t =>
saveLiveStrategySettings({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
...shared,
}),
),
);
await saveLiveStrategySettings({
market: targets[0].market,
strategyId: session.globalStrategyId,
...shared,
});
}
/** 가상투자 중지 — 대상 종목 체크 해제 + 전역 liveStrategyCheck OFF */
export async function stopVirtualLiveOnBackend(
targets: VirtualTargetItem[],
session: VirtualSessionConfig,
): Promise<void> {
if (targets.length === 0) {
await saveLiveStrategySettings({
market: 'KRW-BTC',
strategyId: session.globalStrategyId,
isLiveCheck: false,
executionType: session.executionType,
positionMode: session.positionMode,
skipWatchlistSync: true,
});
return;
}
await syncVirtualTargetsToBackend(targets, session, false);
}
@@ -36,6 +36,7 @@ export function loadVirtualTargets(): VirtualTargetItem[] {
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
try {
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
notifyVirtualSessionChanged();
} catch { /* ignore */ }
}
@@ -58,9 +59,16 @@ export function loadVirtualSession(): VirtualSessionConfig {
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
notifyVirtualSessionChanged();
} catch { /* ignore */ }
}
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
export function notifyVirtualSessionChanged(): void {
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
}
function defaultSession(): VirtualSessionConfig {
return {
globalStrategyId: null,