caudal/programa/herramientas/serie_btc_eur.py
2026-10-13 08:01:06 -05:00

160 lines
6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
serie_btc_eur.py — Genera cartera/serie_btc_eur.csv: el cierre diario del
bitcoin en euros desde 2011, que la app lleva empaquetado.
python3 herramientas/serie_btc_eur.py
Por qué empaquetado: las fuentes gratuitas «al vuelo» solo dan el último año
(CoinGecko) o los últimos 720 días (Kraken), y el historial de un monedero
puede empezar mucho antes. Además, así la app no le cuenta a nadie qué días
te interesan.
Fuentes:
• Desde el 25-abr-2016: cierre diario BTC/EUR de Bitstamp (vela UTC).
• Antes: cierre BTC/USD de Bitstamp × cambio USD→EUR del BCE del mismo día
(o del último día hábil anterior: el BCE no publica en fin de semana).
Contraste (se imprime; si se desvía demasiado, el script para):
• Los últimos 720 días contra el cierre BTC/EUR de Kraken.
• El tramo calculado en dólares contra el BTC/EUR real de Bitstamp del
primer año en que coinciden, para medir el error de ese método.
Este precio es solo una referencia (valorar lo minado o recibido, estimar un
importe que falta, avisar de primas raras). El coste fiscal de una compra es
siempre lo que pagaste de verdad.
"""
from __future__ import annotations
import csv
import datetime as dt
import json
import statistics
import sys
import time
import urllib.request
from pathlib import Path
DESTINO = Path(__file__).resolve().parent.parent / "cartera" / "serie_btc_eur.csv"
INICIO_USD = dt.date(2011, 9, 13)
INICIO_EUR = dt.date(2016, 4, 25)
DESVIO_MAXIMO = 0.03 # 3 % de mediana entre fuentes: más, algo va mal
def _json(url: str):
req = urllib.request.Request(url, headers={"User-Agent": "cartera-serie/1"})
for intento in range(4):
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except OSError:
if intento == 3:
raise
time.sleep(3)
def _dia(ts) -> dt.date:
return dt.datetime.fromtimestamp(int(ts), dt.timezone.utc).date()
def bitstamp(par: str, desde: dt.date, hasta: dt.date) -> dict[dt.date, float]:
cierres: dict[dt.date, float] = {}
inicio = int(dt.datetime(desde.year, desde.month, desde.day, tzinfo=dt.timezone.utc).timestamp())
while True:
velas = _json(f"https://www.bitstamp.net/api/v2/ohlc/{par}/?step=86400&limit=1000&start={inicio}")
velas = velas["data"]["ohlc"]
nuevas = 0
for v in velas:
d = _dia(v["timestamp"])
if desde <= d <= hasta and float(v["close"]) > 0 and d not in cierres:
cierres[d] = float(v["close"])
nuevas += 1
if not velas or not nuevas or _dia(velas[-1]["timestamp"]) >= hasta:
return cierres
inicio = int(velas[-1]["timestamp"]) + 86400
time.sleep(0.5)
def bce_usd_eur(desde: dt.date, hasta: dt.date) -> dict[dt.date, float]:
r = _json(f"https://api.frankfurter.dev/v1/{desde.isoformat()}..{hasta.isoformat()}?from=USD&to=EUR")
return {dt.date.fromisoformat(d): v["EUR"] for d, v in r["rates"].items()}
def kraken_eur() -> dict[dt.date, float]:
r = _json("https://api.kraken.com/0/public/OHLC?pair=XBTEUR&interval=1440")
if r.get("error"):
raise RuntimeError(f"Kraken: {r['error']}")
velas = next(iter(r["result"].values()))
return {_dia(v[0]): float(v[4]) for v in velas}
def _en_dolares(usd: dict, cambio: dict, dia: dt.date) -> float | None:
if dia not in usd:
return None
for atras in range(8): # fin de semana y festivos del BCE
c = cambio.get(dia - dt.timedelta(days=atras))
if c:
return usd[dia] * c
return None
def _desvio(a: dict, b: dict) -> tuple[float, float, int]:
comunes = sorted(set(a) & set(b))
difs = [abs(a[d] / b[d] - 1) for d in comunes if b[d]]
if not difs:
return 0.0, 0.0, 0
return statistics.median(difs), max(difs), len(difs)
def main() -> int:
ayer = dt.datetime.now(dt.timezone.utc).date() - dt.timedelta(days=1)
print("Bitstamp BTC/EUR…")
eur = bitstamp("btceur", INICIO_EUR, ayer)
print("Bitstamp BTC/USD…")
usd = bitstamp("btcusd", INICIO_USD, INICIO_EUR + dt.timedelta(days=365))
print("BCE USD→EUR…")
cambio = bce_usd_eur(INICIO_USD - dt.timedelta(days=10), INICIO_EUR + dt.timedelta(days=365))
print("Kraken BTC/EUR (contraste)…")
kraken = kraken_eur()
serie: dict[dt.date, tuple[float, str]] = {}
dia = INICIO_USD
while dia < INICIO_EUR:
v = _en_dolares(usd, cambio, dia)
if v:
serie[dia] = (v, "bitstamp-usd*bce")
dia += dt.timedelta(days=1)
for d, v in eur.items():
serie[d] = (v, "bitstamp-eur")
# Contrastes
solapado = {d: _en_dolares(usd, cambio, d) for d in usd if d >= INICIO_EUR}
solapado = {d: v for d, v in solapado.items() if v}
med_usd, max_usd, n_usd = _desvio(solapado, eur)
med_kr, max_kr, n_kr = _desvio({d: v for d, (v, _) in serie.items()}, kraken)
print(f"Método dólares×BCE contra BTC/EUR real: mediana {med_usd:.2%}, máximo {max_usd:.2%} ({n_usd} días)")
print(f"Bitstamp contra Kraken: mediana {med_kr:.2%}, máximo {max_kr:.2%} ({n_kr} días)")
if med_usd > DESVIO_MAXIMO or med_kr > DESVIO_MAXIMO or n_kr < 300:
print("✗ Las fuentes no cuadran entre sí: no escribo nada.")
return 1
huecos = []
dia = min(serie)
while dia <= max(serie):
if dia not in serie:
huecos.append(dia)
dia += dt.timedelta(days=1)
tmp = DESTINO.with_suffix(".tmp")
with tmp.open("w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["fecha", "cierre_eur", "fuente"])
for d in sorted(serie):
v, fuente = serie[d]
w.writerow([d.isoformat(), f"{v:.2f}", fuente])
tmp.replace(DESTINO)
print(f"✓ {len(serie)} días, del {min(serie)} al {max(serie)} · {len(huecos)} días sin dato "
f"(la app usa el anterior) → {DESTINO}")
return 0
if __name__ == "__main__":
sys.exit(main())