모의투자 관리 기능 추가

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
@@ -0,0 +1,84 @@
import React, { useCallback } from 'react';
import type { PaperAllocationDto } from '../../utils/backendApi';
import { patchPaperAllocation } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
interface Props {
allocations: PaperAllocationDto[];
selectedMarket?: string;
onSelectMarket?: (market: string) => void;
onChanged?: () => void;
}
const PaperAllocationTable: React.FC<Props> = ({
allocations,
selectedMarket,
onSelectMarket,
onChanged,
}) => {
const toggleActive = useCallback(async (row: PaperAllocationDto) => {
await patchPaperAllocation(row.symbol, { isActive: !row.isActive });
onChanged?.();
}, [onChanged]);
if (!allocations.length) {
return <p className="ptd-muted"> . .</p>;
}
return (
<div className="ptd-alloc-table-wrap">
<table className="ptd-table ptd-alloc-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{allocations.map(row => {
const ret = row.symbolReturnPct ?? 0;
const selected = row.symbol === selectedMarket;
return (
<tr
key={row.symbol}
className={selected ? 'ptd-alloc-row--selected' : ''}
onClick={() => onSelectMarket?.(row.symbol)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') onSelectMarket?.(row.symbol); }}
>
<td>{row.koreanName || coinCode(row.symbol)}</td>
<td>{(row.weightPct ?? 0).toFixed(1)}%</td>
<td>{fmtKrw(row.maxInvestKrw)}</td>
<td>{fmtKrw(row.investedKrw ?? 0)}</td>
<td>{fmtKrw(row.evalAmount ?? 0)}</td>
<td className={ret >= 0 ? 'up' : 'down'}>{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%</td>
<td onClick={e => e.stopPropagation()}>
<button
type="button"
className={`ptd-alloc-toggle${row.isActive !== false ? ' ptd-alloc-toggle--on' : ''}`}
onClick={() => void toggleActive(row)}
title={row.isActive !== false ? '신규 매수 허용' : '신규 매수 차단'}
>
{row.isActive !== false ? 'ON' : 'OFF'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default PaperAllocationTable;
@@ -0,0 +1,62 @@
import React, { useEffect, useMemo, useState } from 'react';
import { loadPaperDailySnapshots, type PaperDailySnapshotDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
interface Props {
refreshKey?: number;
}
const PaperAssetTrendChart: React.FC<Props> = ({ refreshKey = 0 }) => {
const [snapshots, setSnapshots] = useState<PaperDailySnapshotDto[]>([]);
useEffect(() => {
let cancelled = false;
void loadPaperDailySnapshots().then(rows => {
if (!cancelled) setSnapshots(rows);
});
return () => { cancelled = true; };
}, [refreshKey]);
const { path, min, max, last } = useMemo(() => {
if (snapshots.length < 2) return { path: '', min: 0, max: 0, last: 0 };
const vals = snapshots.map(s => s.totalAsset);
const mn = Math.min(...vals);
const mx = Math.max(...vals);
const w = 280;
const h = 48;
const pad = 4;
const range = mx - mn || 1;
const pts = snapshots.map((s, i) => {
const x = pad + (i / (snapshots.length - 1)) * (w - pad * 2);
const y = h - pad - ((s.totalAsset - mn) / range) * (h - pad * 2);
return `${x},${y}`;
});
return { path: `M ${pts.join(' L ')}`, min: mn, max: mx, last: vals[vals.length - 1] };
}, [snapshots]);
if (snapshots.length < 2) {
return (
<div className="ptd-asset-trend ptd-asset-trend--empty">
<span className="ptd-muted"> .</span>
</div>
);
}
return (
<div className="ptd-asset-trend">
<div className="ptd-asset-trend-head">
<span> </span>
<span className="ptd-asset-trend-last">{fmtKrw(last)}</span>
</div>
<svg viewBox="0 0 280 48" className="ptd-asset-trend-svg" preserveAspectRatio="none">
<path d={path} fill="none" stroke="var(--gc-trade-buy, #ef5350)" strokeWidth="1.5" />
</svg>
<div className="ptd-asset-trend-range">
<span>{fmtKrw(min)}</span>
<span>{fmtKrw(max)}</span>
</div>
</div>
);
};
export default PaperAssetTrendChart;
@@ -0,0 +1,47 @@
import React from 'react';
import type { PaperSummaryDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
interface Props {
summary: PaperSummaryDto | null;
}
const PaperInvestmentKpi: React.FC<Props> = ({ summary: s }) => {
if (!s) return <p className="ptd-muted"> </p>;
const retTone = (s.totalReturnPct ?? 0) >= 0 ? 'up' : 'down';
const unTone = (s.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down';
return (
<div className="ptd-invest-kpi">
<div className="ptd-invest-kpi-row ptd-invest-kpi-row--main">
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.totalAsset)}</span>
</div>
<div className={`ptd-invest-kpi-cell ptd-invest-kpi-cell--${retTone}`}>
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{(s.totalReturnPct ?? 0) >= 0 ? '+' : ''}{(s.totalReturnPct ?? 0).toFixed(2)}%</span>
</div>
<div className={`ptd-invest-kpi-cell ptd-invest-kpi-cell--${unTone}`}>
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.unrealizedPnl)}</span>
</div>
</div>
<div className="ptd-invest-kpi-row">
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.cashBalance)}</span>
</div>
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.realizedPnl)}</span>
</div>
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.orderableCash ?? s.cashBalance)}</span>
</div>
</div>
</div>
);
};
export default PaperInvestmentKpi;
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { loadPaperLedger, type PaperLedgerEntryDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
const ENTRY_LABELS: Record<string, string> = {
INITIAL: '초기 예탁',
BUY_SETTLE: '매수 결제',
SELL_SETTLE: '매도 입금',
RESET: '계좌 초기화',
FEE: '수수료',
ADJUST: '조정',
};
interface Props {
refreshKey?: number;
}
const PaperLedgerTab: React.FC<Props> = ({ refreshKey = 0 }) => {
const [entries, setEntries] = useState<PaperLedgerEntryDto[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
void loadPaperLedger({ page: 0, size: 50 }).then(page => {
if (!cancelled) {
setEntries(page.content);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [refreshKey]);
if (loading) return <p className="ptd-muted"> </p>;
if (!entries.length) return <p className="ptd-muted"> .</p>;
return (
<table className="ptd-table ptd-table--compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id}>
<td className="ptd-time">{e.createdAt?.slice(5, 16).replace('T', ' ') ?? '—'}</td>
<td>{ENTRY_LABELS[e.entryType] ?? e.entryType}</td>
<td className={e.amount >= 0 ? 'up' : 'down'}>{e.amount >= 0 ? '+' : ''}{fmtKrw(e.amount)}</td>
<td>{fmtKrw(e.cashAfter)}</td>
<td>{e.symbol?.replace(/^KRW-/, '') ?? '—'}</td>
</tr>
))}
</tbody>
</table>
);
};
export default PaperLedgerTab;
@@ -6,9 +6,10 @@ import PaperSplitPanel from './PaperSplitPanel';
interface Props {
onSettingsSaved?: () => void;
onAccountReset?: () => void;
onOpenSettings?: () => void;
}
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset }) => {
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset, onOpenSettings }) => {
const { defaults, save, isLoaded } = useAppSettings();
if (!isLoaded) {
@@ -28,6 +29,11 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
const top = (
<>
{onOpenSettings && (
<button type="button" className="bps-btn bps-btn--ghost ptd-left-settings-link" onClick={onOpenSettings}>
</button>
)}
<div className="ptd-left-section">
<label className="ptd-left-row">
<span> </span>
@@ -0,0 +1,58 @@
import React, { useCallback } from 'react';
import type { PaperOrderDto } from '../../utils/backendApi';
import { cancelPaperOrder } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
interface Props {
orders: PaperOrderDto[];
onChanged?: () => void;
}
const PaperPendingOrdersList: React.FC<Props> = ({ orders, onChanged }) => {
const handleCancel = useCallback(async (id: number) => {
if (!window.confirm('미체결 주문을 취소할까요?')) return;
await cancelPaperOrder(id);
onChanged?.();
}, [onChanged]);
if (!orders.length) {
return <p className="ptd-muted"> .</p>;
}
return (
<table className="ptd-table ptd-table--compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{orders.map(o => (
<tr key={o.id}>
<td>{coinCode(o.symbol)}</td>
<td className={o.side === 'BUY' ? 'up' : 'down'}>{o.side === 'BUY' ? '매수' : '매도'}</td>
<td>{fmtKrw(o.limitPrice ?? 0)}</td>
<td>{o.quantity}</td>
<td>{o.side === 'BUY' ? fmtKrw(o.reservedCash) : o.reservedQty}</td>
<td>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void handleCancel(o.id)}>
</button>
</td>
</tr>
))}
</tbody>
</table>
);
};
export default PaperPendingOrdersList;
@@ -129,6 +129,20 @@ const PaperTradeHistoryList: React.FC<Props> = ({
{strategyName}
</span>
</div>
{!isBuy && t.realizedPnlDelta != null && (
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"></span>
<span className={`vtd-trade-meta-value ${t.realizedPnlDelta >= 0 ? 'vtd-target-up' : 'vtd-target-dn'}`}>
{t.realizedPnlDelta >= 0 ? '+' : ''}{fmtKrw(t.realizedPnlDelta)}
</span>
</div>
)}
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"> </span>
<span className="vtd-trade-meta-value">{fmtKrw(t.cashAfter)}</span>
</div>
</div>
);
})}