가상투자 설정 수정
This commit is contained in:
@@ -44,4 +44,10 @@ public class LiveStrategySettingsDto {
|
|||||||
/** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */
|
/** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private String positionMode = "LONG_ONLY";
|
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,
|
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
||||||
LiveStrategySettingsDto dto) {
|
LiveStrategySettingsDto dto) {
|
||||||
|
if (!Boolean.TRUE.equals(dto.getSkipGlobalTemplate())) {
|
||||||
persistGlobalTemplate(userId, deviceId, dto);
|
persistGlobalTemplate(userId, deviceId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.getMarket() != null && !dto.getMarket().isBlank()) {
|
if (dto.getMarket() != null && !dto.getMarket().isBlank()) {
|
||||||
enrichStrategyTimeframes(dto);
|
enrichStrategyTimeframes(dto);
|
||||||
@@ -129,7 +131,9 @@ public class LiveStrategySettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(dto.getSkipWatchlistSync())) {
|
||||||
syncAllWatchlistMarkets(userId, deviceId, dto);
|
syncAllWatchlistMarkets(userId, deviceId, dto);
|
||||||
|
}
|
||||||
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
|
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
|
||||||
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
|
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
|
||||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.goldenchart.entity.GcAppSettings;
|
|||||||
import com.goldenchart.entity.GcPaperAccount;
|
import com.goldenchart.entity.GcPaperAccount;
|
||||||
import com.goldenchart.entity.GcPaperPosition;
|
import com.goldenchart.entity.GcPaperPosition;
|
||||||
import com.goldenchart.entity.GcPaperTrade;
|
import com.goldenchart.entity.GcPaperTrade;
|
||||||
|
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||||
import com.goldenchart.repository.GcPaperAccountRepository;
|
import com.goldenchart.repository.GcPaperAccountRepository;
|
||||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||||
import com.goldenchart.repository.GcPaperTradeRepository;
|
import com.goldenchart.repository.GcPaperTradeRepository;
|
||||||
@@ -36,6 +37,7 @@ public class PaperTradingService {
|
|||||||
private final GcPaperPositionRepository positionRepo;
|
private final GcPaperPositionRepository positionRepo;
|
||||||
private final GcPaperTradeRepository tradeRepo;
|
private final GcPaperTradeRepository tradeRepo;
|
||||||
private final AppSettingsService appSettingsService;
|
private final AppSettingsService appSettingsService;
|
||||||
|
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||||
|
|
||||||
// ── 조회 ─────────────────────────────────────────────────────────────────
|
// ── 조회 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -150,7 +152,9 @@ public class PaperTradingService {
|
|||||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||||
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return;
|
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return;
|
||||||
if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) 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 {
|
try {
|
||||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||||
|
|||||||
+100
-26
@@ -84,6 +84,9 @@ import {
|
|||||||
type LiveStrategySettingsDto,
|
type LiveStrategySettingsDto,
|
||||||
type BacktestSignal,
|
type BacktestSignal,
|
||||||
} from './utils/backendApi';
|
} 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 { useIsMobile } from './hooks/useMediaQuery';
|
||||||
import MobileChartDock from './components/MobileChartDock';
|
import MobileChartDock from './components/MobileChartDock';
|
||||||
import LoginModal from './components/LoginModal';
|
import LoginModal from './components/LoginModal';
|
||||||
@@ -583,6 +586,11 @@ function App() {
|
|||||||
const [paperCoinQty, setPaperCoinQty] = useState(0);
|
const [paperCoinQty, setPaperCoinQty] = useState(0);
|
||||||
const [paperRefreshKey, setPaperRefreshKey] = useState(0);
|
const [paperRefreshKey, setPaperRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const handlePaperOrderFilled = useCallback(() => {
|
||||||
|
setPaperRefreshKey(k => k + 1);
|
||||||
|
notifyPaperTradesChanged();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshPaperAccount = useCallback(async (forMarket?: string) => {
|
const refreshPaperAccount = useCallback(async (forMarket?: string) => {
|
||||||
const summary = await loadPaperSummary();
|
const summary = await loadPaperSummary();
|
||||||
if (!summary) return;
|
if (!summary) return;
|
||||||
@@ -698,55 +706,104 @@ function App() {
|
|||||||
|
|
||||||
syncBacktestMarkersRef.current = syncBacktestMarkers;
|
syncBacktestMarkersRef.current = syncBacktestMarkers;
|
||||||
|
|
||||||
// ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ─────────────────
|
// ── 실시간 전략 체크 (가상투자 세션 우선, 없으면 전역+관심종목) ─────────────
|
||||||
const [showLivePanel, setShowLivePanel] = useState(false);
|
const [showLivePanel, setShowLivePanel] = useState(false);
|
||||||
const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]);
|
const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]);
|
||||||
const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState<string[] | undefined>();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!appSettingsLoaded) return;
|
if (!appSettingsLoaded) return;
|
||||||
|
if (isVirtualActive) {
|
||||||
|
const active = activeLiveSettings.find(s => s.market === symbol);
|
||||||
|
setLiveStrategyCandleTypes(active?.strategyCandleTypes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
loadLiveStrategySettings(symbol)
|
loadLiveStrategySettings(symbol)
|
||||||
.then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes))
|
.then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes))
|
||||||
.catch(() => { /* optional */ });
|
.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,
|
market: symbol,
|
||||||
strategyId: appDefaults.liveStrategyId ?? null,
|
strategyId: appDefaults.liveStrategyId ?? null,
|
||||||
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
|
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
|
||||||
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
|
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
|
||||||
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
|
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
|
||||||
strategyCandleTypes: liveStrategyCandleTypes,
|
strategyCandleTypes: liveStrategyCandleTypes,
|
||||||
}), [symbol, appDefaults, liveStrategyCandleTypes]);
|
};
|
||||||
|
}, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes]);
|
||||||
|
|
||||||
/** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */
|
/** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */
|
||||||
const monitoredMarkets = useMemo(() => {
|
const monitoredMarkets = useMemo(() => {
|
||||||
|
if (isVirtualActive) return virtualMonitoredMarkets;
|
||||||
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
|
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
|
||||||
const set = new Set(watchlist);
|
const set = new Set(watchlist);
|
||||||
if (symbol) set.add(symbol);
|
if (symbol) set.add(symbol);
|
||||||
return [...set];
|
return [...set];
|
||||||
}, [watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]);
|
}, [isVirtualActive, virtualMonitoredMarkets, watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]);
|
||||||
|
|
||||||
const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]);
|
const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]);
|
||||||
|
|
||||||
/** STOMP 구독 목록 — API 로드 전에도 관심종목 × 1m 폴백 구독 */
|
|
||||||
const liveStompSubscriptions = useMemo(() => {
|
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;
|
if (marketSubscriptions.length > 0) return marketSubscriptions;
|
||||||
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' }));
|
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' }));
|
||||||
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]);
|
}, [monitoredMarkets, activeLiveSettings, marketSubscriptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (monitoredMarkets.length === 0) {
|
||||||
|
setMarketSubscriptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVirtualActive) {
|
||||||
|
setMarketSubscriptions(expandLiveStrategySubscriptions(
|
||||||
|
activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!appDefaults.liveStrategyCheck) {
|
if (!appDefaults.liveStrategyCheck) {
|
||||||
setMarketSubscriptions([]);
|
setMarketSubscriptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadActiveLiveStrategySettings()
|
loadActiveLiveStrategySettings()
|
||||||
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
|
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
|
||||||
.catch(() => { /* liveStompSubscriptions 가 monitoredMarkets 폴백 사용 */ });
|
.catch(() => { /* liveStompSubscriptions 폴백 */ });
|
||||||
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(',')]);
|
}, [isVirtualActive, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(','), activeLiveSettings]);
|
||||||
|
|
||||||
const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => {
|
const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => {
|
||||||
|
if (isVirtualActive) return;
|
||||||
saveAppDef({
|
saveAppDef({
|
||||||
liveStrategyCheck: saved.isLiveCheck,
|
liveStrategyCheck: saved.isLiveCheck,
|
||||||
liveStrategyId: saved.strategyId ?? undefined,
|
liveStrategyId: saved.strategyId ?? undefined,
|
||||||
@@ -757,11 +814,9 @@ function App() {
|
|||||||
setLiveStrategyCandleTypes(saved.strategyCandleTypes);
|
setLiveStrategyCandleTypes(saved.strategyCandleTypes);
|
||||||
}
|
}
|
||||||
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
|
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
|
||||||
loadActiveLiveStrategySettings()
|
refreshActiveLiveSettings();
|
||||||
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
|
|
||||||
.catch(() => { /* 폴백 유지 */ });
|
|
||||||
}
|
}
|
||||||
}, [saveAppDef, appDefaults.liveStrategyCheck]);
|
}, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings]);
|
||||||
|
|
||||||
const prevFavoritesRef = useRef<string[]>(getFavorites());
|
const prevFavoritesRef = useRef<string[]>(getFavorites());
|
||||||
|
|
||||||
@@ -808,10 +863,21 @@ function App() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [dbLoaded]);
|
}, [dbLoaded]);
|
||||||
|
|
||||||
const liveStrategyName = useMemo(
|
const liveStrategyName = useMemo(() => {
|
||||||
() => liveStrategies.find(s => s.id === appDefaults.liveStrategyId)?.name ?? null,
|
const sid = liveStrategySettings.strategyId;
|
||||||
[liveStrategies, appDefaults.liveStrategyId],
|
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) => {
|
const goToMarketChart = useCallback((market: string) => {
|
||||||
if (layoutDef.count > 1) {
|
if (layoutDef.count > 1) {
|
||||||
@@ -1477,9 +1543,13 @@ function App() {
|
|||||||
activeMarket={symbol}
|
activeMarket={symbol}
|
||||||
enabled={monitoredMarkets.length > 0}
|
enabled={monitoredMarkets.length > 0}
|
||||||
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
||||||
strategyId={appDefaults.liveStrategyId ?? undefined}
|
strategyId={liveStrategySettings.strategyId ?? undefined}
|
||||||
executionType={appDefaults.liveExecutionType ?? 'CANDLE_CLOSE'}
|
executionType={liveStrategySettings.executionType ?? 'CANDLE_CLOSE'}
|
||||||
strategyName={liveStrategyName}
|
strategyName={liveStrategyName}
|
||||||
|
strategyNameByMarket={liveStrategyNameByMarket}
|
||||||
|
executionTypeByMarket={Object.fromEntries(
|
||||||
|
activeLiveSettings.map(s => [s.market, s.executionType ?? 'CANDLE_CLOSE']),
|
||||||
|
)}
|
||||||
chartMarkersActive={menuPage === 'chart'}
|
chartMarkersActive={menuPage === 'chart'}
|
||||||
onMarkersChange={markers => {
|
onMarkersChange={markers => {
|
||||||
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
|
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
|
||||||
@@ -1537,7 +1607,7 @@ function App() {
|
|||||||
defaultMarket={symbol}
|
defaultMarket={symbol}
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
onPaperOrderFilled={() => setPaperRefreshKey(k => k + 1)}
|
onPaperOrderFilled={handlePaperOrderFilled}
|
||||||
onGoChart={m => {
|
onGoChart={m => {
|
||||||
goToMarketChart(m);
|
goToMarketChart(m);
|
||||||
setMenuPage('chart');
|
setMenuPage('chart');
|
||||||
@@ -1552,7 +1622,9 @@ function App() {
|
|||||||
defaultMarket={symbol}
|
defaultMarket={symbol}
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
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}
|
onLiveSettingsChange={handleLiveSettingsChange}
|
||||||
watchlistCount={watchlist.length}
|
watchlistCount={watchlist.length}
|
||||||
monitoredMarkets={monitoredMarkets}
|
monitoredMarkets={monitoredMarkets}
|
||||||
|
virtualDriven={isVirtualActive}
|
||||||
getIndicatorParams={getIndicatorParams}
|
getIndicatorParams={getIndicatorParams}
|
||||||
saveIndicatorParams={saveIndicatorParams}
|
saveIndicatorParams={saveIndicatorParams}
|
||||||
getIndicatorVisual={getIndicatorVisual}
|
getIndicatorVisual={getIndicatorVisual}
|
||||||
@@ -2080,8 +2153,9 @@ function App() {
|
|||||||
market={symbol}
|
market={symbol}
|
||||||
strategies={liveStrategies}
|
strategies={liveStrategies}
|
||||||
settings={liveStrategySettings}
|
settings={liveStrategySettings}
|
||||||
watchlistCount={watchlist.length}
|
watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length}
|
||||||
monitoredMarkets={monitoredMarkets}
|
monitoredMarkets={monitoredMarkets}
|
||||||
|
virtualDriven={isVirtualActive}
|
||||||
onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }}
|
onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }}
|
||||||
onSettingsChange={handleLiveSettingsChange}
|
onSettingsChange={handleLiveSettingsChange}
|
||||||
onClose={() => setShowLivePanel(false)}
|
onClose={() => setShowLivePanel(false)}
|
||||||
@@ -2303,7 +2377,7 @@ function App() {
|
|||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
onPaperOrderFilled={() => {
|
onPaperOrderFilled={() => {
|
||||||
refreshPaperAccount(symbol);
|
refreshPaperAccount(symbol);
|
||||||
setPaperRefreshKey(k => k + 1);
|
handlePaperOrderFilled();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2407,7 +2481,7 @@ function App() {
|
|||||||
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
|
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
|
||||||
onOrderDone={() => {
|
onOrderDone={() => {
|
||||||
refreshPaperAccount(symbol);
|
refreshPaperAccount(symbol);
|
||||||
setPaperRefreshKey(k => k + 1);
|
handlePaperOrderFilled();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import BacktestAnalysisChart from './backtest/BacktestAnalysisChart';
|
|||||||
import BacktestKpiPanel from './backtest/BacktestKpiPanel';
|
import BacktestKpiPanel from './backtest/BacktestKpiPanel';
|
||||||
import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard';
|
import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard';
|
||||||
import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
|
import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
|
||||||
|
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
|
||||||
import {
|
import {
|
||||||
readStoredSize,
|
readStoredSize,
|
||||||
storeSize,
|
storeSize,
|
||||||
@@ -91,6 +92,18 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
window.addEventListener('pointerup', onUp);
|
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 () => {
|
const fetchAll = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [list, trades, summary, strategies] = await Promise.all([
|
const [list, trades, summary, strategies] = await Promise.all([
|
||||||
@@ -110,6 +123,12 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { void fetchAll(); }, [fetchAll]);
|
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 () => {
|
const deleteAll = useCallback(async () => {
|
||||||
if (records.length === 0) return;
|
if (records.length === 0) return;
|
||||||
if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface Props {
|
|||||||
strategyId: number | undefined;
|
strategyId: number | undefined;
|
||||||
executionType: string;
|
executionType: string;
|
||||||
strategyName: string | null;
|
strategyName: string | null;
|
||||||
|
strategyNameByMarket?: Record<string, string>;
|
||||||
|
executionTypeByMarket?: Record<string, string>;
|
||||||
onMarkersChange: (markers: LiveMarker[]) => void;
|
onMarkersChange: (markers: LiveMarker[]) => void;
|
||||||
/** 차트 화면일 때만 마커를 차트에 반영 */
|
/** 차트 화면일 때만 마커를 차트에 반영 */
|
||||||
chartMarkersActive: boolean;
|
chartMarkersActive: boolean;
|
||||||
@@ -32,6 +34,8 @@ export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(fu
|
|||||||
popupEnabled: _popupEnabled,
|
popupEnabled: _popupEnabled,
|
||||||
strategyName,
|
strategyName,
|
||||||
executionType,
|
executionType,
|
||||||
|
strategyNameByMarket,
|
||||||
|
executionTypeByMarket,
|
||||||
onMarkersChange,
|
onMarkersChange,
|
||||||
chartMarkersActive,
|
chartMarkersActive,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
@@ -59,8 +63,8 @@ export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(fu
|
|||||||
signalType: marker.signal,
|
signalType: marker.signal,
|
||||||
price: marker.price,
|
price: marker.price,
|
||||||
candleTime: marker.time,
|
candleTime: marker.time,
|
||||||
strategyName,
|
strategyName: strategyNameByMarket?.[marker.market] ?? strategyName,
|
||||||
executionType,
|
executionType: executionTypeByMarket?.[marker.market] ?? executionType,
|
||||||
candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m',
|
candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m',
|
||||||
});
|
});
|
||||||
// STOMP만 수신·DB 저장 누락 시 배지 동기화
|
// STOMP만 수신·DB 저장 누락 시 배지 동기화
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface LiveStrategyPanelProps {
|
|||||||
settings: LiveStrategySettingsDto;
|
settings: LiveStrategySettingsDto;
|
||||||
watchlistCount?: number;
|
watchlistCount?: number;
|
||||||
monitoredMarkets?: string[];
|
monitoredMarkets?: string[];
|
||||||
|
/** 가상투자 세션에서 제어 — 차트 팝업은 읽기 전용 */
|
||||||
|
virtualDriven?: boolean;
|
||||||
onClearMarkers?: () => void;
|
onClearMarkers?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
||||||
@@ -47,7 +49,7 @@ const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
|
|||||||
|
|
||||||
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||||
theme, market, strategies, settings,
|
theme, market, strategies, settings,
|
||||||
watchlistCount = 0, monitoredMarkets = [],
|
watchlistCount = 0, monitoredMarkets = [], virtualDriven = false,
|
||||||
onClearMarkers, onClose, onSettingsChange,
|
onClearMarkers, onClose, onSettingsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -65,6 +67,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||||||
|
if (virtualDriven) return;
|
||||||
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
||||||
const prev = settings;
|
const prev = settings;
|
||||||
onSettingsChange?.(next);
|
onSettingsChange?.(next);
|
||||||
@@ -84,7 +87,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [settings, market, onClearMarkers, onSettingsChange]);
|
}, [settings, market, onClearMarkers, onSettingsChange, virtualDriven]);
|
||||||
|
|
||||||
const isOn = settings.isLiveCheck;
|
const isOn = settings.isLiveCheck;
|
||||||
const execType = settings.executionType;
|
const execType = settings.executionType;
|
||||||
@@ -190,6 +193,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isOn}
|
checked={isOn}
|
||||||
|
disabled={virtualDriven}
|
||||||
onChange={e => persist({ isLiveCheck: e.target.checked })}
|
onChange={e => persist({ isLiveCheck: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
<span className="lsp-toggle-slider" />
|
<span className="lsp-toggle-slider" />
|
||||||
@@ -200,7 +204,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
<span className="lsp-field-label">전략 선택</span>
|
<span className="lsp-field-label">전략 선택</span>
|
||||||
<select
|
<select
|
||||||
className="lsp-select"
|
className="lsp-select"
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
value={stratId ?? ''}
|
value={stratId ?? ''}
|
||||||
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
||||||
>
|
>
|
||||||
@@ -221,7 +225,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
name="execType"
|
name="execType"
|
||||||
value="CANDLE_CLOSE"
|
value="CANDLE_CLOSE"
|
||||||
checked={execType === 'CANDLE_CLOSE'}
|
checked={execType === 'CANDLE_CLOSE'}
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
|
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
|
||||||
/>
|
/>
|
||||||
<span className="lsp-option-text">
|
<span className="lsp-option-text">
|
||||||
@@ -235,7 +239,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
name="execType"
|
name="execType"
|
||||||
value="REALTIME_TICK"
|
value="REALTIME_TICK"
|
||||||
checked={execType === 'REALTIME_TICK'}
|
checked={execType === 'REALTIME_TICK'}
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
|
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
|
||||||
/>
|
/>
|
||||||
<span className="lsp-option-text">
|
<span className="lsp-option-text">
|
||||||
@@ -255,7 +259,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
name="lsp-posMode"
|
name="lsp-posMode"
|
||||||
value="LONG_ONLY"
|
value="LONG_ONLY"
|
||||||
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
|
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
|
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
|
||||||
/>
|
/>
|
||||||
<span className="lsp-option-text">
|
<span className="lsp-option-text">
|
||||||
@@ -269,7 +273,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
name="lsp-posMode"
|
name="lsp-posMode"
|
||||||
value="SIGNAL_ONLY"
|
value="SIGNAL_ONLY"
|
||||||
checked={settings.positionMode === 'SIGNAL_ONLY'}
|
checked={settings.positionMode === 'SIGNAL_ONLY'}
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
|
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
|
||||||
/>
|
/>
|
||||||
<span className="lsp-option-text">
|
<span className="lsp-option-text">
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ interface SettingsPageProps {
|
|||||||
onLiveSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
onLiveSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
||||||
watchlistCount?: number;
|
watchlistCount?: number;
|
||||||
monitoredMarkets?: string[];
|
monitoredMarkets?: string[];
|
||||||
|
/** 가상투자 세션 실행 중 — 전략 설정은 가상투자 화면에서 */
|
||||||
|
virtualDriven?: boolean;
|
||||||
/** Main 탭 보조지표 기본 파라미터 (DB) */
|
/** Main 탭 보조지표 기본 파라미터 (DB) */
|
||||||
getIndicatorParams?: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
getIndicatorParams?: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
||||||
saveIndicatorParams?: (type: string, params: Record<string, number | string | boolean>) => void;
|
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: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
|
||||||
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
|
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
|
||||||
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, 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: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
|
||||||
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
||||||
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
||||||
@@ -332,16 +334,16 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingSection title="매매 운영 모드">
|
<SettingSection title="매매 운영 모드">
|
||||||
<SettingRow label="실행 대상" desc="모의투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
|
<SettingRow label="실행 대상" desc="가상투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
|
||||||
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
|
<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="LIVE">실거래만</option>
|
||||||
<option value="BOTH">모의 + 실거래 병행</option>
|
<option value="BOTH">모의 + 실거래 병행</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
<SettingSection title="모의투자 사용">
|
<SettingSection title="가상투자 사용">
|
||||||
<SettingRow label="모의투자 활성화" desc="켜면 차트 매수·매도와 전략 시그널이 가상 계좌로 체결됩니다.">
|
<SettingRow label="가상투자 활성화" desc="켜면 가상투자·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
|
||||||
<label className="stg-toggle">
|
<label className="stg-toggle">
|
||||||
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
||||||
<span className="stg-toggle-slider" />
|
<span className="stg-toggle-slider" />
|
||||||
@@ -350,24 +352,16 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
|||||||
</SettingSection>
|
</SettingSection>
|
||||||
<SettingSection title="자동매매">
|
<SettingSection title="자동매매">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="자동매매"
|
label="자동매매 ON/OFF"
|
||||||
desc={
|
desc="가상투자 화면 타이틀바에서 변경합니다. Match 충족 시 자동 매수·매도 여부를 제어합니다."
|
||||||
paperAutoTradeEnabled
|
|
||||||
? 'ON: 실시간 전략이 켜진 상태에서 BUY/SELL 시그널 발생 시 아래 조건으로 모의 계좌에 자동 체결됩니다.'
|
|
||||||
: 'OFF: 시그널은 알림·차트 표시만 하며, 매매는 차트 우측 매수·매도 패널에서 직접 주문합니다.'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<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'}`}>
|
<span className={`stg-badge ${paperAutoTradeEnabled ? 'stg-badge--on' : 'stg-badge--off'}`}>
|
||||||
{paperAutoTradeEnabled ? 'ON' : 'OFF'}
|
{paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'}
|
||||||
</span>
|
</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="자동 매수 예산 (%)"
|
label="자동 매수 예산 (%)"
|
||||||
desc="BUY 시그널 시 주문가능 현금 중 사용 비율. SELL 시그널은 해당 종목 보유 수량 전량 매도."
|
desc="Match 매수 시 주문가능 현금 중 사용 비율. Match 매도는 해당 종목 보유 수량 전량 매도."
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -381,7 +375,7 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{!paperAutoTradeEnabled && (
|
{!paperAutoTradeEnabled && (
|
||||||
<p className="stg-hint">자동매매가 꺼져 있으면 전략 시그널로는 주문되지 않습니다. 수동 주문만 모의 계좌에 반영됩니다.</p>
|
<p className="stg-hint">자동매매 ON/OFF는 가상투자 화면 타이틀바에서 변경하세요. OFF 상태에서는 Match·시그널로 주문되지 않습니다.</p>
|
||||||
)}
|
)}
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
<SettingSection title="계좌·비용">
|
<SettingSection title="계좌·비용">
|
||||||
@@ -722,6 +716,7 @@ interface StrategyPanelProps {
|
|||||||
liveSettings?: LiveStrategySettingsDto;
|
liveSettings?: LiveStrategySettingsDto;
|
||||||
watchlistCount?: number;
|
watchlistCount?: number;
|
||||||
monitoredMarkets?: string[];
|
monitoredMarkets?: string[];
|
||||||
|
virtualDriven?: boolean;
|
||||||
onClearLiveMarkers?: () => void;
|
onClearLiveMarkers?: () => void;
|
||||||
tradeAlertPopup?: boolean;
|
tradeAlertPopup?: boolean;
|
||||||
onTradeAlertPopup?: (v: boolean) => void;
|
onTradeAlertPopup?: (v: boolean) => void;
|
||||||
@@ -734,6 +729,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
liveSettings: liveSettingsProp,
|
liveSettings: liveSettingsProp,
|
||||||
watchlistCount = 0,
|
watchlistCount = 0,
|
||||||
monitoredMarkets = [],
|
monitoredMarkets = [],
|
||||||
|
virtualDriven = false,
|
||||||
onClearLiveMarkers,
|
onClearLiveMarkers,
|
||||||
tradeAlertPopup = true,
|
tradeAlertPopup = true,
|
||||||
onTradeAlertPopup,
|
onTradeAlertPopup,
|
||||||
@@ -763,6 +759,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
}, [liveSettingsProp]);
|
}, [liveSettingsProp]);
|
||||||
|
|
||||||
const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||||||
|
if (virtualDriven) return;
|
||||||
const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket };
|
const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket };
|
||||||
setLiveSaving(true);
|
setLiveSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -775,7 +772,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setLiveSaving(false);
|
setLiveSaving(false);
|
||||||
}
|
}
|
||||||
}, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange]);
|
}, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven]);
|
||||||
|
|
||||||
const isOn = liveSettings.isLiveCheck;
|
const isOn = liveSettings.isLiveCheck;
|
||||||
const execType = liveSettings.executionType;
|
const execType = liveSettings.executionType;
|
||||||
@@ -786,9 +783,16 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* ── 실시간 전략 체크 ─────────────────────────────────────────────── */}
|
{/* ── 실시간 전략 체크 ─────────────────────────────────────────────── */}
|
||||||
<SettingSection title="실시간 전략 체크">
|
<SettingSection title="실시간 전략 체크">
|
||||||
|
{virtualDriven && (
|
||||||
|
<p className="stg-hint" style={{ marginBottom: 12 }}>
|
||||||
|
가상투자 세션이 실행 중입니다. 전략·실행방식·시그널 모드는 <strong>가상투자</strong> 화면에서 변경하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="모니터링 현황"
|
label="모니터링 현황"
|
||||||
desc="★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다. 실시간 체크를 켜고 전략을 선택하세요."
|
desc={virtualDriven
|
||||||
|
? "가상투자 투자대상 종목이 백엔드에서 실시간 전략 체크 대상입니다."
|
||||||
|
: "★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다. 실시간 체크를 켜고 전략을 선택하세요."}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600 }}>
|
<span style={{ fontSize: 12, fontWeight: 600 }}>
|
||||||
@@ -810,6 +814,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isOn}
|
checked={isOn}
|
||||||
|
disabled={virtualDriven}
|
||||||
onChange={e => persistLive({ isLiveCheck: e.target.checked })}
|
onChange={e => persistLive({ isLiveCheck: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
<span className="stg-toggle-slider" />
|
<span className="stg-toggle-slider" />
|
||||||
@@ -830,7 +835,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
className="stg-select"
|
className="stg-select"
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
value={stratId ?? ''}
|
value={stratId ?? ''}
|
||||||
onChange={e => persistLive({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
onChange={e => persistLive({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
||||||
style={{ opacity: isOn ? 1 : 0.45 }}
|
style={{ opacity: isOn ? 1 : 0.45 }}
|
||||||
@@ -854,7 +859,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
name="stg-exec-type"
|
name="stg-exec-type"
|
||||||
value="CANDLE_CLOSE"
|
value="CANDLE_CLOSE"
|
||||||
checked={execType === 'CANDLE_CLOSE'}
|
checked={execType === 'CANDLE_CLOSE'}
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
onChange={() => persistLive({ executionType: 'CANDLE_CLOSE' })}
|
onChange={() => persistLive({ executionType: 'CANDLE_CLOSE' })}
|
||||||
/>
|
/>
|
||||||
<span className="stg-radio-option-text">
|
<span className="stg-radio-option-text">
|
||||||
@@ -868,7 +873,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
|||||||
name="stg-exec-type"
|
name="stg-exec-type"
|
||||||
value="REALTIME_TICK"
|
value="REALTIME_TICK"
|
||||||
checked={execType === 'REALTIME_TICK'}
|
checked={execType === 'REALTIME_TICK'}
|
||||||
disabled={!isOn}
|
disabled={virtualDriven || !isOn}
|
||||||
onChange={() => persistLive({ executionType: 'REALTIME_TICK' })}
|
onChange={() => persistLive({ executionType: 'REALTIME_TICK' })}
|
||||||
/>
|
/>
|
||||||
<span className="stg-radio-option-text">
|
<span className="stg-radio-option-text">
|
||||||
@@ -1334,6 +1339,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onLiveSettingsChange,
|
onLiveSettingsChange,
|
||||||
watchlistCount = 0,
|
watchlistCount = 0,
|
||||||
monitoredMarkets = [],
|
monitoredMarkets = [],
|
||||||
|
virtualDriven = false,
|
||||||
getIndicatorParams,
|
getIndicatorParams,
|
||||||
saveIndicatorParams,
|
saveIndicatorParams,
|
||||||
getIndicatorVisual,
|
getIndicatorVisual,
|
||||||
@@ -1500,6 +1506,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
liveSettings={liveSettings}
|
liveSettings={liveSettings}
|
||||||
watchlistCount={watchlistCount}
|
watchlistCount={watchlistCount}
|
||||||
monitoredMarkets={monitoredMarkets}
|
monitoredMarkets={monitoredMarkets}
|
||||||
|
virtualDriven={virtualDriven}
|
||||||
onClearLiveMarkers={onClearLiveMarkers}
|
onClearLiveMarkers={onClearLiveMarkers}
|
||||||
tradeAlertPopup={tradeAlertPopup}
|
tradeAlertPopup={tradeAlertPopup}
|
||||||
onTradeAlertPopup={onTradeAlertPopup}
|
onTradeAlertPopup={onTradeAlertPopup}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
loadPaperSummary,
|
loadPaperSummary,
|
||||||
|
loadPaperTrades,
|
||||||
loadStrategies,
|
loadStrategies,
|
||||||
saveLiveStrategySettings,
|
saveLiveStrategySettings,
|
||||||
type PaperSummaryDto,
|
type PaperSummaryDto,
|
||||||
|
type PaperTradeDto,
|
||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
import type { Theme, TradeOrderFillRequest } from '../types';
|
import type { Theme, TradeOrderFillRequest } from '../types';
|
||||||
@@ -14,10 +16,17 @@ import type { TickerData } from '../hooks/useMarketTicker';
|
|||||||
import TradeOrderPanel from './TradeOrderPanel';
|
import TradeOrderPanel from './TradeOrderPanel';
|
||||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||||
import PaperSplitPanel from './paper/PaperSplitPanel';
|
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||||
|
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
|
||||||
import BuilderPageShell from './layout/BuilderPageShell';
|
import BuilderPageShell from './layout/BuilderPageShell';
|
||||||
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
|
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
|
||||||
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
|
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
|
||||||
|
import {
|
||||||
|
VirtualSessionHeaderControls,
|
||||||
|
VirtualViewHeaderControls,
|
||||||
|
} from './virtual/VirtualGridUnifiedHeader';
|
||||||
|
import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard';
|
||||||
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
|
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import { useVirtualAutoTrade } from '../hooks/useVirtualAutoTrade';
|
||||||
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
||||||
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||||
import {
|
import {
|
||||||
@@ -25,14 +34,21 @@ import {
|
|||||||
loadVirtualTargets,
|
loadVirtualTargets,
|
||||||
saveVirtualSession,
|
saveVirtualSession,
|
||||||
saveVirtualTargets,
|
saveVirtualTargets,
|
||||||
|
loadVirtualCardViewMode,
|
||||||
|
saveVirtualCardViewMode,
|
||||||
type VirtualSessionConfig,
|
type VirtualSessionConfig,
|
||||||
type VirtualTargetItem,
|
type VirtualTargetItem,
|
||||||
|
type VirtualCardViewMode,
|
||||||
} from '../utils/virtualTradingStorage';
|
} from '../utils/virtualTradingStorage';
|
||||||
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
||||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||||
|
import {
|
||||||
|
syncVirtualTargetsToBackend,
|
||||||
|
stopVirtualLiveOnBackend,
|
||||||
|
} from '../utils/virtualLiveStrategySync';
|
||||||
import '../styles/virtualTradingDashboard.css';
|
import '../styles/virtualTradingDashboard.css';
|
||||||
|
|
||||||
type RightTab = 'trade' | 'orderbook';
|
type RightTab = 'trade' | 'orderbook' | 'history';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
@@ -40,30 +56,34 @@ interface Props {
|
|||||||
defaultMarket?: string;
|
defaultMarket?: string;
|
||||||
paperTradingEnabled?: boolean;
|
paperTradingEnabled?: boolean;
|
||||||
paperAutoTradeEnabled?: boolean;
|
paperAutoTradeEnabled?: boolean;
|
||||||
|
paperAutoTradeBudgetPct?: number;
|
||||||
|
onPaperAutoTradeEnabled?: (enabled: boolean) => void;
|
||||||
onPaperOrderFilled?: () => void;
|
onPaperOrderFilled?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function coinCode(symbol: string): string {
|
|
||||||
return symbol.replace(/^KRW-/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const VirtualTradingPage: React.FC<Props> = ({
|
const VirtualTradingPage: React.FC<Props> = ({
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
tickers,
|
tickers,
|
||||||
defaultMarket = 'KRW-BTC',
|
defaultMarket = 'KRW-BTC',
|
||||||
paperTradingEnabled = true,
|
paperTradingEnabled = true,
|
||||||
paperAutoTradeEnabled = false,
|
paperAutoTradeEnabled = false,
|
||||||
|
paperAutoTradeBudgetPct = 95,
|
||||||
|
onPaperAutoTradeEnabled,
|
||||||
onPaperOrderFilled,
|
onPaperOrderFilled,
|
||||||
}) => {
|
}) => {
|
||||||
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
|
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
|
||||||
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
|
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
|
||||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||||
|
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||||
|
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||||
|
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
const { settings: appSettings } = useAppSettings();
|
const { settings: appSettings } = useAppSettings();
|
||||||
const appChartDefaults = resolveAppDefaults(appSettings);
|
const appChartDefaults = resolveAppDefaults(appSettings);
|
||||||
@@ -72,12 +92,35 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
|
void Promise.all([
|
||||||
loadPaperSummary().then(setSummary).finally(() => setLoading(false));
|
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
|
||||||
|
loadPaperSummary().then(setSummary),
|
||||||
|
loadPaperTrades().then(setTrades),
|
||||||
|
]).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
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(() =>
|
const targetRefs = useMemo(() =>
|
||||||
targets.map(t => ({
|
targets.map(t => ({
|
||||||
@@ -92,6 +135,17 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
session.running,
|
session.running,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useVirtualAutoTrade({
|
||||||
|
enabled: paperTradingEnabled && paperAutoTradeEnabled,
|
||||||
|
session,
|
||||||
|
targets,
|
||||||
|
snapshots,
|
||||||
|
tickers,
|
||||||
|
paperAutoTradeBudgetPct,
|
||||||
|
positions: summary?.positions,
|
||||||
|
onFilled: () => void refreshPaperData({ focusHistory: true }),
|
||||||
|
});
|
||||||
|
|
||||||
const mergedLiveStatus = useMemo(() => {
|
const mergedLiveStatus = useMemo(() => {
|
||||||
if (!session.running) return liveStatusByMarket;
|
if (!session.running) return liveStatusByMarket;
|
||||||
const merged = { ...liveStatusByMarket };
|
const merged = { ...liveStatusByMarket };
|
||||||
@@ -133,8 +187,12 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
}, [selectedMarket, handleSelectMarket]);
|
}, [selectedMarket, handleSelectMarket]);
|
||||||
|
|
||||||
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
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 () => {
|
const handleStart = useCallback(async () => {
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
@@ -145,19 +203,8 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
window.alert('투자전략을 선택하세요.');
|
window.alert('투자전략을 선택하세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const template = {
|
|
||||||
isLiveCheck: true,
|
|
||||||
executionType: session.executionType,
|
|
||||||
positionMode: session.positionMode,
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(targets.map(t =>
|
await syncVirtualTargetsToBackend(targets, session, true);
|
||||||
saveLiveStrategySettings({
|
|
||||||
market: t.market,
|
|
||||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
|
||||||
...template,
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
setSession(s => ({ ...s, running: true }));
|
setSession(s => ({ ...s, running: true }));
|
||||||
} catch {
|
} catch {
|
||||||
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
|
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
|
||||||
@@ -165,20 +212,8 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
}, [targets, session]);
|
}, [targets, session]);
|
||||||
|
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
if (targets.length === 0) {
|
|
||||||
setSession(s => ({ ...s, running: false }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(targets.map(t =>
|
await stopVirtualLiveOnBackend(targets, session);
|
||||||
saveLiveStrategySettings({
|
|
||||||
market: t.market,
|
|
||||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
|
||||||
isLiveCheck: false,
|
|
||||||
executionType: session.executionType,
|
|
||||||
positionMode: session.positionMode,
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
setSession(s => ({ ...s, running: false }));
|
setSession(s => ({ ...s, running: false }));
|
||||||
}, [targets, session]);
|
}, [targets, session]);
|
||||||
@@ -202,24 +237,41 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
isLiveCheck: true,
|
isLiveCheck: true,
|
||||||
executionType: session.executionType,
|
executionType: session.executionType,
|
||||||
positionMode: session.positionMode,
|
positionMode: session.positionMode,
|
||||||
|
skipWatchlistSync: true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [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']) => {
|
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']) => {
|
const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => {
|
||||||
setSession(s => ({ ...s, positionMode }));
|
const next = { ...session, positionMode };
|
||||||
}, []);
|
setSession(next);
|
||||||
|
void resyncRunningSession(next);
|
||||||
|
}, [session, resyncRunningSession]);
|
||||||
|
|
||||||
const rightTabs = (
|
const rightTabs = (
|
||||||
<div className="bps-right-tabs">
|
<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 === '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 === '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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,9 +280,34 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
title="가상투자"
|
title="가상투자"
|
||||||
subtitle="Virtual Trading"
|
subtitle="Virtual Trading"
|
||||||
|
pageClassName="bps-page--vtd"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingText="가상투자 대시보드 로딩…"
|
loadingText="가상투자 대시보드 로딩…"
|
||||||
collapsiblePanels
|
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"
|
leftStorageKey="vtd-left-width"
|
||||||
leftDefaultWidth={380}
|
leftDefaultWidth={380}
|
||||||
leftCollapsedStorageKey="vtd-left-open"
|
leftCollapsedStorageKey="vtd-left-open"
|
||||||
@@ -255,11 +332,6 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
snapshots={snapshots}
|
snapshots={snapshots}
|
||||||
session={session}
|
session={session}
|
||||||
onStrategyChange={handleGlobalStrategyChange}
|
|
||||||
onExecutionTypeChange={handleExecutionTypeChange}
|
|
||||||
onPositionModeChange={handlePositionModeChange}
|
|
||||||
onStart={() => void handleStart()}
|
|
||||||
onStop={() => void handleStop()}
|
|
||||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||||
liveStatusByMarket={mergedLiveStatus}
|
liveStatusByMarket={mergedLiveStatus}
|
||||||
lastTickAtByMarket={lastTickAtByMarket}
|
lastTickAtByMarket={lastTickAtByMarket}
|
||||||
@@ -269,6 +341,9 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
chartRealtimeSource={chartRealtimeSource}
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
tickers={tickers}
|
tickers={tickers}
|
||||||
|
viewMode={viewMode}
|
||||||
|
globalDisplayMode={globalDisplayMode}
|
||||||
|
onFocusMarketChange={setFocusMarket}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
rightTabs={rightTabs}
|
rightTabs={rightTabs}
|
||||||
@@ -292,7 +367,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
showSymbolField
|
showSymbolField
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
onPaperOrderFilled={onPaperOrderFilled}
|
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -308,12 +383,12 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
showSymbolField={false}
|
showSymbolField={false}
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
onPaperOrderFilled={onPaperOrderFilled}
|
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : rightTab === 'orderbook' ? (
|
||||||
<PaperSplitPanel
|
<PaperSplitPanel
|
||||||
className="ptd-split-panel--right"
|
className="ptd-split-panel--right"
|
||||||
topTitle="매도 호가"
|
topTitle="매도 호가"
|
||||||
@@ -339,6 +414,14 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<PaperTradeHistoryList
|
||||||
|
trades={trades}
|
||||||
|
strategyNames={strategyNames}
|
||||||
|
onSelectMarket={handleSelectMarket}
|
||||||
|
emptyText="수동·자동 매매 체결 내역이 없습니다."
|
||||||
|
className="ptd-trade-history--fill"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface BuilderPageShellProps {
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
|
/** 타이틀 우측·중앙 그리드 시작선 정렬 (가상투자 등) */
|
||||||
|
headerCenter?: React.ReactNode;
|
||||||
|
pageClassName?: string;
|
||||||
banner?: React.ReactNode;
|
banner?: React.ReactNode;
|
||||||
leftTitle?: string;
|
leftTitle?: string;
|
||||||
leftTabs?: React.ReactNode;
|
leftTabs?: React.ReactNode;
|
||||||
@@ -99,6 +102,8 @@ export default function BuilderPageShell({
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
headerCenter,
|
||||||
|
pageClassName,
|
||||||
banner,
|
banner,
|
||||||
leftTitle,
|
leftTitle,
|
||||||
leftTabs,
|
leftTabs,
|
||||||
@@ -207,6 +212,14 @@ export default function BuilderPageShell({
|
|||||||
});
|
});
|
||||||
}, [rightCollapsedStorageKey]);
|
}, [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(() => ({
|
const bodyStyle = useMemo(() => ({
|
||||||
'--bps-footer-height': `${footerHeight}px`,
|
'--bps-footer-height': `${footerHeight}px`,
|
||||||
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
||||||
@@ -235,13 +248,23 @@ export default function BuilderPageShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bps-page se-page se-page--${theme}`}>
|
<div
|
||||||
<header className="bps-header">
|
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">
|
<div className="bps-header-left">
|
||||||
<h1 className="bps-title">{title}</h1>
|
<h1 className="bps-title">{title}</h1>
|
||||||
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
||||||
</div>
|
</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>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
headerActions && <div className="bps-header-actions">{headerActions}</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{banner}
|
{banner}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
loadStrategies,
|
loadStrategies,
|
||||||
loadLiveStrategySettings,
|
loadLiveStrategySettings,
|
||||||
saveLiveStrategySettings,
|
|
||||||
type LiveStrategySettingsDto,
|
type LiveStrategySettingsDto,
|
||||||
type PaperSummaryDto,
|
type PaperSummaryDto,
|
||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../../utils/backendApi';
|
} from '../../utils/backendApi';
|
||||||
|
import { loadVirtualSession } from '../../utils/virtualTradingStorage';
|
||||||
import { fmtKrw } from '../TradeOrderPanel';
|
import { fmtKrw } from '../TradeOrderPanel';
|
||||||
import PaperSplitPanel from './PaperSplitPanel';
|
import PaperSplitPanel from './PaperSplitPanel';
|
||||||
|
|
||||||
@@ -25,12 +25,22 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
const [settings, setSettings] = useState<LiveStrategySettingsDto | null>(null);
|
const [settings, setSettings] = useState<LiveStrategySettingsDto | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [virtualRunning, setVirtualRunning] = useState(() => loadVirtualSession().running);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
loadLiveStrategySettings(market)
|
loadLiveStrategySettings(market)
|
||||||
@@ -47,26 +57,10 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [market]);
|
}, [market, virtualRunning]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const selected = strategies.find(s => s.id === settings?.strategyId);
|
const selected = strategies.find(s => s.id === settings?.strategyId);
|
||||||
|
const execLabel = settings?.executionType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
||||||
|
|
||||||
const top = (
|
const top = (
|
||||||
<>
|
<>
|
||||||
@@ -79,55 +73,20 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ptd-left-section">
|
<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">
|
<p className="ptd-left-hint">
|
||||||
적용: {selected.name}
|
실시간 전략·체크 대상 종목은 <strong>가상투자</strong> 화면에서 설정합니다.
|
||||||
{settings?.strategyCandleTypes?.length
|
{virtualRunning ? ' (세션 실행 중)' : ''}
|
||||||
? ` · ${settings.strategyCandleTypes.join(', ')}`
|
</p>
|
||||||
: ''}
|
{settings?.isLiveCheck && selected && (
|
||||||
|
<p className="ptd-left-hint">
|
||||||
|
{market.replace(/^KRW-/, '')}: {selected.name} · {execLabel}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
|
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
|
||||||
{paperAutoTradeEnabled ? '자동매매 ON' : '자동매매 OFF — 설정 탭에서 변경'}
|
{paperAutoTradeEnabled
|
||||||
|
? '자동매매 ON — 가상투자 Match 시 가상 계좌 자동 체결'
|
||||||
|
: '자동매매 OFF — 가상투자 타이틀바에서 변경'}
|
||||||
</p>
|
</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 { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||||
import type { VirtualCardDisplayMode } from './VirtualTargetCard';
|
import type { VirtualCardDisplayMode } from './VirtualTargetCard';
|
||||||
|
|
||||||
interface Props {
|
interface SessionProps {
|
||||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||||
strategies: StrategyDto[];
|
strategies: StrategyDto[];
|
||||||
onStrategyChange: (strategyId: number | null) => void;
|
onStrategyChange: (strategyId: number | null) => void;
|
||||||
@@ -11,15 +11,21 @@ interface Props {
|
|||||||
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
|
onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void;
|
||||||
onStart: () => void;
|
onStart: () => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
viewMode?: VirtualCardViewMode;
|
paperAutoTradeEnabled?: boolean;
|
||||||
onViewModeChange?: (mode: VirtualCardViewMode) => void;
|
onPaperAutoTradeEnabled?: (enabled: boolean) => void;
|
||||||
globalDisplayMode?: VirtualCardDisplayMode;
|
paperTradingEnabled?: boolean;
|
||||||
onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
|
||||||
/** 우측 뷰 토글: full=신호·차트+요약·상세, display-only=요약·상세만, hidden=숨김 */
|
|
||||||
viewControls?: 'full' | 'display-only' | 'hidden';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
session,
|
||||||
strategies,
|
strategies,
|
||||||
onStrategyChange,
|
onStrategyChange,
|
||||||
@@ -27,16 +33,11 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
|||||||
onPositionModeChange,
|
onPositionModeChange,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
viewMode = 'summary',
|
paperAutoTradeEnabled = false,
|
||||||
onViewModeChange,
|
onPaperAutoTradeEnabled,
|
||||||
globalDisplayMode = 'signal',
|
paperTradingEnabled = true,
|
||||||
onGlobalDisplayModeChange,
|
|
||||||
viewControls = 'full',
|
|
||||||
}) => (
|
}) => (
|
||||||
<div className="vtd-unified-head">
|
<div className="vtd-header-session">
|
||||||
<span className="vtd-unified-head-title">종목별 매매시그널 일치 현황</span>
|
|
||||||
|
|
||||||
<div className="vtd-unified-head-center">
|
|
||||||
<div className="vtd-session-field">
|
<div className="vtd-session-field">
|
||||||
<span className="vtd-session-label">투자전략</span>
|
<span className="vtd-session-label">투자전략</span>
|
||||||
<select
|
<select
|
||||||
@@ -94,6 +95,39 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="vtd-session-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -113,10 +147,18 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{viewControls !== 'hidden' && onViewModeChange && (
|
export const VirtualViewHeaderControls: React.FC<ViewProps> = ({
|
||||||
<div className="vtd-unified-head-right">
|
session,
|
||||||
{viewControls === 'full' && onGlobalDisplayModeChange && (
|
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-grid-head-group" role="group" aria-label="전체 종목 신호·차트">
|
||||||
<div className="vtd-view-toggle">
|
<div className="vtd-view-toggle">
|
||||||
@@ -174,8 +216,4 @@ const VirtualGridUnifiedHeader: React.FC<Props> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default VirtualGridUnifiedHeader;
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import VirtualTargetCard, { type VirtualCardDisplayMode } from './VirtualTargetCard';
|
import VirtualTargetCard, { type VirtualCardDisplayMode } from './VirtualTargetCard';
|
||||||
import VirtualGridUnifiedHeader from './VirtualGridUnifiedHeader';
|
|
||||||
import VirtualTargetFocusView from './VirtualTargetFocusView';
|
import VirtualTargetFocusView from './VirtualTargetFocusView';
|
||||||
import type { StrategyDto } from '../../utils/backendApi';
|
import type { StrategyDto } from '../../utils/backendApi';
|
||||||
import type { Theme } from '../../types';
|
import type { Theme } from '../../types';
|
||||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||||
import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } from '../../utils/virtualTradingStorage';
|
import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } from '../../utils/virtualTradingStorage';
|
||||||
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
|
||||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
@@ -16,11 +14,6 @@ interface Props {
|
|||||||
strategies: StrategyDto[];
|
strategies: StrategyDto[];
|
||||||
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
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;
|
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||||
lastTickAtByMarket?: Record<string, number>;
|
lastTickAtByMarket?: Record<string, number>;
|
||||||
@@ -30,11 +23,13 @@ interface Props {
|
|||||||
chartRealtimeSource?: ChartRealtimeSource;
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
tickers?: Map<string, TickerData>;
|
tickers?: Map<string, TickerData>;
|
||||||
|
viewMode: VirtualCardViewMode;
|
||||||
|
globalDisplayMode: VirtualCardDisplayMode;
|
||||||
|
onFocusMarketChange?: (market: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualTargetGrid: React.FC<Props> = ({
|
const VirtualTargetGrid: React.FC<Props> = ({
|
||||||
targets, strategies, snapshots, session,
|
targets, strategies, snapshots, session,
|
||||||
onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop,
|
|
||||||
onTargetStrategyChange,
|
onTargetStrategyChange,
|
||||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
||||||
onSelectMarket,
|
onSelectMarket,
|
||||||
@@ -42,9 +37,10 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
chartRealtimeSource = 'BACKEND_STOMP',
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
tickers,
|
tickers,
|
||||||
|
viewMode,
|
||||||
|
globalDisplayMode,
|
||||||
|
onFocusMarketChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
|
||||||
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
|
||||||
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
||||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -53,14 +49,14 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusMarket && !activeMarkets.has(focusMarket)) {
|
if (focusMarket && !activeMarkets.has(focusMarket)) {
|
||||||
setFocusMarket(null);
|
setFocusMarket(null);
|
||||||
|
onFocusMarketChange?.(null);
|
||||||
}
|
}
|
||||||
}, [activeMarkets, focusMarket]);
|
}, [activeMarkets, focusMarket, onFocusMarketChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveVirtualCardViewMode(viewMode);
|
onFocusMarketChange?.(focusMarket);
|
||||||
}, [viewMode]);
|
}, [focusMarket, onFocusMarketChange]);
|
||||||
|
|
||||||
/** 제거된 종목 override 정리 */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCardDisplayOverrides(prev => {
|
setCardDisplayOverrides(prev => {
|
||||||
const next: Record<string, VirtualCardDisplayMode> = {};
|
const next: Record<string, VirtualCardDisplayMode> = {};
|
||||||
@@ -73,10 +69,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
}, [activeMarkets]);
|
}, [activeMarkets]);
|
||||||
|
|
||||||
const handleGlobalDisplayMode = useCallback((mode: VirtualCardDisplayMode) => {
|
useEffect(() => {
|
||||||
setGlobalDisplayMode(mode);
|
|
||||||
setCardDisplayOverrides({});
|
setCardDisplayOverrides({});
|
||||||
}, []);
|
}, [globalDisplayMode]);
|
||||||
|
|
||||||
const handleCardDisplayMode = useCallback((market: string, mode: VirtualCardDisplayMode) => {
|
const handleCardDisplayMode = useCallback((market: string, mode: VirtualCardDisplayMode) => {
|
||||||
setCardDisplayOverrides(prev => ({ ...prev, [market]: mode }));
|
setCardDisplayOverrides(prev => ({ ...prev, [market]: mode }));
|
||||||
@@ -92,27 +87,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
[focusMarket, targets],
|
[focusMarket, targets],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderUnifiedHeader = (viewControls: 'full' | 'display-only' | 'hidden') => (
|
|
||||||
<VirtualGridUnifiedHeader
|
|
||||||
session={session}
|
|
||||||
strategies={strategies}
|
|
||||||
onStrategyChange={onStrategyChange}
|
|
||||||
onExecutionTypeChange={onExecutionTypeChange}
|
|
||||||
onPositionModeChange={onPositionModeChange}
|
|
||||||
onStart={onStart}
|
|
||||||
onStop={onStop}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
globalDisplayMode={globalDisplayMode}
|
|
||||||
onGlobalDisplayModeChange={handleGlobalDisplayMode}
|
|
||||||
viewControls={viewControls}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="vtd-grid-wrap">
|
<div className="vtd-grid-wrap">
|
||||||
{renderUnifiedHeader('hidden')}
|
|
||||||
<div className="vtd-grid-empty">
|
<div className="vtd-grid-empty">
|
||||||
<p className="vtd-muted">좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.</p>
|
<p className="vtd-muted">좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +99,6 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`vtd-grid-wrap${focusMarket ? ' vtd-grid-wrap--focus' : ''}`}>
|
<div className={`vtd-grid-wrap${focusMarket ? ' vtd-grid-wrap--focus' : ''}`}>
|
||||||
{renderUnifiedHeader(focusMarket ? 'display-only' : 'full')}
|
|
||||||
{focusMarket && focusTarget ? (
|
{focusMarket && focusTarget ? (
|
||||||
<VirtualTargetFocusView
|
<VirtualTargetFocusView
|
||||||
market={focusTarget.market}
|
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; }
|
.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 {
|
.bps-btn {
|
||||||
border: 1px solid var(--se-border);
|
border: 1px solid var(--se-border);
|
||||||
background: var(--se-btn-bg);
|
background: var(--se-btn-bg);
|
||||||
|
|||||||
@@ -827,6 +827,56 @@
|
|||||||
.ptd-table--compact { font-size: 9px; }
|
.ptd-table--compact { font-size: 9px; }
|
||||||
.ptd-table--compact th,
|
.ptd-table--compact th,
|
||||||
.ptd-table--compact td { padding: 5px 6px; }
|
.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 {
|
.ptd-status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 5px;
|
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 {
|
.vtd-unified-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
@@ -465,7 +517,7 @@
|
|||||||
.vtd-grid-wrap {
|
.vtd-grid-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -811,6 +863,11 @@
|
|||||||
color: var(--accent, #3f7ef5);
|
color: var(--accent, #3f7ef5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-view-toggle-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.vtd-grid-head-legend {
|
.vtd-grid-head-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1019,6 +1019,10 @@ export interface LiveStrategySettingsDto {
|
|||||||
candleType?: string;
|
candleType?: string;
|
||||||
/** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */
|
/** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */
|
||||||
strategyCandleTypes?: string[];
|
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_indicators: '설정 · 보조지표',
|
||||||
settings_backtest: '설정 · 백테스팅',
|
settings_backtest: '설정 · 백테스팅',
|
||||||
settings_strategy: '설정 · 전략',
|
settings_strategy: '설정 · 전략',
|
||||||
settings_paper: '설정 · 모의투자',
|
settings_paper: '설정 · 가상투자',
|
||||||
settings_alert: '설정 · 알림',
|
settings_alert: '설정 · 알림',
|
||||||
settings_network: '설정 · 네트워크',
|
settings_network: '설정 · 네트워크',
|
||||||
settings_admin: '설정 · 관리자',
|
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 {
|
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
|
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
|
||||||
|
notifyVirtualSessionChanged();
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +59,16 @@ export function loadVirtualSession(): VirtualSessionConfig {
|
|||||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
|
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
|
||||||
|
notifyVirtualSessionChanged();
|
||||||
} catch { /* ignore */ }
|
} 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 {
|
function defaultSession(): VirtualSessionConfig {
|
||||||
return {
|
return {
|
||||||
globalStrategyId: null,
|
globalStrategyId: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user