Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae5cb7f22 | ||
|
|
dc3b17e10c | ||
|
|
eef721a9ed | ||
|
|
cef8a7f2e1 | ||
|
|
1dde4d27eb | ||
|
|
7804d25c51 | ||
|
|
0816e19740 | ||
|
|
2b326ad54f | ||
|
|
124b6d8558 | ||
|
|
6c544e46e2 |
@@ -1,9 +1,10 @@
|
||||
name: CI/CD
|
||||
|
||||
# Decommission (2026-07): trigger switched from push:main to manual only, so
|
||||
# documentation commits don't rebuild/redeploy images. Run from the Gitea UI
|
||||
# (Actions → Run workflow) if a rebuild is ever needed again.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.gitea.svc.cluster.local:3000
|
||||
@@ -178,7 +179,8 @@ jobs:
|
||||
# keep their current (still existing) tag in the registry.
|
||||
if [ "${{ steps.changes.outputs.build_bot }}" = "true" ]; then
|
||||
sed -i "s|image: .*polymarket-bot[^-].*|image: git.chemavx.xyz/chemavx/polymarket-bot:${TAG}|g" \
|
||||
polymarket-bot/deployment-bot.yaml
|
||||
polymarket-bot/deployment-bot.yaml \
|
||||
polymarket-bot/cronjob-outcomes.yaml
|
||||
fi
|
||||
if [ "${{ steps.changes.outputs.build_api }}" = "true" ]; then
|
||||
sed -i "s|image: .*polymarket-bot-api.*|image: git.chemavx.xyz/chemavx/polymarket-bot-api:${TAG}|g" \
|
||||
|
||||
@@ -1,7 +1,118 @@
|
||||
# polymarket-bot
|
||||
|
||||
Bot de paper-trading para Polymarket con estrategia bayesiana, API FastAPI y
|
||||
dashboard React. Corre en k3s vía GitOps (Gitea Actions → registry → ArgoCD).
|
||||
Bot de **paper trading** para mercados de predicción de Polymarket: estrategia
|
||||
bayesiana sobre el precio de mercado como prior, señales externas en log-odds,
|
||||
gates de riesgo en cascada y un motor de replay determinista para calibración
|
||||
offline. API FastAPI + dashboard React. Desplegado en k3s vía GitOps
|
||||
(Gitea Actions → registry → ArgoCD), con PostgreSQL, secrets por Infisical y
|
||||
observabilidad completa (Grafana, uptime-kuma, Telegram).
|
||||
|
||||
> **📦 Estado del proyecto: ARCHIVADO (julio 2026).**
|
||||
> El 26 de mayo de 2026 la DGOJ [ordenó el bloqueo cautelar de Polymarket y
|
||||
> Kalshi en España](https://www.ordenacionjuego.es/novedades/dgoj-abre-expediente-sancionador-plataformas-polymarket-kalshi-ordena-bloqueo-sus-webs)
|
||||
> por operar sin licencia de juego ([CoinDesk](https://www.coindesk.com/policy/2026/05/26/spain-joins-growing-list-of-countries-shutting-out-polymarket-and-kalshi)).
|
||||
> Con el bloqueo a nivel de ISP la fuente de datos primaria desaparece, así que
|
||||
> el proyecto se jubila de forma ordenada: datos respaldados y verificados,
|
||||
> resultados documentados en el [informe final](docs/informe-final.md), e
|
||||
> infraestructura apagada vía GitOps. El cierre es parte de la historia del
|
||||
> proyecto, no un abandono — los módulos centrales son agnósticos de la fuente
|
||||
> y el pivote natural es Manifold Markets (API abierta, play-money, sin
|
||||
> fricción regulatoria).
|
||||
|
||||
## El problema y la solución
|
||||
|
||||
Los mercados de predicción publican probabilidades implícitas en el precio.
|
||||
La hipótesis: en mercados de bajo volumen hay ineficiencias detectables
|
||||
combinando el precio con señales externas (noticias, sentimiento cripto,
|
||||
mercados espejo en otras plataformas). El bot la pone a prueba **sin arriesgar
|
||||
dinero**, con la disciplina de un sistema real:
|
||||
|
||||
1. **Prior desde el precio** de Polymarket (mid del order book CLOB).
|
||||
2. **Ajuste bayesiano en log-odds** con señales independientes: GNews (con
|
||||
presupuesto de 5 consultas/día y *guardrail* que limita el ajuste a
|
||||
prior±0,25), Fear&Greed, momentum BTC, dominancia BTC, y Manifold Markets
|
||||
como señal cruzada (en modo observacional tras detectarse un bug de
|
||||
inversión — auditado en 165k filas).
|
||||
3. **Gates en cascada** antes de operar: prior extremo (<0,08 / >0,92), edge
|
||||
bruto mínimo por régimen de volatilidad, edge neto positivo tras comisión
|
||||
(2%) y spread, confianza ≥0,55, conflicto de familias, guard de reentrada.
|
||||
4. **Agrupación por familias de mercados**: mercados hermanos del mismo evento
|
||||
(candidatos de una misma elección, umbrales de un mismo precio) comparten
|
||||
`family_key`; solo se mantiene la posición de mayor edge para no apilar la
|
||||
misma apuesta con dos nombres.
|
||||
5. **Sizing por Kelly fraccionado** con techo por posición sobre un bankroll
|
||||
simulado de $10.000.
|
||||
|
||||
Resultado honesto en 81 días: 12 trades (n estadísticamente irrelevante, y el
|
||||
informe lo subraya), realized **+$247,78**, y — más interesante — **~223.000
|
||||
evaluaciones archivadas con su decisión reproducible**, porque el sistema
|
||||
prefirió no operar antes que operar sin ventaja medible.
|
||||
Detalle completo: [docs/informe-final.md](docs/informe-final.md).
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph ext["Fuentes externas"]
|
||||
PM["Polymarket<br/>CLOB + Gamma API"]
|
||||
GN["GNews"]
|
||||
MF["Manifold Markets"]
|
||||
CG["CoinGecko /<br/>alternative.me"]
|
||||
end
|
||||
|
||||
subgraph k3s["k3s · namespace polymarket-bot"]
|
||||
BOT["bot (ciclo ~64s)<br/>estrategia bayesiana + gates"]
|
||||
PG[("PostgreSQL 16<br/>trades · signals · replay")]
|
||||
API["api (FastAPI :8000)"]
|
||||
DASH["dashboard (React/nginx)"]
|
||||
CJ1["CronJob metrics-retention 00:10"]
|
||||
CJ2["CronJob outcomes-joiner 00:30"]
|
||||
end
|
||||
|
||||
subgraph obs["Observabilidad"]
|
||||
GRAF["Grafana"]
|
||||
UK["uptime-kuma"]
|
||||
TG["Telegram"]
|
||||
end
|
||||
|
||||
PM --> BOT
|
||||
GN --> BOT
|
||||
MF --> BOT
|
||||
CG --> BOT
|
||||
BOT --> PG
|
||||
PG --> API
|
||||
API --> DASH
|
||||
API --> GRAF
|
||||
UK --> DASH
|
||||
BOT --> TG
|
||||
PM --> CJ2
|
||||
CJ2 --> PG
|
||||
CJ1 --> PG
|
||||
|
||||
subgraph ci["GitOps"]
|
||||
GA["Gitea Actions"] --> REG["registry"] --> ARGO["ArgoCD<br/>prune + selfHeal"]
|
||||
end
|
||||
ARGO -.-> k3s
|
||||
```
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
- **Paper trading por diseño, no como demo**: el bot exigía ≥10 mercados
|
||||
resueltos y ≥30 días antes de considerarse promocionable a dinero real, y
|
||||
reportaba `n/a (insufficient_sample)` en vez de métricas infladas.
|
||||
- **Todo skip es un dato**: cada evaluación archiva prior, estimación, edge
|
||||
bruto/neto, descomposición por feature en log-odds y el gate exacto que la
|
||||
bloqueó (~55k filas/día en `signals`).
|
||||
- **Replay determinista** (julio 2026): inyección de reloj + re-ejecución de
|
||||
ciclos archivados con 22.697/22.697 decisiones idénticas; joiner diario de
|
||||
resoluciones UMA para calibrar sobre *todas* las evaluaciones, no solo los
|
||||
trades.
|
||||
- **Los errores se cierran y se documentan**: el bug de inversión de Manifold
|
||||
y los conflictos de familia cerraron 5 posiciones en abril; la señal pasó a
|
||||
observacional-only con auditoría completa en vez de eliminarse.
|
||||
- **GitOps estricto**: nunca `kubectl` para cambios persistentes (ArgoCD con
|
||||
`selfHeal` revierte cualquier parche manual); secrets fuera de git vía
|
||||
Infisical operator; smoke test PostSync con notificación a Telegram.
|
||||
|
||||
## Componentes
|
||||
|
||||
@@ -11,10 +122,19 @@ dashboard React. Corre en k3s vía GitOps (Gitea Actions → registry → ArgoCD
|
||||
| api | `api/` (+ `bot/` como librería) | `polymarket-bot-api` | `uvicorn api.main:app` |
|
||||
| dashboard | `dashboard/` | `polymarket-bot-dashboard` | nginx estático |
|
||||
|
||||
Dashboard: https://polymarket.chemavx.xyz
|
||||
Dashboard (hasta el apagado): https://polymarket.chemavx.xyz
|
||||
|
||||
## Stack
|
||||
|
||||
Python 3 (asyncio) · FastAPI · React + Vite · PostgreSQL 16 · k3s · ArgoCD ·
|
||||
Gitea Actions + BuildKit · Infisical (secrets) · Grafana + Prometheus ·
|
||||
uptime-kuma · Telegram Bot API.
|
||||
|
||||
## CI/CD
|
||||
|
||||
> ⚠️ **Decomisión**: el trigger automático `push:main` se desactivó en la Fase 1
|
||||
> del archivado; el workflow queda solo bajo `workflow_dispatch` manual.
|
||||
|
||||
`.gitea/workflows/ci.yml` construye **solo las imágenes cuyos ficheros
|
||||
cambiaron** en el push (diff contra `github.event.before`):
|
||||
|
||||
@@ -33,3 +153,17 @@ sincroniza vía webhook en segundos.
|
||||
```bash
|
||||
python3 -m pytest tests/ -q
|
||||
```
|
||||
|
||||
## Datos y backup
|
||||
|
||||
Base de datos respaldada y verificada (2026-07-06): `pg_dump -Fc` + exports
|
||||
CSV.gz de las tablas de señales y auditoría, con checksums y restore de prueba
|
||||
(recuentos idénticos 11/11 tablas). Ubicación: almacenamiento de backups del
|
||||
clúster con sync offsite. El dump final se toma en la fase de apagado.
|
||||
|
||||
## Documentación
|
||||
|
||||
- [Informe final de paper trading](docs/informe-final.md) — resultados,
|
||||
pipeline de datos y estado de la BD al cierre.
|
||||
- `docs/pivot-manifold.md` — notas de diseño del posible pivote (Fase 5,
|
||||
pendiente).
|
||||
|
||||
@@ -826,6 +826,75 @@ class Database:
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
""", rows)
|
||||
|
||||
# ── Replay R2: outcomes + calibration metrics ────────────────────────────
|
||||
|
||||
async def get_unresolved_archived_market_ids(self) -> list[str]:
|
||||
"""Archived markets (present in signals) with no stored outcome yet."""
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT DISTINCT s.market_id FROM signals s
|
||||
LEFT JOIN market_outcomes o ON o.market_id = s.market_id
|
||||
WHERE o.market_id IS NULL
|
||||
ORDER BY s.market_id
|
||||
""")
|
||||
return [r["market_id"] for r in rows]
|
||||
|
||||
async def upsert_market_outcome(
|
||||
self, market_id: str, outcome: float, resolved_at
|
||||
) -> None:
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
INSERT INTO market_outcomes (market_id, outcome, resolved_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (market_id) DO UPDATE
|
||||
SET outcome = EXCLUDED.outcome,
|
||||
resolved_at = EXCLUDED.resolved_at,
|
||||
fetched_at = NOW()
|
||||
""", market_id, outcome, resolved_at)
|
||||
|
||||
async def get_outcome_coverage(self) -> dict:
|
||||
"""How much of the archive is scorable: resolved vs archived markets."""
|
||||
async with self._pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT market_id) FROM signals) AS archived,
|
||||
(SELECT COUNT(*) FROM market_outcomes
|
||||
WHERE market_id IN (SELECT DISTINCT market_id FROM signals)
|
||||
) AS resolved
|
||||
""")
|
||||
return dict(row)
|
||||
|
||||
async def get_calibration_rows(self, run_id: Optional[str] = None) -> list[dict]:
|
||||
"""Every archived evaluation with a full estimate AND a known outcome.
|
||||
|
||||
run_id None scores the R0 archive (signals); a run_id scores that
|
||||
replay run's re-estimates instead (counterfactual calibration).
|
||||
Rows without estimated_prob (skipped before estimation: prior_extreme,
|
||||
unsupported, family, no_signals) carry no model prediction to score.
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
if run_id is None:
|
||||
rows = await conn.fetch("""
|
||||
SELECT s.market_id, s.category,
|
||||
s.estimated_prob, s.prior_prob, o.outcome
|
||||
FROM signals s
|
||||
JOIN market_outcomes o ON o.market_id = s.market_id
|
||||
WHERE s.estimated_prob IS NOT NULL
|
||||
AND s.prior_prob IS NOT NULL
|
||||
""")
|
||||
else:
|
||||
rows = await conn.fetch("""
|
||||
SELECT d.market_id, m.category,
|
||||
d.estimated_prob, d.prior_prob, o.outcome
|
||||
FROM replay_decisions d
|
||||
JOIN market_outcomes o ON o.market_id = d.market_id
|
||||
LEFT JOIN markets m ON m.id = d.market_id
|
||||
WHERE d.run_id = $1
|
||||
AND d.estimated_prob IS NOT NULL
|
||||
AND d.prior_prob IS NOT NULL
|
||||
""", run_id)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def mark_manifold_audit_used(self, audit_id: str) -> None:
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
|
||||
@@ -431,3 +431,24 @@ CREATE TABLE IF NOT EXISTS replay_decisions (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_decisions_run ON replay_decisions(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_decisions_mkt ON replay_decisions(market_id);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Replay R2: outcomes + calibration metrics
|
||||
--
|
||||
-- One row per resolved market, fetched from the Gamma API via
|
||||
-- get_market_resolution() (UMA-final only: a market closed but still in
|
||||
-- proposal/dispute is not stored). outcome is the final YES price:
|
||||
-- 1.0 = YES won, 0.0 = NO won.
|
||||
--
|
||||
-- Joining signals (or replay_decisions) to market_outcomes scores every
|
||||
-- archived estimate against reality — Brier / log-loss of estimated_prob
|
||||
-- benchmarked against the market price (prior_prob) on the same rows,
|
||||
-- answering "does the model add value over the market?" across ALL
|
||||
-- evaluations, not just executed trades.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS market_outcomes (
|
||||
market_id TEXT PRIMARY KEY,
|
||||
outcome DOUBLE PRECISION NOT NULL,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Replay R2 — outcomes + calibration metrics.
|
||||
|
||||
Two phases, one CLI:
|
||||
|
||||
1. Fetch: for every archived market (present in `signals`) without a stored
|
||||
outcome, ask the Gamma API via PolymarketClient.get_market_resolution()
|
||||
— the same UMA-finality gate the trading loop uses to settle positions.
|
||||
Definitive resolutions are upserted into `market_outcomes`; open, disputed
|
||||
or ambiguous markets are simply retried on the next invocation. There is
|
||||
no data-loss urgency here (unlike the R0 recorder): Gamma reports past
|
||||
resolutions at any time, so running this lazily loses nothing.
|
||||
|
||||
2. Score: join archived estimates to outcomes and compute Brier / log-loss of
|
||||
estimated_prob, benchmarked against the market price (prior_prob) on the
|
||||
same rows. This scores ALL evaluations with a full estimate — the sample
|
||||
multiplier the phase plan calls for — not just executed trades. With
|
||||
--run-id it scores a replay run's re-estimates instead (counterfactual
|
||||
calibration: did config X predict better than the market?).
|
||||
|
||||
Reading the numbers: lower is better for both metrics; model < prior means
|
||||
the model added information over the market price. Micro averages weight
|
||||
every evaluation equally, so long-lived markets (~1 evaluation/min while in
|
||||
the universe) dominate; macro averages score each market once (mean of its
|
||||
evaluations) and answer the same question per market. Evaluations of one
|
||||
market minutes apart are highly autocorrelated — n_evaluations overstates
|
||||
the effective sample size, n_markets is the honest one.
|
||||
|
||||
CLI:
|
||||
python -m bot.outcomes # fetch new outcomes, then score archive
|
||||
python -m bot.outcomes --fetch-only
|
||||
python -m bot.outcomes --metrics-only
|
||||
python -m bot.outcomes --run-id UUID # score a replay run (implies no fetch)
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from bot.data.db import Database
|
||||
from bot.data.polymarket import PolymarketClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Clip probabilities before log() so a (theoretical) hard 0/1 estimate on a
|
||||
# wrong outcome scores ~20.7 nats instead of infinity poisoning the mean.
|
||||
LOGLOSS_EPS = 1e-9
|
||||
|
||||
|
||||
async def fetch_outcomes(poly, market_ids: list[str]) -> list[dict]:
|
||||
"""Resolve archived markets against Gamma; returns only definitive ones.
|
||||
|
||||
Sequential on purpose: ~50 markets per invocation, and the Gamma API has
|
||||
no bulk endpoint. get_market_resolution() already returns None on API
|
||||
errors and resolved=False on open/disputed/ambiguous markets.
|
||||
"""
|
||||
resolved = []
|
||||
for market_id in market_ids:
|
||||
res = await poly.get_market_resolution(market_id)
|
||||
if res is None or not res.resolved or res.resolution is None:
|
||||
continue
|
||||
resolved.append({
|
||||
"market_id": market_id,
|
||||
"outcome": res.resolution,
|
||||
"resolved_at": res.resolved_at,
|
||||
})
|
||||
return resolved
|
||||
|
||||
|
||||
def _logloss(p: float, outcome: float) -> float:
|
||||
p = min(max(p, LOGLOSS_EPS), 1.0 - LOGLOSS_EPS)
|
||||
return -math.log(p) if outcome == 1.0 else -math.log(1.0 - p)
|
||||
|
||||
|
||||
def compute_calibration(rows: list[dict]) -> Optional[dict]:
|
||||
"""Score estimated_prob vs prior_prob against outcomes; None if no rows.
|
||||
|
||||
rows: dicts with market_id, category, estimated_prob, prior_prob, outcome.
|
||||
Pure function — the CLI feeds it DB rows, tests feed it literals.
|
||||
"""
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
n = len(rows)
|
||||
brier_model = sum((r["estimated_prob"] - r["outcome"]) ** 2 for r in rows) / n
|
||||
brier_prior = sum((r["prior_prob"] - r["outcome"]) ** 2 for r in rows) / n
|
||||
logloss_model = sum(_logloss(r["estimated_prob"], r["outcome"]) for r in rows) / n
|
||||
logloss_prior = sum(_logloss(r["prior_prob"], r["outcome"]) for r in rows) / n
|
||||
|
||||
by_market: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
by_market[r["market_id"]].append(r)
|
||||
market_briers = [
|
||||
(
|
||||
sum((r["estimated_prob"] - r["outcome"]) ** 2 for r in mrows) / len(mrows),
|
||||
sum((r["prior_prob"] - r["outcome"]) ** 2 for r in mrows) / len(mrows),
|
||||
)
|
||||
for mrows in by_market.values()
|
||||
]
|
||||
brier_model_macro = sum(b[0] for b in market_briers) / len(market_briers)
|
||||
brier_prior_macro = sum(b[1] for b in market_briers) / len(market_briers)
|
||||
|
||||
by_category: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
by_category[r["category"] or "unknown"].append(r)
|
||||
per_category = {
|
||||
cat: {
|
||||
"n": len(crows),
|
||||
"markets": len({r["market_id"] for r in crows}),
|
||||
"brier_model": sum((r["estimated_prob"] - r["outcome"]) ** 2
|
||||
for r in crows) / len(crows),
|
||||
"brier_prior": sum((r["prior_prob"] - r["outcome"]) ** 2
|
||||
for r in crows) / len(crows),
|
||||
}
|
||||
for cat, crows in sorted(by_category.items())
|
||||
}
|
||||
|
||||
return {
|
||||
"n_evaluations": n,
|
||||
"n_markets": len(by_market),
|
||||
"brier_model": brier_model,
|
||||
"brier_prior": brier_prior,
|
||||
"brier_model_macro": brier_model_macro,
|
||||
"brier_prior_macro": brier_prior_macro,
|
||||
"logloss_model": logloss_model,
|
||||
"logloss_prior": logloss_prior,
|
||||
"per_category": per_category,
|
||||
}
|
||||
|
||||
|
||||
def print_report(metrics: Optional[dict], source: str) -> None:
|
||||
if metrics is None:
|
||||
print(f"calibration : no scorable rows yet for {source} "
|
||||
"(no archived estimate has a resolved outcome)")
|
||||
return
|
||||
print(f"calibration : {source} — {metrics['n_evaluations']} evaluations, "
|
||||
f"{metrics['n_markets']} markets")
|
||||
print(f"{'':14s}{'model':>10s}{'market':>10s}{'delta':>10s}")
|
||||
for label, m_key, p_key in (
|
||||
("Brier micro", "brier_model", "brier_prior"),
|
||||
("Brier macro", "brier_model_macro", "brier_prior_macro"),
|
||||
("logloss micro", "logloss_model", "logloss_prior"),
|
||||
):
|
||||
m, p = metrics[m_key], metrics[p_key]
|
||||
print(f" {label:12s}{m:>10.4f}{p:>10.4f}{m - p:>+10.4f}")
|
||||
print(" (delta < 0 = model beats the market price)")
|
||||
for cat, c in metrics["per_category"].items():
|
||||
print(f" {cat:12s}n={c['n']:<6d} markets={c['markets']:<3d} "
|
||||
f"brier model {c['brier_model']:.4f} vs market {c['brier_prior']:.4f}")
|
||||
|
||||
|
||||
async def _amain(args: argparse.Namespace) -> None:
|
||||
db = Database()
|
||||
await db.connect()
|
||||
try:
|
||||
if not args.metrics_only and args.run_id is None:
|
||||
pending = await db.get_unresolved_archived_market_ids()
|
||||
poly = PolymarketClient()
|
||||
try:
|
||||
resolved = await fetch_outcomes(poly, pending)
|
||||
finally:
|
||||
await poly.close()
|
||||
for out in resolved:
|
||||
await db.upsert_market_outcome(
|
||||
out["market_id"], out["outcome"], out["resolved_at"]
|
||||
)
|
||||
print(f"outcomes : {len(resolved)} newly resolved "
|
||||
f"(of {len(pending)} pending markets checked)")
|
||||
|
||||
coverage = await db.get_outcome_coverage()
|
||||
print(f"coverage : {coverage['resolved']}/{coverage['archived']} "
|
||||
"archived markets resolved")
|
||||
|
||||
if args.fetch_only:
|
||||
return
|
||||
rows = await db.get_calibration_rows(run_id=args.run_id)
|
||||
source = f"replay run {args.run_id}" if args.run_id else "R0 archive"
|
||||
print_report(compute_calibration(rows), source)
|
||||
finally:
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m bot.outcomes",
|
||||
description="Fetch market resolutions and score archived estimates.",
|
||||
)
|
||||
parser.add_argument("--fetch-only", action="store_true",
|
||||
help="only fetch/store outcomes, skip metrics")
|
||||
parser.add_argument("--metrics-only", action="store_true",
|
||||
help="skip the Gamma fetch, score what is stored")
|
||||
parser.add_argument("--run-id", default=None,
|
||||
help="score a replay run's re-estimates instead of "
|
||||
"the R0 archive (implies --metrics-only)")
|
||||
args = parser.parse_args()
|
||||
if args.fetch_only and args.metrics_only:
|
||||
parser.error("--fetch-only and --metrics-only are mutually exclusive")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
asyncio.run(_amain(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,171 @@
|
||||
# Informe final de paper trading — polymarket-bot
|
||||
|
||||
**Snapshot: 2026-07-06** · Generado desde la base de datos de producción con el bot
|
||||
aún operativo, como parte de la Fase 1 del plan de decomisión (bloqueo ISP de
|
||||
Polymarket en España, mayo 2026).
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Advertencia de tamaño muestral (léase primero)
|
||||
|
||||
**12 trades ejecutados, de los cuales solo 2 llegaron a resolución, no constituyen
|
||||
evidencia estadística de nada.** Ni el P&L positivo demuestra que la estrategia
|
||||
funcione, ni su ausencia lo habría refutado. El propio bot lo sabía: sus criterios
|
||||
de promoción a dinero real exigían ≥10 mercados resueltos y ≥30 días de
|
||||
observación (`promotion_ready: false`, win rate y Sharpe deliberadamente
|
||||
reportados como `n/a (insufficient_sample)` en toda la instrumentación).
|
||||
|
||||
El valor demostrable de este proyecto no está en el P&L: está en la
|
||||
infraestructura de evaluación, el pipeline de datos y la disciplina de gates que
|
||||
impidió que el bot tradease sin ventaja medible. Este informe presenta el P&L
|
||||
como lo que es — **una anécdota** — y dedica el grueso al sistema.
|
||||
|
||||
---
|
||||
|
||||
## 1. Resultados de trading (anecdóticos)
|
||||
|
||||
Bankroll simulado: **$10.000** (USDC virtual) · Capital desplegado: **$2.447** ·
|
||||
Periodo de actividad: **14–22 de abril de 2026** (los 12 trades se abrieron en 9
|
||||
días; después, el endurecimiento de los gates de edge y confianza dejó el bot en
|
||||
0 trades — funcionando, evaluando ~1.350 ciclos/día, sin encontrar ventaja neta
|
||||
que superara sus propios umbrales).
|
||||
|
||||
### P&L realizado: **+$247,78** (2 mercados resueltos)
|
||||
|
||||
| Mercado | Dirección | Entrada | Resultado | P&L neto |
|
||||
|---|---|---|---|---|
|
||||
| Will Ken Paxton win the 2026 Texas Republican Primary? | BUY_YES | 0,618 | ✅ YES (2026-06-11) | **+$299,06** |
|
||||
| Will NVIDIA be the largest company in the world…? | BUY_NO | 0,156 | ❌ YES (2026-06-30) | **−$51,28** |
|
||||
|
||||
P&L neto de comisiones (2% simulado por lado). 1 acierto / 1 fallo: n=2.
|
||||
|
||||
### P&L no realizado (mark-to-market): **+$809,59** (5 posiciones abiertas)
|
||||
|
||||
Marcado a los precios reales de Polymarket (Gamma API) el 2026-07-06. Estas
|
||||
posiciones **no están cerradas**: el número de abajo cambiaría cada día que los
|
||||
mercados sigan moviéndose, y varios no resuelven hasta noviembre de 2026.
|
||||
|
||||
| Mercado | Dirección | Entrada | Precio actual (lado) | Coste neto | MTM P&L |
|
||||
|---|---|---|---|---|---|
|
||||
| Karen Bass gana la alcaldía de LA 2026 | BUY_YES | 0,268 | 0,595 | $510,00 | **+$600,07** |
|
||||
| OpenAI IPO antes del 31-dic-2026 | BUY_NO | 0,618 | 0,785 | $510,00 | **+$125,11** |
|
||||
| Demócratas ganan el Senado de Texas 2026 | BUY_YES | 0,418 | 0,435 | $509,49 | **+$10,32** |
|
||||
| Demócratas ganan gobernación de Ohio 2026 | BUY_NO | 0,458 | 0,520 | $407,74 | **+$46,12** |
|
||||
| Republicanos ganan gobernación de Nebraska 2026 | BUY_NO | 0,158 | 0,170 | $510,00 | **+$27,97** |
|
||||
|
||||
> Nota metodológica: el tracker interno reportaba `unrealized_pnl_est = +$522,73`,
|
||||
> pero esa cifra es la **expectativa del propio modelo a fecha de entrada**
|
||||
> (edge_net × coste), no un marcado a mercado. La tabla de arriba usa precios
|
||||
> reales de mercado del día del snapshot, que es el criterio honesto. Ambas
|
||||
> cifras coinciden en el signo; ninguna de las dos es un resultado realizado.
|
||||
|
||||
### Los otros 5 cierres (sin P&L): la historia real de abril
|
||||
|
||||
De los 7 trades cerrados, solo 2 cerraron por resolución. Los otros 5 los cerró
|
||||
el propio bot al detectar defectos en su lógica — y son la parte más
|
||||
instructiva del histórico:
|
||||
|
||||
- **3 por conflicto de familia**: dos posiciones sobre mercados hermanos del
|
||||
mismo evento (p. ej. YES-demócratas y YES-republicanos en la misma
|
||||
gobernación) — el agrupador de familias los detectó y cerró el lado débil.
|
||||
- **2 por el bug de inversión de Manifold**: la señal cruzada de Manifold se
|
||||
aplicaba invertida en mercados espejo. Detectado por instrumentación, las
|
||||
posiciones afectadas se cerraron y el matching se movió a modo
|
||||
observacional-only con auditoría completa (165.538 filas en
|
||||
`manifold_match_audit`).
|
||||
- **1 excluido de métricas** (`invalid_manifold_match_legacy`, P&L $0,00).
|
||||
|
||||
Después de esa semana el bot no volvió a operar: los gates endurecidos
|
||||
(edge neto > mínimo por régimen, confianza ≥ 0,55, guardrail de noticias
|
||||
prior±0,25) filtraron el 100% de las ~223.000 evaluaciones posteriores.
|
||||
**Un sistema de paper trading cuyo resultado principal es "no tradees sin
|
||||
ventaja" es un resultado**, y está instrumentado hasta el último skip.
|
||||
|
||||
---
|
||||
|
||||
## 2. El sistema (el activo real)
|
||||
|
||||
### Pipeline de señales
|
||||
|
||||
| Métrica | Valor (snapshot 2026-07-06) |
|
||||
|---|---|
|
||||
| Ciclos de evaluación archivados | **5.356** (cadencia ~64 s) |
|
||||
| Evaluaciones mercado-ciclo archivadas | **222.738** |
|
||||
| Mercados distintos evaluados | 87 (events 114,9k · politics 96,0k · crypto/finance 7,7k · tech 4,2k evaluaciones) |
|
||||
| Archivo `signals` operativo desde | 2026-07-02 (replay R0) — crece ~55k filas/día |
|
||||
| Histórico de métricas diarias | **81 días** (2026-04-14 → 2026-07-06, `metrics_daily`) |
|
||||
| Snapshots de contexto externo | 5.356 (BTC, dominancia, Fear&Greed por ciclo) |
|
||||
|
||||
Cada evaluación archiva el prior, la probabilidad estimada, el edge bruto/neto,
|
||||
la descomposición por feature (Fear&Greed, momentum, noticias, Manifold,
|
||||
dominancia BTC en log-odds), el gate que la bloqueó y el contexto de mercado —
|
||||
suficiente para reproducir la decisión completa offline.
|
||||
|
||||
### Motor de replay (R0–R2, julio 2026)
|
||||
|
||||
- Inyección de reloj + replay determinista de ciclos archivados:
|
||||
**22.697/22.697 decisiones idénticas** al reproducir el histórico.
|
||||
- Joiner diario de resoluciones (CronJob 00:30 UTC) contra la Gamma API:
|
||||
cobertura de outcomes 9/87 mercados al snapshot, creciendo sola.
|
||||
- Diseñado para calibrar sobre **todas** las evaluaciones (no solo trades),
|
||||
esquivando el problema del n=12.
|
||||
|
||||
### Estrategia (congelada desde 2026-07-03)
|
||||
|
||||
Prior desde el precio de Polymarket → ajuste bayesiano en log-odds con señales
|
||||
externas (GNews con presupuesto de 5 consultas/día y guardrail prior±0,25;
|
||||
Fear&Greed; momentum BTC; Manifold observacional) → gates en cascada: prior
|
||||
extremo, edge bruto por régimen de volatilidad, edge neto tras comisión 2% y
|
||||
spread, confianza mínima 0,55, conflictos de familia, guard de reentrada.
|
||||
Sizing por Kelly fraccionado con techo por posición.
|
||||
|
||||
### Infraestructura
|
||||
|
||||
3 imágenes (bot / API FastAPI / dashboard) construidas por Gitea Actions solo
|
||||
cuando cambian sus fuentes, desplegadas por ArgoCD (prune + selfHeal) en k3s;
|
||||
PostgreSQL 16 en StatefulSet; secrets vía Infisical operator; smoke test
|
||||
PostSync contra la API; backups nocturnos con sync offsite (rclone → MEGA);
|
||||
observabilidad en Grafana + uptime-kuma + notificaciones Telegram (deploys,
|
||||
checkpoints del bot, fallos de sync). Retención de métricas y join de outcomes
|
||||
como CronJobs. **88 días de uptime del StatefulSet al snapshot.**
|
||||
|
||||
Detalle honesto de observabilidad: el panel *Win Rate* del dashboard de Grafana
|
||||
(`docs/dashboard-grafana-2026-07-06.png`) pasó vacío los 81 días. No es una
|
||||
query rota: la API emite `win_rate: null` hasta tener ≥10 mercados resueltos
|
||||
(hubo 2), y Grafana no pinta nada con un null. El panel vacío es el criterio
|
||||
de promoción del bot aplicándose a su propia monitorización.
|
||||
|
||||
---
|
||||
|
||||
## 3. Estado de la base de datos al snapshot
|
||||
|
||||
| Tabla | Filas | Contenido |
|
||||
|---|---|---|
|
||||
| `signals` | 222.549* | Archivo de evaluaciones por ciclo (desde 2026-07-02) |
|
||||
| `manifold_match_audit` | 165.538 | Auditoría completa del matching Polymarket↔Manifold |
|
||||
| `replay_decisions` | 22.697 | Decisiones reproducidas por el motor de replay |
|
||||
| `ext_snapshots` | 5.353* | Contexto externo por ciclo |
|
||||
| `metrics_daily` | 516 | Cierres diarios (81 días) + snapshots intradía de hoy |
|
||||
| `markets` | 87 | Universo evaluado |
|
||||
| `trades` | 12 | Ledger completo de paper trades |
|
||||
| `market_outcomes` | 9 | Resoluciones UMA-final joineadas |
|
||||
| `replay_runs` / `manifold_eval_cooldown` / `checkpoint_alerts` | 1 / 76 / 3 | Metadatos |
|
||||
|
||||
\* La BD sigue viva; las tablas por ciclo crecen ~55k y ~1,3k filas/día
|
||||
respectivamente. Backup verificado del 2026-07-06 en
|
||||
`/data/backups/backups/polymarket-decommission/` (pg_dump -Fc + CSV.gz +
|
||||
checksums; restore de prueba con recuentos idénticos 11/11 tablas). El dump
|
||||
final se hará en la Fase 3, justo antes del apagado.
|
||||
|
||||
---
|
||||
|
||||
## 4. Conclusión
|
||||
|
||||
El proyecto se archiva por una causa externa (bloqueo regulatorio del origen de
|
||||
datos), no por fracaso técnico. Lo que queda: un motor de evaluación bayesiano
|
||||
determinista y auditable, un pipeline de datos que archivó cada decisión con su
|
||||
contexto completo, y la evidencia — anecdótica en P&L, sólida en ingeniería —
|
||||
de que el sistema prefería no operar antes que operar sin ventaja. Todos los
|
||||
módulos centrales (edge, familias, replay, observabilidad) son agnósticos de la
|
||||
fuente y quedan listos para un eventual pivote a Manifold
|
||||
(`docs/pivot-manifold.md`, Fase 5).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
+1
-1
@@ -2,7 +2,7 @@
|
||||
asyncpg==0.29.0
|
||||
httpx==0.27.0
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
uvicorn[standard]==0.51.0
|
||||
pydantic==2.7.0
|
||||
|
||||
# Polymarket (install from PyPI when ready for real trading)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Replay R2 tests — outcome fetching and calibration scoring."""
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.data.polymarket import MarketResolution
|
||||
from bot.outcomes import (
|
||||
LOGLOSS_EPS,
|
||||
compute_calibration,
|
||||
fetch_outcomes,
|
||||
print_report,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class FakePoly:
|
||||
"""get_market_resolution stand-in driven by a dict of canned responses."""
|
||||
|
||||
def __init__(self, responses: dict):
|
||||
self.responses = responses
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def get_market_resolution(self, market_id: str):
|
||||
self.calls.append(market_id)
|
||||
return self.responses.get(market_id)
|
||||
|
||||
|
||||
RESOLVED_AT = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _row(market_id="m1", category="politics", est=0.6, prior=0.5, outcome=1.0):
|
||||
return {
|
||||
"market_id": market_id,
|
||||
"category": category,
|
||||
"estimated_prob": est,
|
||||
"prior_prob": prior,
|
||||
"outcome": outcome,
|
||||
}
|
||||
|
||||
|
||||
# ── fetch_outcomes ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fetch_keeps_only_definitive_resolutions():
|
||||
poly = FakePoly({
|
||||
"yes": MarketResolution(resolved=True, resolution=1.0,
|
||||
resolved_at=RESOLVED_AT),
|
||||
"no": MarketResolution(resolved=True, resolution=0.0,
|
||||
resolved_at=None),
|
||||
"open": MarketResolution(resolved=False),
|
||||
"disputed": MarketResolution(resolved=False),
|
||||
"apierror": None, # get_market_resolution returns None on HTTP errors
|
||||
})
|
||||
out = asyncio.run(
|
||||
fetch_outcomes(poly, ["yes", "no", "open", "disputed", "apierror"])
|
||||
)
|
||||
assert poly.calls == ["yes", "no", "open", "disputed", "apierror"]
|
||||
assert out == [
|
||||
{"market_id": "yes", "outcome": 1.0, "resolved_at": RESOLVED_AT},
|
||||
{"market_id": "no", "outcome": 0.0, "resolved_at": None},
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_empty_list_is_noop():
|
||||
poly = FakePoly({})
|
||||
assert asyncio.run(fetch_outcomes(poly, [])) == []
|
||||
assert poly.calls == []
|
||||
|
||||
|
||||
# ── compute_calibration ──────────────────────────────────────────────────────
|
||||
|
||||
def test_no_rows_returns_none():
|
||||
assert compute_calibration([]) is None
|
||||
|
||||
|
||||
def test_single_row_known_values():
|
||||
m = compute_calibration([_row(est=0.8, prior=0.6, outcome=1.0)])
|
||||
assert m["n_evaluations"] == 1
|
||||
assert m["n_markets"] == 1
|
||||
assert m["brier_model"] == pytest.approx((0.8 - 1.0) ** 2)
|
||||
assert m["brier_prior"] == pytest.approx((0.6 - 1.0) ** 2)
|
||||
assert m["logloss_model"] == pytest.approx(-math.log(0.8))
|
||||
assert m["logloss_prior"] == pytest.approx(-math.log(0.6))
|
||||
# one market: macro == micro
|
||||
assert m["brier_model_macro"] == pytest.approx(m["brier_model"])
|
||||
assert m["brier_prior_macro"] == pytest.approx(m["brier_prior"])
|
||||
|
||||
|
||||
def test_logloss_no_outcome_branch():
|
||||
m = compute_calibration([_row(est=0.2, prior=0.7, outcome=0.0)])
|
||||
assert m["logloss_model"] == pytest.approx(-math.log(0.8))
|
||||
assert m["logloss_prior"] == pytest.approx(-math.log(0.3))
|
||||
|
||||
|
||||
def test_logloss_clipping_keeps_hard_miss_finite():
|
||||
# A hard 1.0 estimate on a NO outcome must not produce inf.
|
||||
m = compute_calibration([_row(est=1.0, prior=0.5, outcome=0.0)])
|
||||
assert math.isfinite(m["logloss_model"])
|
||||
assert m["logloss_model"] == pytest.approx(-math.log(LOGLOSS_EPS))
|
||||
|
||||
|
||||
def test_micro_weights_evaluations_macro_weights_markets():
|
||||
# Market a: 3 evaluations, model error 0.1; market b: 1 evaluation, error 0.5.
|
||||
rows = [
|
||||
_row(market_id="a", est=0.9, prior=0.8, outcome=1.0),
|
||||
_row(market_id="a", est=0.9, prior=0.8, outcome=1.0),
|
||||
_row(market_id="a", est=0.9, prior=0.8, outcome=1.0),
|
||||
_row(market_id="b", est=0.5, prior=0.6, outcome=1.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert m["n_evaluations"] == 4
|
||||
assert m["n_markets"] == 2
|
||||
# micro: (3*0.01 + 0.25) / 4 ; macro: (0.01 + 0.25) / 2
|
||||
assert m["brier_model"] == pytest.approx((3 * 0.01 + 0.25) / 4)
|
||||
assert m["brier_model_macro"] == pytest.approx((0.01 + 0.25) / 2)
|
||||
assert m["brier_prior"] == pytest.approx((3 * 0.04 + 0.16) / 4)
|
||||
assert m["brier_prior_macro"] == pytest.approx((0.04 + 0.16) / 2)
|
||||
|
||||
|
||||
def test_model_beating_market_gives_negative_delta():
|
||||
# est closer to the outcome than the price on every row
|
||||
rows = [
|
||||
_row(market_id="a", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="b", est=0.3, prior=0.45, outcome=0.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert m["brier_model"] < m["brier_prior"]
|
||||
assert m["logloss_model"] < m["logloss_prior"]
|
||||
|
||||
|
||||
def test_per_category_grouping_and_unknown():
|
||||
rows = [
|
||||
_row(market_id="a", category="politics", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="b", category="politics", est=0.7, prior=0.6, outcome=1.0),
|
||||
_row(market_id="c", category=None, est=0.4, prior=0.5, outcome=0.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert set(m["per_category"]) == {"politics", "unknown"}
|
||||
pol = m["per_category"]["politics"]
|
||||
assert pol["n"] == 2 and pol["markets"] == 2
|
||||
assert pol["brier_model"] == pytest.approx((0.04 + 0.09) / 2)
|
||||
unk = m["per_category"]["unknown"]
|
||||
assert unk["n"] == 1 and unk["markets"] == 1
|
||||
assert unk["brier_model"] == pytest.approx(0.16)
|
||||
|
||||
|
||||
def test_repeated_market_counts_once_in_markets():
|
||||
rows = [
|
||||
_row(market_id="a", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="a", est=0.7, prior=0.55, outcome=1.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert m["n_markets"] == 1
|
||||
assert m["per_category"]["politics"]["markets"] == 1
|
||||
|
||||
|
||||
# ── print_report ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_report_handles_no_metrics(capsys):
|
||||
print_report(None, "R0 archive")
|
||||
assert "no scorable rows yet" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_report_prints_all_metric_lines(capsys):
|
||||
m = compute_calibration([
|
||||
_row(market_id="a", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="b", category=None, est=0.4, prior=0.5, outcome=0.0),
|
||||
])
|
||||
print_report(m, "R0 archive")
|
||||
out = capsys.readouterr().out
|
||||
assert "2 evaluations, 2 markets" in out
|
||||
for label in ("Brier micro", "Brier macro", "logloss micro",
|
||||
"politics", "unknown"):
|
||||
assert label in out
|
||||
Reference in New Issue
Block a user