Houroux commited on
Commit
e2ce547
·
verified ·
1 Parent(s): 79412bc

Publish Render-ready Polymarket collector

Browse files
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .git
2
+ .venv*
3
+ __pycache__
4
+ *.py[cod]
5
+ .env
6
+ data
7
+ *.zip
.env.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Public market-data collector only. No wallet or Polymarket private key is used.
2
+ DATA_DIR=/var/lib/polymarket-collector
3
+ MAX_MARKETS=75
4
+ MIN_DAILY_REWARD=0
5
+ MIN_VOLUME_24H=0
6
+ DISCOVERY_INTERVAL_SECONDS=900
7
+ ROTATION_SECONDS=900
8
+ MAX_FILE_MIB=64
9
+ FLUSH_INTERVAL_SECONDS=2
10
+ HEALTH_HOST=127.0.0.1
11
+ HEALTH_PORT=8080
12
+ STALE_AFTER_SECONDS=180
13
+ LOG_LEVEL=INFO
14
+
15
+ # Optional Google Drive archive through rclone.
16
+ # Example after running `rclone config`: gdrive:polymarket-data
17
+ RCLONE_REMOTE=
18
+ LOCAL_RETENTION_DAYS=7
19
+ REMOVE_AFTER_UPLOAD=false
20
+ BACKUP_MIN_AGE=2m
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ .coverage
6
+ data/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ DATA_DIR=/data \
6
+ HEALTH_HOST=0.0.0.0 \
7
+ PORT=8080
8
+
9
+ RUN apt-get update \
10
+ && apt-get install -y --no-install-recommends ca-certificates tini \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ WORKDIR /app
14
+ COPY pyproject.toml README.md ./
15
+ COPY polymarket_collector ./polymarket_collector
16
+ COPY scripts ./scripts
17
+ COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
18
+ RUN pip install --no-cache-dir . \
19
+ && chmod +x /app/scripts/polymarket-backup /app/deploy/container-entrypoint.sh
20
+
21
+ EXPOSE 8080
22
+ ENTRYPOINT ["/usr/bin/tini", "--", "/app/deploy/container-entrypoint.sh"]
README.md ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Polymarket LP Collector
3
+ emoji: 📈
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 8080
8
+ ---
9
+
10
+ # Collecteur de données LP Polymarket
11
+
12
+ Ce projet enregistre en continu les données publiques de niveau 2 des marchés
13
+ Polymarket qui distribuent des récompenses de liquidité. Il est conçu pour une
14
+ petite VM Google Cloud `e2-micro` : mémoire limitée, fichiers rotatifs et aucune
15
+ base de données résidente.
16
+
17
+ Il ne signe aucun ordre, ne contient aucune clé de portefeuille et ne peut pas
18
+ trader. Son unique rôle est de créer un historique propre pour les futurs
19
+ backtests et modèles d'apprentissage automatique.
20
+
21
+ ## Données collectées
22
+
23
+ - carnets complets (`book`) ;
24
+ - changements de niveaux (`price_change`) ;
25
+ - meilleurs bid/ask ;
26
+ - dernières transactions ;
27
+ - changements de tick et résolutions ;
28
+ - métadonnées, volumes et paramètres de récompenses LP ;
29
+ - heure de réception locale en nanosecondes pour mesurer les délais.
30
+
31
+ Les fichiers terminés sont écrits sous la forme :
32
+
33
+ ```text
34
+ data/
35
+ market_ws/YYYY/MM/DD/market_ws-....jsonl.gz
36
+ discovery/YYYY/MM/DD/discovery-....jsonl.gz
37
+ ```
38
+
39
+ Chaque ligne est un objet JSON autonome. Les fichiers `.part` sont récupérés et
40
+ compressés automatiquement au prochain démarrage après une coupure brutale.
41
+
42
+ ## Exécution locale
43
+
44
+ ```bash
45
+ python3 -m venv .venv
46
+ . .venv/bin/activate
47
+ pip install -e .
48
+ cp .env.example .env
49
+ DATA_DIR="$PWD/data" polymarket-collector
50
+ ```
51
+
52
+ Tester uniquement l'accès aux API publiques avant de démarrer :
53
+
54
+ ```bash
55
+ polymarket-collector --check
56
+ ```
57
+
58
+ État du service :
59
+
60
+ ```bash
61
+ curl http://127.0.0.1:8080/healthz
62
+ curl http://127.0.0.1:8080/metrics
63
+ ```
64
+
65
+ Arrêter avec `Ctrl+C`. Le service finalise alors proprement les fichiers en
66
+ cours.
67
+
68
+ ## Déploiement Google Cloud
69
+
70
+ Depuis un ordinateur ou Google Cloud Shell ayant `gcloud` authentifié :
71
+
72
+ ```bash
73
+ ./scripts/deploy_gcp.sh ID_DU_PROJET_GCP
74
+ ```
75
+
76
+ Le script crée par défaut une VM `e2-micro` Ubuntu 24.04 dans `us-east1-b`, un
77
+ disque standard de 30 Go, copie le projet, puis installe et démarre le service.
78
+
79
+ Commandes utiles après installation :
80
+
81
+ ```bash
82
+ gcloud compute ssh polymarket-collector --zone us-east1-b
83
+ sudo systemctl status polymarket-collector
84
+ sudo journalctl -u polymarket-collector -f
85
+ curl http://127.0.0.1:8080/healthz
86
+ ```
87
+
88
+ ## Sauvegarde vers Google Drive
89
+
90
+ Sur la VM :
91
+
92
+ ```bash
93
+ sudo rclone config
94
+ sudoedit /etc/polymarket-collector.env
95
+ ```
96
+
97
+ Définir ensuite, par exemple :
98
+
99
+ ```text
100
+ RCLONE_REMOTE=gdrive:polymarket-data
101
+ LOCAL_RETENTION_DAYS=7
102
+ ```
103
+
104
+ Puis activer la sauvegarde :
105
+
106
+ ```bash
107
+ sudo systemctl enable --now polymarket-backup.timer
108
+ sudo systemctl start polymarket-backup.service
109
+ sudo journalctl -u polymarket-backup.service -n 100
110
+ ```
111
+
112
+ Les fichiers locaux âgés de plus de `LOCAL_RETENTION_DAYS` ne sont supprimés
113
+ qu'après une copie `rclone` réussie. Mettre la valeur à `0` pour désactiver le
114
+ nettoyage local.
115
+
116
+ ## Hébergement gratuit sans dépassement : Render + Hugging Face
117
+
118
+ L'architecture recommandée pour une collecte continue sans VM personnelle est :
119
+
120
+ 1. un Web Service public **Render Free**, sans moyen de paiement enregistré ;
121
+ 2. UptimeRobot Free sur `/healthz`, toutes les cinq minutes, pour maintenir une
122
+ visite régulière et surveiller le service ;
123
+ 3. un dataset Hugging Face public pour les archives. Les fichiers locaux sont
124
+ supprimés uniquement après la réussite du commit distant.
125
+
126
+ Le conteneur est prêt dans `Dockerfile` et le profil Render dans `render.yaml`.
127
+ Créer un jeton Hugging Face avec droit d'écriture, puis publier le dépôt source :
128
+
129
+ ```bash
130
+ python scripts/publish_hf_source.py
131
+ ```
132
+
133
+ Créer ensuite un Web Service Render depuis l'URL Git publique affichée, choisir
134
+ le plan `Free`, et fournir `HF_TOKEN` comme secret. Le profil utilise 40 marchés,
135
+ une rotation à 256 Mio et un envoi groupé toutes les heures. Render dispose de
136
+ 750 heures gratuites par mois et suspend le service au lieu de facturer lorsque
137
+ le compte ne contient aucun moyen de paiement. Son disque est temporaire : les
138
+ archives ne sont supprimées qu'après confirmation du commit Hugging Face.
139
+
140
+ Hugging Face Spaces n'est plus recommandé : les Spaces Docker sur `cpu-basic`
141
+ exigent désormais un abonnement Hugging Face PRO.
142
+
143
+ Koyeb n'est plus recommandé : depuis son rapprochement avec Mistral, les
144
+ nouveaux utilisateurs doivent fournir un moyen de paiement et souscrire un
145
+ forfait payant.
146
+
147
+ ## Tests
148
+
149
+ ```bash
150
+ python -m unittest discover -s tests -v
151
+ ```
152
+
153
+ Les tests ne contactent pas Polymarket et ne nécessitent aucune clé.
154
+
155
+ ## Limites importantes
156
+
157
+ - Avec 1 Go de RAM et 30 Go de disque, commencer avec 75 marchés maximum.
158
+ - Une VM gratuite aux États-Unis convient à la collecte, pas au futur bot de
159
+ trading sensible à la latence.
160
+ - Le service conserve les messages bruts : les transformations en Parquet et
161
+ l'entraînement se feront sur l'ordinateur équipé de la RTX 3060.
deploy/container-entrypoint.sh ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
+
4
+ DATA_DIR="${DATA_DIR:-/data}"
5
+ BACKUP_INTERVAL_SECONDS="${BACKUP_INTERVAL_SECONDS:-120}"
6
+ RCLONE_CONFIG_FILE="${RCLONE_CONFIG_FILE:-/tmp/rclone.conf}"
7
+ collector_pid=""
8
+ backup_pid=""
9
+
10
+ if ! [[ "$BACKUP_INTERVAL_SECONDS" =~ ^[1-9][0-9]*$ ]]; then
11
+ echo "BACKUP_INTERVAL_SECONDS must be a positive integer." >&2
12
+ exit 1
13
+ fi
14
+
15
+ mkdir -p "$DATA_DIR"
16
+
17
+ if [[ -n "${RCLONE_CONFIG_B64:-}" ]]; then
18
+ printf '%s' "$RCLONE_CONFIG_B64" | base64 --decode >"$RCLONE_CONFIG_FILE"
19
+ chmod 600 "$RCLONE_CONFIG_FILE"
20
+ export RCLONE_CONFIG="$RCLONE_CONFIG_FILE"
21
+ fi
22
+
23
+ if [[ "${REQUIRE_REMOTE_BACKUP:-false}" == "true" ]]; then
24
+ if [[ -n "${HF_TOKEN:-}" ]]; then
25
+ :
26
+ elif [[ -n "${RCLONE_REMOTE:-}" && -s "$RCLONE_CONFIG_FILE" ]]; then
27
+ :
28
+ else
29
+ echo "Remote backup is required but neither Hugging Face nor rclone is configured." >&2
30
+ exit 1
31
+ fi
32
+ fi
33
+
34
+ stop_children() {
35
+ trap - EXIT INT TERM
36
+ if [[ -n "$collector_pid" ]]; then
37
+ kill -TERM "$collector_pid" 2>/dev/null || true
38
+ fi
39
+ if [[ -n "$backup_pid" ]]; then
40
+ kill -TERM "$backup_pid" 2>/dev/null || true
41
+ fi
42
+ wait "$collector_pid" 2>/dev/null || true
43
+ wait "$backup_pid" 2>/dev/null || true
44
+ }
45
+ trap stop_children EXIT INT TERM
46
+
47
+ if [[ -n "${HF_TOKEN:-}" ]]; then
48
+ python -m polymarket_collector.hf_backup --loop &
49
+ backup_pid=$!
50
+ elif [[ -n "${RCLONE_REMOTE:-}" ]]; then
51
+ (
52
+ while true; do
53
+ if ! ENV_FILE=/dev/null DATA_DIR="$DATA_DIR" \
54
+ /app/scripts/polymarket-backup; then
55
+ echo "Backup failed; files are kept locally and will be retried." >&2
56
+ fi
57
+ sleep "$BACKUP_INTERVAL_SECONDS"
58
+ done
59
+ ) &
60
+ backup_pid=$!
61
+ fi
62
+
63
+ polymarket-collector &
64
+ collector_pid=$!
65
+ set +e
66
+ wait "$collector_pid"
67
+ status=$?
68
+ set -e
69
+ collector_pid=""
70
+ exit "$status"
deploy/hf-dataset-card.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ task_categories:
4
+ - time-series-forecasting
5
+ - reinforcement-learning
6
+ tags:
7
+ - polymarket
8
+ - prediction-markets
9
+ - order-book
10
+ - market-microstructure
11
+ ---
12
+
13
+ # Polymarket rewarded-market L2 history
14
+
15
+ Append-only archives of public Polymarket CLOB WebSocket messages for markets
16
+ eligible for liquidity rewards. The dataset is intended for market
17
+ microstructure research, backtests and machine-learning experiments.
18
+
19
+ No wallet data, private keys or authenticated trading information is collected.
20
+ Files are gzip-compressed JSON Lines and grouped by source and UTC date.
21
+ The source data comes from Polymarket's public CLOB interfaces and remains
22
+ subject to the applicable source-platform terms.
deploy/koyeb.env.example ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the Koyeb service type strictly on "free".
2
+ MAX_MARKETS=40
3
+ DATA_DIR=/data
4
+ HEALTH_HOST=0.0.0.0
5
+ PORT=8080
6
+ ROTATION_SECONDS=300
7
+ MAX_FILE_MIB=32
8
+ BACKUP_INTERVAL_SECONDS=120
9
+ BACKUP_MIN_AGE=30s
10
+ REMOVE_AFTER_UPLOAD=true
11
+ REQUIRE_REMOTE_BACKUP=true
12
+ RCLONE_REMOTE=gdrive:polymarket-data
13
+
14
+ # Secret only: base64 of an rclone.conf containing the Google Drive token.
15
+ RCLONE_CONFIG_B64=
deploy/polymarket-backup.service ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Unit]
2
+ Description=Archive Polymarket collector files with rclone
3
+ After=network-online.target polymarket-collector.service
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=oneshot
8
+ EnvironmentFile=-/etc/polymarket-collector.env
9
+ ExecStart=/usr/local/sbin/polymarket-backup
10
+ Nice=10
11
+ IOSchedulingClass=idle
12
+
deploy/polymarket-backup.timer ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Unit]
2
+ Description=Hourly Polymarket data archive
3
+
4
+ [Timer]
5
+ OnBootSec=20min
6
+ OnUnitActiveSec=1h
7
+ RandomizedDelaySec=5min
8
+ Persistent=true
9
+
10
+ [Install]
11
+ WantedBy=timers.target
12
+
deploy/polymarket-collector.service ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Unit]
2
+ Description=Polymarket LP public market-data collector
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ User=polymarket
9
+ Group=polymarket
10
+ WorkingDirectory=/opt/polymarket-collector
11
+ EnvironmentFile=-/etc/polymarket-collector.env
12
+ ExecStart=/opt/polymarket-collector/.venv/bin/polymarket-collector
13
+ Restart=always
14
+ RestartSec=5
15
+ TimeoutStopSec=30
16
+
17
+ # Keep the small VPS usable even if a malformed feed causes a memory spike.
18
+ MemoryMax=700M
19
+ TasksMax=64
20
+ NoNewPrivileges=true
21
+ PrivateTmp=true
22
+ ProtectSystem=strict
23
+ ProtectHome=true
24
+ ProtectKernelTunables=true
25
+ ProtectKernelModules=true
26
+ ProtectControlGroups=true
27
+ LockPersonality=true
28
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
29
+ ReadWritePaths=/var/lib/polymarket-collector
30
+
31
+ [Install]
32
+ WantedBy=multi-user.target
33
+
polymarket_collector/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Polymarket LP market-data collector."""
2
+
3
+ __version__ = "0.1.0"
4
+
polymarket_collector/__main__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from .main import cli
2
+
3
+
4
+ if __name__ == "__main__":
5
+ cli()
6
+
polymarket_collector/config.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ def _load_env_file(path: Path) -> None:
9
+ """Load a small dotenv-compatible file without adding a dependency."""
10
+ if not path.is_file():
11
+ return
12
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
13
+ line = raw_line.strip()
14
+ if not line or line.startswith("#") or "=" not in line:
15
+ continue
16
+ key, value = line.split("=", 1)
17
+ key = key.strip()
18
+ value = value.strip().strip("\"").strip("'")
19
+ if key:
20
+ os.environ.setdefault(key, value)
21
+
22
+
23
+ def _int(name: str, default: int, minimum: int = 0) -> int:
24
+ raw = os.getenv(name, str(default))
25
+ try:
26
+ value = int(raw)
27
+ except ValueError as exc:
28
+ raise ValueError(f"{name} must be an integer, got {raw!r}") from exc
29
+ if value < minimum:
30
+ raise ValueError(f"{name} must be >= {minimum}, got {value}")
31
+ return value
32
+
33
+
34
+ def _float(name: str, default: float, minimum: float = 0) -> float:
35
+ raw = os.getenv(name, str(default))
36
+ try:
37
+ value = float(raw)
38
+ except ValueError as exc:
39
+ raise ValueError(f"{name} must be a number, got {raw!r}") from exc
40
+ if value < minimum:
41
+ raise ValueError(f"{name} must be >= {minimum}, got {value}")
42
+ return value
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Config:
47
+ data_dir: Path
48
+ max_markets: int
49
+ min_daily_reward: float
50
+ min_volume_24h: float
51
+ discovery_interval_seconds: int
52
+ rotation_seconds: int
53
+ max_file_bytes: int
54
+ flush_interval_seconds: int
55
+ health_host: str
56
+ health_port: int
57
+ stale_after_seconds: int
58
+ log_level: str
59
+ clob_base_url: str = "https://clob.polymarket.com"
60
+ websocket_url: str = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
61
+
62
+ @classmethod
63
+ def from_env(cls) -> "Config":
64
+ env_file = Path(os.getenv("ENV_FILE", ".env"))
65
+ _load_env_file(env_file)
66
+ return cls(
67
+ data_dir=Path(os.getenv("DATA_DIR", "data")).expanduser().resolve(),
68
+ max_markets=_int("MAX_MARKETS", 75, 1),
69
+ min_daily_reward=_float("MIN_DAILY_REWARD", 0),
70
+ min_volume_24h=_float("MIN_VOLUME_24H", 0),
71
+ discovery_interval_seconds=_int(
72
+ "DISCOVERY_INTERVAL_SECONDS", 900, 30
73
+ ),
74
+ rotation_seconds=_int("ROTATION_SECONDS", 900, 60),
75
+ max_file_bytes=_int("MAX_FILE_MIB", 64, 1) * 1024 * 1024,
76
+ flush_interval_seconds=_int("FLUSH_INTERVAL_SECONDS", 2, 1),
77
+ health_host=os.getenv("HEALTH_HOST", "127.0.0.1"),
78
+ # PaaS providers such as Koyeb inject PORT. HEALTH_PORT keeps
79
+ # precedence so local/systemd installations remain configurable.
80
+ health_port=_int(
81
+ "HEALTH_PORT", int(os.getenv("PORT", "8080")), 1
82
+ ),
83
+ stale_after_seconds=_int("STALE_AFTER_SECONDS", 180, 30),
84
+ log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
85
+ clob_base_url=os.getenv(
86
+ "CLOB_BASE_URL", "https://clob.polymarket.com"
87
+ ).rstrip("/"),
88
+ websocket_url=os.getenv(
89
+ "WEBSOCKET_URL",
90
+ "wss://ws-subscriptions-clob.polymarket.com/ws/market",
91
+ ),
92
+ )
polymarket_collector/discovery.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import urllib.parse
6
+ import urllib.request
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+
11
+ USER_AGENT = "polymarket-lp-collector/0.1 (+public-market-data-only)"
12
+ PAGE_SIZE = 500
13
+ CURRENT_REWARDS_PAGE_LIMIT = 4
14
+ CANDIDATE_MULTIPLIER = 4
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class MarketSelection:
19
+ markets: tuple[dict[str, Any], ...]
20
+ asset_ids: tuple[str, ...]
21
+
22
+
23
+ def daily_reward(market: dict[str, Any]) -> float:
24
+ explicit = market.get("total_daily_rate")
25
+ if explicit is not None:
26
+ try:
27
+ return float(explicit)
28
+ except (TypeError, ValueError):
29
+ pass
30
+ total = 0.0
31
+ for reward in market.get("rewards_config") or []:
32
+ try:
33
+ total += float(reward.get("rate_per_day") or 0)
34
+ except (TypeError, ValueError):
35
+ continue
36
+ return total
37
+
38
+
39
+ def select_markets(
40
+ markets: list[dict[str, Any]],
41
+ *,
42
+ max_markets: int,
43
+ min_daily_reward: float,
44
+ min_volume_24h: float,
45
+ ) -> MarketSelection:
46
+ eligible: list[dict[str, Any]] = []
47
+ for market in markets:
48
+ tokens = market.get("tokens") or []
49
+ token_ids = [str(t.get("token_id", "")) for t in tokens if t.get("token_id")]
50
+ if len(token_ids) < 2:
51
+ continue
52
+ try:
53
+ volume = float(market.get("volume_24hr") or 0)
54
+ except (TypeError, ValueError):
55
+ volume = 0.0
56
+ reward = daily_reward(market)
57
+ if reward < min_daily_reward or volume < min_volume_24h:
58
+ continue
59
+ normalized = dict(market)
60
+ normalized["collector_daily_reward"] = reward
61
+ normalized["collector_volume_24hr"] = volume
62
+ eligible.append(normalized)
63
+
64
+ eligible.sort(
65
+ key=lambda m: (
66
+ float(m.get("collector_daily_reward") or 0),
67
+ float(m.get("collector_volume_24hr") or 0),
68
+ ),
69
+ reverse=True,
70
+ )
71
+ selected = eligible[:max_markets]
72
+
73
+ seen: set[str] = set()
74
+ asset_ids: list[str] = []
75
+ for market in selected:
76
+ for token in market.get("tokens") or []:
77
+ token_id = str(token.get("token_id", ""))
78
+ if token_id and token_id not in seen:
79
+ seen.add(token_id)
80
+ asset_ids.append(token_id)
81
+ return MarketSelection(tuple(selected), tuple(asset_ids))
82
+
83
+
84
+ def _get_json(url: str, timeout: int = 30) -> dict[str, Any]:
85
+ request = urllib.request.Request(
86
+ url,
87
+ headers={"Accept": "application/json", "User-Agent": USER_AGENT},
88
+ )
89
+ with urllib.request.urlopen(request, timeout=timeout) as response:
90
+ if response.status != 200:
91
+ raise RuntimeError(f"HTTP {response.status} from {url}")
92
+ payload = json.loads(response.read())
93
+ if not isinstance(payload, dict):
94
+ raise RuntimeError(f"Unexpected response type from {url}")
95
+ return payload
96
+
97
+
98
+ async def discover_rewarded_markets(
99
+ base_url: str,
100
+ *,
101
+ max_markets: int,
102
+ min_daily_reward: float,
103
+ min_volume_24h: float,
104
+ ) -> MarketSelection:
105
+ endpoint = f"{base_url}/rewards/markets/multi"
106
+ cursor: str | None = None
107
+ all_markets: list[dict[str, Any]] = []
108
+ # The endpoint can contain many thousands of short-lived sports markets.
109
+ # It is already sorted by reward rate, so fetching a bounded candidate set
110
+ # avoids delaying the initial websocket subscription by several minutes.
111
+ candidate_limit = max(PAGE_SIZE, max_markets * CANDIDATE_MULTIPLIER)
112
+
113
+ for _ in range(100):
114
+ params: list[tuple[str, str]] = [
115
+ ("page_size", str(PAGE_SIZE)),
116
+ ("order_by", "rate_per_day"),
117
+ ("position", "DESC"),
118
+ ]
119
+ if min_volume_24h > 0:
120
+ params.append(("min_volume_24hr", str(min_volume_24h)))
121
+ if cursor:
122
+ params.append(("next_cursor", cursor))
123
+ url = f"{endpoint}?{urllib.parse.urlencode(params)}"
124
+ payload = await asyncio.to_thread(_get_json, url)
125
+ page = payload.get("data") or []
126
+ if not isinstance(page, list):
127
+ raise RuntimeError("Rewards API returned a non-list data field")
128
+ all_markets.extend(item for item in page if isinstance(item, dict))
129
+ cursor = str(payload.get("next_cursor") or "LTE=")
130
+ if cursor == "LTE=" or not page or len(all_markets) >= candidate_limit:
131
+ break
132
+ else:
133
+ raise RuntimeError("Rewards pagination exceeded 100 pages")
134
+
135
+ # The current endpoint includes native + sponsored daily totals. Preserve
136
+ # both raw responses so historic selection decisions remain reproducible.
137
+ current_by_condition: dict[str, dict[str, Any]] = {}
138
+ cursor = None
139
+ current_endpoint = f"{base_url}/rewards/markets/current"
140
+ # Current rewards are not sorted and can also span many thousands of
141
+ # entries. A bounded enrichment pass keeps discovery timely; the raw
142
+ # candidate reward configuration remains available as the fallback.
143
+ for _ in range(CURRENT_REWARDS_PAGE_LIMIT):
144
+ params = []
145
+ if cursor:
146
+ params.append(("next_cursor", cursor))
147
+ suffix = f"?{urllib.parse.urlencode(params)}" if params else ""
148
+ payload = await asyncio.to_thread(_get_json, current_endpoint + suffix)
149
+ page = payload.get("data") or []
150
+ if not isinstance(page, list):
151
+ raise RuntimeError("Current rewards API returned a non-list data field")
152
+ for item in page:
153
+ if isinstance(item, dict) and item.get("condition_id"):
154
+ current_by_condition[str(item["condition_id"])] = item
155
+ cursor = str(payload.get("next_cursor") or "LTE=")
156
+ if cursor == "LTE=" or not page:
157
+ break
158
+
159
+ for market in all_markets:
160
+ current = current_by_condition.get(str(market.get("condition_id", "")))
161
+ if current:
162
+ market["current_rewards"] = current
163
+ if current.get("total_daily_rate") is not None:
164
+ market["total_daily_rate"] = current["total_daily_rate"]
165
+
166
+ return select_markets(
167
+ all_markets,
168
+ max_markets=max_markets,
169
+ min_daily_reward=min_daily_reward,
170
+ min_volume_24h=min_volume_24h,
171
+ )
polymarket_collector/health.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Final
6
+
7
+ from .state import CollectorState
8
+
9
+
10
+ MAX_REQUEST_BYTES: Final = 8192
11
+
12
+
13
+ class HealthServer:
14
+ def __init__(
15
+ self,
16
+ state: CollectorState,
17
+ host: str,
18
+ port: int,
19
+ stale_after_seconds: int,
20
+ ) -> None:
21
+ self.state = state
22
+ self.host = host
23
+ self.port = port
24
+ self.stale_after_seconds = stale_after_seconds
25
+ self._server: asyncio.Server | None = None
26
+
27
+ async def start(self) -> None:
28
+ self._server = await asyncio.start_server(
29
+ self._handle, host=self.host, port=self.port
30
+ )
31
+
32
+ async def close(self) -> None:
33
+ if self._server is not None:
34
+ self._server.close()
35
+ await self._server.wait_closed()
36
+
37
+ async def _handle(
38
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
39
+ ) -> None:
40
+ try:
41
+ raw = await asyncio.wait_for(
42
+ reader.readuntil(b"\r\n\r\n"), timeout=2
43
+ )
44
+ if len(raw) > MAX_REQUEST_BYTES:
45
+ raise ValueError("request too large")
46
+ first_line = raw.split(b"\r\n", 1)[0].decode("ascii", "replace")
47
+ _, path, _ = first_line.split(" ", 2)
48
+ if path == "/healthz":
49
+ payload = self.state.as_dict(self.stale_after_seconds)
50
+ status = 200 if payload["healthy"] else 503
51
+ await self._respond_json(writer, status, payload)
52
+ elif path == "/metrics":
53
+ await self._respond_metrics(writer)
54
+ else:
55
+ await self._respond_json(writer, 404, {"error": "not_found"})
56
+ except Exception:
57
+ await self._respond_json(writer, 400, {"error": "bad_request"})
58
+ finally:
59
+ writer.close()
60
+ await writer.wait_closed()
61
+
62
+ async def _respond_json(self, writer: asyncio.StreamWriter, status: int, payload: object) -> None:
63
+ body = json.dumps(payload, separators=(",", ":")).encode()
64
+ await self._respond(writer, status, "application/json", body)
65
+
66
+ async def _respond_metrics(self, writer: asyncio.StreamWriter) -> None:
67
+ healthy, _ = self.state.health(self.stale_after_seconds)
68
+ lines = [
69
+ "# TYPE polymarket_collector_up gauge",
70
+ f"polymarket_collector_up {1 if healthy else 0}",
71
+ "# TYPE polymarket_collector_markets gauge",
72
+ f"polymarket_collector_markets {self.state.markets}",
73
+ "# TYPE polymarket_collector_assets gauge",
74
+ f"polymarket_collector_assets {self.state.assets}",
75
+ "# TYPE polymarket_collector_messages_total counter",
76
+ f"polymarket_collector_messages_total {self.state.messages_total}",
77
+ "# TYPE polymarket_collector_reconnects_total counter",
78
+ f"polymarket_collector_reconnects_total {self.state.reconnects_total}",
79
+ "",
80
+ ]
81
+ await self._respond(writer, 200, "text/plain; version=0.0.4", "\n".join(lines).encode())
82
+
83
+ async def _respond(
84
+ self,
85
+ writer: asyncio.StreamWriter,
86
+ status: int,
87
+ content_type: str,
88
+ body: bytes,
89
+ ) -> None:
90
+ reason = {200: "OK", 400: "Bad Request", 404: "Not Found", 503: "Service Unavailable"}.get(status, "Error")
91
+ headers = (
92
+ f"HTTP/1.1 {status} {reason}\r\n"
93
+ f"Content-Type: {content_type}\r\n"
94
+ f"Content-Length: {len(body)}\r\n"
95
+ "Connection: close\r\n\r\n"
96
+ ).encode("ascii")
97
+ writer.write(headers + body)
98
+ await writer.drain()
99
+
polymarket_collector/hf_backup.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from huggingface_hub import CommitOperationAdd, HfApi
10
+
11
+
12
+ LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ def completed_archives(
16
+ data_dir: Path,
17
+ *,
18
+ min_age_seconds: int,
19
+ max_files: int = 80,
20
+ ) -> list[Path]:
21
+ cutoff = time.time() - min_age_seconds
22
+ paths = [
23
+ path
24
+ for path in data_dir.rglob("*.jsonl.gz")
25
+ if path.is_file() and path.stat().st_mtime <= cutoff
26
+ ]
27
+ return sorted(paths)[:max_files]
28
+
29
+
30
+ def upload_once(
31
+ *,
32
+ data_dir: Path,
33
+ repo_id: str,
34
+ token: str,
35
+ min_age_seconds: int,
36
+ api: HfApi | None = None,
37
+ ) -> int:
38
+ paths = completed_archives(
39
+ data_dir,
40
+ min_age_seconds=min_age_seconds,
41
+ )
42
+ if not paths:
43
+ return 0
44
+
45
+ client = api or HfApi(token=token)
46
+ operations = [
47
+ CommitOperationAdd(
48
+ path_in_repo=path.relative_to(data_dir).as_posix(),
49
+ path_or_fileobj=path,
50
+ )
51
+ for path in paths
52
+ ]
53
+ client.create_commit(
54
+ repo_id=repo_id,
55
+ repo_type="dataset",
56
+ operations=operations,
57
+ commit_message=f"Add {len(paths)} Polymarket archive(s)",
58
+ )
59
+ for path in paths:
60
+ path.unlink()
61
+ LOGGER.info("Uploaded and removed %d local archive(s)", len(paths))
62
+ return len(paths)
63
+
64
+
65
+ def run_loop() -> None:
66
+ data_dir = Path(os.getenv("DATA_DIR", "/data")).resolve()
67
+ token = os.environ["HF_TOKEN"]
68
+ api = HfApi(token=token)
69
+ repo_id = os.getenv("HF_DATASET_REPO")
70
+ if not repo_id:
71
+ namespace = api.whoami()["name"]
72
+ repo_id = f"{namespace}/polymarket-l2-history"
73
+ LOGGER.info("Using inferred Hugging Face dataset: %s", repo_id)
74
+ interval = int(os.getenv("BACKUP_INTERVAL_SECONDS", "3600"))
75
+ min_age = int(os.getenv("BACKUP_MIN_AGE_SECONDS", "120"))
76
+ if interval < 60 or min_age < 0:
77
+ raise ValueError("Invalid backup interval or minimum age")
78
+
79
+ while True:
80
+ try:
81
+ upload_once(
82
+ data_dir=data_dir,
83
+ repo_id=repo_id,
84
+ token=token,
85
+ min_age_seconds=min_age,
86
+ api=api,
87
+ )
88
+ except Exception:
89
+ LOGGER.exception("Hugging Face backup failed; retrying later")
90
+ time.sleep(interval)
91
+
92
+
93
+ def cli() -> None:
94
+ parser = argparse.ArgumentParser(
95
+ description="Upload completed collector archives to a HF dataset."
96
+ )
97
+ parser.add_argument("--loop", action="store_true")
98
+ args = parser.parse_args()
99
+ logging.basicConfig(
100
+ level=os.getenv("LOG_LEVEL", "INFO"),
101
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
102
+ )
103
+ if args.loop:
104
+ run_loop()
105
+ return
106
+
107
+ upload_once(
108
+ data_dir=Path(os.getenv("DATA_DIR", "/data")).resolve(),
109
+ repo_id=os.environ["HF_DATASET_REPO"],
110
+ token=os.environ["HF_TOKEN"],
111
+ min_age_seconds=int(os.getenv("BACKUP_MIN_AGE_SECONDS", "120")),
112
+ )
113
+
114
+
115
+ if __name__ == "__main__":
116
+ cli()
polymarket_collector/main.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import argparse
5
+ import json
6
+ import logging
7
+ import signal
8
+ import sys
9
+ import time
10
+ from typing import Any
11
+
12
+ from .config import Config
13
+ from .discovery import MarketSelection, discover_rewarded_markets
14
+ from .health import HealthServer
15
+ from .state import CollectorState
16
+ from .storage import RotatingJsonlWriter
17
+ from .stream import MarketStream
18
+
19
+
20
+ LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ async def _wait_or_stop(stop_event: asyncio.Event, timeout: float) -> None:
24
+ try:
25
+ await asyncio.wait_for(stop_event.wait(), timeout)
26
+ except TimeoutError:
27
+ pass
28
+
29
+
30
+ async def _discover(config: Config) -> MarketSelection:
31
+ return await discover_rewarded_markets(
32
+ config.clob_base_url,
33
+ max_markets=config.max_markets,
34
+ min_daily_reward=config.min_daily_reward,
35
+ min_volume_24h=config.min_volume_24h,
36
+ )
37
+
38
+
39
+ async def run(config: Config) -> None:
40
+ stop_event = asyncio.Event()
41
+ loop = asyncio.get_running_loop()
42
+ for sig in (signal.SIGINT, signal.SIGTERM):
43
+ try:
44
+ loop.add_signal_handler(sig, stop_event.set)
45
+ except NotImplementedError:
46
+ pass
47
+
48
+ state = CollectorState()
49
+ market_writer = RotatingJsonlWriter(
50
+ config.data_dir / "market_ws",
51
+ "market_ws",
52
+ rotation_seconds=config.rotation_seconds,
53
+ max_file_bytes=config.max_file_bytes,
54
+ flush_interval_seconds=config.flush_interval_seconds,
55
+ )
56
+ discovery_writer = RotatingJsonlWriter(
57
+ config.data_dir / "discovery",
58
+ "discovery",
59
+ rotation_seconds=config.rotation_seconds,
60
+ max_file_bytes=config.max_file_bytes,
61
+ flush_interval_seconds=config.flush_interval_seconds,
62
+ )
63
+ await market_writer.start()
64
+ await discovery_writer.start()
65
+
66
+ health = HealthServer(
67
+ state,
68
+ config.health_host,
69
+ config.health_port,
70
+ config.stale_after_seconds,
71
+ )
72
+ await health.start()
73
+ LOGGER.info(
74
+ "Health endpoint listening on http://%s:%d/healthz",
75
+ config.health_host,
76
+ config.health_port,
77
+ )
78
+
79
+ stream = MarketStream(config.websocket_url, market_writer, state)
80
+ stream_task = asyncio.create_task(stream.run(stop_event), name="market-stream")
81
+
82
+ try:
83
+ failure_delay = 10.0
84
+ while not stop_event.is_set():
85
+ try:
86
+ selection = await _discover(config)
87
+ discovered_at_ns = time.time_ns()
88
+ state.discovered_at = discovered_at_ns / 1_000_000_000
89
+ state.markets = len(selection.markets)
90
+ state.assets = len(selection.asset_ids)
91
+ state.last_error = None
92
+ await discovery_writer.write(
93
+ {
94
+ "record_type": "rewarded_market_discovery",
95
+ "received_at_ns": discovered_at_ns,
96
+ "market_count": len(selection.markets),
97
+ "asset_count": len(selection.asset_ids),
98
+ "markets": list(selection.markets),
99
+ }
100
+ )
101
+ stream.set_assets(selection.asset_ids)
102
+ LOGGER.info(
103
+ "Selected %d rewarded markets (%d assets)",
104
+ len(selection.markets),
105
+ len(selection.asset_ids),
106
+ )
107
+ failure_delay = 10.0
108
+ await _wait_or_stop(
109
+ stop_event, config.discovery_interval_seconds
110
+ )
111
+ except asyncio.CancelledError:
112
+ raise
113
+ except Exception as exc:
114
+ state.discovery_failures_total += 1
115
+ state.last_error = f"discovery: {type(exc).__name__}: {exc}"
116
+ LOGGER.exception("Market discovery failed")
117
+ await discovery_writer.write(
118
+ {
119
+ "record_type": "discovery_error",
120
+ "received_at_ns": time.time_ns(),
121
+ "error_type": type(exc).__name__,
122
+ "error": str(exc),
123
+ }
124
+ )
125
+ await _wait_or_stop(stop_event, failure_delay)
126
+ failure_delay = min(failure_delay * 2, 300)
127
+ finally:
128
+ stop_event.set()
129
+ try:
130
+ await asyncio.wait_for(stream_task, timeout=10)
131
+ except TimeoutError:
132
+ stream_task.cancel()
133
+ await asyncio.gather(stream_task, return_exceptions=True)
134
+ await health.close()
135
+ await asyncio.gather(
136
+ market_writer.close(), discovery_writer.close()
137
+ )
138
+ LOGGER.info("Collector stopped cleanly")
139
+
140
+
141
+ def cli() -> None:
142
+ try:
143
+ parser = argparse.ArgumentParser(
144
+ description="Collect public L2 data for rewarded Polymarket markets."
145
+ )
146
+ parser.add_argument(
147
+ "--check",
148
+ action="store_true",
149
+ help="test public API discovery and exit without opening the collector",
150
+ )
151
+ args = parser.parse_args()
152
+ config = Config.from_env()
153
+ logging.basicConfig(
154
+ level=getattr(logging, config.log_level, logging.INFO),
155
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
156
+ )
157
+ if args.check:
158
+ selection = asyncio.run(_discover(config))
159
+ print(
160
+ json.dumps(
161
+ {
162
+ "ok": True,
163
+ "markets": len(selection.markets),
164
+ "assets": len(selection.asset_ids),
165
+ "top_markets": [
166
+ {
167
+ "question": market.get("question"),
168
+ "daily_reward": market.get("collector_daily_reward"),
169
+ "volume_24hr": market.get("collector_volume_24hr"),
170
+ }
171
+ for market in selection.markets[:5]
172
+ ],
173
+ },
174
+ ensure_ascii=False,
175
+ indent=2,
176
+ )
177
+ )
178
+ else:
179
+ asyncio.run(run(config))
180
+ except KeyboardInterrupt:
181
+ pass
182
+ except Exception as exc:
183
+ print(f"fatal: {type(exc).__name__}: {exc}", file=sys.stderr)
184
+ raise SystemExit(1) from exc
185
+
186
+
187
+ if __name__ == "__main__":
188
+ cli()
polymarket_collector/state.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+
9
+ def _iso(timestamp: float | None) -> str | None:
10
+ if timestamp is None:
11
+ return None
12
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class CollectorState:
17
+ started_at: float = field(default_factory=time.time)
18
+ discovered_at: float | None = None
19
+ connected_at: float | None = None
20
+ disconnected_at: float | None = None
21
+ last_message_at: float | None = None
22
+ last_pong_at: float | None = None
23
+ connected: bool = False
24
+ markets: int = 0
25
+ assets: int = 0
26
+ messages_total: int = 0
27
+ invalid_messages_total: int = 0
28
+ reconnects_total: int = 0
29
+ discovery_failures_total: int = 0
30
+ last_error: str | None = None
31
+
32
+ def health(self, stale_after_seconds: int) -> tuple[bool, str]:
33
+ if self.discovered_at is None:
34
+ return False, "waiting_for_discovery"
35
+ if self.assets == 0:
36
+ return False, "no_rewarded_markets"
37
+ if not self.connected:
38
+ return False, "websocket_disconnected"
39
+ if self.last_message_at is None:
40
+ return False, "waiting_for_first_message"
41
+ if time.time() - self.last_message_at > stale_after_seconds:
42
+ return False, "market_data_stale"
43
+ return True, "ok"
44
+
45
+ def as_dict(self, stale_after_seconds: int) -> dict[str, Any]:
46
+ healthy, reason = self.health(stale_after_seconds)
47
+ return {
48
+ "healthy": healthy,
49
+ "reason": reason,
50
+ "started_at": _iso(self.started_at),
51
+ "uptime_seconds": round(time.time() - self.started_at, 3),
52
+ "discovered_at": _iso(self.discovered_at),
53
+ "connected_at": _iso(self.connected_at),
54
+ "disconnected_at": _iso(self.disconnected_at),
55
+ "last_message_at": _iso(self.last_message_at),
56
+ "last_pong_at": _iso(self.last_pong_at),
57
+ "connected": self.connected,
58
+ "markets": self.markets,
59
+ "assets": self.assets,
60
+ "messages_total": self.messages_total,
61
+ "invalid_messages_total": self.invalid_messages_total,
62
+ "reconnects_total": self.reconnects_total,
63
+ "discovery_failures_total": self.discovery_failures_total,
64
+ "last_error": self.last_error,
65
+ }
66
+
polymarket_collector/storage.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import gzip
5
+ import json
6
+ import os
7
+ import shutil
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, BinaryIO
12
+
13
+
14
+ def _compress_part(path: Path) -> Path:
15
+ destination = path.with_suffix("")
16
+ if destination.suffix != ".jsonl":
17
+ destination = Path(str(path).removesuffix(".part"))
18
+ destination = destination.with_suffix(destination.suffix + ".gz")
19
+ temporary = Path(str(destination) + ".tmp")
20
+ try:
21
+ with path.open("rb") as source, gzip.open(
22
+ temporary, "wb", compresslevel=5
23
+ ) as target:
24
+ shutil.copyfileobj(source, target, length=1024 * 1024)
25
+ os.replace(temporary, destination)
26
+ path.unlink(missing_ok=True)
27
+ return destination
28
+ except Exception:
29
+ temporary.unlink(missing_ok=True)
30
+ raise
31
+
32
+
33
+ class RotatingJsonlWriter:
34
+ def __init__(
35
+ self,
36
+ root: Path,
37
+ source: str,
38
+ *,
39
+ rotation_seconds: int,
40
+ max_file_bytes: int,
41
+ flush_interval_seconds: int,
42
+ ) -> None:
43
+ self.root = root
44
+ self.source = source
45
+ self.rotation_seconds = rotation_seconds
46
+ self.max_file_bytes = max_file_bytes
47
+ self.flush_interval_seconds = flush_interval_seconds
48
+ self._file: BinaryIO | None = None
49
+ self._path: Path | None = None
50
+ self._opened_monotonic = 0.0
51
+ self._last_flush_monotonic = 0.0
52
+ self._size = 0
53
+ self._sequence = 0
54
+ self._compression_tasks: set[asyncio.Task[Path]] = set()
55
+ self._compression_errors: list[BaseException] = []
56
+
57
+ @property
58
+ def current_path(self) -> str | None:
59
+ return str(self._path) if self._path else None
60
+
61
+ async def start(self) -> None:
62
+ self.root.mkdir(parents=True, exist_ok=True)
63
+ await asyncio.to_thread(self._recover_parts)
64
+
65
+ def _recover_parts(self) -> None:
66
+ for temporary in self.root.rglob("*.jsonl.gz.tmp"):
67
+ temporary.unlink(missing_ok=True)
68
+ for part in sorted(self.root.rglob("*.jsonl.part")):
69
+ if part.stat().st_size == 0:
70
+ part.unlink(missing_ok=True)
71
+ else:
72
+ _compress_part(part)
73
+
74
+ def _open(self) -> None:
75
+ now = datetime.now(timezone.utc)
76
+ directory = self.root / now.strftime("%Y/%m/%d")
77
+ directory.mkdir(parents=True, exist_ok=True)
78
+ self._sequence += 1
79
+ stamp = now.strftime("%Y%m%dT%H%M%S.%fZ")
80
+ self._path = directory / (
81
+ f"{self.source}-{stamp}-{os.getpid()}-{self._sequence:04d}.jsonl.part"
82
+ )
83
+ self._file = self._path.open("ab", buffering=64 * 1024)
84
+ self._size = self._path.stat().st_size
85
+ self._opened_monotonic = time.monotonic()
86
+ self._last_flush_monotonic = self._opened_monotonic
87
+
88
+ async def write(self, record: dict[str, Any]) -> None:
89
+ if self._file is None:
90
+ self._open()
91
+ encoded = json.dumps(
92
+ record, ensure_ascii=False, separators=(",", ":")
93
+ ).encode("utf-8") + b"\n"
94
+ assert self._file is not None
95
+ self._file.write(encoded)
96
+ self._size += len(encoded)
97
+
98
+ now = time.monotonic()
99
+ if now - self._last_flush_monotonic >= self.flush_interval_seconds:
100
+ self._file.flush()
101
+ self._last_flush_monotonic = now
102
+ if (
103
+ self._size >= self.max_file_bytes
104
+ or now - self._opened_monotonic >= self.rotation_seconds
105
+ ):
106
+ self._rotate()
107
+
108
+ def _rotate(self) -> None:
109
+ if self._file is None or self._path is None:
110
+ return
111
+ self._file.flush()
112
+ os.fsync(self._file.fileno())
113
+ self._file.close()
114
+ part = self._path
115
+ self._file = None
116
+ self._path = None
117
+ task = asyncio.create_task(asyncio.to_thread(_compress_part, part))
118
+ self._compression_tasks.add(task)
119
+ task.add_done_callback(self._compression_done)
120
+
121
+ def _compression_done(self, task: asyncio.Task[Path]) -> None:
122
+ self._compression_tasks.discard(task)
123
+ try:
124
+ task.result()
125
+ except BaseException as exc:
126
+ self._compression_errors.append(exc)
127
+
128
+ async def close(self) -> None:
129
+ self._rotate()
130
+ if self._compression_tasks:
131
+ await asyncio.gather(
132
+ *tuple(self._compression_tasks), return_exceptions=True
133
+ )
134
+ if self._compression_errors:
135
+ raise RuntimeError(f"Compression failed: {self._compression_errors[0]}")
polymarket_collector/stream.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import random
7
+ import time
8
+ import uuid
9
+ from collections.abc import Iterable
10
+ from typing import Any
11
+
12
+ from websockets.asyncio.client import connect
13
+ from websockets.exceptions import ConnectionClosed
14
+
15
+ from .state import CollectorState
16
+ from .storage import RotatingJsonlWriter
17
+
18
+
19
+ LOGGER = logging.getLogger(__name__)
20
+
21
+
22
+ class MarketStream:
23
+ def __init__(
24
+ self,
25
+ url: str,
26
+ writer: RotatingJsonlWriter,
27
+ state: CollectorState,
28
+ ) -> None:
29
+ self.url = url
30
+ self.writer = writer
31
+ self.state = state
32
+ self._desired_assets: set[str] = set()
33
+ self._assets_changed = asyncio.Event()
34
+
35
+ def set_assets(self, asset_ids: Iterable[str]) -> None:
36
+ desired = {str(asset_id) for asset_id in asset_ids if asset_id}
37
+ if desired != self._desired_assets:
38
+ self._desired_assets = desired
39
+ self._assets_changed.set()
40
+
41
+ async def run(self, stop_event: asyncio.Event) -> None:
42
+ delay = 1.0
43
+ while not stop_event.is_set():
44
+ if not self._desired_assets:
45
+ await self._wait_for_assets_or_stop(stop_event)
46
+ continue
47
+ try:
48
+ connected_for = await self._connected_loop(stop_event)
49
+ delay = 1.0 if connected_for >= 30 else min(delay * 2, 60)
50
+ except asyncio.CancelledError:
51
+ raise
52
+ except Exception as exc:
53
+ self.state.last_error = f"websocket: {type(exc).__name__}: {exc}"
54
+ LOGGER.warning("WebSocket disconnected: %s", exc)
55
+ delay = min(delay * 2, 60)
56
+
57
+ self.state.connected = False
58
+ self.state.disconnected_at = time.time()
59
+ if not stop_event.is_set():
60
+ self.state.reconnects_total += 1
61
+ try:
62
+ await asyncio.wait_for(stop_event.wait(), delay + random.random())
63
+ except TimeoutError:
64
+ pass
65
+
66
+ async def _wait_for_assets_or_stop(self, stop_event: asyncio.Event) -> None:
67
+ assets_task = asyncio.create_task(self._assets_changed.wait())
68
+ stop_task = asyncio.create_task(stop_event.wait())
69
+ done, pending = await asyncio.wait(
70
+ {assets_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
71
+ )
72
+ for task in pending:
73
+ task.cancel()
74
+ await asyncio.gather(*pending, return_exceptions=True)
75
+ if assets_task in done:
76
+ self._assets_changed.clear()
77
+
78
+ async def _connected_loop(self, stop_event: asyncio.Event) -> float:
79
+ connection_id = uuid.uuid4().hex
80
+ connected_monotonic = time.monotonic()
81
+ async with connect(
82
+ self.url,
83
+ open_timeout=20,
84
+ close_timeout=5,
85
+ ping_interval=None,
86
+ max_size=None,
87
+ ) as websocket:
88
+ subscribed = set(self._desired_assets)
89
+ await websocket.send(
90
+ json.dumps(
91
+ {
92
+ "assets_ids": sorted(subscribed),
93
+ "type": "market",
94
+ "custom_feature_enabled": True,
95
+ },
96
+ separators=(",", ":"),
97
+ )
98
+ )
99
+ if subscribed == self._desired_assets:
100
+ self._assets_changed.clear()
101
+ else:
102
+ self._assets_changed.set()
103
+ self.state.connected = True
104
+ self.state.connected_at = time.time()
105
+ self.state.last_error = None
106
+ LOGGER.info("WebSocket connected for %d assets", len(subscribed))
107
+ last_ping = 0.0
108
+
109
+ while not stop_event.is_set():
110
+ if self._assets_changed.is_set():
111
+ self._assets_changed.clear()
112
+ subscribed = await self._sync_subscriptions(websocket, subscribed)
113
+ if subscribed != self._desired_assets:
114
+ self._assets_changed.set()
115
+ if not subscribed:
116
+ break
117
+
118
+ now = time.monotonic()
119
+ if now - last_ping >= 10:
120
+ await websocket.send("PING")
121
+ last_ping = now
122
+
123
+ try:
124
+ message = await asyncio.wait_for(websocket.recv(), timeout=1)
125
+ except TimeoutError:
126
+ continue
127
+ except ConnectionClosed:
128
+ break
129
+
130
+ if isinstance(message, bytes):
131
+ message = message.decode("utf-8", "replace")
132
+ if message == "PONG":
133
+ self.state.last_pong_at = time.time()
134
+ continue
135
+ await self._record_message(message, connection_id)
136
+
137
+ return time.monotonic() - connected_monotonic
138
+
139
+ async def _sync_subscriptions(self, websocket: Any, subscribed: set[str]) -> set[str]:
140
+ desired = set(self._desired_assets)
141
+ removed = sorted(subscribed - desired)
142
+ added = sorted(desired - subscribed)
143
+ if removed:
144
+ await websocket.send(
145
+ json.dumps(
146
+ {"assets_ids": removed, "operation": "unsubscribe"},
147
+ separators=(",", ":"),
148
+ )
149
+ )
150
+ if added:
151
+ await websocket.send(
152
+ json.dumps(
153
+ {
154
+ "assets_ids": added,
155
+ "operation": "subscribe",
156
+ "custom_feature_enabled": True,
157
+ },
158
+ separators=(",", ":"),
159
+ )
160
+ )
161
+ if added or removed:
162
+ LOGGER.info(
163
+ "WebSocket subscriptions updated: +%d -%d (%d total)",
164
+ len(added),
165
+ len(removed),
166
+ len(desired),
167
+ )
168
+ return desired
169
+
170
+ async def _record_message(self, raw_message: str, connection_id: str) -> None:
171
+ received_at_ns = time.time_ns()
172
+ try:
173
+ payload = json.loads(raw_message)
174
+ except json.JSONDecodeError:
175
+ self.state.invalid_messages_total += 1
176
+ await self.writer.write(
177
+ {
178
+ "record_type": "invalid_market_ws",
179
+ "received_at_ns": received_at_ns,
180
+ "connection_id": connection_id,
181
+ "raw": raw_message,
182
+ }
183
+ )
184
+ return
185
+
186
+ messages = payload if isinstance(payload, list) else [payload]
187
+ for item in messages:
188
+ if not isinstance(item, dict):
189
+ self.state.invalid_messages_total += 1
190
+ continue
191
+ await self.writer.write(
192
+ {
193
+ "record_type": "market_ws",
194
+ "received_at_ns": received_at_ns,
195
+ "connection_id": connection_id,
196
+ "payload": item,
197
+ }
198
+ )
199
+ self.state.messages_total += 1
200
+ self.state.last_message_at = time.time()
polymarket_lp_collector.egg-info/PKG-INFO ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: polymarket-lp-collector
3
+ Version: 0.1.0
4
+ Summary: Low-resource Polymarket LP market-data collector
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huggingface-hub<2,>=1.0
8
+ Requires-Dist: websockets<17,>=15
9
+
10
+ ---
11
+ title: Polymarket LP Collector
12
+ emoji: 📈
13
+ colorFrom: green
14
+ colorTo: blue
15
+ sdk: docker
16
+ app_port: 8080
17
+ ---
18
+
19
+ # Collecteur de données LP Polymarket
20
+
21
+ Ce projet enregistre en continu les données publiques de niveau 2 des marchés
22
+ Polymarket qui distribuent des récompenses de liquidité. Il est conçu pour une
23
+ petite VM Google Cloud `e2-micro` : mémoire limitée, fichiers rotatifs et aucune
24
+ base de données résidente.
25
+
26
+ Il ne signe aucun ordre, ne contient aucune clé de portefeuille et ne peut pas
27
+ trader. Son unique rôle est de créer un historique propre pour les futurs
28
+ backtests et modèles d'apprentissage automatique.
29
+
30
+ ## Données collectées
31
+
32
+ - carnets complets (`book`) ;
33
+ - changements de niveaux (`price_change`) ;
34
+ - meilleurs bid/ask ;
35
+ - dernières transactions ;
36
+ - changements de tick et résolutions ;
37
+ - métadonnées, volumes et paramètres de récompenses LP ;
38
+ - heure de réception locale en nanosecondes pour mesurer les délais.
39
+
40
+ Les fichiers terminés sont écrits sous la forme :
41
+
42
+ ```text
43
+ data/
44
+ market_ws/YYYY/MM/DD/market_ws-....jsonl.gz
45
+ discovery/YYYY/MM/DD/discovery-....jsonl.gz
46
+ ```
47
+
48
+ Chaque ligne est un objet JSON autonome. Les fichiers `.part` sont récupérés et
49
+ compressés automatiquement au prochain démarrage après une coupure brutale.
50
+
51
+ ## Exécution locale
52
+
53
+ ```bash
54
+ python3 -m venv .venv
55
+ . .venv/bin/activate
56
+ pip install -e .
57
+ cp .env.example .env
58
+ DATA_DIR="$PWD/data" polymarket-collector
59
+ ```
60
+
61
+ Tester uniquement l'accès aux API publiques avant de démarrer :
62
+
63
+ ```bash
64
+ polymarket-collector --check
65
+ ```
66
+
67
+ État du service :
68
+
69
+ ```bash
70
+ curl http://127.0.0.1:8080/healthz
71
+ curl http://127.0.0.1:8080/metrics
72
+ ```
73
+
74
+ Arrêter avec `Ctrl+C`. Le service finalise alors proprement les fichiers en
75
+ cours.
76
+
77
+ ## Déploiement Google Cloud
78
+
79
+ Depuis un ordinateur ou Google Cloud Shell ayant `gcloud` authentifié :
80
+
81
+ ```bash
82
+ ./scripts/deploy_gcp.sh ID_DU_PROJET_GCP
83
+ ```
84
+
85
+ Le script crée par défaut une VM `e2-micro` Ubuntu 24.04 dans `us-east1-b`, un
86
+ disque standard de 30 Go, copie le projet, puis installe et démarre le service.
87
+
88
+ Commandes utiles après installation :
89
+
90
+ ```bash
91
+ gcloud compute ssh polymarket-collector --zone us-east1-b
92
+ sudo systemctl status polymarket-collector
93
+ sudo journalctl -u polymarket-collector -f
94
+ curl http://127.0.0.1:8080/healthz
95
+ ```
96
+
97
+ ## Sauvegarde vers Google Drive
98
+
99
+ Sur la VM :
100
+
101
+ ```bash
102
+ sudo rclone config
103
+ sudoedit /etc/polymarket-collector.env
104
+ ```
105
+
106
+ Définir ensuite, par exemple :
107
+
108
+ ```text
109
+ RCLONE_REMOTE=gdrive:polymarket-data
110
+ LOCAL_RETENTION_DAYS=7
111
+ ```
112
+
113
+ Puis activer la sauvegarde :
114
+
115
+ ```bash
116
+ sudo systemctl enable --now polymarket-backup.timer
117
+ sudo systemctl start polymarket-backup.service
118
+ sudo journalctl -u polymarket-backup.service -n 100
119
+ ```
120
+
121
+ Les fichiers locaux âgés de plus de `LOCAL_RETENTION_DAYS` ne sont supprimés
122
+ qu'après une copie `rclone` réussie. Mettre la valeur à `0` pour désactiver le
123
+ nettoyage local.
124
+
125
+ ## Hébergement gratuit sans dépassement : Hugging Face Spaces
126
+
127
+ L'architecture recommandée pour une collecte continue sans VM personnelle est :
128
+
129
+ 1. un Space public sur le matériel **CPU Basic gratuit** ;
130
+ 2. UptimeRobot Free sur `/healthz`, toutes les cinq minutes, pour maintenir une
131
+ visite régulière et surveiller le service ;
132
+ 3. un dataset Hugging Face public pour les archives. Les fichiers locaux sont
133
+ supprimés uniquement après la réussite du commit distant.
134
+
135
+ Le conteneur est prêt dans `Dockerfile`. Créer un jeton Hugging Face avec droit
136
+ d'écriture, puis lancer :
137
+
138
+ ```bash
139
+ HF_TOKEN=hf_... python scripts/deploy_hf.py
140
+ ```
141
+
142
+ Le script crée le Space et le dataset, force le matériel `cpu-basic`, ajoute le
143
+ jeton comme secret du Space et applique le profil suivant : 40 marchés,
144
+ rotation à 256 Mio et envoi groupé toutes les heures. Cela évite une quantité
145
+ excessive de petits commits tout en gardant plusieurs heures de marge sur le
146
+ disque temporaire.
147
+
148
+ Koyeb n'est plus recommandé : depuis son rapprochement avec Mistral, les
149
+ nouveaux utilisateurs doivent fournir un moyen de paiement et souscrire un
150
+ forfait payant.
151
+
152
+ ## Tests
153
+
154
+ ```bash
155
+ python -m unittest discover -s tests -v
156
+ ```
157
+
158
+ Les tests ne contactent pas Polymarket et ne nécessitent aucune clé.
159
+
160
+ ## Limites importantes
161
+
162
+ - Avec 1 Go de RAM et 30 Go de disque, commencer avec 75 marchés maximum.
163
+ - Une VM gratuite aux États-Unis convient à la collecte, pas au futur bot de
164
+ trading sensible à la latence.
165
+ - Le service conserve les messages bruts : les transformations en Parquet et
166
+ l'entraînement se feront sur l'ordinateur équipé de la RTX 3060.
polymarket_lp_collector.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ polymarket_collector/__init__.py
4
+ polymarket_collector/__main__.py
5
+ polymarket_collector/config.py
6
+ polymarket_collector/discovery.py
7
+ polymarket_collector/health.py
8
+ polymarket_collector/hf_backup.py
9
+ polymarket_collector/main.py
10
+ polymarket_collector/state.py
11
+ polymarket_collector/storage.py
12
+ polymarket_collector/stream.py
13
+ polymarket_lp_collector.egg-info/PKG-INFO
14
+ polymarket_lp_collector.egg-info/SOURCES.txt
15
+ polymarket_lp_collector.egg-info/dependency_links.txt
16
+ polymarket_lp_collector.egg-info/entry_points.txt
17
+ polymarket_lp_collector.egg-info/requires.txt
18
+ polymarket_lp_collector.egg-info/top_level.txt
19
+ tests/test_config.py
20
+ tests/test_discovery.py
21
+ tests/test_health.py
22
+ tests/test_hf_backup.py
23
+ tests/test_storage.py
24
+ tests/test_stream.py
polymarket_lp_collector.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
polymarket_lp_collector.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ polymarket-collector = polymarket_collector.main:cli
polymarket_lp_collector.egg-info/requires.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ huggingface-hub<2,>=1.0
2
+ websockets<17,>=15
polymarket_lp_collector.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ polymarket_collector
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "polymarket-lp-collector"
7
+ version = "0.1.0"
8
+ description = "Low-resource Polymarket LP market-data collector"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "huggingface-hub>=1.0,<2",
13
+ "websockets>=15,<17",
14
+ ]
15
+
16
+ [project.scripts]
17
+ polymarket-collector = "polymarket_collector.main:cli"
18
+
19
+ [tool.setuptools.packages.find]
20
+ include = ["polymarket_collector*"]
render.yaml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: polymarket-lp-collector
4
+ runtime: docker
5
+ plan: free
6
+ region: frankfurt
7
+ autoDeployTrigger: off
8
+ healthCheckPath: /healthz
9
+ envVars:
10
+ - key: HF_TOKEN
11
+ sync: false
12
+ - key: MAX_MARKETS
13
+ value: "40"
14
+ - key: DATA_DIR
15
+ value: /data
16
+ - key: HEALTH_HOST
17
+ value: 0.0.0.0
18
+ - key: ROTATION_SECONDS
19
+ value: "3600"
20
+ - key: MAX_FILE_MIB
21
+ value: "256"
22
+ - key: BACKUP_INTERVAL_SECONDS
23
+ value: "3600"
24
+ - key: BACKUP_MIN_AGE_SECONDS
25
+ value: "120"
26
+ - key: REQUIRE_REMOTE_BACKUP
27
+ value: "true"
scripts/deploy_gcp.sh ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PROJECT_ID="${1:-}"
5
+ INSTANCE="${INSTANCE_NAME:-polymarket-collector}"
6
+ ZONE="${GCP_ZONE:-us-east1-b}"
7
+ MACHINE_TYPE="${GCP_MACHINE_TYPE:-e2-micro}"
8
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+
10
+ if [[ -z "$PROJECT_ID" ]]; then
11
+ echo "Usage: $0 GOOGLE_CLOUD_PROJECT_ID" >&2
12
+ exit 2
13
+ fi
14
+ if ! command -v gcloud >/dev/null 2>&1; then
15
+ echo "gcloud is required. Run this script from Google Cloud Shell or install the Google Cloud CLI." >&2
16
+ exit 2
17
+ fi
18
+ if [[ ! -f "$ROOT_DIR/pyproject.toml" ]]; then
19
+ echo "Project files not found next to this script." >&2
20
+ exit 2
21
+ fi
22
+
23
+ ACTIVE_ACCOUNT="$(gcloud auth list --filter=status:ACTIVE --format='value(account)' | head -1)"
24
+ if [[ -z "$ACTIVE_ACCOUNT" ]]; then
25
+ echo "No active Google Cloud account. Run: gcloud auth login" >&2
26
+ exit 2
27
+ fi
28
+
29
+ gcloud config set project "$PROJECT_ID" >/dev/null
30
+ gcloud services enable compute.googleapis.com --project "$PROJECT_ID"
31
+
32
+ if ! gcloud compute instances describe "$INSTANCE" --zone "$ZONE" \
33
+ --project "$PROJECT_ID" >/dev/null 2>&1; then
34
+ gcloud compute instances create "$INSTANCE" \
35
+ --project "$PROJECT_ID" \
36
+ --zone "$ZONE" \
37
+ --machine-type "$MACHINE_TYPE" \
38
+ --provisioning-model STANDARD \
39
+ --network-tier STANDARD \
40
+ --image-family ubuntu-2404-lts-amd64 \
41
+ --image-project ubuntu-os-cloud \
42
+ --boot-disk-type pd-standard \
43
+ --boot-disk-size 30GB \
44
+ --metadata enable-oslogin=TRUE \
45
+ --labels app=polymarket-collector
46
+ else
47
+ echo "Instance $INSTANCE already exists; updating the collector."
48
+ fi
49
+
50
+ ARCHIVE="$(mktemp --suffix=.tar.gz)"
51
+ trap 'rm -f "$ARCHIVE"' EXIT
52
+ tar -czf "$ARCHIVE" \
53
+ --exclude=.git --exclude=.venv --exclude=data --exclude='*.egg-info' \
54
+ -C "$ROOT_DIR" .
55
+
56
+ gcloud compute scp "$ARCHIVE" "$INSTANCE:/tmp/polymarket-collector.tar.gz" \
57
+ --zone "$ZONE" --project "$PROJECT_ID"
58
+ gcloud compute ssh "$INSTANCE" --zone "$ZONE" --project "$PROJECT_ID" \
59
+ --command 'workdir=$(mktemp -d); tar -xzf /tmp/polymarket-collector.tar.gz -C "$workdir"; sudo bash "$workdir/scripts/install_vps.sh" "$workdir"'
60
+
61
+ echo
62
+ echo "Deployment complete."
63
+ echo "Connect: gcloud compute ssh $INSTANCE --zone $ZONE --project $PROJECT_ID"
64
+ echo "The collector uses public data only and has no Polymarket trading key."
65
+
scripts/deploy_hf.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from huggingface_hub import HfApi
8
+
9
+
10
+ PROJECT_DIR = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def main() -> None:
14
+ token = os.getenv("HF_TOKEN") or getpass.getpass("Hugging Face write token: ")
15
+ if not token.startswith("hf_"):
16
+ raise SystemExit("The token does not look like a Hugging Face token.")
17
+
18
+ api = HfApi(token=token)
19
+ account = api.whoami()
20
+ namespace = account["name"]
21
+ space_id = os.getenv(
22
+ "HF_SPACE_REPO", f"{namespace}/polymarket-lp-collector"
23
+ )
24
+ dataset_id = os.getenv(
25
+ "HF_DATASET_REPO", f"{namespace}/polymarket-l2-history"
26
+ )
27
+
28
+ api.create_repo(
29
+ repo_id=dataset_id,
30
+ repo_type="dataset",
31
+ private=False,
32
+ exist_ok=True,
33
+ )
34
+ api.upload_file(
35
+ path_or_fileobj=PROJECT_DIR / "deploy/hf-dataset-card.md",
36
+ path_in_repo="README.md",
37
+ repo_id=dataset_id,
38
+ repo_type="dataset",
39
+ commit_message="Add dataset card",
40
+ )
41
+
42
+ api.create_repo(
43
+ repo_id=space_id,
44
+ repo_type="space",
45
+ space_sdk="docker",
46
+ private=False,
47
+ exist_ok=True,
48
+ )
49
+ api.add_space_secret(
50
+ repo_id=space_id,
51
+ key="HF_TOKEN",
52
+ value=token,
53
+ description="Uploads collector archives to the public dataset.",
54
+ )
55
+ variables = {
56
+ "HF_DATASET_REPO": dataset_id,
57
+ "MAX_MARKETS": "40",
58
+ "DATA_DIR": "/data",
59
+ "HEALTH_HOST": "0.0.0.0",
60
+ "PORT": "8080",
61
+ "ROTATION_SECONDS": "3600",
62
+ "MAX_FILE_MIB": "256",
63
+ "BACKUP_INTERVAL_SECONDS": "3600",
64
+ "BACKUP_MIN_AGE_SECONDS": "120",
65
+ "REQUIRE_REMOTE_BACKUP": "true",
66
+ }
67
+ for key, value in variables.items():
68
+ api.add_space_variable(repo_id=space_id, key=key, value=value)
69
+
70
+ api.upload_folder(
71
+ folder_path=PROJECT_DIR,
72
+ repo_id=space_id,
73
+ repo_type="space",
74
+ ignore_patterns=[
75
+ ".git/**",
76
+ ".venv/**",
77
+ ".venv-hf/**",
78
+ ".tools/**",
79
+ "data/**",
80
+ "upload/**",
81
+ "**/__pycache__/**",
82
+ "*.zip",
83
+ ".env",
84
+ ".koyeb.yaml",
85
+ ],
86
+ commit_message="Deploy Polymarket LP collector",
87
+ )
88
+ api.request_space_hardware(repo_id=space_id, hardware="cpu-basic")
89
+ runtime = api.get_space_runtime(repo_id=space_id)
90
+
91
+ print(f"Space: https://huggingface.co/spaces/{space_id}")
92
+ print(f"Dataset: https://huggingface.co/datasets/{dataset_id}")
93
+ print(f"Hardware requested: {runtime.requested_hardware or runtime.hardware}")
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
scripts/deploy_koyeb.sh ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
+
4
+ PROJECT_DIR="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
5
+ RCLONE_CONFIG_FILE="${RCLONE_CONFIG_FILE:-$HOME/.config/rclone/rclone.conf}"
6
+ APP_SERVICE="${KOYEB_APP_SERVICE:-polymarket-collector/collector}"
7
+ SECRET_NAME="${KOYEB_RCLONE_SECRET:-polymarket-rclone-config}"
8
+
9
+ if ! command -v koyeb >/dev/null 2>&1; then
10
+ echo "Koyeb CLI is missing: https://www.koyeb.com/docs/build-and-deploy/cli/installation" >&2
11
+ exit 1
12
+ fi
13
+ if [[ ! -f "$PROJECT_DIR/Dockerfile" ]]; then
14
+ echo "Dockerfile not found in $PROJECT_DIR" >&2
15
+ exit 1
16
+ fi
17
+ if [[ ! -s "$RCLONE_CONFIG_FILE" ]]; then
18
+ echo "rclone config not found: $RCLONE_CONFIG_FILE" >&2
19
+ exit 1
20
+ fi
21
+
22
+ if koyeb secrets describe "$SECRET_NAME" >/dev/null 2>&1; then
23
+ base64 -w0 "$RCLONE_CONFIG_FILE" \
24
+ | koyeb secrets update "$SECRET_NAME" --value-from-stdin >/dev/null
25
+ else
26
+ base64 -w0 "$RCLONE_CONFIG_FILE" \
27
+ | koyeb secrets create "$SECRET_NAME" --value-from-stdin >/dev/null
28
+ fi
29
+
30
+ koyeb deploy "$PROJECT_DIR" "$APP_SERVICE" \
31
+ --archive-builder docker \
32
+ --instance-type free \
33
+ --regions fra \
34
+ --ports 8080:http \
35
+ --routes /:8080 \
36
+ --checks 8080:http:/healthz \
37
+ --checks-grace-period 8080=180 \
38
+ --scale 1 \
39
+ --env MAX_MARKETS=40 \
40
+ --env DATA_DIR=/data \
41
+ --env HEALTH_HOST=0.0.0.0 \
42
+ --env PORT=8080 \
43
+ --env ROTATION_SECONDS=300 \
44
+ --env MAX_FILE_MIB=32 \
45
+ --env BACKUP_INTERVAL_SECONDS=120 \
46
+ --env BACKUP_MIN_AGE=30s \
47
+ --env REMOVE_AFTER_UPLOAD=true \
48
+ --env REQUIRE_REMOTE_BACKUP=true \
49
+ --env RCLONE_REMOTE=gdrive:polymarket-data \
50
+ --env "RCLONE_CONFIG_B64={{secret.$SECRET_NAME}}" \
51
+ --wait \
52
+ --wait-timeout 10m
53
+
54
+ koyeb services get "$APP_SERVICE"
scripts/install_vps.sh ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SOURCE_DIR="${1:-}"
5
+ INSTALL_DIR=/opt/polymarket-collector
6
+ DATA_DIR=/var/lib/polymarket-collector
7
+ ENV_FILE=/etc/polymarket-collector.env
8
+
9
+ if [[ -z "$SOURCE_DIR" || ! -f "$SOURCE_DIR/pyproject.toml" ]]; then
10
+ echo "Usage: sudo $0 /path/to/polymarket-collector" >&2
11
+ exit 2
12
+ fi
13
+ if [[ "${EUID}" -ne 0 ]]; then
14
+ echo "This installer must run as root." >&2
15
+ exit 2
16
+ fi
17
+
18
+ export DEBIAN_FRONTEND=noninteractive
19
+ apt-get update
20
+ apt-get install -y --no-install-recommends \
21
+ ca-certificates python3 python3-pip python3-venv rclone
22
+
23
+ if ! id polymarket >/dev/null 2>&1; then
24
+ useradd --system --home-dir "$DATA_DIR" --create-home \
25
+ --shell /usr/sbin/nologin polymarket
26
+ fi
27
+
28
+ mkdir -p "$INSTALL_DIR" "$DATA_DIR"
29
+ cp -a "$SOURCE_DIR/." "$INSTALL_DIR/"
30
+ python3 -m venv "$INSTALL_DIR/.venv"
31
+ "$INSTALL_DIR/.venv/bin/pip" install --no-cache-dir --upgrade pip
32
+ "$INSTALL_DIR/.venv/bin/pip" install --no-cache-dir "$INSTALL_DIR"
33
+
34
+ if [[ ! -f "$ENV_FILE" ]]; then
35
+ cp "$INSTALL_DIR/.env.example" "$ENV_FILE"
36
+ fi
37
+ chown root:polymarket "$ENV_FILE"
38
+ chmod 0640 "$ENV_FILE"
39
+ chown -R polymarket:polymarket "$DATA_DIR"
40
+
41
+ install -m 0644 "$INSTALL_DIR/deploy/polymarket-collector.service" \
42
+ /etc/systemd/system/polymarket-collector.service
43
+ install -m 0644 "$INSTALL_DIR/deploy/polymarket-backup.service" \
44
+ /etc/systemd/system/polymarket-backup.service
45
+ install -m 0644 "$INSTALL_DIR/deploy/polymarket-backup.timer" \
46
+ /etc/systemd/system/polymarket-backup.timer
47
+ install -m 0755 "$INSTALL_DIR/scripts/polymarket-backup" \
48
+ /usr/local/sbin/polymarket-backup
49
+
50
+ systemctl daemon-reload
51
+ systemctl enable --now polymarket-collector.service
52
+
53
+ echo
54
+ echo "Collector installed."
55
+ echo "Status: systemctl status polymarket-collector"
56
+ echo "Logs: journalctl -u polymarket-collector -f"
57
+ echo "Health: curl http://127.0.0.1:8080/healthz"
58
+ echo "Drive: run 'sudo rclone config', set RCLONE_REMOTE in $ENV_FILE,"
59
+ echo " then enable polymarket-backup.timer."
60
+
scripts/polymarket-backup ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ENV_FILE="${ENV_FILE:-/etc/polymarket-collector.env}"
5
+ if [[ -f "$ENV_FILE" ]]; then
6
+ set -a
7
+ # shellcheck disable=SC1090
8
+ source "$ENV_FILE"
9
+ set +a
10
+ fi
11
+
12
+ DATA_DIR="${DATA_DIR:-/var/lib/polymarket-collector}"
13
+ RCLONE_REMOTE="${RCLONE_REMOTE:-}"
14
+ LOCAL_RETENTION_DAYS="${LOCAL_RETENTION_DAYS:-0}"
15
+ REMOVE_AFTER_UPLOAD="${REMOVE_AFTER_UPLOAD:-false}"
16
+ BACKUP_MIN_AGE="${BACKUP_MIN_AGE:-2m}"
17
+
18
+ if [[ -z "$RCLONE_REMOTE" ]]; then
19
+ echo "RCLONE_REMOTE is empty; backup is not configured."
20
+ exit 0
21
+ fi
22
+ if [[ ! -d "$DATA_DIR" ]]; then
23
+ echo "Data directory does not exist: $DATA_DIR" >&2
24
+ exit 1
25
+ fi
26
+ if ! [[ "$LOCAL_RETENTION_DAYS" =~ ^[0-9]+$ ]]; then
27
+ echo "LOCAL_RETENTION_DAYS must be a non-negative integer." >&2
28
+ exit 1
29
+ fi
30
+
31
+ case "${REMOVE_AFTER_UPLOAD,,}" in
32
+ 1|true|yes) RCLONE_ACTION="move" ;;
33
+ 0|false|no) RCLONE_ACTION="copy" ;;
34
+ *)
35
+ echo "REMOVE_AFTER_UPLOAD must be true or false." >&2
36
+ exit 1
37
+ ;;
38
+ esac
39
+
40
+ LOCK_FILE="${BACKUP_LOCK_FILE:-/tmp/polymarket-backup.lock}"
41
+ exec 9>"$LOCK_FILE"
42
+ flock -n 9 || exit 0
43
+
44
+ rclone "$RCLONE_ACTION" "$DATA_DIR" "$RCLONE_REMOTE" \
45
+ --include "*.jsonl.gz" \
46
+ --min-age "$BACKUP_MIN_AGE" \
47
+ --checksum \
48
+ --transfers 2 \
49
+ --checkers 4 \
50
+ --retries 3 \
51
+ --low-level-retries 10 \
52
+ --create-empty-src-dirs
53
+
54
+ if (( LOCAL_RETENTION_DAYS > 0 )); then
55
+ find "$DATA_DIR" -type f -name "*.jsonl.gz" \
56
+ -mtime "+$LOCAL_RETENTION_DAYS" -delete
57
+ fi
scripts/publish_hf_source.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from huggingface_hub import HfApi
8
+
9
+
10
+ PROJECT_DIR = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def main() -> None:
14
+ token = os.getenv("HF_TOKEN") or getpass.getpass("Hugging Face write token: ")
15
+ if not token.startswith("hf_"):
16
+ raise SystemExit("The token does not look like a Hugging Face token.")
17
+
18
+ api = HfApi(token=token)
19
+ namespace = api.whoami()["name"]
20
+ repo_id = f"{namespace}/polymarket-lp-collector-source"
21
+ api.create_repo(repo_id=repo_id, private=False, exist_ok=True)
22
+ api.upload_folder(
23
+ folder_path=PROJECT_DIR,
24
+ repo_id=repo_id,
25
+ ignore_patterns=[
26
+ ".git/**",
27
+ ".venv/**",
28
+ ".venv-hf/**",
29
+ ".tools/**",
30
+ "data/**",
31
+ "upload/**",
32
+ "**/__pycache__/**",
33
+ "*.zip",
34
+ ".env",
35
+ ".koyeb.yaml",
36
+ ],
37
+ commit_message="Publish Render-ready Polymarket collector",
38
+ )
39
+ print(f"Source: https://huggingface.co/{repo_id}")
40
+ print(f"Git: https://huggingface.co/{repo_id}")
41
+ print(f"Dataset: https://huggingface.co/datasets/{namespace}/polymarket-l2-history")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
tests/test_config.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from polymarket_collector.config import Config
10
+
11
+
12
+ class ConfigTests(unittest.TestCase):
13
+ def test_loads_dotenv_and_respects_process_environment(self) -> None:
14
+ with tempfile.TemporaryDirectory() as temporary:
15
+ env_file = Path(temporary) / ".env"
16
+ env_file.write_text(
17
+ "MAX_MARKETS=12\nHEALTH_PORT=9090\nMIN_DAILY_REWARD=2.5\n",
18
+ encoding="utf-8",
19
+ )
20
+ clean = {
21
+ key: value
22
+ for key, value in os.environ.items()
23
+ if key
24
+ not in {"MAX_MARKETS", "HEALTH_PORT", "MIN_DAILY_REWARD"}
25
+ }
26
+ clean["ENV_FILE"] = str(env_file)
27
+ clean["MAX_MARKETS"] = "15"
28
+ with patch.dict(os.environ, clean, clear=True):
29
+ config = Config.from_env()
30
+ self.assertEqual(config.max_markets, 15)
31
+ self.assertEqual(config.health_port, 9090)
32
+ self.assertEqual(config.min_daily_reward, 2.5)
33
+
34
+ def test_port_is_used_when_health_port_is_absent(self) -> None:
35
+ clean = {
36
+ key: value
37
+ for key, value in os.environ.items()
38
+ if key not in {"HEALTH_PORT", "PORT", "ENV_FILE"}
39
+ }
40
+ clean["ENV_FILE"] = "/does/not/exist"
41
+ clean["PORT"] = "8765"
42
+ with patch.dict(os.environ, clean, clear=True):
43
+ config = Config.from_env()
44
+ self.assertEqual(config.health_port, 8765)
45
+
46
+ def test_rejects_invalid_limits(self) -> None:
47
+ with patch.dict(
48
+ os.environ,
49
+ {"ENV_FILE": "/does/not/exist", "MAX_MARKETS": "0"},
50
+ clear=True,
51
+ ):
52
+ with self.assertRaises(ValueError):
53
+ Config.from_env()
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
tests/test_discovery.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from polymarket_collector.discovery import daily_reward, select_markets
6
+
7
+
8
+ def market(
9
+ identifier: str,
10
+ reward: float,
11
+ volume: float,
12
+ token_suffix: str,
13
+ ) -> dict:
14
+ return {
15
+ "condition_id": identifier,
16
+ "volume_24hr": volume,
17
+ "rewards_config": [{"rate_per_day": reward}],
18
+ "tokens": [
19
+ {"token_id": f"yes-{token_suffix}", "outcome": "YES"},
20
+ {"token_id": f"no-{token_suffix}", "outcome": "NO"},
21
+ ],
22
+ }
23
+
24
+
25
+ class DiscoveryTests(unittest.TestCase):
26
+ def test_daily_reward_sums_configs(self) -> None:
27
+ self.assertEqual(
28
+ daily_reward(
29
+ {"rewards_config": [{"rate_per_day": 2}, {"rate_per_day": "1.5"}]}
30
+ ),
31
+ 3.5,
32
+ )
33
+
34
+ def test_selects_by_reward_then_volume(self) -> None:
35
+ candidates = [
36
+ market("a", 2, 1_000, "a"),
37
+ market("b", 5, 10, "b"),
38
+ market("c", 2, 2_000, "c"),
39
+ market("d", 0.5, 10_000, "d"),
40
+ ]
41
+ selection = select_markets(
42
+ candidates,
43
+ max_markets=2,
44
+ min_daily_reward=1,
45
+ min_volume_24h=0,
46
+ )
47
+ self.assertEqual(
48
+ [item["condition_id"] for item in selection.markets], ["b", "c"]
49
+ )
50
+ self.assertEqual(
51
+ selection.asset_ids, ("yes-b", "no-b", "yes-c", "no-c")
52
+ )
53
+
54
+ def test_skips_market_without_two_tokens(self) -> None:
55
+ invalid = market("a", 10, 1_000, "a")
56
+ invalid["tokens"] = invalid["tokens"][:1]
57
+ selection = select_markets(
58
+ [invalid],
59
+ max_markets=10,
60
+ min_daily_reward=0,
61
+ min_volume_24h=0,
62
+ )
63
+ self.assertEqual(selection.markets, ())
64
+
65
+
66
+ if __name__ == "__main__":
67
+ unittest.main()
68
+
tests/test_health.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ import unittest
7
+
8
+ from polymarket_collector.health import HealthServer
9
+ from polymarket_collector.state import CollectorState
10
+
11
+
12
+ class HealthTests(unittest.IsolatedAsyncioTestCase):
13
+ async def test_health_endpoint(self) -> None:
14
+ state = CollectorState(
15
+ discovered_at=time.time(),
16
+ connected_at=time.time(),
17
+ last_message_at=time.time(),
18
+ connected=True,
19
+ markets=4,
20
+ assets=8,
21
+ )
22
+ server = HealthServer(state, "127.0.0.1", 0, stale_after_seconds=60)
23
+ await server.start()
24
+ assert server._server is not None
25
+ port = server._server.sockets[0].getsockname()[1]
26
+
27
+ reader, writer = await asyncio.open_connection("127.0.0.1", port)
28
+ writer.write(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\n\r\n")
29
+ await writer.drain()
30
+ response = await reader.read()
31
+ writer.close()
32
+ await writer.wait_closed()
33
+ await server.close()
34
+
35
+ _, body = response.split(b"\r\n\r\n", 1)
36
+ payload = json.loads(body)
37
+ self.assertTrue(payload["healthy"])
38
+ self.assertEqual(payload["markets"], 4)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ unittest.main()
43
+
tests/test_hf_backup.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from polymarket_collector.hf_backup import completed_archives, upload_once
9
+
10
+
11
+ class FakeApi:
12
+ def __init__(self, fail: bool = False) -> None:
13
+ self.fail = fail
14
+ self.calls: list[dict[str, object]] = []
15
+
16
+ def create_commit(self, **kwargs: object) -> None:
17
+ self.calls.append(kwargs)
18
+ if self.fail:
19
+ raise RuntimeError("upload failed")
20
+
21
+
22
+ class HuggingFaceBackupTests(unittest.TestCase):
23
+ def test_lists_only_completed_archives(self) -> None:
24
+ with tempfile.TemporaryDirectory() as temporary:
25
+ root = Path(temporary)
26
+ archive = root / "a.jsonl.gz"
27
+ archive.write_bytes(b"archive")
28
+ os.utime(archive, (1, 1))
29
+ (root / "active.jsonl.part").write_bytes(b"active")
30
+ self.assertEqual(
31
+ completed_archives(root, min_age_seconds=0), [archive]
32
+ )
33
+
34
+ def test_deletes_only_after_successful_commit(self) -> None:
35
+ with tempfile.TemporaryDirectory() as temporary:
36
+ root = Path(temporary)
37
+ archive = root / "market_ws" / "a.jsonl.gz"
38
+ archive.parent.mkdir()
39
+ archive.write_bytes(b"archive")
40
+ os.utime(archive, (1, 1))
41
+
42
+ api = FakeApi()
43
+ count = upload_once(
44
+ data_dir=root,
45
+ repo_id="user/data",
46
+ token="token",
47
+ min_age_seconds=0,
48
+ api=api, # type: ignore[arg-type]
49
+ )
50
+ self.assertEqual(count, 1)
51
+ self.assertFalse(archive.exists())
52
+ operations = api.calls[0]["operations"]
53
+ self.assertEqual(operations[0].path_in_repo, "market_ws/a.jsonl.gz")
54
+
55
+ def test_keeps_files_when_commit_fails(self) -> None:
56
+ with tempfile.TemporaryDirectory() as temporary:
57
+ root = Path(temporary)
58
+ archive = root / "a.jsonl.gz"
59
+ archive.write_bytes(b"archive")
60
+ os.utime(archive, (1, 1))
61
+ with self.assertRaises(RuntimeError):
62
+ upload_once(
63
+ data_dir=root,
64
+ repo_id="user/data",
65
+ token="token",
66
+ min_age_seconds=0,
67
+ api=FakeApi(fail=True), # type: ignore[arg-type]
68
+ )
69
+ self.assertTrue(archive.exists())
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
tests/test_storage.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import json
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from polymarket_collector.storage import RotatingJsonlWriter
10
+
11
+
12
+ class StorageTests(unittest.IsolatedAsyncioTestCase):
13
+ async def test_rotates_and_compresses_jsonl(self) -> None:
14
+ with tempfile.TemporaryDirectory() as temporary:
15
+ root = Path(temporary)
16
+ writer = RotatingJsonlWriter(
17
+ root,
18
+ "test",
19
+ rotation_seconds=3600,
20
+ max_file_bytes=1,
21
+ flush_interval_seconds=1,
22
+ )
23
+ await writer.start()
24
+ await writer.write({"hello": "world"})
25
+ await writer.close()
26
+
27
+ files = list(root.rglob("*.jsonl.gz"))
28
+ self.assertEqual(len(files), 1)
29
+ with gzip.open(files[0], "rt", encoding="utf-8") as handle:
30
+ self.assertEqual(json.loads(handle.readline()), {"hello": "world"})
31
+ self.assertEqual(list(root.rglob("*.part")), [])
32
+
33
+ async def test_recovers_part_file_on_start(self) -> None:
34
+ with tempfile.TemporaryDirectory() as temporary:
35
+ root = Path(temporary)
36
+ part = root / "2026/07/22/crashed.jsonl.part"
37
+ part.parent.mkdir(parents=True)
38
+ part.write_text('{"recovered":true}\n', encoding="utf-8")
39
+
40
+ writer = RotatingJsonlWriter(
41
+ root,
42
+ "test",
43
+ rotation_seconds=3600,
44
+ max_file_bytes=1024,
45
+ flush_interval_seconds=1,
46
+ )
47
+ await writer.start()
48
+ await writer.close()
49
+
50
+ recovered = list(root.rglob("*.jsonl.gz"))
51
+ self.assertEqual(len(recovered), 1)
52
+ with gzip.open(recovered[0], "rt", encoding="utf-8") as handle:
53
+ self.assertEqual(json.loads(handle.readline()), {"recovered": True})
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
58
+
tests/test_stream.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import gzip
5
+ import json
6
+ import tempfile
7
+ import time
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ from websockets.asyncio.server import serve
12
+
13
+ from polymarket_collector.state import CollectorState
14
+ from polymarket_collector.storage import RotatingJsonlWriter
15
+ from polymarket_collector.stream import MarketStream
16
+
17
+
18
+ class StreamTests(unittest.IsolatedAsyncioTestCase):
19
+ async def test_records_public_market_message(self) -> None:
20
+ subscription_received = asyncio.Event()
21
+
22
+ async def handler(websocket) -> None:
23
+ subscription = json.loads(await websocket.recv())
24
+ self.assertEqual(subscription["assets_ids"], ["asset-1", "asset-2"])
25
+ subscription_received.set()
26
+ await websocket.send(
27
+ json.dumps(
28
+ {
29
+ "event_type": "book",
30
+ "asset_id": "asset-1",
31
+ "market": "condition-1",
32
+ "bids": [],
33
+ "asks": [],
34
+ "timestamp": "1",
35
+ }
36
+ )
37
+ )
38
+ while True:
39
+ try:
40
+ message = await websocket.recv()
41
+ except Exception:
42
+ return
43
+ if message == "PING":
44
+ await websocket.send("PONG")
45
+
46
+ with tempfile.TemporaryDirectory() as temporary:
47
+ writer = RotatingJsonlWriter(
48
+ Path(temporary),
49
+ "stream",
50
+ rotation_seconds=3600,
51
+ max_file_bytes=1024 * 1024,
52
+ flush_interval_seconds=1,
53
+ )
54
+ await writer.start()
55
+ state = CollectorState()
56
+ stop = asyncio.Event()
57
+ async with serve(handler, "127.0.0.1", 0) as websocket_server:
58
+ port = websocket_server.sockets[0].getsockname()[1]
59
+ stream = MarketStream(
60
+ f"ws://127.0.0.1:{port}", writer, state
61
+ )
62
+ stream.set_assets(["asset-1", "asset-2"])
63
+ task = asyncio.create_task(stream.run(stop))
64
+ await asyncio.wait_for(subscription_received.wait(), 2)
65
+ deadline = time.monotonic() + 2
66
+ while state.messages_total < 1 and time.monotonic() < deadline:
67
+ await asyncio.sleep(0.01)
68
+ stop.set()
69
+ await asyncio.wait_for(task, 3)
70
+ await writer.close()
71
+
72
+ self.assertEqual(state.messages_total, 1)
73
+ archive = next(Path(temporary).rglob("*.jsonl.gz"))
74
+ with gzip.open(archive, "rt", encoding="utf-8") as handle:
75
+ record = json.loads(handle.readline())
76
+ self.assertEqual(record["payload"]["event_type"], "book")
77
+ self.assertIn("received_at_ns", record)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ unittest.main()
82
+