""" bme.py — El precio de mercado de bonos, obligaciones y Letras en BME. BME publica en su web los precios de la sesión de renta fija (AIAF y SENAF) a través de un canal SignalR. Aquí se usa su modo «long polling», que son peticiones HTTP normales, y el protocolo MessagePack (msgpack_min.py): 1. POST …/negotiate?negotiateVersion=1 → connectionToken 2. POST …?id=TOKEN {"protocol":"messagepack","version":1}\\x1e 3. POST …?id=TOKEN la invocación GetSessionPrices (Segment, Group, Search) 4. GET …?id=TOKEN → «{}\\x1e» del saludo y la respuesta, en marcos MessagePack Cada fila de la respuesta es una lista sin nombres de campo; su orden se dedujo de las columnas de la web (ver CAMPOS). Los precios van en % del nominal y sin el cupón corrido. Solo se pregunta por los ISIN de la cartera. """ from __future__ import annotations import datetime as dt import json import re from . import msgpack_min as mp URL = "https://apiweb.bolsasymercados.es/Market/v1/SignalR/FI/Prices" ORIGEN = "https://www.bolsasymercados.es" # (segmento, grupo, nombre): deuda pública, corporativa y extranjera de AIAF, SENAF y MARF MERCADOS = [("SD", "DE", "AIAF deuda pública"), ("MV", "", "SENAF"), ("SD", "CP,DT", "AIAF deuda corporativa"), ("SD", "EX", "AIAF deuda extranjera"), ("AF", "", "MARF")] CAMPOS = ["fecha", "segmento", "grupo", "tipo", "nombre_tipo", "nombre", "isin", "ticker", "ordenes_compra", "importe_compra", "tir_compra", "precio_compra", "precio_venta", "tir_venta", "importe_venta", "ordenes_venta", "ultimo", "tir_ultimo", "volumen"] ISIN = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$") class Canal: """Una conexión al canal de precios de BME.""" def __init__(self, sesion): self.s = sesion cab = {"Origin": ORIGEN, "Content-Length": "0"} r = self.s.post(f"{URL}/negotiate?negotiateVersion=1", data=b"", headers=cab, timeout=30) r.raise_for_status() self.url = f"{URL}?id={r.json()['connectionToken']}" self.s.post(self.url, data=json.dumps({"protocol": "messagepack", "version": 1}) + "\x1e", headers={"Origin": ORIGEN}, timeout=30) self.n = 0 def precios(self, segmento: str, grupo: str, buscar: str) -> list[dict]: self.n += 1 ident = str(self.n) inv = [1, {}, ident, "GetSessionPrices", [{"Language": "es", "Segment": segmento, "Group": grupo, "Search": buscar, "Page": 0, "PageSize": 0}], []] self.s.post(self.url, data=mp.marco(mp.empaquetar(inv)), headers={"Origin": ORIGEN, "Content-Type": "application/octet-stream"}, timeout=30) for _ in range(6): crudo = self.s.get(self.url, headers={"Origin": ORIGEN}, timeout=60).content if crudo.startswith(b"{"): # la respuesta al saludo crudo = crudo[crudo.index(b"\x1e") + 1:] for m in mp.marcos(crudo): if isinstance(m, list) and m and m[0] == 3 and m[2] == ident: if m[3] == 1: # error del servidor raise RuntimeError(str(m[4])) datos = (m[4] if len(m) > 4 and isinstance(m[4], dict) else {}).get("Data") or [] return [dict(zip(CAMPOS, fila)) for fila in datos if isinstance(fila, list)] if isinstance(m, list) and m and m[0] == 7: raise RuntimeError("BME cerró la conexión") raise RuntimeError("BME no contestó") def precio(fila: dict) -> tuple[float, str] | None: """(precio en % del nominal, de dónde sale): el último negociado o, si no hubo operaciones, el punto medio entre la mejor compra y la mejor venta.""" if (fila.get("ultimo") or 0) > 0: return float(fila["ultimo"]), "último precio negociado" compra, venta = fila.get("precio_compra") or 0, fila.get("precio_venta") or 0 if compra > 0 and venta > 0: return round((compra + venta) / 2, 4), "punto medio entre compra y venta" return None def consultar(isins: list[str], sesion) -> dict[str, dict]: """El precio de cada ISIN en el primer mercado de BME que lo tenga: {isin: {fecha, precio, origen, mercado, nombre}}. Los que no cotizan, fuera.""" buscados = [i.strip().upper() for i in isins if ISIN.match((i or "").strip().upper())] if not buscados: return {} canal = Canal(sesion) salida = {} for isin in buscados: for seg, grupo, nombre in MERCADOS: filas = [f for f in canal.precios(seg, grupo, isin) if f.get("isin") == isin] p = next((precio(f) for f in filas if precio(f)), None) if p: fila = filas[0] fecha = str(fila.get("fecha") or dt.date.today().strftime("%Y%m%d")) salida[isin] = {"fecha": f"{fecha[:4]}-{fecha[4:6]}-{fecha[6:8]}", "precio": p[0], "origen": p[1], "mercado": nombre, "nombre": fila.get("nombre", "")} break return salida