goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -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;