CI/CD / build-and-push (push) Successful in 10s
sharpe_ratio was hardcoded to 0.0 in MetricsTracker and exposed as 'or 0' in /api/summary. With only 1 resolved trade (~40 flat days plus one +299 jump) any computed Sharpe is statistically meaningless, so: - bot/metrics/sharpe.py: annualized Sharpe (sqrt(365)) from daily total_pnl closes, normalized by bankroll; sharpe_with_gate() returns None + status until >=30 days observed AND >=10 resolved trades. - Database.get_daily_pnl_closes(): last metrics_daily snapshot per UTC day, oldest first — the return series input. - MetricsTracker: stores the real (gated) Sharpe in the snapshot, NULL below the gate; log line now includes sharpe. - /api/summary: live Sharpe + sharpe_status/days_observed/min_* fields explaining why it is null; resolved_count now live from COUNT(*). - promotion_ready: requires resolved>=10, days>=30, and non-null win_rate/calibration/sharpe plus existing thresholds — a single lucky resolved trade can no longer promote. - Dashboard Sharpe card shows the insufficient-sample explanation when null instead of a bare em dash. Tests: 13 new in tests/test_sharpe_gate.py (formula, gate, API contract, tracker snapshot); verified failing pre-fix. Suite: 62 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
321 lines
9.8 KiB
React
321 lines
9.8 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={
|
|
summary.sharpe_ratio == null
|
|
? `Muestra insuficiente: ${summary.resolved_count}/${summary.min_resolved_required} resueltos, ${summary.days_observed}/${summary.min_days_required} días`
|
|
: 'Objetivo ≥ 0.5'
|
|
}
|
|
progress={summary.sharpe_ratio == null ? 0 : 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`}
|
|
/>
|
|
<MetricCard
|
|
title="Cash Disponible"
|
|
value={fmtUSD(summary.cash_available)}
|
|
subtitle={`${fmtPct(summary.cash_available / summary.paper_bankroll)} del bankroll`}
|
|
progress={summary.cash_available / summary.paper_bankroll}
|
|
/>
|
|
</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>
|
|
)
|
|
}
|