160 lines
6 KiB
Python
160 lines
6 KiB
Python
"""
|
||
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())
|