모의투자 관리 기능 추가

This commit is contained in:
Macbook
2026-05-31 03:47:07 +09:00
parent 9d7dddfa57
commit 16d0e2c226
78 changed files with 4688 additions and 972 deletions
+6 -2
View File
@@ -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'),
+180 -16
View File
@@ -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' });
}
+7 -5
View File
@@ -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') {
+93
View File
@@ -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 '화면 캡처에 실패했습니다.';
}
+16 -1
View File
@@ -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 */