d16456cb92
# 사용자별 격리 - 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>
152 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|