모의투자 관리 기능 추가
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user