Files
tradeing/frontend/app/trades/page.tsx
T
chpark d16456cb92 사용자별 격리 시스템 + 사용자 관리 + 라이브 PnL%
# 사용자별 격리
- JWT 토큰에 uid 추가 (auth.get_uid 헬퍼)
- PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT
- SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환)
- 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가
- alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화
- core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용
- ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드

# 사용자 관리 (admin only)
- users_db: delete_user / admin_reset_password / set_role
- /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호)
- /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달
- 사이드바 adminOnly 필터 — admin role 만 메뉴 노출

# 대시보드 개선
- 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200)
- 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산)
- 메트릭 카드 분리 (실거래 vs 실시간 open)

# 안정성
- api.ts: error.detail array/object 안전 처리 ([object Object] 방지)
- Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:14:23 +09:00

152 lines
8.1 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { api } from '@/lib/api';
import { Card, PageHeader, Stat } from '@/components/ui';
const Plot = dynamic(() => import('react-plotly.js'), { ssr: false });
export default function TradesPage() {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/api/trades?limit=500').then(setRows).finally(() => setLoading(false));
}, []);
// 실현된 트레이드 (실거래 결과만): stop_loss + reversal — cancelled 는 진입 취소(실거래 X)
const closed = rows.filter(r => ['stop_loss', 'reversal'].includes(r.status));
const cancelled = rows.filter(r => r.status === 'cancelled').length;
const openRows = rows.filter(r => r.status === 'open');
const open = openRows.length;
const wins = closed.filter(r => (r.pnl_pct ?? 0) > 0).length;
const losses = closed.length - wins;
const winRate = closed.length ? (wins / closed.length * 100).toFixed(1) : '0.0';
const cumPnl = closed.reduce((s, r) => s + (r.pnl_pct ?? 0), 0).toFixed(2);
const avgPnl = closed.length ? (closed.reduce((s, r) => s + (r.pnl_pct ?? 0), 0) / closed.length).toFixed(2) : '0.00';
// open 트레이드 실시간 PnL% (backend 가 live_pnl_pct 채워줌)
const openWithLive = openRows.filter(r => r.live_pnl_pct != null);
const openLiveSum = openWithLive.reduce((s, r) => s + (r.live_pnl_pct ?? 0), 0);
const openLiveAvg = openWithLive.length ? (openLiveSum / openWithLive.length).toFixed(2) : '0.00';
const openLiveSumStr = openLiveSum.toFixed(2);
const openWins = openWithLive.filter(r => (r.live_pnl_pct ?? 0) > 0).length;
const openLosses = openWithLive.filter(r => (r.live_pnl_pct ?? 0) <= 0).length;
// 누적 PnL 시계열
const sorted = [...closed].sort((a, b) => (a.exit_time ?? '').localeCompare(b.exit_time ?? ''));
let cum = 0;
const cumX: any[] = []; const cumY: any[] = []; const colors: any[] = [];
for (const r of sorted) {
cum += r.pnl_pct ?? 0;
cumX.push(r.exit_time);
cumY.push(cum);
colors.push((r.pnl_pct ?? 0) > 0 ? '#26a69a' : '#ef5350');
}
return (
<div>
<PageHeader title="📈 트레이드 이력" subtitle={`${rows.length}건 · 실거래 ${closed.length} · 진행 중 ${open} · 취소 ${cancelled}`} />
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-3">
<Stat label="총 트레이드" value={rows.length} hint={`취소 ${cancelled} 포함`} />
<Stat label="진행 중" value={open} hint={`실시간 ${openWithLive.length}건 추적`} />
<Stat label="실거래 종료" value={closed.length} hint="stop_loss + reversal" />
<Stat label="승률 (실거래)" value={`${winRate}%`} hint={`${wins}W / ${losses}L`} />
<Stat label="평균 PnL%" value={`${parseFloat(avgPnl) >= 0 ? '+' : ''}${avgPnl}%`} />
<Stat label="누적 PnL%" value={`${parseFloat(cumPnl) >= 0 ? '+' : ''}${cumPnl}%`} />
</div>
{/* 실시간 (open) 메트릭 - 별도 라인 */}
{open > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
<Stat label="🔴 진행 중 합산 PnL%" value={`${parseFloat(openLiveSumStr) >= 0 ? '+' : ''}${openLiveSumStr}%`}
hint="현재가 기준 (실시간)" />
<Stat label="🔴 진행 중 평균 PnL%" value={`${parseFloat(openLiveAvg) >= 0 ? '+' : ''}${openLiveAvg}%`}
hint={`${openWithLive.length}건 평균`} />
<Stat label="🔴 진행 중 W/L" value={`${openWins}W / ${openLosses}L`}
hint={openWithLive.length ? `현재 승률 ${(openWins / openWithLive.length * 100).toFixed(1)}%` : ''} />
<Stat label="🔴 진행 중 + 실거래 누적" value={`${(parseFloat(cumPnl) + openLiveSum).toFixed(2)}%`}
hint="실현 + 미실현" />
</div>
)}
{sorted.length > 0 && (
<Card className="mb-5">
<div className="text-sm font-bold text-slate-800 mb-2"> PnL %</div>
<Plot
data={[
{
type: 'scatter', mode: 'lines+markers',
x: cumX, y: cumY,
line: { color: '#2962ff', width: 2 },
marker: { color: colors, size: 6 },
name: '누적 PnL',
},
]}
layout={{
height: 280, margin: { l: 50, r: 20, t: 10, b: 30 },
paper_bgcolor: '#ffffff', plot_bgcolor: '#ffffff',
font: { family: 'Pretendard, sans-serif', size: 11 },
xaxis: { gridcolor: '#e5e7eb' }, yaxis: { gridcolor: '#e5e7eb', title: { text: 'PnL %' } },
showlegend: false,
}}
config={{ displayModeBar: false }}
style={{ width: '100%' }}
useResizeHandler
/>
</Card>
)}
<Card>
<div className="text-sm font-bold text-slate-800 mb-3"> </div>
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
{['진입시간', '심볼', 'TF', '방향', '신호', '진입가', '손절가', '청산시간', '청산가', '사유', 'PnL%', '상태'].map(h => (
<th key={h} className="px-3 py-2 text-left font-semibold border-b border-slate-200">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && <tr><td colSpan={12} className="p-4 text-center text-slate-400"> ...</td></tr>}
{!loading && rows.length === 0 && <tr><td colSpan={12} className="p-4 text-center text-slate-400"> </td></tr>}
{rows.map(r => {
const isOpen = r.status === 'open';
const showPnl = isOpen ? r.live_pnl_pct : r.pnl_pct;
const pnlClass = (showPnl ?? 0) > 0 ? 'text-green-600' : (showPnl ?? 0) < 0 ? 'text-red-600' : 'text-slate-500';
return (
<tr key={r.id} className={`border-b border-slate-100 hover:bg-slate-50 ${isOpen ? 'bg-blue-50/40' : ''}`}>
<td className="px-3 py-2 font-mono">{(r.entry_time || '').slice(0, 19).replace('T', ' ')}</td>
<td className="px-3 py-2">{r.symbol}</td>
<td className="px-3 py-2">{r.interval}</td>
<td className={`px-3 py-2 font-semibold ${r.direction === 'long' ? 'text-green-600' : 'text-red-600'}`}>{r.direction}</td>
<td className="px-3 py-2">{r.signal_types}</td>
<td className="px-3 py-2 font-mono text-right">{r.entry_price?.toLocaleString()}</td>
<td className="px-3 py-2 font-mono text-right">{r.stop_price?.toLocaleString()}</td>
<td className="px-3 py-2 font-mono">{(r.exit_time || '').slice(0, 19).replace('T', ' ')}</td>
<td className="px-3 py-2 font-mono text-right">
{isOpen
? (r.current_price != null ? <span className="text-blue-600 italic">{r.current_price.toLocaleString()} <span className="text-[10px]">()</span></span> : '-')
: (r.exit_price?.toLocaleString() ?? '-')}
</td>
<td className="px-3 py-2">{isOpen ? <span className="text-blue-500 italic"> </span> : (r.exit_reason || '-')}</td>
<td className={`px-3 py-2 font-mono text-right font-bold ${pnlClass} ${isOpen ? 'italic' : ''}`}>
{showPnl != null ? `${showPnl > 0 ? '+' : ''}${showPnl.toFixed(2)}%${isOpen ? ' *' : ''}` : '-'}
</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${isOpen ? 'bg-blue-100 text-blue-800' : r.status === 'stop_loss' ? 'bg-red-100 text-red-800' : 'bg-slate-100 text-slate-700'}`}>
{r.status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}