CI/CD / build-and-push (push) Successful in 2m24s
- Reconstruct dashboard from compiled container: App.jsx, main.jsx, index.css - nginx.conf with SPA routing and /api proxy to api:8000 - Multi-stage Dockerfile: node:20-alpine build + nginx:alpine serve - Add third kaniko build step in ci.yml for chemavx/polymarket-bot-dashboard - Update k8s manifest sed to patch deployment-dashboard.yaml image on each push Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
9.2 KiB
React
311 lines
9.2 KiB
React
import { useEffect, useState, useCallback } from 'react'
|
|
import {
|
|
ResponsiveContainer,
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
} from 'recharts'
|
|
|
|
const REFRESH_MS = 30_000
|
|
|
|
function fmt(n, decimals = 2) {
|
|
if (n == null) return '—'
|
|
return Number(n).toFixed(decimals)
|
|
}
|
|
|
|
function fmtUSD(n) {
|
|
if (n == null) return '—'
|
|
const abs = Math.abs(n)
|
|
const sign = n < 0 ? '-' : ''
|
|
return `${sign}$${abs.toFixed(2)}`
|
|
}
|
|
|
|
function fmtPct(n) {
|
|
if (n == null) return '—'
|
|
return `${(n * 100).toFixed(1)}%`
|
|
}
|
|
|
|
function fmtTime(ts) {
|
|
if (!ts) return '—'
|
|
const d = new Date(ts)
|
|
return d.toLocaleString('es-ES', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function MetricCard({ title, value, subtitle, progress, progressColor }) {
|
|
return (
|
|
<div className="metric-card">
|
|
<div className="metric-header">
|
|
<span className="metric-title">{title}</span>
|
|
</div>
|
|
<div className="metric-value">{value}</div>
|
|
{subtitle && <div className="metric-subtitle">{subtitle}</div>}
|
|
{progress != null && (
|
|
<div className="progress-bar">
|
|
<div
|
|
className="progress-fill"
|
|
style={{
|
|
width: `${Math.min(100, Math.max(0, progress * 100))}%`,
|
|
background: progressColor || 'var(--blue)',
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomTooltip({ active, payload, label }) {
|
|
if (!active || !payload?.length) return null
|
|
return (
|
|
<div style={{
|
|
background: 'var(--surface2)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 8,
|
|
padding: '10px 14px',
|
|
fontSize: 12,
|
|
}}>
|
|
<div style={{ color: 'var(--text-muted)', marginBottom: 4 }}>{label}</div>
|
|
{payload.map((p) => (
|
|
<div key={p.dataKey} style={{ color: p.color }}>
|
|
P&L: {fmtUSD(p.value)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
const [summary, setSummary] = useState(null)
|
|
const [trades, setTrades] = useState([])
|
|
const [history, setHistory] = useState([])
|
|
const [lastUpdate, setLastUpdate] = useState(null)
|
|
const [error, setError] = useState(null)
|
|
|
|
const fetchAll = useCallback(async () => {
|
|
try {
|
|
const [sumRes, tradesRes, metricsRes] = await Promise.all([
|
|
fetch('/api/summary'),
|
|
fetch('/api/trades?limit=50'),
|
|
fetch('/api/metrics'),
|
|
])
|
|
if (!sumRes.ok || !tradesRes.ok || !metricsRes.ok) throw new Error('API error')
|
|
|
|
const [sumData, tradesData, metricsData] = await Promise.all([
|
|
sumRes.json(),
|
|
tradesRes.json(),
|
|
metricsRes.json(),
|
|
])
|
|
|
|
setSummary(sumData)
|
|
setTrades(tradesData.trades || [])
|
|
|
|
const raw = (metricsData.history || []).slice().reverse()
|
|
setHistory(
|
|
raw.map((r) => ({
|
|
date: new Date(r.timestamp).toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }),
|
|
pnl: r.total_pnl,
|
|
}))
|
|
)
|
|
setLastUpdate(new Date())
|
|
setError(null)
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchAll()
|
|
const id = setInterval(fetchAll, REFRESH_MS)
|
|
return () => clearInterval(id)
|
|
}, [fetchAll])
|
|
|
|
if (!summary && !error) {
|
|
return (
|
|
<div className="loading">
|
|
<div className="spinner" />
|
|
<span>Cargando...</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error && !summary) {
|
|
return (
|
|
<div className="loading">
|
|
<span>Error conectando con la API: {error}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const pnlColor = summary.total_pnl >= 0 ? 'var(--green)' : 'var(--red)'
|
|
|
|
return (
|
|
<div className="app">
|
|
{/* Header */}
|
|
<div className="header">
|
|
<div className="header-left">
|
|
<h1>
|
|
Polymarket Bot
|
|
{summary.paper_mode && <span className="badge paper">PAPER</span>}
|
|
</h1>
|
|
</div>
|
|
<div className="header-right">
|
|
{lastUpdate && (
|
|
<span className="last-update">
|
|
Actualizado: {lastUpdate.toLocaleTimeString('es-ES')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Promotion banner */}
|
|
{summary.promotion_ready && (
|
|
<div className="promotion-banner">
|
|
<span className="banner-icon">🚀</span>
|
|
<div>
|
|
<strong>Listo para producción</strong> — El bot cumple los criterios de
|
|
rendimiento. Considera desactivar <code>PAPER_MODE</code>.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Metrics grid */}
|
|
<div className="metrics-grid">
|
|
<MetricCard
|
|
title="Bankroll"
|
|
value={fmtUSD(summary.paper_bankroll)}
|
|
subtitle="Capital inicial (paper)"
|
|
/>
|
|
<MetricCard
|
|
title="P&L Total"
|
|
value={<span style={{ color: pnlColor }}>{fmtUSD(summary.total_pnl)}</span>}
|
|
subtitle={`${fmtPct(summary.total_pnl / summary.paper_bankroll)} sobre bankroll`}
|
|
progress={summary.total_pnl / summary.paper_bankroll + 0.5}
|
|
progressColor={pnlColor}
|
|
/>
|
|
<MetricCard
|
|
title="Win Rate"
|
|
value={fmtPct(summary.win_rate)}
|
|
subtitle="Objetivo ≥ 52%"
|
|
progress={summary.win_rate}
|
|
progressColor={summary.win_rate >= 0.52 ? 'var(--green)' : 'var(--amber)'}
|
|
/>
|
|
<MetricCard
|
|
title="Sharpe"
|
|
value={fmt(summary.sharpe_ratio)}
|
|
subtitle="Objetivo ≥ 0.5"
|
|
progress={Math.min(1, summary.sharpe_ratio / 2)}
|
|
progressColor={summary.sharpe_ratio >= 0.5 ? 'var(--green)' : 'var(--amber)'}
|
|
/>
|
|
<MetricCard
|
|
title="Calibration"
|
|
value={fmt(summary.calibration_score)}
|
|
subtitle="Objetivo ≥ 0.7"
|
|
progress={summary.calibration_score}
|
|
progressColor={summary.calibration_score >= 0.7 ? 'var(--green)' : 'var(--amber)'}
|
|
/>
|
|
<MetricCard
|
|
title="Capital Deployed"
|
|
value={fmtUSD(summary.total_deployed)}
|
|
subtitle={`${summary.total_trades} trades`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Performance chart */}
|
|
<div className="section">
|
|
<h2>Performance Over Time</h2>
|
|
{history.length === 0 ? (
|
|
<div className="empty-chart">Sin datos históricos aún</div>
|
|
) : (
|
|
<div className="chart-container">
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<AreaChart data={history} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="pnlGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="var(--blue)" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="var(--blue)" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tick={{ fill: 'var(--text-muted)', fontSize: 11 }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={(v) => `$${v}`}
|
|
tick={{ fill: 'var(--text-muted)', fontSize: 11 }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
width={60}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="pnl"
|
|
stroke="var(--blue)"
|
|
strokeWidth={2}
|
|
fill="url(#pnlGrad)"
|
|
dot={false}
|
|
activeDot={{ r: 4, fill: 'var(--blue)' }}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent trades table */}
|
|
<div className="section">
|
|
<h2>Recent Trades</h2>
|
|
{trades.length === 0 ? (
|
|
<div className="empty-table">Sin trades registrados aún</div>
|
|
) : (
|
|
<div className="table-wrapper">
|
|
<table className="trade-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Market</th>
|
|
<th>Dir</th>
|
|
<th>Size</th>
|
|
<th>Price</th>
|
|
<th>Shares</th>
|
|
<th>Fee</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{trades.map((t) => (
|
|
<tr key={t.id}>
|
|
<td style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
|
|
{fmtTime(t.timestamp)}
|
|
</td>
|
|
<td className="market-question">{t.question}</td>
|
|
<td>
|
|
<span className={`direction-badge ${t.direction?.toLowerCase()}`}>
|
|
{t.direction}
|
|
</span>
|
|
</td>
|
|
<td>{fmtUSD(t.size_usdc)}</td>
|
|
<td>{fmt(t.entry_price, 3)}</td>
|
|
<td>{fmt(t.shares, 2)}</td>
|
|
<td style={{ color: 'var(--text-muted)' }}>{fmtUSD(t.fee_usdc)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|