Jelajahi Sumber

feat(paper): D4 M3(frontend) + 프로필 모의투자 성적 — 모의투자 화면

- /paper 홈(계좌 카드·입출금 모달·리더보드 Top10·체결규칙 고지),
  /paper/trade(종목검색·주문·산정결과), /paper/portfolio(보유/주문/입출금
  3탭·주문취소), /paper/leaderboard(다열·정렬3·기간3 SSR).
- API 프록시: /api/paper/[...path](GET/POST/DELETE), /api/wallet(가용 토큰).
  SSR 익명(리더보드·트랙레코드)은 lib/api/paper.ts.
- 프로필 "모의투자 성적" 탭(/user/[sid]/(tabs)/paper) + Header navTabs 모의투자.
- types/paper.ts, lib/utils/paper.ts(bp/토큰 포맷). 전 카피 "토큰" 용어.

주의: Paper API enum 은 숫자 직렬화(Web.Api JsonStringEnumConverter 부재) →
프론트도 숫자 기준으로 정합. Stocks(문자열) 컨벤션 통일은 후속.
npm run build exit 0 (66 pages), lint 클린.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 minggu lalu
induk
melakukan
815e74b2c4

+ 132 - 0
app/(main)/paper/_components/PaperAccountCard.tsx

@@ -0,0 +1,132 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { fetchApi, cn } from '@/lib/utils/client';
+import { formatBpPercent, bpDirection, formatTokenNumber } from '@/lib/utils/paper';
+import type {
+	PaperAccountResponse,
+	PaperMyRankResponse
+} from '@/types/paper';
+import Loading from '@/app/component/Loading';
+import PaperDepositWithdrawModal from './PaperDepositWithdrawModal';
+
+// 내 계좌 카드 — 잔액/예약/평가자산/좌수/누적수익률/순위 + 입금·출금 모달.
+// 계좌 미개설(hasAccount=false) 시 첫 입금 안내.
+export default function PaperAccountCard()
+{
+	const [account, setAccount] = useState<PaperAccountResponse|null>(null);
+	const [rank, setRank] = useState<PaperMyRankResponse|null>(null);
+	const [loading, setLoading] = useState(true);
+	const [modal, setModal] = useState<'deposit'|'withdraw'|null>(null);
+
+	const load = useCallback(async () => {
+		setLoading(true);
+
+		const [accRes, rankRes] = await Promise.all([
+			fetchApi<PaperAccountResponse>('/api/paper/account', { silent: true }),
+			fetchApi<PaperMyRankResponse>('/api/paper/leaderboard/me?sort=return&period=all', { silent: true })
+		]);
+
+		setAccount(accRes.success ? (accRes.data ?? null) : null);
+		setRank(rankRes.success ? (rankRes.data ?? null) : null);
+		setLoading(false);
+	}, []);
+
+	useEffect(() => {
+		load();
+	}, [load]);
+
+	const handleDone = useCallback(() => {
+		setModal(null);
+		load();
+	}, [load]);
+
+	if (loading) {
+		return (
+			<div className='paper__account-card'>
+				<Loading type={2} />
+			</div>
+		);
+	}
+
+	if (!account || !account.hasAccount) {
+		return (
+			<>
+				<div className='paper__empty-account'>
+					<div className='paper__empty-account-title'>아직 모의투자 계좌가 없어요</div>
+					<p className='paper__empty-account-desc'>
+						지갑 토큰을 입금하면 계좌가 자동으로 개설됩니다.<br />
+						입금한 토큰으로 실제 종목을 매매하고, 언제든 다시 지갑으로 출금할 수 있어요.
+					</p>
+					<button type='button' className='paper__btn paper__btn--primary' onClick={() => setModal('deposit')}>
+						첫 입금하고 시작하기
+					</button>
+				</div>
+
+				{modal === 'deposit' && (
+					<PaperDepositWithdrawModal
+						mode='deposit'
+						account={null}
+						onClose={() => setModal(null)}
+						onDone={handleDone}
+					/>
+				)}
+			</>
+		);
+	}
+
+	const returnDir = bpDirection(account.cumReturnBp);
+
+	return (
+		<>
+			<div className='paper__account-card'>
+				<div className='paper__metrics'>
+					<div className='paper__metric'>
+						<span className='paper__metric-label'>가용 토큰</span>
+						<span className='paper__metric-value'>{formatTokenNumber(account.token)}</span>
+					</div>
+					<div className='paper__metric'>
+						<span className='paper__metric-label'>매수 예약</span>
+						<span className='paper__metric-value'>{formatTokenNumber(account.reservedToken)}</span>
+					</div>
+					<div className='paper__metric'>
+						<span className='paper__metric-label'>평가자산</span>
+						<span className='paper__metric-value'>{formatTokenNumber(account.equity)}</span>
+					</div>
+					<div className='paper__metric'>
+						<span className='paper__metric-label'>보유 좌수</span>
+						<span className='paper__metric-value'>{account.units.toLocaleString('ko-KR', { maximumFractionDigits: 4 })}</span>
+					</div>
+					<div className='paper__metric'>
+						<span className='paper__metric-label'>누적 수익률</span>
+						<span className={cn('paper__metric-value', `paper__value--${returnDir}`)}>{formatBpPercent(account.cumReturnBp)}</span>
+					</div>
+					<div className='paper__metric'>
+						<span className='paper__metric-label'>순위 (수익률·전체)</span>
+						<span className='paper__metric-value'>
+							{rank && rank.hasRank ? `${rank.rank.toLocaleString()}위 / ${rank.total.toLocaleString()}` : '미등재'}
+						</span>
+					</div>
+				</div>
+
+				<div className='paper__actions'>
+					<button type='button' className='paper__btn paper__btn--primary' onClick={() => setModal('deposit')}>
+						입금
+					</button>
+					<button type='button' className='paper__btn' onClick={() => setModal('withdraw')}>
+						출금
+					</button>
+				</div>
+			</div>
+
+			{modal && (
+				<PaperDepositWithdrawModal
+					mode={modal}
+					account={account}
+					onClose={() => setModal(null)}
+					onDone={handleDone}
+				/>
+			)}
+		</>
+	);
+}

+ 168 - 0
app/(main)/paper/_components/PaperDepositWithdrawModal.tsx

@@ -0,0 +1,168 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { formatTokenNumber } from '@/lib/utils/paper';
+import type { PaperAccountResponse, PaperDepositResponse, PaperWithdrawResponse } from '@/types/paper';
+
+// 지갑 토큰(Reward+Airdrop) 합산용 최소 타입 — GET /api/wallet
+interface WalletBalanceRow {
+	type: string;
+	amount: number;
+}
+interface WalletResponse {
+	balances: WalletBalanceRow[];
+}
+
+type Props = {
+	mode: 'deposit'|'withdraw';
+	account: PaperAccountResponse|null;
+	onClose: () => void;
+	onDone: () => void;
+};
+
+const TOKEN_TYPES = ['Reward', 'Airdrop'];
+
+export default function PaperDepositWithdrawModal({ mode, account, onClose, onDone }: Props)
+{
+	const isDeposit = mode === 'deposit';
+	const [amount, setAmount] = useState('');
+	const [all, setAll] = useState(false);
+	const [walletToken, setWalletToken] = useState<number|null>(null);
+	const [busy, setBusy] = useState(false);
+	const [error, setError] = useState<string|null>(null);
+
+	// 입금 모드: 지갑 가용 토큰 조회
+	useEffect(() => {
+		if (!isDeposit) {
+			return;
+		}
+
+		let cancelled = false;
+
+		(async () => {
+			const res = await fetchApi<WalletResponse>('/api/wallet', { silent: true });
+			if (cancelled) {
+				return;
+			}
+
+			if (res.success && res.data) {
+				const sum = res.data.balances
+					.filter(c => TOKEN_TYPES.includes(c.type))
+					.reduce((acc, c) => acc + c.amount, 0);
+				setWalletToken(sum);
+			}
+			else {
+				setWalletToken(null);
+			}
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, [isDeposit]);
+
+	const submit = useCallback(async () => {
+		setError(null);
+
+		const numeric = Number(amount);
+
+		if (!isDeposit && all) {
+			// 전액 출금 — 금액 불필요
+		}
+		else if (!Number.isInteger(numeric) || numeric <= 0) {
+			setError('1 이상의 정수 토큰 금액을 입력해 주세요.');
+			return;
+		}
+
+		setBusy(true);
+
+		const endpoint = isDeposit ? '/api/paper/account/deposit' : '/api/paper/account/withdraw';
+		const body = isDeposit
+			? { tokenAmount: numeric }
+			: (all ? { all: true } : { tokenAmount: numeric });
+
+		const res = await fetchApi<PaperDepositResponse|PaperWithdrawResponse>(endpoint, {
+			method: 'POST',
+			body,
+			silent: true
+		});
+
+		setBusy(false);
+
+		if (res.success && res.data) {
+			onDone();
+		}
+		else {
+			setError(res.message || `${isDeposit ? '입금' : '출금'}에 실패했습니다.`);
+		}
+	}, [amount, all, isDeposit, onDone]);
+
+	return (
+		<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
+			<DialogContent>
+				<DialogHeader>
+					<DialogTitle>{isDeposit ? '토큰 입금' : '토큰 출금'}</DialogTitle>
+				</DialogHeader>
+
+				<div className='paper-modal__body'>
+					{isDeposit ? (
+						<p className='paper-modal__balance'>
+							지갑 가용 토큰 <strong>{walletToken === null ? '—' : formatTokenNumber(walletToken)}</strong>
+						</p>
+					) : (
+						<p className='paper-modal__balance'>
+							계좌 가용 토큰 <strong>{formatTokenNumber(account?.token ?? 0)}</strong>
+						</p>
+					)}
+
+					{!isDeposit && (
+						<label className='paper-modal__all'>
+							<input
+								type='checkbox'
+								checked={all}
+								onChange={e => setAll(e.target.checked)}
+							/>
+							가용 토큰 전액 출금
+						</label>
+					)}
+
+					{!(!isDeposit && all) && (
+						<div className='paper-modal__field'>
+							<span className='paper-modal__label'>{isDeposit ? '입금' : '출금'} 토큰</span>
+							<input
+								className='paper-modal__input'
+								type='number'
+								min={1}
+								step={1}
+								inputMode='numeric'
+								value={amount}
+								onChange={e => setAmount(e.target.value)}
+								placeholder='토큰 수량'
+								autoFocus
+							/>
+						</div>
+					)}
+
+					<p className='paper-modal__hint'>
+						{isDeposit
+							? '입금한 토큰만큼 좌수가 발행되며, 지갑 토큰이 차감됩니다.'
+							: '출금한 토큰만큼 좌수가 소각되며, 지갑 토큰으로 환급됩니다.'}
+					</p>
+
+					{error && <p className='paper-modal__error' role='alert'>{error}</p>}
+
+					<div className='paper-modal__footer'>
+						<button type='button' className='paper__btn' onClick={onClose} disabled={busy}>
+							취소
+						</button>
+						<button type='button' className='paper__btn paper__btn--primary' onClick={submit} disabled={busy}>
+							{busy ? '처리 중...' : (isDeposit ? '입금' : '출금')}
+						</button>
+					</div>
+				</div>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 76 - 0
app/(main)/paper/_components/PaperLeaderboardMini.tsx

@@ -0,0 +1,76 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { cn, fetchApi } from '@/lib/utils/client';
+import { formatBpPercent, bpDirection } from '@/lib/utils/paper';
+import type { PaperLeaderboardResponse, PaperLeaderboardRow } from '@/types/paper';
+
+// 홈 리더보드 Top10 미니 — 수익률·전체 기준
+export default function PaperLeaderboardMini()
+{
+	const [rows, setRows] = useState<PaperLeaderboardRow[]>([]);
+	const [loading, setLoading] = useState(true);
+
+	useEffect(() => {
+		let cancelled = false;
+
+		(async () => {
+			const res = await fetchApi<PaperLeaderboardResponse>('/api/paper/leaderboard?sort=return&period=all&page=1', { silent: true });
+			if (cancelled) {
+				return;
+			}
+
+			setRows(res.success ? (res.data?.rows ?? []).slice(0, 10) : []);
+			setLoading(false);
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, []);
+
+	return (
+		<section className='paper__section'>
+			<div className='paper__section-head'>
+				<h2 className='paper__section-title'>리더보드 Top 10</h2>
+				<Link href='/paper/leaderboard' className='paper__section-link'>전체 보기</Link>
+			</div>
+
+			{loading ? (
+				<div className='paper__empty'>불러오는 중...</div>
+			) : rows.length === 0 ? (
+				<div className='paper__empty'>아직 등재된 참여자가 없습니다.</div>
+			) : (
+				<div className='paper__table-wrap'>
+					<table className='paper__table'>
+						<thead>
+							<tr>
+								<th className='paper__th--center'>순위</th>
+								<th className='paper__th--left'>닉네임</th>
+								<th>수익률</th>
+							</tr>
+						</thead>
+						<tbody>
+							{rows.map(row => {
+								const dir = bpDirection(row.returnBp);
+
+								return (
+									<tr key={row.memberSID}>
+										<td className='paper__td--center paper__rank'>{row.rank}</td>
+										<td className='paper__td--left'>
+											<Link href={`/user/${row.memberSID}/paper`} className='paper__nick-name'>
+												{row.nickname || '익명'}
+											</Link>
+										</td>
+										<td className={cn(`paper__value--${dir}`)}>{formatBpPercent(row.returnBp)}</td>
+									</tr>
+								);
+							})}
+						</tbody>
+					</table>
+				</div>
+			)}
+		</section>
+	);
+}

+ 35 - 0
app/(main)/paper/_components/PaperNav.tsx

@@ -0,0 +1,35 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+const TABS = [
+	{ href: '/paper', label: '홈' },
+	{ href: '/paper/trade', label: '주문' },
+	{ href: '/paper/portfolio', label: '포트폴리오' },
+	{ href: '/paper/leaderboard', label: '리더보드' }
+];
+
+export default function PaperNav()
+{
+	const pathname = usePathname();
+
+	return (
+		<nav className='paper__nav' aria-label='모의투자 메뉴'>
+			{TABS.map(tab => {
+				const active = tab.href === '/paper' ? pathname === '/paper' : pathname.startsWith(tab.href);
+
+				return (
+					<Link
+						key={tab.href}
+						href={tab.href}
+						className={`paper__nav-link${active ? ' paper__nav-link--active' : ''}`}
+						aria-current={active ? 'page' : undefined}
+					>
+						{tab.label}
+					</Link>
+				);
+			})}
+		</nav>
+	);
+}

+ 17 - 0
app/(main)/paper/_components/PaperNotice.tsx

@@ -0,0 +1,17 @@
+// 체결 규칙 고지 박스 (d4 §③ 체결 규칙) — 홈·주문 페이지 공용. 서버 컴포넌트 (정적 텍스트)
+
+export default function PaperNotice()
+{
+	return (
+		<div className='paper__notice' role='note'>
+			<div className='paper__notice-title'>체결 규칙 안내</div>
+			<ul className='paper__notice-list'>
+				<li>주문은 24시간 접수되며, 접수 시각에 따라 시가 또는 종가로 체결됩니다.</li>
+				<li>장 시작 전(09:00 이전) 접수 = 당일 시가, 장중(09:00~15:30) 접수 = 당일 종가, 이후·휴장일 접수 = 다음 영업일 시가.</li>
+				<li>실제 체결(기표)은 기준 시세가 도착하는 <strong>최대 1영업일 뒤</strong>에 이루어집니다. (지연 체결형)</li>
+				<li>취소는 산정된 취소 가능 시한 이전까지만 가능합니다.</li>
+				<li>1주문 금액은 계좌 평가자산의 30%를 초과할 수 없습니다.</li>
+			</ul>
+		</div>
+	);
+}

+ 347 - 0
app/(main)/paper/_components/PaperPortfolio.tsx

@@ -0,0 +1,347 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import Link from 'next/link';
+import { cn, fetchApi, getDateTime } from '@/lib/utils/client';
+import { bpDirection, formatBpPercent, formatTokenNumber } from '@/lib/utils/paper';
+import Loading from '@/app/component/Loading';
+import Pagination from '@/app/component/Pagination';
+import {
+	PaperOrderStatus,
+	PAPER_SIDE_LABEL,
+	PAPER_FILL_RULE_LABEL,
+	PAPER_ORDER_STATUS_LABEL,
+	PAPER_LEDGER_TYPE_LABEL,
+	PaperOrderSide,
+	PaperLedgerType
+} from '@/types/paper';
+import type {
+	PaperPositionsResponse,
+	PaperPositionRow,
+	PaperOrdersResponse,
+	PaperOrderRow,
+	PaperLedgerResponse,
+	PaperLedgerRow,
+	PaperOrderStatusValue
+} from '@/types/paper';
+
+type TabKey = 'positions'|'orders'|'ledger';
+const PER_PAGE = 20;
+
+const STATUS_BADGE_CLASS: Record<PaperOrderStatusValue, string> = {
+	[PaperOrderStatus.Pending]: 'paper__badge--pending',
+	[PaperOrderStatus.Filled]: 'paper__badge--filled',
+	[PaperOrderStatus.Cancelled]: 'paper__badge--cancelled',
+	[PaperOrderStatus.Rejected]: 'paper__badge--rejected'
+};
+
+export default function PaperPortfolio()
+{
+	const [tab, setTab] = useState<TabKey>('positions');
+
+	return (
+		<div>
+			<div className='paper__tabs'>
+				<button type='button' className={cn('paper__tab', tab === 'positions' && 'paper__tab--active')} onClick={() => setTab('positions')}>보유</button>
+				<button type='button' className={cn('paper__tab', tab === 'orders' && 'paper__tab--active')} onClick={() => setTab('orders')}>주문</button>
+				<button type='button' className={cn('paper__tab', tab === 'ledger' && 'paper__tab--active')} onClick={() => setTab('ledger')}>입출금</button>
+			</div>
+
+			{tab === 'positions' && <PositionsTab />}
+			{tab === 'orders' && <OrdersTab />}
+			{tab === 'ledger' && <LedgerTab />}
+		</div>
+	);
+}
+
+// ── 보유 포지션 ──
+function PositionsTab()
+{
+	const [rows, setRows] = useState<PaperPositionRow[]>([]);
+	const [loading, setLoading] = useState(true);
+
+	useEffect(() => {
+		let cancelled = false;
+
+		(async () => {
+			const res = await fetchApi<PaperPositionsResponse>('/api/paper/positions', { silent: true });
+			if (cancelled) {
+				return;
+			}
+
+			setRows(res.success ? (res.data?.list ?? []) : []);
+			setLoading(false);
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, []);
+
+	if (loading) {
+		return <Loading type={2} />;
+	}
+
+	if (rows.length === 0) {
+		return <div className='paper__empty'>보유 중인 종목이 없습니다.</div>;
+	}
+
+	return (
+		<div className='paper__table-wrap'>
+			<table className='paper__table'>
+				<thead>
+					<tr>
+						<th className='paper__th--left'>종목</th>
+						<th>보유수량</th>
+						<th>매도예약</th>
+						<th>평균단가</th>
+						<th>현재가</th>
+						<th>평가금액</th>
+						<th>평가손익</th>
+						<th>수익률</th>
+					</tr>
+				</thead>
+				<tbody>
+					{rows.map(row => {
+						const dir = bpDirection(row.unrealizedPnLBp);
+
+						return (
+							<tr key={row.stockCode}>
+								<td className='paper__td--left'>
+									<Link href={`/stock/${row.stockCode}`} className='paper__stock-name'>{row.stockName}</Link>
+									<span className='paper__stock-code'>{row.stockCode}</span>
+								</td>
+								<td>{row.quantity.toLocaleString()}</td>
+								<td>{row.reservedQuantity.toLocaleString()}</td>
+								<td>{row.avgPrice.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}</td>
+								<td>{row.latestClose === null ? '—' : row.latestClose.toLocaleString()}</td>
+								<td>{formatTokenNumber(row.marketValue)}</td>
+								<td className={cn(`paper__value--${dir}`)}>{formatTokenNumber(row.unrealizedPnL)}</td>
+								<td className={cn(`paper__value--${dir}`)}>{formatBpPercent(row.unrealizedPnLBp)}</td>
+							</tr>
+						);
+					})}
+				</tbody>
+			</table>
+		</div>
+	);
+}
+
+// ── 주문·체결 내역 ──
+function OrdersTab()
+{
+	const [rows, setRows] = useState<PaperOrderRow[]>([]);
+	const [total, setTotal] = useState(0);
+	const [page, setPage] = useState(1);
+	const [status, setStatus] = useState<PaperOrderStatusValue|''>('');
+	const [loading, setLoading] = useState(true);
+	const [cancelingID, setCancelingID] = useState<number|null>(null);
+
+	const load = useCallback(async () => {
+		setLoading(true);
+
+		const query = new URLSearchParams();
+		query.set('page', String(page));
+		query.set('perPage', String(PER_PAGE));
+		if (status !== '') {
+			query.set('status', String(status));
+		}
+
+		const res = await fetchApi<PaperOrdersResponse>(`/api/paper/orders?${query.toString()}`, { silent: true });
+
+		if (res.success && res.data) {
+			setRows(res.data.list);
+			setTotal(res.data.total);
+		}
+		else {
+			setRows([]);
+			setTotal(0);
+		}
+
+		setLoading(false);
+	}, [page, status]);
+
+	useEffect(() => {
+		load();
+	}, [load]);
+
+	const cancelOrder = useCallback(async (id: number) => {
+		if (!confirm('주문을 취소하시겠어요?')) {
+			return;
+		}
+
+		setCancelingID(id);
+		const res = await fetchApi(`/api/paper/orders/${id}`, { method: 'DELETE', silent: true });
+		setCancelingID(null);
+
+		if (res.success) {
+			load();
+		}
+		else {
+			alert(res.message || '주문 취소에 실패했습니다.');
+		}
+	}, [load]);
+
+	const canCancel = (row: PaperOrderRow) => row.status === PaperOrderStatus.Pending && new Date(row.cancelableUntil).getTime() > Date.now();
+
+	return (
+		<div>
+			<div className='paper__tab-group'>
+				<span className='paper__tab-group-label'>상태</span>
+				<button type='button' className={cn('paper__tab', status === '' && 'paper__tab--active')} onClick={() => { setStatus(''); setPage(1); }}>전체</button>
+				<button type='button' className={cn('paper__tab', status === PaperOrderStatus.Pending && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Pending); setPage(1); }}>접수</button>
+				<button type='button' className={cn('paper__tab', status === PaperOrderStatus.Filled && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Filled); setPage(1); }}>체결</button>
+				<button type='button' className={cn('paper__tab', status === PaperOrderStatus.Cancelled && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Cancelled); setPage(1); }}>취소</button>
+				<button type='button' className={cn('paper__tab', status === PaperOrderStatus.Rejected && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Rejected); setPage(1); }}>거부</button>
+			</div>
+
+			{loading ? (
+				<Loading type={2} />
+			) : rows.length === 0 ? (
+				<div className='paper__empty'>주문 내역이 없습니다.</div>
+			) : (
+				<>
+					<div className='paper__table-wrap'>
+						<table className='paper__table'>
+							<thead>
+								<tr>
+									<th className='paper__th--left'>종목</th>
+									<th className='paper__th--center'>구분</th>
+									<th className='paper__th--center'>기준</th>
+									<th>수량</th>
+									<th>예약금액</th>
+									<th>기준일</th>
+									<th className='paper__th--center'>상태</th>
+									<th>체결가</th>
+									<th>실현손익</th>
+									<th className='paper__th--center'>취소</th>
+								</tr>
+							</thead>
+							<tbody>
+								{rows.map(row => {
+									const pnlDir = bpDirection(row.fill?.realizedPnL ?? null);
+
+									return (
+										<tr key={row.id}>
+											<td className='paper__td--left'>
+												<Link href={`/stock/${row.stockCode}`} className='paper__stock-name'>{row.stockName}</Link>
+												<span className='paper__stock-code'>{row.stockCode}</span>
+											</td>
+											<td className='paper__td--center'>
+												<span className={cn('paper__badge', row.side === PaperOrderSide.Buy ? 'paper__badge--buy' : 'paper__badge--sell')}>
+													{PAPER_SIDE_LABEL[row.side]}
+												</span>
+											</td>
+											<td className='paper__td--center'>{PAPER_FILL_RULE_LABEL[row.fillRule]}</td>
+											<td>{row.quantity.toLocaleString()}</td>
+											<td>{formatTokenNumber(row.reservedAmount)}</td>
+											<td>{row.targetDate}</td>
+											<td className='paper__td--center'>
+												<span className={cn('paper__badge', STATUS_BADGE_CLASS[row.status])} title={row.rejectReason ?? undefined}>
+													{PAPER_ORDER_STATUS_LABEL[row.status]}
+												</span>
+											</td>
+											<td>{row.fill ? row.fill.price.toLocaleString() : '—'}</td>
+											<td className={cn(row.fill?.realizedPnL != null && `paper__value--${pnlDir}`)}>
+												{row.fill?.realizedPnL != null ? formatTokenNumber(row.fill.realizedPnL) : '—'}
+											</td>
+											<td className='paper__td--center'>
+												{canCancel(row) ? (
+													<button
+														type='button'
+														className='paper__btn paper__btn--danger paper__btn--sm'
+														onClick={() => cancelOrder(row.id)}
+														disabled={cancelingID === row.id}
+													>
+														{cancelingID === row.id ? '취소 중' : '취소'}
+													</button>
+												) : (
+													'—'
+												)}
+											</td>
+										</tr>
+									);
+								})}
+							</tbody>
+						</table>
+					</div>
+
+					<Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
+				</>
+			)}
+		</div>
+	);
+}
+
+// ── 입출금 내역 ──
+function LedgerTab()
+{
+	const [rows, setRows] = useState<PaperLedgerRow[]>([]);
+	const [total, setTotal] = useState(0);
+	const [page, setPage] = useState(1);
+	const [loading, setLoading] = useState(true);
+
+	const load = useCallback(async () => {
+		setLoading(true);
+
+		const res = await fetchApi<PaperLedgerResponse>(`/api/paper/ledger?page=${page}&perPage=${PER_PAGE}`, { silent: true });
+
+		if (res.success && res.data) {
+			setRows(res.data.list);
+			setTotal(res.data.total);
+		}
+		else {
+			setRows([]);
+			setTotal(0);
+		}
+
+		setLoading(false);
+	}, [page]);
+
+	useEffect(() => {
+		load();
+	}, [load]);
+
+	if (loading) {
+		return <Loading type={2} />;
+	}
+
+	if (rows.length === 0) {
+		return <div className='paper__empty'>입출금 내역이 없습니다.</div>;
+	}
+
+	return (
+		<>
+			<div className='paper__table-wrap'>
+				<table className='paper__table'>
+					<thead>
+						<tr>
+							<th className='paper__th--center'>구분</th>
+							<th>금액</th>
+							<th>좌수 변동</th>
+							<th>이후 잔액</th>
+							<th className='paper__th--left'>일시</th>
+						</tr>
+					</thead>
+					<tbody>
+						{rows.map(row => (
+							<tr key={row.id}>
+								<td className='paper__td--center'>
+									<span className={cn('paper__badge', row.type === PaperLedgerType.Deposit ? 'paper__badge--filled' : 'paper__badge--cancelled')}>
+										{PAPER_LEDGER_TYPE_LABEL[row.type]}
+									</span>
+								</td>
+								<td>{formatTokenNumber(row.tokenAmount)}</td>
+								<td>{row.unitsDelta.toLocaleString('ko-KR', { maximumFractionDigits: 4 })}</td>
+								<td>{formatTokenNumber(row.balanceAfter)}</td>
+								<td className='paper__td--left'>{getDateTime(row.createdAt)}</td>
+							</tr>
+						))}
+					</tbody>
+				</table>
+			</div>
+
+			<Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
+		</>
+	);
+}

+ 238 - 0
app/(main)/paper/_components/PaperTradeForm.tsx

@@ -0,0 +1,238 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import '@/app/styles/header-search.scss';
+import '@/app/styles/stock-common.scss';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
+import { formatTokenNumber } from '@/lib/utils/paper';
+import {
+	PaperOrderSide,
+	PAPER_FILL_RULE_LABEL,
+	PAPER_SIDE_LABEL
+} from '@/types/paper';
+import type {
+	PaperOrderSideValue,
+	PaperAccountResponse,
+	PaperPlaceOrderResponse,
+	PaperFillRuleValue
+} from '@/types/paper';
+import type { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
+
+const DEBOUNCE_MS = 300;
+const LISTBOX_ID = 'paper-trade-suggest-listbox';
+
+// 모의투자 주문 폼 — 종목 검색(suggest) + 매수/매도 + 수량 → POST /api/paper/orders
+export default function PaperTradeForm()
+{
+	const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
+	const requestSeq = useRef(0);
+
+	const [account, setAccount] = useState<PaperAccountResponse|null>(null);
+	const [keyword, setKeyword] = useState('');
+	const [suggests, setSuggests] = useState<StockSuggestItem[]>([]);
+	const [open, setOpen] = useState(false);
+	const [selected, setSelected] = useState<StockSuggestItem|null>(null);
+	const [side, setSide] = useState<PaperOrderSideValue>(PaperOrderSide.Buy);
+	const [quantity, setQuantity] = useState('');
+	const [busy, setBusy] = useState(false);
+	const [error, setError] = useState<string|null>(null);
+	const [result, setResult] = useState<PaperPlaceOrderResponse|null>(null);
+
+	const loadAccount = useCallback(async () => {
+		const res = await fetchApi<PaperAccountResponse>('/api/paper/account', { silent: true });
+		setAccount(res.success ? (res.data ?? null) : null);
+	}, []);
+
+	useEffect(() => {
+		loadAccount();
+	}, [loadAccount]);
+
+	useEffect(() => {
+		return () => {
+			if (debounceRef.current) {
+				clearTimeout(debounceRef.current);
+			}
+		};
+	}, []);
+
+	const requestSuggest = useCallback(async (q: string) => {
+		const seq = ++requestSeq.current;
+		const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
+
+		if (seq !== requestSeq.current) {
+			return;
+		}
+
+		setSuggests(res.success ? (res.data?.list ?? []) : []);
+		setOpen(true);
+	}, []);
+
+	const handleKeywordChange = useCallback((value: string) => {
+		setKeyword(value);
+		setSelected(null);
+
+		if (debounceRef.current) {
+			clearTimeout(debounceRef.current);
+		}
+
+		const trimmed = value.trim();
+		if (!trimmed) {
+			requestSeq.current++;
+			setSuggests([]);
+			setOpen(false);
+			return;
+		}
+
+		debounceRef.current = setTimeout(() => {
+			requestSuggest(trimmed);
+		}, DEBOUNCE_MS);
+	}, [requestSuggest]);
+
+	const pickStock = useCallback((item: StockSuggestItem) => {
+		setSelected(item);
+		setKeyword(`${item.name} (${item.code})`);
+		setSuggests([]);
+		setOpen(false);
+	}, []);
+
+	const submit = useCallback(async () => {
+		setError(null);
+		setResult(null);
+
+		if (!selected) {
+			setError('종목을 선택해 주세요.');
+			return;
+		}
+
+		const qty = Number(quantity);
+		if (!Number.isInteger(qty) || qty <= 0) {
+			setError('1주 이상의 수량을 입력해 주세요.');
+			return;
+		}
+
+		setBusy(true);
+
+		const res = await fetchApi<PaperPlaceOrderResponse>('/api/paper/orders', {
+			method: 'POST',
+			body: { stockCode: selected.code, side, quantity: qty },
+			silent: true
+		});
+
+		setBusy(false);
+
+		if (res.success && res.data) {
+			setResult(res.data);
+			setQuantity('');
+			loadAccount();
+		}
+		else {
+			setError(res.message || '주문 접수에 실패했습니다.');
+		}
+	}, [selected, quantity, side, loadAccount]);
+
+	const fillRuleLabel = (rule: PaperFillRuleValue) => PAPER_FILL_RULE_LABEL[rule] ?? '—';
+
+	return (
+		<div className='paper__account-card'>
+			<div className='paper__metric'>
+				<span className='paper__metric-label'>가용 토큰</span>
+				<span className='paper__metric-value'>{account ? formatTokenNumber(account.token) : '—'}</span>
+			</div>
+
+			<div className='paper-modal__body paper__trade-body'>
+				{/* 종목 검색 */}
+				<div className='paper-modal__field paper__field--suggest'>
+					<span className='paper-modal__label'>종목</span>
+					<input
+						className='paper-modal__input'
+						type='search'
+						value={keyword}
+						onChange={e => handleKeywordChange(e.target.value)}
+						onFocus={() => { if (suggests.length > 0) setOpen(true); }}
+						placeholder='종목명 또는 코드'
+						autoComplete='off'
+						role='combobox'
+						aria-expanded={open}
+						aria-controls={LISTBOX_ID}
+						aria-autocomplete='list'
+					/>
+					{open && suggests.length > 0 && (
+						<ul id={LISTBOX_ID} className='header-suggest' role='listbox' aria-label='종목 자동완성'>
+							{suggests.map(item => (
+								<li
+									key={item.code}
+									role='option'
+									aria-selected={selected?.code === item.code}
+									className='header-suggest__item'
+									onMouseDown={e => { e.preventDefault(); pickStock(item); }}
+								>
+									<span className='header-suggest__name'>{item.name}</span>
+									<span className='header-suggest__code'>{item.code}</span>
+									<span className={`stock-badge stock-badge--${item.market.toLowerCase()}`}>{item.market}</span>
+								</li>
+							))}
+						</ul>
+					)}
+				</div>
+
+				{/* 매수/매도 */}
+				<div className='paper-modal__field'>
+					<span className='paper-modal__label'>주문 구분</span>
+					<div className='paper__tabs'>
+						<button
+							type='button'
+							className={`paper__tab${side === PaperOrderSide.Buy ? ' paper__tab--active' : ''}`}
+							onClick={() => setSide(PaperOrderSide.Buy)}
+						>
+							{PAPER_SIDE_LABEL[PaperOrderSide.Buy]}
+						</button>
+						<button
+							type='button'
+							className={`paper__tab${side === PaperOrderSide.Sell ? ' paper__tab--active' : ''}`}
+							onClick={() => setSide(PaperOrderSide.Sell)}
+						>
+							{PAPER_SIDE_LABEL[PaperOrderSide.Sell]}
+						</button>
+					</div>
+				</div>
+
+				{/* 수량 */}
+				<div className='paper-modal__field'>
+					<span className='paper-modal__label'>수량 (주)</span>
+					<input
+						className='paper-modal__input'
+						type='number'
+						min={1}
+						step={1}
+						inputMode='numeric'
+						value={quantity}
+						onChange={e => setQuantity(e.target.value)}
+						placeholder='주문 수량'
+					/>
+				</div>
+
+				{error && <p className='paper-modal__error' role='alert'>{error}</p>}
+
+				{result && (
+					<div className='paper__notice' role='status'>
+						<div className='paper__notice-title'>주문이 접수되었습니다</div>
+						<ul className='paper__notice-list'>
+							<li>주문번호: {result.orderID}</li>
+							<li>구분: {PAPER_SIDE_LABEL[result.side]} · 체결 기준: {fillRuleLabel(result.fillRule)}</li>
+							<li>수량: {result.quantity.toLocaleString()}주</li>
+							<li>예약 금액: {formatTokenNumber(result.reservedAmount)}</li>
+							<li>체결 기준일(TargetDate): {result.targetDate}</li>
+							<li>취소 가능 시한: {getDateTime(result.cancelableUntil)}</li>
+						</ul>
+					</div>
+				)}
+
+				<div className='paper-modal__footer'>
+					<button type='button' className='paper__btn paper__btn--primary' onClick={submit} disabled={busy}>
+						{busy ? '접수 중...' : '주문 접수'}
+					</button>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 152 - 0
app/(main)/paper/leaderboard/page.tsx

@@ -0,0 +1,152 @@
+import '../style.scss';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import PaperNav from '../_components/PaperNav';
+import { fetchPaperLeaderboard } from '@/lib/api/paper';
+import { changeDirection, changeSymbol } from '@/lib/utils/stock';
+import { formatBpPercent, formatBpAbs, bpDirection } from '@/lib/utils/paper';
+import type { PaperLeaderboardSort, PaperLeaderboardPeriod } from '@/types/paper';
+
+export const metadata: Metadata = {
+	title: '모의투자 리더보드 — 개미투자',
+	description: '수익률·승률·MDD 기준 모의투자 순위 (주간/월간/전체)'
+};
+
+const SORT_TABS: { value: PaperLeaderboardSort; label: string }[] = [
+	{ value: 'return', label: '수익률' },
+	{ value: 'winrate', label: '승률' },
+	{ value: 'mdd', label: 'MDD' }
+];
+
+const PERIOD_TABS: { value: PaperLeaderboardPeriod; label: string }[] = [
+	{ value: 'week', label: '주간' },
+	{ value: 'month', label: '월간' },
+	{ value: 'all', label: '전체' }
+];
+
+const PAGE_SIZE = 50;
+
+type Props = {
+	searchParams: Promise<{
+		sort?: string;
+		period?: string;
+		page?: string;
+	}>;
+};
+
+export default async function PaperLeaderboardPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const sort: PaperLeaderboardSort = SORT_TABS.some(c => c.value === query.sort) ? (query.sort as PaperLeaderboardSort) : 'return';
+	const period: PaperLeaderboardPeriod = PERIOD_TABS.some(c => c.value === query.period) ? (query.period as PaperLeaderboardPeriod) : 'all';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchPaperLeaderboard({ sort, period, page });
+	const rows = res.success ? (res.data?.rows ?? []) : [];
+
+	const buildHref = (next: { sort?: PaperLeaderboardSort; period?: PaperLeaderboardPeriod }) => {
+		const params = new URLSearchParams();
+		params.set('sort', next.sort ?? sort);
+		params.set('period', next.period ?? period);
+		return `/paper/leaderboard?${params.toString()}`;
+	};
+
+	return (
+		<div className='paper'>
+			<div className='paper__head'>
+				<h1 className='paper__title'>리더보드</h1>
+				<span className='paper__subtitle'>순위는 명예·프로필 배지만 부여됩니다 (별도 상금 없음)</span>
+			</div>
+
+			<PaperNav />
+
+			<div className='paper__tab-group'>
+				<span className='paper__tab-group-label'>정렬</span>
+				{SORT_TABS.map(tab => (
+					<Link
+						key={tab.value}
+						href={buildHref({ sort: tab.value })}
+						className={`paper__tab${sort === tab.value ? ' paper__tab--active' : ''}`}
+						{...(sort === tab.value ? { 'aria-current': 'page' as const } : {})}
+					>
+						{tab.label}
+					</Link>
+				))}
+			</div>
+
+			<div className='paper__tab-group'>
+				<span className='paper__tab-group-label'>기간</span>
+				{PERIOD_TABS.map(tab => (
+					<Link
+						key={tab.value}
+						href={buildHref({ period: tab.value })}
+						className={`paper__tab${period === tab.value ? ' paper__tab--active' : ''}`}
+						{...(period === tab.value ? { 'aria-current': 'page' as const } : {})}
+					>
+						{tab.label}
+					</Link>
+				))}
+			</div>
+
+			{!res.success ? (
+				<div className='paper__error' role='alert'>리더보드를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.</div>
+			) : rows.length === 0 ? (
+				<div className='paper__empty'>아직 등재된 참여자가 없습니다.</div>
+			) : (
+				<div className='paper__table-wrap'>
+					<table className='paper__table'>
+						<caption className='sr-only'>모의투자 리더보드 — 순위·닉네임·수익률·승률·MDD·체결수</caption>
+						<thead>
+							<tr>
+								<th className='paper__th--center'>순위</th>
+								<th className='paper__th--left'>닉네임</th>
+								<th>수익률</th>
+								<th>승률</th>
+								<th>MDD</th>
+								<th>체결수</th>
+							</tr>
+						</thead>
+						<tbody>
+							{rows.map(row => {
+								const dir = bpDirection(row.returnBp);
+								const symbol = changeSymbol(changeDirection(row.returnBp));
+
+								return (
+									<tr key={row.memberSID}>
+										<td className='paper__td--center paper__rank'>{row.rank}</td>
+										<td className='paper__td--left'>
+											<span className='paper__nick'>
+												{row.thumb ? (
+													// eslint-disable-next-line @next/next/no-img-element
+													<img src={row.thumb} alt='' className='paper__nick-thumb' />
+												) : null}
+												<Link href={`/user/${row.memberSID}/paper`} className='paper__nick-name'>
+													{row.nickname || '익명'}
+												</Link>
+											</span>
+										</td>
+										<td className={`paper__value--${dir}`}>{symbol} {formatBpPercent(row.returnBp)}</td>
+										<td>{formatBpAbs(row.winRateBp)}</td>
+										<td className='paper__value--down'>{formatBpAbs(row.mddBp)}</td>
+										<td>{row.fillCount.toLocaleString()}</td>
+									</tr>
+								);
+							})}
+						</tbody>
+					</table>
+				</div>
+			)}
+
+			{res.success && rows.length > 0 && (
+				<nav className='paper__actions' aria-label='페이지'>
+					{page > 1 && (
+						<Link href={`/paper/leaderboard?sort=${sort}&period=${period}&page=${page - 1}`} className='paper__btn paper__btn--sm'>이전</Link>
+					)}
+					{rows.length >= PAGE_SIZE && (
+						<Link href={`/paper/leaderboard?sort=${sort}&period=${period}&page=${page + 1}`} className='paper__btn paper__btn--sm'>다음</Link>
+					)}
+				</nav>
+			)}
+		</div>
+	);
+}

+ 31 - 0
app/(main)/paper/page.tsx

@@ -0,0 +1,31 @@
+import './style.scss';
+import { Metadata } from 'next';
+import PaperNav from './_components/PaperNav';
+import PaperAccountCard from './_components/PaperAccountCard';
+import PaperLeaderboardMini from './_components/PaperLeaderboardMini';
+import PaperNotice from './_components/PaperNotice';
+
+export const metadata: Metadata = {
+	title: '모의투자 — 개미투자',
+	description: '토큰으로 실제 종목을 매매하는 모의투자 — 계좌·리더보드·체결 규칙'
+};
+
+export default function PaperHomePage()
+{
+	return (
+		<div className='paper'>
+			<div className='paper__head'>
+				<h1 className='paper__title'>모의투자</h1>
+				<span className='paper__subtitle'>토큰으로 실제 종목을 매매하는 지연 체결형 훈련</span>
+			</div>
+
+			<PaperNav />
+
+			<PaperAccountCard />
+
+			<PaperNotice />
+
+			<PaperLeaderboardMini />
+		</div>
+	);
+}

+ 25 - 0
app/(main)/paper/portfolio/page.tsx

@@ -0,0 +1,25 @@
+import '../style.scss';
+import { Metadata } from 'next';
+import PaperNav from '../_components/PaperNav';
+import PaperPortfolio from '../_components/PaperPortfolio';
+
+export const metadata: Metadata = {
+	title: '모의투자 포트폴리오 — 개미투자',
+	description: '보유 종목·주문 내역·입출금 내역'
+};
+
+export default function PaperPortfolioPage()
+{
+	return (
+		<div className='paper'>
+			<div className='paper__head'>
+				<h1 className='paper__title'>포트폴리오</h1>
+				<span className='paper__subtitle'>보유·주문·입출금 내역</span>
+			</div>
+
+			<PaperNav />
+
+			<PaperPortfolio />
+		</div>
+	);
+}

+ 526 - 0
app/(main)/paper/style.scss

@@ -0,0 +1,526 @@
+// ─────────────────────────────────────────────────────────────
+// 모의투자(Paper Trading) 공용 스타일 — /paper, /paper/trade, /paper/portfolio, /paper/leaderboard + 프로필 성적 탭
+// D6 디자인 토큰만 사용 (색 하드코딩 금지). BEM 네이밍.
+// ─────────────────────────────────────────────────────────────
+
+.paper {
+	max-width: 960px;
+	margin: 0 auto;
+	padding: 16px 16px 40px;
+
+	&__head {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		flex-wrap: wrap;
+		gap: 8px;
+		margin-bottom: 4px;
+	}
+
+	&__title {
+		font-size: 1.5rem;
+		font-weight: 800;
+		color: var(--fg-default);
+	}
+
+	&__subtitle {
+		font-size: 0.8125rem;
+		color: var(--text-muted);
+	}
+
+	// ── 페이지 내 서브 네비 (홈·주문·포트폴리오·리더보드) ──
+	&__nav {
+		display: flex;
+		gap: 4px;
+		flex-wrap: wrap;
+		margin: 12px 0 20px;
+		border-bottom: 1px solid var(--border-default);
+	}
+
+	&__nav-link {
+		padding: 10px 16px;
+		font-size: 0.9375rem;
+		font-weight: 600;
+		color: var(--text-muted);
+		text-decoration: none;
+		border-bottom: 3px solid transparent;
+		margin-bottom: -1px;
+
+		&:hover {
+			color: var(--fg-default);
+		}
+
+		&--active {
+			color: var(--fg-default);
+			border-bottom-color: var(--az-accent);
+		}
+	}
+
+	// ── 계좌 카드 ──
+	&__account-card {
+		border: 1px solid var(--border-default);
+		border-radius: 12px;
+		background: var(--bg-card);
+		padding: 20px;
+		margin-bottom: 20px;
+	}
+
+	&__metrics {
+		display: grid;
+		grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+		gap: 16px;
+	}
+
+	&__metric {
+		display: flex;
+		flex-direction: column;
+		gap: 4px;
+	}
+
+	&__metric-label {
+		font-size: 0.75rem;
+		color: var(--text-muted);
+	}
+
+	&__metric-value {
+		font-size: 1.125rem;
+		font-weight: 700;
+		color: var(--fg-default);
+		font-variant-numeric: tabular-nums;
+	}
+
+	// ── 계좌 액션 버튼 ──
+	&__actions {
+		display: flex;
+		gap: 8px;
+		flex-wrap: wrap;
+		margin-top: 20px;
+	}
+
+	&__btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		gap: 6px;
+		padding: 8px 18px;
+		border-radius: 8px;
+		font-size: 0.875rem;
+		font-weight: 600;
+		cursor: pointer;
+		border: 1px solid var(--border-strong);
+		background: transparent;
+		color: var(--fg-default);
+		transition: background-color 0.15s ease, border-color 0.15s ease;
+
+		&:hover:not(:disabled) {
+			background: var(--bg-subtle);
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+
+		&--primary {
+			background: var(--btn-primary);
+			border-color: var(--btn-primary);
+			color: var(--btn-primary-text);
+
+			&:hover:not(:disabled) {
+				background: var(--btn-primary-hover);
+				border-color: var(--btn-primary-hover);
+			}
+		}
+
+		&--danger {
+			border-color: var(--color-danger);
+			color: var(--color-danger);
+
+			&:hover:not(:disabled) {
+				background: var(--color-danger-bg);
+			}
+		}
+
+		&--sm {
+			padding: 4px 10px;
+			font-size: 0.75rem;
+			border-radius: 6px;
+		}
+	}
+
+	// ── 계좌 미개설 안내 ──
+	&__empty-account {
+		border: 1px dashed var(--border-strong);
+		border-radius: 12px;
+		background: var(--bg-card);
+		padding: 32px 20px;
+		text-align: center;
+		margin-bottom: 20px;
+	}
+
+	&__empty-account-title {
+		font-size: 1.125rem;
+		font-weight: 700;
+		color: var(--fg-default);
+		margin-bottom: 8px;
+	}
+
+	&__empty-account-desc {
+		font-size: 0.875rem;
+		color: var(--text-secondary);
+		line-height: 1.6;
+		margin-bottom: 16px;
+	}
+
+	// ── 체결 규칙 고지 박스 ──
+	&__notice {
+		border: 1px solid var(--color-warning-text);
+		background: var(--color-warning-bg);
+		border-radius: 10px;
+		padding: 14px 16px;
+		margin: 20px 0;
+	}
+
+	&__notice-title {
+		font-size: 0.875rem;
+		font-weight: 700;
+		color: var(--color-warning-text);
+		margin-bottom: 6px;
+	}
+
+	&__notice-list {
+		margin: 0;
+		padding-left: 18px;
+		font-size: 0.8125rem;
+		color: var(--text-secondary);
+		line-height: 1.7;
+	}
+
+	// ── 섹션 ──
+	&__section {
+		margin: 24px 0;
+	}
+
+	&__section-head {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		margin-bottom: 12px;
+	}
+
+	&__section-title {
+		font-size: 1.125rem;
+		font-weight: 700;
+		color: var(--fg-default);
+	}
+
+	&__section-link {
+		font-size: 0.8125rem;
+		color: var(--text-link);
+		text-decoration: none;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	&__empty {
+		text-align: center;
+		color: var(--text-muted);
+		padding: 32px 16px;
+		font-size: 0.9375rem;
+	}
+
+	&__error {
+		border: 1px solid var(--color-danger-border);
+		background: var(--color-danger-bg);
+		border-radius: 10px;
+		padding: 14px 16px;
+		color: var(--color-danger);
+		font-size: 0.875rem;
+	}
+
+	// ── 고밀도 테이블 (포트폴리오·리더보드·주문) ──
+	&__table-wrap {
+		overflow-x: auto;
+		border: 1px solid var(--border-default);
+		border-radius: 10px;
+	}
+
+	&__table {
+		width: 100%;
+		border-collapse: collapse;
+		font-size: 0.8125rem;
+		white-space: nowrap;
+
+		th,
+		td {
+			padding: 8px 12px;
+			text-align: right;
+			font-variant-numeric: tabular-nums;
+		}
+
+		thead th {
+			background: var(--bg-subtle);
+			color: var(--text-muted);
+			font-weight: 600;
+			border-bottom: 1px solid var(--border-default);
+			position: sticky;
+			top: 0;
+		}
+
+		tbody tr {
+			border-bottom: 1px solid var(--border-light);
+
+			&:last-child {
+				border-bottom: none;
+			}
+
+			&:hover {
+				background: var(--bg-subtle-hover);
+			}
+		}
+
+		td.paper__td--left,
+		th.paper__th--left {
+			text-align: left;
+		}
+
+		td.paper__td--center,
+		th.paper__th--center {
+			text-align: center;
+		}
+	}
+
+	&__stock-name {
+		font-weight: 600;
+		color: var(--fg-default);
+	}
+
+	&__stock-code {
+		font-size: 0.6875rem;
+		color: var(--text-muted);
+		margin-left: 6px;
+	}
+
+	// ── 상태 뱃지 ──
+	&__badge {
+		display: inline-block;
+		padding: 1px 8px;
+		border-radius: 999px;
+		font-size: 0.6875rem;
+		font-weight: 600;
+		border: 1px solid var(--border-strong);
+		color: var(--text-secondary);
+
+		&--buy {
+			color: var(--price-up);
+			border-color: var(--price-up);
+		}
+
+		&--sell {
+			color: var(--price-down);
+			border-color: var(--price-down);
+		}
+
+		&--pending {
+			color: var(--color-warning-text);
+			border-color: var(--color-warning-text);
+		}
+
+		&--filled {
+			color: var(--color-success);
+			border-color: var(--color-success);
+		}
+
+		&--cancelled {
+			color: var(--text-muted);
+			border-color: var(--border-strong);
+		}
+
+		&--rejected {
+			color: var(--color-danger);
+			border-color: var(--color-danger);
+		}
+	}
+
+	// ── 값 색 (수익/손실) ──
+	&__value--up {
+		color: var(--price-up);
+	}
+
+	&__value--down {
+		color: var(--price-down);
+	}
+
+	&__value--flat {
+		color: var(--price-flat);
+	}
+
+	// ── 탭 (리더보드 정렬·기간) ──
+	&__tabs {
+		display: flex;
+		gap: 6px;
+		flex-wrap: wrap;
+		margin-bottom: 12px;
+	}
+
+	&__tab {
+		padding: 6px 14px;
+		border-radius: 999px;
+		font-size: 0.8125rem;
+		font-weight: 600;
+		text-decoration: none;
+		border: 1px solid var(--border-default);
+		color: var(--text-secondary);
+		background: transparent;
+
+		&:hover {
+			background: var(--bg-subtle);
+			color: var(--fg-default);
+		}
+
+		&--active {
+			background: var(--az-accent);
+			border-color: var(--az-accent);
+			color: var(--btn-primary-text);
+
+			&:hover {
+				background: var(--az-accent-hover);
+				color: var(--btn-primary-text);
+			}
+		}
+	}
+
+	&__tab-group {
+		display: flex;
+		align-items: center;
+		gap: 10px;
+		flex-wrap: wrap;
+		margin-bottom: 8px;
+	}
+
+	&__tab-group-label {
+		font-size: 0.75rem;
+		color: var(--text-muted);
+		min-width: 44px;
+	}
+
+	// ── 리더보드 순위·닉네임 셀 ──
+	&__rank {
+		font-weight: 700;
+		color: var(--fg-default);
+	}
+
+	&__nick {
+		display: inline-flex;
+		align-items: center;
+		gap: 8px;
+	}
+
+	&__nick-thumb {
+		width: 24px;
+		height: 24px;
+		border-radius: 50%;
+		object-fit: cover;
+		background: var(--bg-subtle);
+		flex-shrink: 0;
+	}
+
+	&__nick-name {
+		color: var(--fg-default);
+		text-decoration: none;
+		font-weight: 600;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	&__me-row {
+		background: var(--sidebar-active-bg);
+	}
+}
+
+// ── 입금/출금 모달 (base-ui Dialog Popup content 확장) ──
+.paper-modal {
+	&__body {
+		display: flex;
+		flex-direction: column;
+		gap: 14px;
+	}
+
+	&__balance {
+		font-size: 0.8125rem;
+		color: var(--text-secondary);
+
+		strong {
+			color: var(--fg-default);
+			font-variant-numeric: tabular-nums;
+		}
+	}
+
+	&__field {
+		display: flex;
+		flex-direction: column;
+		gap: 6px;
+	}
+
+	&__label {
+		font-size: 0.8125rem;
+		font-weight: 600;
+		color: var(--fg-default);
+	}
+
+	&__input {
+		padding: 10px 12px;
+		border-radius: 8px;
+		border: 1px solid var(--border-default);
+		background: var(--bg-input);
+		color: var(--fg-default);
+		font-size: 0.9375rem;
+		font-variant-numeric: tabular-nums;
+		outline: none;
+
+		&:focus {
+			border-color: var(--az-accent);
+		}
+	}
+
+	&__hint {
+		font-size: 0.75rem;
+		color: var(--text-muted);
+	}
+
+	&__error {
+		font-size: 0.8125rem;
+		color: var(--color-danger);
+		margin: 0;
+	}
+
+	// suggest 드롭다운 anchor
+	&__field--suggest {
+		position: relative;
+	}
+
+	// 주문 폼 본문 (가용 토큰 metric 아래 간격)
+	&__trade-body {
+		margin-top: 16px;
+	}
+
+	&__all {
+		display: inline-flex;
+		align-items: center;
+		gap: 6px;
+		font-size: 0.8125rem;
+		color: var(--text-secondary);
+		cursor: pointer;
+	}
+
+	&__footer {
+		display: flex;
+		justify-content: flex-end;
+		gap: 8px;
+		margin-top: 4px;
+	}
+}

+ 28 - 0
app/(main)/paper/trade/page.tsx

@@ -0,0 +1,28 @@
+import '../style.scss';
+import { Metadata } from 'next';
+import PaperNav from '../_components/PaperNav';
+import PaperTradeForm from '../_components/PaperTradeForm';
+import PaperNotice from '../_components/PaperNotice';
+
+export const metadata: Metadata = {
+	title: '모의투자 주문 — 개미투자',
+	description: '종목을 검색해 매수·매도 주문을 접수합니다 (지연 체결형)'
+};
+
+export default function PaperTradePage()
+{
+	return (
+		<div className='paper'>
+			<div className='paper__head'>
+				<h1 className='paper__title'>주문</h1>
+				<span className='paper__subtitle'>종목 검색 후 매수·매도 주문을 접수하세요</span>
+			</div>
+
+			<PaperNav />
+
+			<PaperTradeForm />
+
+			<PaperNotice />
+		</div>
+	);
+}

+ 62 - 0
app/(main)/user/[sid]/(tabs)/paper/page.tsx

@@ -0,0 +1,62 @@
+import '@/app/(main)/paper/style.scss';
+import { TrendingUp, Target, TrendingDown, ListChecks } from 'lucide-react';
+import { fetchPaperTrackRecord } from '@/lib/api/paper';
+import { formatBpPercent, formatBpAbs, bpDirection } from '@/lib/utils/paper';
+
+type Props = {
+	params: Promise<{ sid: string }>;
+};
+
+// 프로필 "모의투자 성적" 탭 (익명 허용 · SSR) — 누적수익률·승률·MDD·체결수
+export default async function UserProfilePaperPage({ params }: Props)
+{
+	const { sid } = await params;
+	const res = await fetchPaperTrackRecord(sid);
+	const record = res.success ? res.data : null;
+
+	if (!res.success) {
+		return <div className='paper__error' role='alert'>모의투자 성적을 불러오지 못했습니다.</div>;
+	}
+
+	if (!record || !record.hasAccount) {
+		return <div className='user-profile__empty'>아직 모의투자에 참여하지 않았습니다.</div>;
+	}
+
+	const returnDir = bpDirection(record.cumReturnBp);
+
+	return (
+		<section className='user-profile__stats' aria-label='모의투자 성적'>
+			<div className='user-profile__stat'>
+				<TrendingUp size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+				<div className='user-profile__stat-body'>
+					<span className='user-profile__stat-label'>누적 수익률</span>
+					<span className={`user-profile__stat-value paper__value--${returnDir}`}>{formatBpPercent(record.cumReturnBp)}</span>
+				</div>
+			</div>
+
+			<div className='user-profile__stat'>
+				<Target size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+				<div className='user-profile__stat-body'>
+					<span className='user-profile__stat-label'>승률</span>
+					<span className='user-profile__stat-value'>{formatBpAbs(record.winRateBp)}</span>
+				</div>
+			</div>
+
+			<div className='user-profile__stat'>
+				<TrendingDown size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+				<div className='user-profile__stat-body'>
+					<span className='user-profile__stat-label'>최대 낙폭 (MDD)</span>
+					<span className='user-profile__stat-value paper__value--down'>{formatBpAbs(record.mddBp)}</span>
+				</div>
+			</div>
+
+			<div className='user-profile__stat'>
+				<ListChecks size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+				<div className='user-profile__stat-body'>
+					<span className='user-profile__stat-label'>체결 수</span>
+					<span className='user-profile__stat-value'>{record.fillCount.toLocaleString()}</span>
+				</div>
+			</div>
+		</section>
+	);
+}

+ 2 - 1
app/(main)/user/[sid]/_component/UserProfileTabs.tsx

@@ -13,7 +13,8 @@ export default function UserProfileTabs({ memberSID }: Props) {
 	const tabs = [
 		{ key: null, label: '게시글', href: `/user/${memberSID}` },
 		{ key: 'comments', label: '댓글', href: `/user/${memberSID}/comments` },
-		{ key: 'feed', label: '피드', href: `/user/${memberSID}/feed` }
+		{ key: 'feed', label: '피드', href: `/user/${memberSID}/feed` },
+		{ key: 'paper', label: '모의투자 성적', href: `/user/${memberSID}/paper` }
 	];
 
 	return (

+ 37 - 0
app/api/paper/[...path]/route.ts

@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/paper/${path.join('/')}`;
+	const url = new URL(request.url);
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
+	return NextResponse.json(res);
+}
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/paper/${path.join('/')}`;
+	const contentType = request.headers.get('content-type') || '';
+	const raw = await request.arrayBuffer();
+
+	if (raw.byteLength > 0) {
+		const res: ResultDto = await fetchJson(endpoint, {
+			method: 'POST',
+			body: raw,
+			headers: contentType ? { 'Content-Type': contentType } : undefined
+		});
+		return NextResponse.json(res);
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST' });
+	return NextResponse.json(res);
+}
+
+export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/paper/${path.join('/')}`;
+	const res: ResultDto = await fetchJson(endpoint, { method: 'DELETE' });
+	return NextResponse.json(res);
+}

+ 11 - 0
app/api/wallet/[[...path]]/route.ts

@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/wallet${path && path.length > 0 ? `/${path.join('/')}` : ''}`;
+	const url = new URL(request.url);
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
+	return NextResponse.json(res);
+}

+ 1 - 0
app/component/Header.tsx

@@ -24,6 +24,7 @@ const channelNavItems = [
 
 const baseNavItems = [
 	{ href: '/stock', label: '종목' },
+	{ href: '/paper', label: '모의투자' },
 	{ href: '/games', label: '게임' },
 	{ href: '/feed/all', label: '피드' },
 	{ href: '/chat', label: '채팅' },

+ 34 - 0
lib/api/paper.ts

@@ -0,0 +1,34 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import {
+	PaperLeaderboardResponse,
+	PaperLeaderboardSort,
+	PaperLeaderboardPeriod,
+	PaperTrackRecordResponse
+} from '@/types/paper';
+import { fetchJson } from '@/lib/utils/server';
+
+// 리더보드 (익명 허용 · SSR). sort: return|winrate|mdd, period: week|month|all
+export async function fetchPaperLeaderboard(params: { sort?: PaperLeaderboardSort; period?: PaperLeaderboardPeriod; page?: number }): Promise<ResultDto<PaperLeaderboardResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.sort) queryParams.set('sort', params.sort);
+	if (params.period) queryParams.set('period', params.period);
+	queryParams.set('page', String(params.page ?? 1));
+
+	return await fetchJson<PaperLeaderboardResponse>(`/api/paper/leaderboard?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: { 'Accept': 'application/json' }
+	});
+}
+
+// 프로필 모의투자 성적 (익명 허용 · 회원 SID 기준)
+export async function fetchPaperTrackRecord(memberSID: string): Promise<ResultDto<PaperTrackRecordResponse>>
+{
+	return await fetchJson<PaperTrackRecordResponse>(`/api/paper/members/${encodeURIComponent(memberSID)}/track-record`, {
+		method: 'GET',
+		headers: { 'Accept': 'application/json' }
+	});
+}

+ 50 - 0
lib/utils/paper.ts

@@ -0,0 +1,50 @@
+// 모의투자 표시용 순수 유틸 (서버·클라이언트 공용) — bp(basis point)·토큰 포맷
+// bp = 만분율. 예: 1234bp = 12.34%
+
+import type { ChangeDirection } from '@/lib/utils/stock';
+
+// bp 부호 방향 (수익률·손익 색 병행 표기용)
+export function bpDirection(bp: number|null|undefined): ChangeDirection {
+	if (bp === null || bp === undefined || bp === 0) {
+		return 'flat';
+	}
+
+	return bp > 0 ? 'up' : 'down';
+}
+
+// bp → % 문자열 (부호 포함). 예: 1234 → "+12.34%"
+export function formatBpPercent(bp: number|null|undefined): string {
+	if (bp === null || bp === undefined) {
+		return '—';
+	}
+
+	const sign = bp > 0 ? '+' : '';
+	return `${sign}${(bp / 100).toFixed(2)}%`;
+}
+
+// bp → % 절대값 (부호 없이, 승률·MDD 등 방향 무의미한 지표용). 예: 6500 → "65.00%"
+export function formatBpAbs(bp: number|null|undefined): string {
+	if (bp === null || bp === undefined) {
+		return '—';
+	}
+
+	return `${(Math.abs(bp) / 100).toFixed(2)}%`;
+}
+
+// 토큰 금액 (천 단위 구분 + "토큰" 단위)
+export function formatToken(value: number|null|undefined): string {
+	if (value === null || value === undefined) {
+		return '—';
+	}
+
+	return `${value.toLocaleString('ko-KR')} 토큰`;
+}
+
+// 토큰 금액 (단위 없이 숫자만)
+export function formatTokenNumber(value: number|null|undefined): string {
+	if (value === null || value === undefined) {
+		return '—';
+	}
+
+	return value.toLocaleString('ko-KR');
+}

+ 202 - 0
types/paper.ts

@@ -0,0 +1,202 @@
+// 모의투자(Paper Trading) 도메인 타입 — Backend Application/Features/Api/Paper/*/Response.cs 대응 (camelCase 직렬화)
+// ⚠️ enum 은 Web.Api 에 JsonStringEnumConverter 미등록 → byte 값(숫자)으로 직렬화됨.
+//    (Stocks 는 Response 에서 .ToString() 으로 문자열화했으나 Paper 는 raw enum 타입 그대로라 숫자 전송)
+
+// ── enum (byte 값, d4 §③ / Domain/Entities/Paper/ValueObject/*) ──
+export const PaperOrderSide = {
+	Buy: 1,
+	Sell: 2
+} as const;
+export type PaperOrderSideValue = (typeof PaperOrderSide)[keyof typeof PaperOrderSide];
+
+export const PaperFillRule = {
+	Open: 1,
+	Close: 2
+} as const;
+export type PaperFillRuleValue = (typeof PaperFillRule)[keyof typeof PaperFillRule];
+
+export const PaperOrderStatus = {
+	Pending: 1,
+	Filled: 2,
+	Cancelled: 3,
+	Rejected: 4
+} as const;
+export type PaperOrderStatusValue = (typeof PaperOrderStatus)[keyof typeof PaperOrderStatus];
+
+export const PaperLedgerType = {
+	Deposit: 1,
+	Withdraw: 2
+} as const;
+export type PaperLedgerTypeValue = (typeof PaperLedgerType)[keyof typeof PaperLedgerType];
+
+// ── 표시 라벨 ──
+export const PAPER_SIDE_LABEL: Record<PaperOrderSideValue, string> = {
+	[PaperOrderSide.Buy]: '매수',
+	[PaperOrderSide.Sell]: '매도'
+};
+
+export const PAPER_FILL_RULE_LABEL: Record<PaperFillRuleValue, string> = {
+	[PaperFillRule.Open]: '시가',
+	[PaperFillRule.Close]: '종가'
+};
+
+export const PAPER_ORDER_STATUS_LABEL: Record<PaperOrderStatusValue, string> = {
+	[PaperOrderStatus.Pending]: '접수',
+	[PaperOrderStatus.Filled]: '체결',
+	[PaperOrderStatus.Cancelled]: '취소',
+	[PaperOrderStatus.Rejected]: '거부'
+};
+
+export const PAPER_LEDGER_TYPE_LABEL: Record<PaperLedgerTypeValue, string> = {
+	[PaperLedgerType.Deposit]: '입금',
+	[PaperLedgerType.Withdraw]: '출금'
+};
+
+// ── GET /api/paper/account ──
+export interface PaperAccountResponse {
+	hasAccount: boolean;
+	token: number;
+	reservedToken: number;
+	units: number;
+	equity: number;
+	cumReturnBp: number;
+	positionsCount: number;
+}
+
+// ── POST /api/paper/account/deposit ──
+export interface PaperDepositResponse {
+	token: number;
+	units: number;
+	unitsIssued: number;
+}
+
+// ── POST /api/paper/account/withdraw ──
+export interface PaperWithdrawResponse {
+	token: number;
+	units: number;
+	unitsBurned: number;
+	credited: number;
+	burned: number;
+}
+
+// ── GET /api/paper/positions ──
+export interface PaperPositionRow {
+	stockCode: string;
+	stockName: string;
+	quantity: number;
+	reservedQuantity: number;
+	avgPrice: number;
+	latestClose: number|null;
+	marketValue: number;
+	unrealizedPnL: number;
+	unrealizedPnLBp: number;
+}
+
+export interface PaperPositionsResponse {
+	list: PaperPositionRow[];
+}
+
+// ── POST /api/paper/orders ──
+export interface PaperPlaceOrderResponse {
+	orderID: number;
+	side: PaperOrderSideValue;
+	fillRule: PaperFillRuleValue;
+	targetDate: string;
+	cancelableUntil: string;
+	quantity: number;
+	reservedAmount: number;
+}
+
+// ── GET /api/paper/orders ──
+export interface PaperFillInfo {
+	price: number;
+	quantity: number;
+	fee: number;
+	tax: number;
+	amount: number;
+	realizedPnL: number|null;
+	priceDate: string;
+	filledAt: string;
+}
+
+export interface PaperOrderRow {
+	id: number;
+	stockCode: string;
+	stockName: string;
+	side: PaperOrderSideValue;
+	fillRule: PaperFillRuleValue;
+	quantity: number;
+	reservedAmount: number;
+	targetDate: string;
+	cancelableUntil: string;
+	status: PaperOrderStatusValue;
+	rejectReason: string|null;
+	createdAt: string;
+	fill: PaperFillInfo|null;
+}
+
+export interface PaperOrdersResponse {
+	total: number;
+	list: PaperOrderRow[];
+}
+
+// ── GET /api/paper/ledger ──
+export interface PaperLedgerRow {
+	id: number;
+	type: PaperLedgerTypeValue;
+	tokenAmount: number;
+	unitsDelta: number;
+	balanceAfter: number;
+	createdAt: string;
+}
+
+export interface PaperLedgerResponse {
+	total: number;
+	list: PaperLedgerRow[];
+}
+
+// ── GET /api/paper/leaderboard ──
+export type PaperLeaderboardSort = 'return'|'winrate'|'mdd';
+export type PaperLeaderboardPeriod = 'week'|'month'|'all';
+
+export interface PaperLeaderboardRow {
+	rank: number;
+	memberSID: string;
+	nickname: string|null;
+	thumb: string|null;
+	returnBp: number;
+	winRateBp: number;
+	mddBp: number;
+	fillCount: number;
+}
+
+export interface PaperLeaderboardResponse {
+	sort: string;
+	period: string;
+	page: number;
+	pageSize: number;
+	rows: PaperLeaderboardRow[];
+}
+
+// ── GET /api/paper/leaderboard/me ──
+export interface PaperMyRankResponse {
+	sort: string;
+	period: string;
+	hasRank: boolean;
+	rank: number;
+	total: number;
+	returnBp: number;
+	winRateBp: number;
+	mddBp: number;
+	fillCount: number;
+}
+
+// ── GET /api/paper/members/{sid}/track-record ──
+export interface PaperTrackRecordResponse {
+	hasAccount: boolean;
+	cumReturnBp: number;
+	mddBp: number;
+	winRateBp: number;
+	fillCount: number;
+	latestTradeDate: string|null;
+}