goldenChat base source add
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 모의투자 화면 — 계좌 요약, 보유종목, 체결내역
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
resetPaperAccount,
|
||||
type PaperPositionDto,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { fmtKrw } from './TradeOrderPanel';
|
||||
|
||||
type TabId = 'overview' | 'positions' | 'trades';
|
||||
|
||||
interface Props {
|
||||
tickers?: Map<string, { tradePrice: number | null }>;
|
||||
onGoChart?: (market: string) => void;
|
||||
/** 외부 체결(알림 모달 등) 후 목록 갱신 트리거 */
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function buildMarkPrices(
|
||||
summary: PaperSummaryDto | null,
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): Record<string, number> {
|
||||
const m: Record<string, number> = {};
|
||||
if (summary?.positions) {
|
||||
for (const p of summary.positions) {
|
||||
const t = tickers?.get(p.symbol)?.tradePrice;
|
||||
if (t != null && t > 0) m[p.symbol] = t;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function positionPriceKey(
|
||||
positions: PaperPositionDto[] | undefined,
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): string {
|
||||
if (!positions?.length) return '';
|
||||
return positions
|
||||
.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({ tickers, onGoChart, refreshKey = 0 }) => {
|
||||
const [tab, setTab] = useState<TabId>('overview');
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const tickersRef = useRef(tickers);
|
||||
tickersRef.current = tickers;
|
||||
|
||||
const summaryRef = useRef(summary);
|
||||
summaryRef.current = summary;
|
||||
|
||||
const loadData = useCallback(async (opts?: { showSpinner?: boolean }) => {
|
||||
const showSpinner = opts?.showSpinner ?? false;
|
||||
if (showSpinner) setRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const base = await loadPaperSummary();
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
const full = Object.keys(marks).length > 0
|
||||
? await loadPaperSummary(marks)
|
||||
: base;
|
||||
setSummary(full);
|
||||
setTrades(await loadPaperTrades());
|
||||
lastPriceKeyRef.current = positionPriceKey(full?.positions, tickersRef.current);
|
||||
} catch {
|
||||
if (showSpinner) setError('모의투자 데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (showSpinner) setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 최초 진입·수동 새로고침 */
|
||||
const refresh = useCallback(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshKey <= 0) return;
|
||||
void loadData();
|
||||
}, [refreshKey, loadData]);
|
||||
|
||||
/** 보유 종목 시세만 조용히 갱신 (tickers Map 참조 변경마다 전체 로딩 방지) */
|
||||
const priceKey = positionPriceKey(summary?.positions, tickers);
|
||||
const lastPriceKeyRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!priceKey || initialLoading) return;
|
||||
if (priceKey === lastPriceKeyRef.current) return;
|
||||
lastPriceKeyRef.current = priceKey;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const base = summaryRef.current;
|
||||
if (!base?.positions?.length) return;
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
if (Object.keys(marks).length === 0) return;
|
||||
try {
|
||||
const full = await loadPaperSummary(marks);
|
||||
if (!cancelled) setSummary(full);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [priceKey, initialLoading]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 보유 종목과 체결 이력이 모두 삭제됩니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) {
|
||||
setSummary(res);
|
||||
setTrades([]);
|
||||
lastPriceKeyRef.current = '';
|
||||
alert('모의투자 계좌가 초기화되었습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const s = summary;
|
||||
const retUp = (s?.totalReturnPct ?? 0) >= 0;
|
||||
const showPanelLoading = initialLoading;
|
||||
|
||||
return (
|
||||
<div className="paper-page">
|
||||
<header className="paper-header">
|
||||
<div>
|
||||
<h1 className="paper-title">모의투자</h1>
|
||||
<p className="paper-subtitle">
|
||||
실시간 차트·전략 시그널과 연동된 가상 계좌입니다. 실제 거래소 주문은 발생하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="paper-header-actions">
|
||||
<button type="button" className="paper-btn" onClick={refresh} disabled={refreshing}>
|
||||
{refreshing ? '새로고침 중…' : '새로고침'}
|
||||
</button>
|
||||
<button type="button" className="paper-btn paper-btn--danger" onClick={handleReset}>
|
||||
계좌 초기화
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!s?.enabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--warn">
|
||||
모의투자가 꺼져 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s?.enabled && s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 ON — 실시간 전략 시그널(BUY/SELL) 발생 시 모의 계좌에 자동 체결됩니다.
|
||||
(매수: 가용현금 {s.autoTradeBudgetPct}% · 매도: 보유 전량)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s?.enabled && !s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 OFF — 시그널은 알림만 표시되며, 매매는 차트 우측 매수·매도에서 수동으로 진행합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="paper-banner paper-banner--error">{error}</div>}
|
||||
|
||||
<div className="paper-summary-grid">
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 평가자산</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.totalAsset) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주문가능 현금</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.cashBalance) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주식 평가금액</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.stockEvalAmount) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 수익률</span>
|
||||
<span className={`paper-card-value ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${s.totalReturnPct >= 0 ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">평가손익</span>
|
||||
<span className={`paper-card-value ${(s?.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.unrealizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">실현손익</span>
|
||||
<span className={`paper-card-value ${(s?.realizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.realizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="paper-tabs">
|
||||
{(['overview', 'positions', 'trades'] as TabId[]).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`paper-tab${tab === id ? ' active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{id === 'overview' ? '계좌 개요' : id === 'positions' ? '보유종목' : '체결내역'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="paper-panel">
|
||||
{showPanelLoading && <p className="paper-muted">로딩 중...</p>}
|
||||
|
||||
{!showPanelLoading && tab === 'overview' && s && (
|
||||
<dl className="paper-dl">
|
||||
<dt>초기 자본</dt><dd>{fmtKrw(s.initialCapital)} KRW</dd>
|
||||
<dt>수수료율</dt><dd>{s.feeRatePct}%</dd>
|
||||
<dt>슬리피지</dt><dd>{s.slippagePct}%</dd>
|
||||
<dt>최소 주문</dt><dd>{fmtKrw(s.minOrderKrw)} KRW</dd>
|
||||
<dt>자동매매</dt>
|
||||
<dd>{s.autoTradeEnabled ? `ON (매수 시 가용현금 ${s.autoTradeBudgetPct}%)` : 'OFF (수동 매매)'}</dd>
|
||||
<dt>보유 종목 수</dt><dd>{s.positions.length}개</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'positions' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<th>수량</th>
|
||||
<th>평균단가</th>
|
||||
<th>현재가</th>
|
||||
<th>평가금액</th>
|
||||
<th>평가손익</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={7} className="paper-muted">보유 종목이 없습니다.</td></tr>
|
||||
) : s!.positions.map(p => (
|
||||
<tr key={p.symbol}>
|
||||
<td>
|
||||
<strong>{getKoreanName(p.symbol)}</strong>
|
||||
<span className="paper-sym">{p.symbol}</span>
|
||||
</td>
|
||||
<td>{p.quantity.toFixed(8).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(p.avgPrice)}</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<td>{p.evalAmount != null ? fmtKrw(p.evalAmount) : '—'}</td>
|
||||
<td className={(p.profitLoss ?? 0) >= 0 ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${fmtKrw(p.profitLoss)} (${(p.profitLossPct ?? 0).toFixed(2)}%)` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{onGoChart && (
|
||||
<button type="button" className="paper-link" onClick={() => onGoChart(p.symbol)}>
|
||||
차트
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'trades' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>출처</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>수수료</th>
|
||||
<th>체결후 현금</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr><td colSpan={8} className="paper-muted">체결 내역이 없습니다.</td></tr>
|
||||
) : trades.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="paper-time">{t.createdAt?.replace('T', ' ').slice(0, 19) ?? '—'}</td>
|
||||
<td>{getKoreanName(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side}</td>
|
||||
<td>{t.source === 'STRATEGY' ? '전략' : '수동'}</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td>{t.quantity.toFixed(6).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(t.feeAmount)}</td>
|
||||
<td>{fmtKrw(t.cashAfter)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperTradingPage;
|
||||
Reference in New Issue
Block a user