diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index f4be67b..f7f2461 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -47,6 +47,18 @@ jobs:
build_file: Dockerfile.api
extra_args: --insecure --skip-tls-verify
+ - name: Build and push dashboard image
+ uses: aevea/action-kaniko@master
+ with:
+ registry: git.chemavx.xyz
+ username: chemavx
+ password: ${{ secrets.CI_TOKEN }}
+ image: chemavx/polymarket-bot-dashboard
+ tag: ${{ steps.tag.outputs.TAG }}
+ path: dashboard
+ build_file: Dockerfile
+ extra_args: --insecure --skip-tls-verify
+
- name: Update k8s manifests
run: |
TAG=${{ steps.tag.outputs.TAG }}
@@ -61,10 +73,13 @@ jobs:
polymarket-bot/deployment-bot.yaml
sed -i "s|image: .*polymarket-bot-api.*|image: git.chemavx.xyz/chemavx/polymarket-bot-api:${TAG}|g" \
polymarket-bot/deployment-api.yaml
+ sed -i "s|image: .*polymarket-bot-dashboard.*|image: git.chemavx.xyz/chemavx/polymarket-bot-dashboard:${TAG}|g" \
+ polymarket-bot/deployment-dashboard.yaml
sed -i "s|imagePullPolicy: Never|imagePullPolicy: Always|g" \
polymarket-bot/deployment-bot.yaml \
- polymarket-bot/deployment-api.yaml
+ polymarket-bot/deployment-api.yaml \
+ polymarket-bot/deployment-dashboard.yaml
- git add polymarket-bot/deployment-bot.yaml polymarket-bot/deployment-api.yaml
+ git add polymarket-bot/deployment-bot.yaml polymarket-bot/deployment-api.yaml polymarket-bot/deployment-dashboard.yaml
git diff --cached --quiet || git commit -m "ci: update polymarket-bot images to ${TAG} [skip ci]"
git push
diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile
new file mode 100644
index 0000000..788115a
--- /dev/null
+++ b/dashboard/Dockerfile
@@ -0,0 +1,19 @@
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+COPY package.json .
+RUN npm install
+
+COPY index.html .
+COPY vite.config.js .
+COPY src/ ./src/
+
+RUN npm run build
+
+FROM nginx:alpine
+
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 0000000..522eab7
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Polymarket Bot
+
+
+
+
+
+
diff --git a/dashboard/nginx.conf b/dashboard/nginx.conf
new file mode 100644
index 0000000..14b54a4
--- /dev/null
+++ b/dashboard/nginx.conf
@@ -0,0 +1,18 @@
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api {
+ proxy_pass http://api:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript;
+}
diff --git a/dashboard/package.json b/dashboard/package.json
new file mode 100644
index 0000000..86428ae
--- /dev/null
+++ b/dashboard/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "polymarket-dashboard",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "recharts": "^2.12.4"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.2.1",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx
new file mode 100644
index 0000000..6c3bac5
--- /dev/null
+++ b/dashboard/src/App.jsx
@@ -0,0 +1,310 @@
+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 (
+
+
+ {title}
+
+
{value}
+ {subtitle &&
{subtitle}
}
+ {progress != null && (
+
+ )}
+
+ )
+}
+
+function CustomTooltip({ active, payload, label }) {
+ if (!active || !payload?.length) return null
+ return (
+
+
{label}
+ {payload.map((p) => (
+
+ P&L: {fmtUSD(p.value)}
+
+ ))}
+
+ )
+}
+
+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 (
+
+ )
+ }
+
+ if (error && !summary) {
+ return (
+
+ Error conectando con la API: {error}
+
+ )
+ }
+
+ const pnlColor = summary.total_pnl >= 0 ? 'var(--green)' : 'var(--red)'
+
+ return (
+
+ {/* Header */}
+
+
+
+ Polymarket Bot
+ {summary.paper_mode && PAPER}
+
+
+
+ {lastUpdate && (
+
+ Actualizado: {lastUpdate.toLocaleTimeString('es-ES')}
+
+ )}
+
+
+
+ {/* Promotion banner */}
+ {summary.promotion_ready && (
+
+
🚀
+
+ Listo para producción — El bot cumple los criterios de
+ rendimiento. Considera desactivar PAPER_MODE.
+
+
+ )}
+
+ {/* Metrics grid */}
+
+
+ {fmtUSD(summary.total_pnl)}}
+ subtitle={`${fmtPct(summary.total_pnl / summary.paper_bankroll)} sobre bankroll`}
+ progress={summary.total_pnl / summary.paper_bankroll + 0.5}
+ progressColor={pnlColor}
+ />
+ = 0.52 ? 'var(--green)' : 'var(--amber)'}
+ />
+ = 0.5 ? 'var(--green)' : 'var(--amber)'}
+ />
+ = 0.7 ? 'var(--green)' : 'var(--amber)'}
+ />
+
+
+
+ {/* Performance chart */}
+
+
Performance Over Time
+ {history.length === 0 ? (
+
Sin datos históricos aún
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ `$${v}`}
+ tick={{ fill: 'var(--text-muted)', fontSize: 11 }}
+ axisLine={false}
+ tickLine={false}
+ width={60}
+ />
+ } />
+
+
+
+
+ )}
+
+
+ {/* Recent trades table */}
+
+
Recent Trades
+ {trades.length === 0 ? (
+
Sin trades registrados aún
+ ) : (
+
+
+
+
+ | Time |
+ Market |
+ Dir |
+ Size |
+ Price |
+ Shares |
+ Fee |
+
+
+
+ {trades.map((t) => (
+
+ |
+ {fmtTime(t.timestamp)}
+ |
+ {t.question} |
+
+
+ {t.direction}
+
+ |
+ {fmtUSD(t.size_usdc)} |
+ {fmt(t.entry_price, 3)} |
+ {fmt(t.shares, 2)} |
+ {fmtUSD(t.fee_usdc)} |
+
+ ))}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
new file mode 100644
index 0000000..2dc8d3d
--- /dev/null
+++ b/dashboard/src/index.css
@@ -0,0 +1,257 @@
+:root {
+ --bg: #0F1117;
+ --surface: #1A1D27;
+ --surface2: #232635;
+ --border: rgba(255, 255, 255, 0.08);
+ --text: #F1F5F9;
+ --text-muted: #94A3B8;
+ --blue: #3B82F6;
+ --green: #22C55E;
+ --red: #EF4444;
+ --amber: #F59E0B;
+ --purple: #8B5CF6;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, Inter, sans-serif;
+ font-size: 14px;
+ min-height: 100vh;
+}
+
+.app {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 24px;
+}
+
+/* Header */
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+}
+
+.header h1 {
+ font-size: 22px;
+ font-weight: 600;
+}
+
+.badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ margin-left: 12px;
+}
+
+.badge.paper {
+ background: #f59e0b26;
+ color: var(--amber);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.last-update {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+/* Promotion banner */
+.promotion-banner {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ background: #22c55e1a;
+ border: 1px solid rgba(34, 197, 94, 0.3);
+ border-radius: 12px;
+ padding: 16px 20px;
+ margin-bottom: 24px;
+}
+
+.promotion-banner .banner-icon {
+ font-size: 24px;
+}
+
+.promotion-banner code {
+ background: #ffffff1a;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-family: monospace;
+ font-size: 12px;
+}
+
+/* Metrics grid */
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 16px;
+ margin-bottom: 32px;
+}
+
+.metric-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 16px 20px;
+}
+
+.metric-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 4px;
+}
+
+.metric-title {
+ color: var(--text-muted);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.metric-value {
+ font-size: 22px;
+ font-weight: 600;
+}
+
+.metric-subtitle {
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+.progress-bar {
+ height: 3px;
+ background: #ffffff14;
+ border-radius: 2px;
+ margin-top: 10px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.5s ease;
+}
+
+/* Sections */
+.section {
+ margin-bottom: 32px;
+}
+
+.section h2 {
+ font-size: 16px;
+ font-weight: 500;
+ margin-bottom: 16px;
+ color: var(--text-muted);
+}
+
+.chart-container {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+}
+
+.empty-chart,
+.empty-table {
+ text-align: center;
+ padding: 48px;
+ color: var(--text-muted);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
+
+/* Trade table */
+.table-wrapper {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow-x: auto;
+}
+
+.trade-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.trade-table th {
+ padding: 12px 16px;
+ text-align: left;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+}
+
+.trade-table td {
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border);
+ font-size: 13px;
+}
+
+.trade-table tr:last-child td {
+ border-bottom: none;
+}
+
+.trade-table tr:hover td {
+ background: #ffffff05;
+}
+
+.market-question {
+ max-width: 360px;
+ color: var(--text-muted);
+}
+
+.direction-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.direction-badge.yes {
+ background: #22c55e1a;
+ color: var(--green);
+}
+
+.direction-badge.no {
+ background: #ef44441a;
+ color: var(--red);
+}
+
+/* Loading */
+.loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ gap: 16px;
+ color: var(--text-muted);
+}
+
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 2px solid var(--border);
+ border-top-color: var(--blue);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
diff --git a/dashboard/src/main.jsx b/dashboard/src/main.jsx
new file mode 100644
index 0000000..5e8d112
--- /dev/null
+++ b/dashboard/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+)
diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js
new file mode 100644
index 0000000..510593b
--- /dev/null
+++ b/dashboard/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://api:8000',
+ changeOrigin: true,
+ },
+ },
+ },
+})