180 lines
7.2 KiB
TypeScript
180 lines
7.2 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import type { BotTrade, TrumpPost } from '@/types'
|
||
import { getTrades, getPosts } from '@/lib/api'
|
||
|
||
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
|
||
const { decimals = 2, sign = false } = opts
|
||
if (n == null || isNaN(n)) return '—'
|
||
const abs = Math.abs(n)
|
||
const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||
if (n < 0) return '-$' + s
|
||
if (sign && n > 0) return '+$' + s
|
||
return '$' + s
|
||
}
|
||
|
||
function fmtPct(n: number) {
|
||
const s = n.toFixed(2) + '%'
|
||
return n >= 0 ? '+' + s : s
|
||
}
|
||
|
||
function fmtHold(s: number) {
|
||
if (s < 60) return s + 's'
|
||
const m = Math.floor(s / 60)
|
||
if (m < 60) return m + 'm'
|
||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
|
||
}
|
||
|
||
function SourceIcon({ source }: { source: string }) {
|
||
if (source === 'x') return <div className="src-ico x" style={{ width: 28, height: 28, fontSize: 12 }}>𝕏</div>
|
||
return <div className="src-ico truth" style={{ width: 28, height: 28, fontSize: 12 }}>T</div>
|
||
}
|
||
|
||
export default function TradesPage() {
|
||
const [trades, setTrades] = useState<BotTrade[]>([])
|
||
const [posts, setPosts] = useState<TrumpPost[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [assetFilter, setAssetFilter] = useState('all')
|
||
const [sideFilter, setSideFilter] = useState('all')
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
getTrades(200, 1).catch(() => []),
|
||
getPosts(500, 1).catch(() => []),
|
||
]).then(([t, p]) => {
|
||
setTrades(t)
|
||
setPosts(p)
|
||
}).finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
const filtered = trades.filter(t => {
|
||
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
|
||
if (sideFilter !== 'all' && t.side !== sideFilter) return false
|
||
return true
|
||
})
|
||
|
||
const totalPnl = filtered.reduce((s, t) => s + t.pnl_usd, 0)
|
||
const wins = filtered.filter(t => t.pnl_usd > 0).length
|
||
const avgHold = filtered.length > 0 ? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length) : 0
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1 className="page-title">Trades</h1>
|
||
<p className="page-sub">Every trade your bot executed. Transparent and auditable.</p>
|
||
</div>
|
||
<div className="row gap-s">
|
||
<span className="chip">30 days</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||
<div className="kpi">
|
||
<div className="label">Total trades</div>
|
||
<div className="value">{filtered.length}</div>
|
||
<div className="foot"><span>Filtered</span></div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="label">Win rate</div>
|
||
<div className="value">{filtered.length ? ((wins / filtered.length) * 100).toFixed(1) + '%' : '—'}</div>
|
||
<div className="foot"><span>{wins}W · {filtered.length - wins}L</span></div>
|
||
</div>
|
||
<div className="kpi accent">
|
||
<div className="label">Net P&L</div>
|
||
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
|
||
<div className="foot"><span>All assets</span></div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="label">Avg hold</div>
|
||
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
|
||
<div className="foot"><span>Per trade</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="row between" style={{ margin: '20px 0 14px' }}>
|
||
<div className="row gap-s">
|
||
<div className="nav-tabs">
|
||
{(['all', 'BTC', 'ETH'] as const).map(a => (
|
||
<button key={a} className={`nav-tab ${assetFilter === a ? 'active' : ''}`} onClick={() => setAssetFilter(a)}>
|
||
{a === 'all' ? 'All assets' : a}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="nav-tabs">
|
||
{(['all', 'long', 'short'] as const).map(s => (
|
||
<button key={s} className={`nav-tab ${sideFilter === s ? 'active' : ''}`} onClick={() => setSideFilter(s)}>
|
||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>Loading…</div>}
|
||
|
||
{!loading && (
|
||
<div className="card flush" style={{ overflow: 'hidden' }}>
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Asset</th>
|
||
<th>Side</th>
|
||
<th>Entry</th>
|
||
<th>Exit</th>
|
||
<th>Hold</th>
|
||
<th>Trigger</th>
|
||
<th>P&L</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.length === 0 && (
|
||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>No trades found</td></tr>
|
||
)}
|
||
{filtered.map(t => {
|
||
const triggerPost = posts.find(p => p.id === t.trigger_post_id)
|
||
const roiPct = ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
|
||
return (
|
||
<tr key={t.id}>
|
||
<td>
|
||
<div className="row gap-s">
|
||
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
|
||
<span style={{ fontWeight: 500 }}>{t.asset}</span>
|
||
</div>
|
||
</td>
|
||
<td><span className={`side-pill ${t.side}`}>{t.side === 'long' ? '↗ LONG' : '↘ SHORT'}</span></td>
|
||
<td className="mono">${t.entry_price.toLocaleString()}</td>
|
||
<td className="mono">${t.exit_price.toLocaleString()}</td>
|
||
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
|
||
<td style={{ maxWidth: 280 }}>
|
||
{triggerPost ? (
|
||
<div className="row gap-s" style={{ alignItems: 'center' }}>
|
||
<SourceIcon source={triggerPost.source} />
|
||
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{triggerPost.text.slice(0, 50)}…
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<div className="stack" style={{ alignItems: 'flex-end' }}>
|
||
<span className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}>
|
||
{fmtMoney(t.pnl_usd, { sign: true })}
|
||
</span>
|
||
<span className={`delta ${roiPct >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>{fmtPct(roiPct)}</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|