가상투자 설정 수정
This commit is contained in:
@@ -44,4 +44,10 @@ public class LiveStrategySettingsDto {
|
||||
/** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */
|
||||
@Builder.Default
|
||||
private String positionMode = "LONG_ONLY";
|
||||
|
||||
/** true면 관심종목 전체 동기화 생략 (가상투자 per-market 저장) */
|
||||
private Boolean skipWatchlistSync;
|
||||
|
||||
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
|
||||
private Boolean skipGlobalTemplate;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,9 @@ public class LiveStrategySettingsService {
|
||||
|
||||
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
||||
LiveStrategySettingsDto dto) {
|
||||
if (!Boolean.TRUE.equals(dto.getSkipGlobalTemplate())) {
|
||||
persistGlobalTemplate(userId, deviceId, dto);
|
||||
}
|
||||
|
||||
if (dto.getMarket() != null && !dto.getMarket().isBlank()) {
|
||||
enrichStrategyTimeframes(dto);
|
||||
@@ -129,7 +131,9 @@ public class LiveStrategySettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!Boolean.TRUE.equals(dto.getSkipWatchlistSync())) {
|
||||
syncAllWatchlistMarkets(userId, deviceId, dto);
|
||||
}
|
||||
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
|
||||
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.goldenchart.entity.GcAppSettings;
|
||||
import com.goldenchart.entity.GcPaperAccount;
|
||||
import com.goldenchart.entity.GcPaperPosition;
|
||||
import com.goldenchart.entity.GcPaperTrade;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.repository.GcPaperAccountRepository;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.repository.GcPaperTradeRepository;
|
||||
@@ -36,6 +37,7 @@ public class PaperTradingService {
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final GcPaperTradeRepository tradeRepo;
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
|
||||
// ── 조회 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -150,7 +152,9 @@ public class PaperTradingService {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return;
|
||||
if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return;
|
||||
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck())) return;
|
||||
boolean marketActive = liveSettingsRepo.findActiveByMarket(market).stream()
|
||||
.anyMatch(s -> Boolean.TRUE.equals(s.getIsLiveCheck()));
|
||||
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return;
|
||||
|
||||
try {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
|
||||
+100
-26
@@ -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(() => ({
|
||||
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,
|
||||
}), [symbol, appDefaults, 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 저장 누락 시 배지 동기화
|
||||
|
||||
@@ -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>
|
||||
{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">
|
||||
적용: {selected.name}
|
||||
{settings?.strategyCandleTypes?.length
|
||||
? ` · ${settings.strategyCandleTypes.join(', ')}`
|
||||
: ''}
|
||||
실시간 전략·체크 대상 종목은 <strong>가상투자</strong> 화면에서 설정합니다.
|
||||
{virtualRunning ? ' (세션 실행 중)' : ''}
|
||||
</p>
|
||||
{settings?.isLiveCheck && selected && (
|
||||
<p className="ptd-left-hint">
|
||||
{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,16 +33,11 @@ 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-unified-head-center">
|
||||
<div className="vtd-header-session">
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">투자전략</span>
|
||||
<select
|
||||
@@ -94,6 +95,39 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
||||
</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"
|
||||
@@ -113,10 +147,18 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{viewControls !== 'hidden' && onViewModeChange && (
|
||||
<div className="vtd-unified-head-right">
|
||||
{viewControls === 'full' && onGlobalDisplayModeChange && (
|
||||
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">
|
||||
@@ -174,8 +216,4 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default VirtualGridUnifiedHeader;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1019,6 +1019,10 @@ export interface LiveStrategySettingsDto {
|
||||
candleType?: string;
|
||||
/** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */
|
||||
strategyCandleTypes?: string[];
|
||||
/** true면 관심종목 동기화 생략 (가상투자 per-market) */
|
||||
skipWatchlistSync?: boolean;
|
||||
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
|
||||
skipGlobalTemplate?: boolean;
|
||||
}
|
||||
|
||||
/** 실시간 전략 체크 설정 로드 */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user