모의투자 관리 기능 추가
This commit is contained in:
@@ -20,7 +20,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
||||
// ── 상단 메뉴 ──
|
||||
P('메뉴-대시보드', 'dashboard', '홈'),
|
||||
P('메뉴-실시간 차트', 'chart', '실시간차트', '캔들', '차트'),
|
||||
P('메뉴-가상매매', 'virtual', '가상', '모의'),
|
||||
P('메뉴-모의투자', 'paper', '모의투자', '투자금', '페이퍼'),
|
||||
P('메뉴-모의투자-투자금 관리', 'paper', 'allocation', '한도', 'KPI'),
|
||||
P('메뉴-가상매매', 'virtual', '가상', '가상매매'),
|
||||
P('메뉴-추세검색', 'trend', '추세', '검색'),
|
||||
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
|
||||
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
|
||||
@@ -64,7 +66,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
||||
P('메뉴-설정-보조지표 설정', 'indicators', '지표기본값', 'Main탭'),
|
||||
P('메뉴-설정-백테스팅', 'bt옵션', '자본'),
|
||||
P('메뉴-설정-전략 설정', 'strategy', '전략기본'),
|
||||
P('메뉴-설정-가상매매', 'paper', '가상자본'),
|
||||
P('메뉴-설정-모의투자', 'paper', '모의투자', '모의자본', '수수료'),
|
||||
P('메뉴-설정-가상매매', 'virtual', '투자대상', '한도'),
|
||||
P('메뉴-설정-실거래', 'live', '업비트', 'API'),
|
||||
P('메뉴-설정-추세검색', 'trend설정', '배점'),
|
||||
P('메뉴-설정-알림 설정', 'alert설정', '소리', '팝업'),
|
||||
P('메뉴-설정-네트워크', 'network', 'API', 'WebSocket'),
|
||||
|
||||
@@ -610,21 +610,42 @@ export interface PaperPositionDto {
|
||||
profitLossPct?: number;
|
||||
}
|
||||
|
||||
export interface PaperAllocationDto {
|
||||
id?: number;
|
||||
symbol: string;
|
||||
koreanName?: string | null;
|
||||
maxInvestKrw: number;
|
||||
budgetPct?: number | null;
|
||||
strategyId?: number | null;
|
||||
isActive?: boolean;
|
||||
pinned?: boolean;
|
||||
sortOrder?: number;
|
||||
investedKrw?: number;
|
||||
availableBuyKrw?: number;
|
||||
evalAmount?: number;
|
||||
symbolReturnPct?: number;
|
||||
weightPct?: number;
|
||||
}
|
||||
|
||||
export interface PaperSummaryDto {
|
||||
enabled: boolean;
|
||||
initialCapital: number;
|
||||
cashBalance: number;
|
||||
stockEvalAmount: number;
|
||||
totalAsset: number;
|
||||
unrealizedPnl: number;
|
||||
realizedPnl: number;
|
||||
totalReturnPct: number;
|
||||
feeRatePct: number;
|
||||
slippagePct: number;
|
||||
minOrderKrw: number;
|
||||
autoTradeEnabled: boolean;
|
||||
autoTradeBudgetPct: number;
|
||||
positions: PaperPositionDto[];
|
||||
enabled: boolean;
|
||||
initialCapital: number;
|
||||
cashBalance: number;
|
||||
stockEvalAmount: number;
|
||||
totalAsset: number;
|
||||
unrealizedPnl: number;
|
||||
realizedPnl: number;
|
||||
totalReturnPct: number;
|
||||
feeRatePct: number;
|
||||
slippagePct: number;
|
||||
minOrderKrw: number;
|
||||
autoTradeEnabled: boolean;
|
||||
autoTradeBudgetPct: number;
|
||||
reservedCash?: number;
|
||||
orderableCash?: number;
|
||||
initialCapitalSnapshot?: number;
|
||||
positions: PaperPositionDto[];
|
||||
allocations?: PaperAllocationDto[];
|
||||
}
|
||||
|
||||
export interface PaperTradeDto {
|
||||
@@ -640,13 +661,79 @@ export interface PaperTradeDto {
|
||||
feeAmount: number;
|
||||
netAmount: number;
|
||||
cashAfter: number;
|
||||
realizedPnlDelta?: number | null;
|
||||
positionQtyAfter?: number | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PaperPageDto<T> {
|
||||
content: T[];
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PaperLedgerEntryDto {
|
||||
id: number;
|
||||
entryType: string;
|
||||
amount: number;
|
||||
cashBefore: number;
|
||||
cashAfter: number;
|
||||
refTradeId?: number | null;
|
||||
symbol?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PaperDailySnapshotDto {
|
||||
snapshotDate: string;
|
||||
cashBalance: number;
|
||||
stockEvalAmount: number;
|
||||
totalAsset: number;
|
||||
dailyPnl: number;
|
||||
dailyReturnPct: number;
|
||||
cumulativeReturnPct: number;
|
||||
}
|
||||
|
||||
export interface PaperOrderDto {
|
||||
id: number;
|
||||
symbol: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
orderType: string;
|
||||
limitPrice?: number | null;
|
||||
quantity: number;
|
||||
filledQuantity: number;
|
||||
status: string;
|
||||
reservedCash: number;
|
||||
reservedQty: number;
|
||||
orderKind: string;
|
||||
source: string;
|
||||
strategyId?: number | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PaperPlaceOrderResult {
|
||||
trade?: PaperTradeDto | null;
|
||||
order?: PaperOrderDto | null;
|
||||
}
|
||||
|
||||
export interface PaperAllocationBulkItem {
|
||||
symbol: string;
|
||||
koreanName?: string;
|
||||
maxInvestKrw?: number;
|
||||
budgetPct?: number;
|
||||
strategyId?: number | null;
|
||||
isActive?: boolean;
|
||||
pinned?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface PaperOrderRequest {
|
||||
market: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
orderKind?: 'limit' | 'market';
|
||||
orderType?: 'MARKET' | 'LIMIT';
|
||||
price: number;
|
||||
quantity: number;
|
||||
/** 수량 0일 때 매수 예산 비율 (가용현금 %) */
|
||||
@@ -671,13 +758,90 @@ export async function loadPaperTrades(): Promise<PaperTradeDto[]> {
|
||||
return (await request<PaperTradeDto[]>('/paper/trades')) ?? [];
|
||||
}
|
||||
|
||||
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperTradeDto | null> {
|
||||
return request<PaperTradeDto>('/paper/orders', {
|
||||
export async function loadPaperTradesPaged(params?: {
|
||||
symbol?: string;
|
||||
side?: string;
|
||||
source?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PaperPageDto<PaperTradeDto>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.symbol) q.set('symbol', params.symbol);
|
||||
if (params?.side) q.set('side', params.side);
|
||||
if (params?.source) q.set('source', params.source);
|
||||
if (params?.from) q.set('from', params.from);
|
||||
if (params?.to) q.set('to', params.to);
|
||||
if (params?.page != null) q.set('page', String(params.page));
|
||||
if (params?.size != null) q.set('size', String(params.size));
|
||||
const qs = q.toString();
|
||||
return (await request<PaperPageDto<PaperTradeDto>>(`/paper/trades${qs ? `?${qs}` : ''}`)) ?? {
|
||||
content: [], page: 0, size: 50, totalElements: 0, totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPaperAllocations(): Promise<PaperAllocationDto[]> {
|
||||
return (await request<PaperAllocationDto[]>('/paper/allocations')) ?? [];
|
||||
}
|
||||
|
||||
export async function putPaperAllocationsBulk(items: PaperAllocationBulkItem[]): Promise<PaperAllocationDto[]> {
|
||||
return (await request<PaperAllocationDto[]>('/paper/allocations/bulk', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items }),
|
||||
})) ?? [];
|
||||
}
|
||||
|
||||
export async function patchPaperAllocation(
|
||||
symbol: string,
|
||||
body: Partial<Pick<PaperAllocationDto, 'maxInvestKrw' | 'budgetPct' | 'strategyId' | 'isActive' | 'pinned' | 'sortOrder' | 'koreanName'>>,
|
||||
): Promise<PaperAllocationDto | null> {
|
||||
return request<PaperAllocationDto>(`/paper/allocations/${encodeURIComponent(symbol)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPaperLedger(params?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PaperPageDto<PaperLedgerEntryDto>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.from) q.set('from', params.from);
|
||||
if (params?.to) q.set('to', params.to);
|
||||
if (params?.page != null) q.set('page', String(params.page));
|
||||
if (params?.size != null) q.set('size', String(params.size));
|
||||
const qs = q.toString();
|
||||
return (await request<PaperPageDto<PaperLedgerEntryDto>>(`/paper/ledger${qs ? `?${qs}` : ''}`)) ?? {
|
||||
content: [], page: 0, size: 50, totalElements: 0, totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPaperDailySnapshots(from?: string, to?: string): Promise<PaperDailySnapshotDto[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (from) q.set('from', from);
|
||||
if (to) q.set('to', to);
|
||||
const qs = q.toString();
|
||||
return (await request<PaperDailySnapshotDto[]>(`/paper/snapshots/daily${qs ? `?${qs}` : ''}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function loadPaperPendingOrders(): Promise<PaperOrderDto[]> {
|
||||
return (await request<PaperOrderDto[]>('/paper/orders')) ?? [];
|
||||
}
|
||||
|
||||
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperPlaceOrderResult | null> {
|
||||
return request<PaperPlaceOrderResult>('/paper/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelPaperOrder(orderId: number): Promise<PaperOrderDto | null> {
|
||||
return request<PaperOrderDto>(`/paper/orders/${orderId}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function resetPaperAccount(): Promise<PaperSummaryDto | null> {
|
||||
return request<PaperSummaryDto>('/paper/reset', { method: 'POST' });
|
||||
}
|
||||
|
||||
@@ -9,14 +9,15 @@ export type TopMenuId = MenuPage;
|
||||
|
||||
export type SettingsCategoryId =
|
||||
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
|
||||
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
| 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'trend-search', 'alert', 'network', 'admin',
|
||||
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'virtual', 'live',
|
||||
'trend-search', 'alert', 'network', 'admin',
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
@@ -43,7 +44,9 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
settings_indicators: '설정 · 보조지표',
|
||||
settings_backtest: '설정 · 백테스팅',
|
||||
settings_strategy: '설정 · 전략',
|
||||
settings_paper: '설정 · 가상매매',
|
||||
settings_paper: '설정 · 모의투자',
|
||||
settings_virtual: '설정 · 가상매매',
|
||||
settings_live: '설정 · 실거래',
|
||||
'settings_trend-search': '설정 · 추세검색',
|
||||
settings_alert: '설정 · 알림',
|
||||
settings_network: '설정 · 네트워크',
|
||||
@@ -69,7 +72,6 @@ export function canAccessMenu(
|
||||
permissions: Record<string, boolean> | null | undefined,
|
||||
menuId: string,
|
||||
): boolean {
|
||||
if (menuId === 'paper') return false;
|
||||
if (menuId === 'strategy') return false;
|
||||
if (!permissions) return menuId === 'chart';
|
||||
if (menuId === 'strategy-editor') {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/** 화면 캡처 영역 → 업로드용 PNG File */
|
||||
|
||||
export interface ScreenCaptureRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function clientRectToVideoPixels(
|
||||
rect: ScreenCaptureRect,
|
||||
videoEl: HTMLVideoElement,
|
||||
): ScreenCaptureRect | null {
|
||||
const bounds = videoEl.getBoundingClientRect();
|
||||
if (bounds.width <= 0 || bounds.height <= 0) return null;
|
||||
const vw = videoEl.videoWidth;
|
||||
const vh = videoEl.videoHeight;
|
||||
if (vw <= 0 || vh <= 0) return null;
|
||||
|
||||
const toVidX = (cx: number) => ((cx - bounds.left) / bounds.width) * vw;
|
||||
const toVidY = (cy: number) => ((cy - bounds.top) / bounds.height) * vh;
|
||||
|
||||
const x1 = Math.max(0, Math.min(vw, Math.round(toVidX(rect.left))));
|
||||
const y1 = Math.max(0, Math.min(vh, Math.round(toVidY(rect.top))));
|
||||
const x2 = Math.max(0, Math.min(vw, Math.round(toVidX(rect.left + rect.width))));
|
||||
const y2 = Math.max(0, Math.min(vh, Math.round(toVidY(rect.top + rect.height))));
|
||||
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
if (width < 2 || height < 2) return null;
|
||||
|
||||
return { left: x1, top: y1, width, height };
|
||||
}
|
||||
|
||||
export async function cropVideoFrameToFile(
|
||||
videoEl: HTMLVideoElement,
|
||||
rect: ScreenCaptureRect,
|
||||
): Promise<File> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('CANVAS_FAILED');
|
||||
|
||||
ctx.drawImage(
|
||||
videoEl,
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.width,
|
||||
rect.height,
|
||||
0,
|
||||
0,
|
||||
rect.width,
|
||||
rect.height,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve, 'image/png');
|
||||
});
|
||||
if (!blob) throw new Error('BLOB_FAILED');
|
||||
|
||||
return new File([blob], `screen-capture-${Date.now()}.png`, {
|
||||
type: 'image/png',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestDisplayCaptureStream(): Promise<MediaStream> {
|
||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||
throw new Error('DISPLAY_CAPTURE_UNSUPPORTED');
|
||||
}
|
||||
return navigator.mediaDevices.getDisplayMedia({
|
||||
video: { displaySurface: 'browser' } as MediaTrackConstraints,
|
||||
audio: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopMediaStream(stream: MediaStream | null): void {
|
||||
stream?.getTracks().forEach(t => t.stop());
|
||||
}
|
||||
|
||||
export function captureErrorMessage(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
if (e.name === 'NotAllowedError' || e.message === 'DISPLAY_CAPTURE_UNSUPPORTED') {
|
||||
return '화면 캡처 권한이 거부되었거나 이 브라우저에서 지원하지 않습니다.';
|
||||
}
|
||||
if (e.message === 'REGION_TOO_SMALL') {
|
||||
return '선택 영역이 너무 작습니다. 다시 드래그해 주세요.';
|
||||
}
|
||||
if (e.message) return e.message;
|
||||
}
|
||||
return '화면 캡처에 실패했습니다.';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { saveLiveStrategySettings } from './backendApi';
|
||||
import { putPaperAllocationsBulk, saveLiveStrategySettings } from './backendApi';
|
||||
import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
|
||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||
@@ -47,6 +47,21 @@ export async function syncVirtualTargetsToBackend(
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await putPaperAllocationsBulk(
|
||||
targets.map((t, i) => ({
|
||||
symbol: t.market,
|
||||
koreanName: t.koreanName,
|
||||
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||
pinned: !!t.pinned,
|
||||
sortOrder: i,
|
||||
isActive: true,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
/* paper allocation sync best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** 가상투자 중지 — 대상 종목 체크 해제 + 전역 liveStrategyCheck OFF */
|
||||
|
||||
Reference in New Issue
Block a user