caudal/programa/pruebas/test_precios.py
esetheyers cf7cc9a8c7 Caudal 0.1.2
Diseño para móvil, programa de escritorio para Linux (un solo fichero) y versiones firmadas: manifiesto de huellas, firma PGP y la clave del autor (VERIFICAR.md).
2026-10-14 16:42:53 -05:00

337 lines
15 KiB
Python

"""
Pruebas de las fuentes de precios, sin tocar internet.
Cada prueba usa una respuesta REAL guardada en `pruebas/respuestas/`, recortada
a lo que el programa lee. Así, el día que haya que cambiar algo de `precios.py`,
estas pruebas dicen si se ha roto la lectura de alguna fuente; y cuando una web
cambie de verdad, lo dirá «Comprobar las fuentes» (que sí sale a internet).
Todos los identificadores que salen aquí son públicos y genéricos —un fondo
indexado, el ETF de S&P 500 más común, bitcoin y el oro—: no dicen nada de la
cartera de nadie.
Aquí solo se prueba el núcleo. Las fuentes de gestoras concretas, si la
instalación las trae, se apartan mientras duran estas pruebas y tienen las
suyas en otro fichero.
python3 -m unittest discover -s pruebas -v
"""
from __future__ import annotations
import datetime as dt
import json
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from cartera import fuentes, precios # noqa: E402
RESPUESTAS = Path(__file__).resolve().parent / "respuestas"
# Qué fichero contesta a cada dirección
RUTAS = (
("morningstar.com/api", "morningstar.json"),
("morningstar.es/api", "morningstar.json"),
("morningstar.co.uk/api", "morningstar.json"),
("cnmv.es", "cnmv.html"),
("api/v3/simple/price", "coingecko.json"),
("currencies/xau.json", "fawaz_xau.json"),
("currencies/usd.json", "fawaz_usd.json"),
("instrument/search/main", "ls_busqueda.json"),
("chart/dataForInstrument", "ls_grafico.json"),
("ls-tc.de/de/", "ls_ficha.html"),
)
class Respuesta:
def __init__(self, texto: str, status_code: int = 200):
self.text = texto
self.status_code = status_code
def json(self):
return json.loads(self.text)
def raise_for_status(self):
if self.status_code >= 400:
raise OSError(f"HTTP {self.status_code}")
class SesionFalsa:
"""Contesta con los ficheros guardados. `caidas` son trozos de dirección que
fallan, para simular una fuente rota."""
rutas = RUTAS
respuestas = (RESPUESTAS,) # carpetas donde buscar cada fichero, por orden
def __init__(self, cambios: dict[str, str] | None = None, caidas: tuple[str, ...] = ()):
self.cambios = cambios or {} # trozo de dirección → texto de respuesta
self.caidas = caidas
self.pedidas: list[str] = []
def get(self, url, params=None, headers=None, timeout=None):
self.pedidas.append(url)
for trozo in self.caidas:
if trozo in url:
raise OSError("la red no va")
for trozo, texto in self.cambios.items():
if trozo in url:
return Respuesta(texto)
for trozo, fichero in self.rutas:
if trozo in url:
carpeta = next(c for c in self.respuestas if (c / fichero).exists())
return Respuesta((carpeta / fichero).read_text(encoding="utf-8"))
raise AssertionError(f"la prueba no esperaba una petición a {url}")
REGISTROS = ("FONDOS_POR_ISIN", "FONDOS_POR_NOMBRE", "SONDAS_EXTRA")
class Base(unittest.TestCase):
con_gestoras = False # las pruebas del núcleo corren como la versión sin fuentes de gestoras
def setUp(self):
precios.limpiar_cache()
self.espera = precios.ESPERA_MORNINGSTAR
precios.ESPERA_MORNINGSTAR = 0 # sin cortesía con Morningstar en las pruebas
self.registros = {n: list(getattr(precios, n)) for n in REGISTROS}
if not self.con_gestoras:
for n in REGISTROS:
getattr(precios, n).clear()
def tearDown(self):
precios.ESPERA_MORNINGSTAR = self.espera
for n in REGISTROS:
getattr(precios, n)[:] = self.registros[n]
precios.limpiar_cache()
class TestFondos(Base):
def test_morningstar_lee_precio_y_fecha(self):
p = precios.morningstar("IE00B03HCZ61", SesionFalsa())
self.assertEqual(p.precio, 200.8208)
self.assertEqual(p.fecha, dt.date(2026, 9, 21))
self.assertEqual(p.fuente, "morningstar")
def test_morningstar_no_confunde_el_precio_con_miles(self):
"""El JSON trae 200.8208: el punto es decimal, no separador de miles."""
p = precios.morningstar("IE00B03HCZ61", SesionFalsa())
self.assertLess(p.precio, 1000)
def test_morningstar_sin_resultados_no_es_un_fallo(self):
sesion = SesionFalsa({"morningstar": (RESPUESTAS / "morningstar_sin_resultados.json").read_text()})
avisos: list[str] = []
self.assertIsNone(precios.morningstar("ES0000000000", sesion, avisos))
self.assertEqual(avisos, []) # el ISIN no está: la fuente funciona
def test_morningstar_caido_prueba_los_tres_hosts_y_avisa(self):
sesion = SesionFalsa(caidas=("morningstar",))
avisos: list[str] = []
self.assertIsNone(precios.morningstar("IE00B03HCZ61", sesion, avisos))
self.assertEqual(len(sesion.pedidas), 3)
self.assertIn("Morningstar", avisos[0])
def test_cnmv_avisa_de_que_ya_no_publica_el_valor(self):
avisos: list[str] = []
self.assertIsNone(precios.cnmv("IE00B03HCZ61", SesionFalsa(), avisos))
self.assertIn("ya no publica", avisos[0])
class TestOtrasFuentes(Base):
def test_coingecko(self):
p = precios.coingecko("bitcoin", SesionFalsa())
self.assertEqual(p.precio, 75368)
self.assertEqual(p.fuente, "coingecko")
def test_coingecko_moneda_desconocida(self):
sesion = SesionFalsa({"simple/price": "{}"})
self.assertIsNone(precios.coingecko("no-existe", sesion))
def test_metal_oro_por_onza(self):
p = precios.metal("xau", SesionFalsa())
self.assertAlmostEqual(p.precio, 3779.94311611, places=5)
self.assertEqual(p.fecha, dt.date(2026, 9, 22))
def test_ls_exchange_valora_al_precio_de_compra_como_las_apps_de_los_brokers(self):
p = precios.ls_exchange("IE00B5BMR087", SesionFalsa())
self.assertEqual(p.precio, 730.66) # el de compra (bid), no el medio (731,03)
self.assertEqual(p.fuente, "ls-exchange")
def test_ls_exchange_sin_la_ficha_usa_el_precio_medio_y_lo_dice(self):
avisos = []
p = precios.ls_exchange("IE00B5BMR087", SesionFalsa(caidas=("ls-tc.de/de/",)), avisos)
self.assertEqual(p.precio, 731.03)
self.assertIn("uso el medio", avisos[0])
def test_ls_exchange_no_se_fia_de_un_precio_de_compra_disparatado(self):
ficha = (RESPUESTAS / "ls_ficha.html").read_text().replace("730,6600", "73,0660")
p = precios.ls_exchange("IE00B5BMR087", SesionFalsa({"ls-tc.de/de/": ficha}))
self.assertEqual(p.precio, 731.03)
def test_ls_exchange_con_el_mercado_cerrado_usa_el_cierre_anterior(self):
sesion = SesionFalsa({"dataForInstrument": (RESPUESTAS / "ls_grafico_cerrado.json").read_text()})
p = precios.ls_exchange("IE00B5BMR087", sesion)
self.assertEqual(p.precio, 730.66) # el último precio de compra; la fecha, la del cierre
self.assertLess(p.fecha, dt.date.today())
self.assertLess(p.fecha.weekday(), 5) # nunca un sábado o un domingo
def test_ls_exchange_cerrado_y_sin_ficha_usa_el_cierre_anterior(self):
sesion = SesionFalsa({"dataForInstrument": (RESPUESTAS / "ls_grafico_cerrado.json").read_text()},
caidas=("ls-tc.de/de/",))
self.assertEqual(precios.ls_exchange("IE00B5BMR087", sesion).precio, 730.1)
def test_ls_exchange_isin_desconocido(self):
sesion = SesionFalsa({"search/main": "[]"})
self.assertIsNone(precios.ls_exchange("XX0000000000", sesion))
def test_cambio_de_dolares_a_euros(self):
valor = precios.tipo_cambio(dt.date(2026, 9, 22), "usd", "eur", SesionFalsa())
self.assertAlmostEqual(valor, 0.8533)
def test_a_euros_no_toca_los_euros(self):
self.assertEqual(precios.a_euros(100.0, "EUR", SesionFalsa()), 100.0)
def grafico_yahoo(cierres, moneda="EUR") -> str:
"""Respuesta inventada de la API de gráficos de Yahoo: [(fecha, cierre|None)]."""
marcas = [int(dt.datetime(f.year, f.month, f.day, 7, tzinfo=dt.timezone.utc).timestamp()) for f, _ in cierres]
return json.dumps({"chart": {"result": [{"meta": {"currency": moneda}, "timestamp": marcas,
"indicators": {"quote": [{"close": [c for _, c in cierres]}]}}],
"error": None}})
class TestYahooFondos(Base):
def test_ultimo_cierre_publicado_y_su_fecha(self):
texto = grafico_yahoo([(dt.date(2026, 9, 21), 15.1), (dt.date(2026, 9, 22), 15.2),
(dt.date(2026, 9, 23), None), (dt.date(2026, 9, 24), None)])
p = precios.yahoo_fondo("0P0000TEST", SesionFalsa({"finance.yahoo.com/v8": texto}))
self.assertEqual((p.precio, p.fecha, p.fuente, p.codigo), (15.2, dt.date(2026, 9, 22), "yahoo", "0P0000TEST.F"))
def test_en_dolares_se_pasa_a_euros(self):
texto = grafico_yahoo([(dt.date(2026, 9, 22), 100.0)], moneda="USD")
p = precios.yahoo_fondo("0P0000TEST.F", SesionFalsa({"finance.yahoo.com/v8": texto}))
self.assertAlmostEqual(p.precio, 100.0 * precios.tipo_cambio(dt.date(2026, 9, 22), "usd", "eur",
SesionFalsa()))
def test_una_accion_por_su_ticker_sin_bibliotecas_de_mas(self):
sesion = SesionFalsa({"finance.yahoo.com/v8": grafico_yahoo([(dt.date(2026, 9, 21), 100.0), (dt.date(2026, 9, 22), None)], "USD")})
p = precios.yahoo("spy", sesion)
self.assertEqual((p.fecha, p.fuente), (dt.date(2026, 9, 21), "yahoo"))
self.assertAlmostEqual(p.precio, precios.a_euros(100.0, "USD", sesion))
self.assertTrue(any("/SPY" in u for u in sesion.pedidas))
self.assertNotIn("yfinance", sys.modules)
def test_simbolo_desconocido_o_sin_cierres(self):
avisos = []
vacio = json.dumps({"chart": {"result": None, "error": {"code": "Not Found"}}})
self.assertIsNone(precios.yahoo_fondo("0P0000NADA", SesionFalsa({"finance.yahoo.com/v8": vacio}), avisos))
self.assertIn("Yahoo no tiene 0P0000NADA.F", avisos)
sin_cierres = grafico_yahoo([(dt.date(2026, 9, 22), None)])
self.assertIsNone(precios.yahoo_fondo("0P0000TEST", SesionFalsa({"finance.yahoo.com/v8": sin_cierres})))
class TestQueFuenteLeTocaACadaActivo(Base):
def elegir(self, activo, sesion=None):
return precios.de_activo(activo, sesion or SesionFalsa())
def test_fondo_con_isin_va_a_morningstar(self):
p = self.elegir({"id": "F1", "tipo": "Fondo", "codigo": "IE00B03HCZ61", "nombre": "Un fondo"})
self.assertEqual(p.fuente, "morningstar")
self.assertEqual(p.activo, "F1")
def test_plan_con_codigo_dgs_no_pregunta_a_yahoo(self):
sesion = SesionFalsa()
self.elegir({"id": "P1", "tipo": "PlanPensiones", "codigo": "N0000", "nombre": "Plan De Ejemplo Ppi"},
sesion)
self.assertFalse(any("yahoo" in u for u in sesion.pedidas))
def test_cripto_va_a_coingecko(self):
self.assertEqual(self.elegir({"id": "C1", "tipo": "Cripto", "codigo": "bitcoin"}).fuente, "coingecko")
def test_metal_va_a_fawaz(self):
self.assertEqual(self.elegir({"id": "M1", "tipo": "Metal", "codigo": "xau"}).fuente, "fawaz")
def test_etf_con_isin_va_a_ls_exchange(self):
"""En euros, en un mercado alemán que cotiza de día y de tarde."""
self.assertEqual(self.elegir({"id": "E1", "tipo": "ETF", "codigo": "IE00B5BMR087"}).fuente, "ls-exchange")
def test_accion_con_ticker_va_a_yahoo(self):
llamadas = []
original = precios.yahoo
precios.yahoo = lambda t, s, a=None: llamadas.append(t) or precios.Precio("", t, 1.0, dt.date.today(), "yahoo")
try:
p = self.elegir({"id": "A1", "tipo": "Accion", "codigo": "SPY"})
finally:
precios.yahoo = original
self.assertEqual(llamadas, ["SPY"])
self.assertEqual(p.fuente, "yahoo")
def test_un_activo_de_precio_manual_no_consulta_nada(self):
sesion = SesionFalsa()
self.assertIsNone(self.elegir({"id": "CC1", "tipo": "Cuenta", "codigo": ""}, sesion))
self.assertEqual(sesion.pedidas, [])
class TestComprobarLasFuentes(Base):
def test_todas_bien_menos_la_cnmv(self):
informe = precios.comprobar(SesionFalsa({"finance.yahoo": "", "query": ""}))
por_clave = {f["fuente"]: f for f in informe["fuentes"]}
for clave in ("morningstar", "coingecko", "fawaz", "ls-exchange", "cambio"):
self.assertTrue(por_clave[clave]["ok"], f"{clave}: {por_clave[clave]['detalle']}")
self.assertFalse(por_clave["cnmv"]["ok"])
self.assertTrue(por_clave["cnmv"]["esperado"]) # rota a sabiendas desde 2026
self.assertNotIn("CNMV", informe["rotas"])
def test_dice_cual_esta_rota(self):
informe = precios.comprobar(SesionFalsa(caidas=("morningstar", "finance.yahoo")))
self.assertIn("Morningstar", informe["rotas"])
self.assertNotIn("CoinGecko", informe["rotas"])
def test_no_consulta_ningun_activo_de_la_cartera(self):
"""Las sondas son identificadores públicos fijos, escritos en el código."""
sesion = SesionFalsa({"finance.yahoo": "", "query": ""})
precios.comprobar(sesion)
for url in sesion.pedidas:
self.assertNotIn("ES0113728", url) # ningún ISIN que no sea el de la sonda
class TestLoQueUsaLaAplicacion(Base):
def test_fuentes_precio_devuelve_el_registro_listo_para_guardar(self):
r, mensaje = fuentes.precio({"id": "C1", "tipo": "Cripto", "codigo": "bitcoin"}, SesionFalsa())
self.assertEqual(r, {"activo": "C1", "fecha": r["fecha"], "precio": 75368.0, "fuente": "coingecko"})
self.assertEqual(mensaje, "")
def test_fuentes_precio_explica_por_que_no_hay_precio(self):
sesion = SesionFalsa(caidas=("coingecko",))
r, mensaje = fuentes.precio({"id": "C1", "tipo": "Cripto", "codigo": "bitcoin"}, sesion)
self.assertIsNone(r)
self.assertIn("CoinGecko", mensaje)
def test_una_fuente_rota_no_tumba_la_actualizacion_entera(self):
activos = [{"id": "C1", "tipo": "Cripto", "codigo": "bitcoin"},
{"id": "M1", "tipo": "Metal", "codigo": "xau"}]
sesion = SesionFalsa(caidas=("coingecko",))
informe = [dict(zip(("activo", "ok"), (a["id"], fuentes.precio(a, sesion)[0] is not None)))
for a in activos]
self.assertEqual(informe, [{"activo": "C1", "ok": False}, {"activo": "M1", "ok": True}])
def test_la_ruta_de_comprobar_esta_conectada(self):
import tempfile
from cartera import servidor
from cartera.operaciones import Cartera
original = fuentes.comprobar
fuentes.comprobar = lambda: {"fuentes": [], "rotas": [], "fecha": "2026-09-22T00:00:00"}
try:
with tempfile.TemporaryDirectory() as carpeta:
rutas = servidor.App(Cartera(Path(carpeta))).rutas_post()
self.assertEqual(rutas["/api/fuentes/comprobar"]({})["rotas"], [])
finally:
fuentes.comprobar = original
if __name__ == "__main__":
unittest.main()