Publish Render-ready Polymarket collector
Browse files- .dockerignore +7 -0
- .env.example +20 -0
- .gitignore +10 -0
- Dockerfile +22 -0
- README.md +161 -0
- deploy/container-entrypoint.sh +70 -0
- deploy/hf-dataset-card.md +22 -0
- deploy/koyeb.env.example +15 -0
- deploy/polymarket-backup.service +12 -0
- deploy/polymarket-backup.timer +12 -0
- deploy/polymarket-collector.service +33 -0
- polymarket_collector/__init__.py +4 -0
- polymarket_collector/__main__.py +6 -0
- polymarket_collector/config.py +92 -0
- polymarket_collector/discovery.py +171 -0
- polymarket_collector/health.py +99 -0
- polymarket_collector/hf_backup.py +116 -0
- polymarket_collector/main.py +188 -0
- polymarket_collector/state.py +66 -0
- polymarket_collector/storage.py +135 -0
- polymarket_collector/stream.py +200 -0
- polymarket_lp_collector.egg-info/PKG-INFO +166 -0
- polymarket_lp_collector.egg-info/SOURCES.txt +24 -0
- polymarket_lp_collector.egg-info/dependency_links.txt +1 -0
- polymarket_lp_collector.egg-info/entry_points.txt +2 -0
- polymarket_lp_collector.egg-info/requires.txt +2 -0
- polymarket_lp_collector.egg-info/top_level.txt +1 -0
- pyproject.toml +20 -0
- render.yaml +27 -0
- scripts/deploy_gcp.sh +65 -0
- scripts/deploy_hf.py +97 -0
- scripts/deploy_koyeb.sh +54 -0
- scripts/install_vps.sh +60 -0
- scripts/polymarket-backup +57 -0
- scripts/publish_hf_source.py +45 -0
- tests/test_config.py +57 -0
- tests/test_discovery.py +68 -0
- tests/test_health.py +43 -0
- tests/test_hf_backup.py +73 -0
- tests/test_storage.py +58 -0
- tests/test_stream.py +82 -0
.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 |
+
|