""" Pruebas de «Devolución»: el dinero que te devuelven de un gasto ya apuntado. Lo que distingue una Devolución de un Dividendo es DÓNDE se refleja, no cuánto: el euro entra el mismo día, así que la TIR y la ganancia total salen iguales, pero la devolución **baja el coste** del activo en vez de contar como una renta. Casi todas las pruebas de aquí son esa comparación. python3 -m unittest discover -s pruebas -v """ from __future__ import annotations import copy import datetime as dt import sys import tempfile import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from cartera import actividades # noqa: E402 from cartera.calculo import calcular # noqa: E402 from cartera.datos import Datos # noqa: E402 from cartera.interprete import interpretar # noqa: E402 from cartera.operaciones import Cartera, ErrorOperacion # noqa: E402 from cartera.vista import resumen # noqa: E402 HOY = dt.date(2026, 9, 20) MINERIA = "Minería" DEVUELTO = 1000.0 def cartera(tipo_devolucion: str) -> Datos: """Bitcoin comprado, más la minería: aparatos, su devolución y lo minado. `tipo_devolucion` es «Dividendo» (como se apuntaba antes) o «Devolucion». """ return Datos( [{"id": "C1", "nombre": "Bitcoin", "tipo": "Cripto", "codigo": "bitcoin", "unidad": "BTC", "estado": "abierta", "fecha_inicio": "2025-01-01"}], [{"id": "m1", "fecha": "2025-01-15", "activo": "C1", "tipo": "Compra", "cantidad": 0.5, "precio": 40000.0, "importe": 20000.0}, {"id": "g1", "fecha": "2026-01-10", "activo": "C1", "tipo": "Gasto", "importe": 6000.0, "notas": "ASIC", "actividad": MINERIA}, {"id": "d1", "fecha": "2026-03-01", "activo": "C1", "tipo": tipo_devolucion, "importe": DEVUELTO, "notas": "Devolución de los ASIC", "estimado": True, "actividad": MINERIA}, {"id": "h1", "fecha": "2026-07-01", "activo": "C1", "tipo": "Gasto", "importe": 70.0, "notas": "Hosting", "actividad": MINERIA}, {"id": "b1", "fecha": "2026-07-20", "activo": "C1", "tipo": "Mineria", "cantidad": 0.05, "actividad": MINERIA}], [{"fecha": "2026-09-19", "activo": "C1", "precio": 100000.0, "fuente": "prueba"}], []) class TestFrenteAlDividendo(unittest.TestCase): """La misma operación apuntada de las dos maneras.""" def setUp(self): self.como_dividendo = calcular(cartera("Dividendo"), HOY) self.como_devolucion = calcular(cartera("Devolucion"), HOY) def motor(self, r): return r["motor"]["activos"]["C1"] def hoja(self, r): return r["hoja"]["activos"]["C1"] def test_la_tir_no_cambia(self): """El euro entra el mismo día: el flujo es idéntico.""" self.assertAlmostEqual(self.motor(self.como_dividendo)["irr"], self.motor(self.como_devolucion)["irr"], places=9) def test_la_ganancia_total_no_cambia(self): self.assertAlmostEqual(self.motor(self.como_dividendo)["ganancia"], self.motor(self.como_devolucion)["ganancia"], places=6) def test_el_coste_baja_justo_lo_devuelto(self): self.assertAlmostEqual(self.hoja(self.como_dividendo)["coste"] - self.hoja(self.como_devolucion)["coste"], DEVUELTO, places=6) def test_el_precio_medio_baja(self): self.assertLess(self.hoja(self.como_devolucion)["precio_medio"], self.hoja(self.como_dividendo)["precio_medio"]) def test_la_ganancia_de_la_tabla_sube_lo_que_baja_el_coste(self): self.assertAlmostEqual(self.hoja(self.como_devolucion)["ganancia"] - self.hoja(self.como_dividendo)["ganancia"], DEVUELTO, places=6) def test_el_roi_sube_porque_has_puesto_menos(self): self.assertGreater(self.motor(self.como_devolucion)["roi"], self.motor(self.como_dividendo)["roi"]) def test_no_toca_el_twr(self): """Como el Gasto y el Dividendo: no marca precio, no es rentabilidad.""" self.assertAlmostEqual(self.motor(self.como_dividendo)["twr"], self.motor(self.como_devolucion)["twr"], places=9) def test_los_globales_cuadran_igual(self): for clave in ("irr", "twr", "valor_total"): self.assertAlmostEqual(self.como_dividendo["motor"]["global"][clave], self.como_devolucion["motor"]["global"][clave], places=6) # El ROI global sí sube: el denominador es lo puesto, y ahora es menor self.assertGreater(self.como_devolucion["motor"]["global"]["roi"], self.como_dividendo["motor"]["global"]["roi"]) class TestEnLaPantalla(unittest.TestCase): def test_deja_de_contar_como_dividendo(self): con = resumen(cartera("Dividendo"), HOY) sin = resumen(cartera("Devolucion"), HOY) fila = lambda r: next(f for f in r["abiertos"] if f["id"] == "C1") # noqa: E731 self.assertAlmostEqual(fila(con)["dividendos"], DEVUELTO) self.assertAlmostEqual(fila(sin)["dividendos"], 0.0) self.assertAlmostEqual(con["global"]["dividendos"] - sin["global"]["dividendos"], DEVUELTO) def test_el_valor_de_la_cartera_no_se_mueve(self): self.assertAlmostEqual(resumen(cartera("Dividendo"), HOY)["global"]["valor"], resumen(cartera("Devolucion"), HOY)["global"]["valor"]) class TestEnLaActividad(unittest.TestCase): """La tarjeta de minería ya trataba las devoluciones como menor coste: lo que cambia es que ahora lo dice el tipo de operación, no una convención.""" def ficha(self, tipo): return actividades.resumen(cartera(tipo), HOY)[0] def test_lo_puesto_baja(self): self.assertAlmostEqual(self.ficha("Dividendo")["invertido"] - self.ficha("Devolucion")["invertido"], DEVUELTO, places=6) def test_el_neto_y_el_coste_por_bitcoin_no_cambian(self): a, b = self.ficha("Dividendo"), self.ficha("Devolucion") self.assertAlmostEqual(a["invertido"] - a["recuperado"], b["invertido"] - b["recuperado"], places=6) self.assertAlmostEqual(a["coste_unidad"], b["coste_unidad"], places=6) self.assertAlmostEqual(a["ganancia"], b["ganancia"], places=6) class TestGuardarla(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) c = Cartera(Path(self.tmp.name)) self.addCleanup(c.cerrar) c.datos = copy.deepcopy(cartera("Devolucion")) c.datos.movimientos = [m for m in c.datos.movimientos if m["id"] != "d1"] c._guardar() self.c = c def test_pide_el_importe(self): with self.assertRaises(ErrorOperacion) as e: self.c.guardar_movimiento({"activo": "C1", "tipo": "Devolucion", "fecha": "2026-03-01"}) self.assertIn("devuelto", str(e.exception)) def test_se_guarda_sin_cantidad_ni_precio(self): self.c.guardar_movimiento({"activo": "C1", "tipo": "Devolucion", "fecha": "2026-03-01", "importe": 1000, "cantidad": 3, "precio": 9, "estimado": True, "actividad": MINERIA}) m = next(m for m in self.c.datos.vivos("movimientos") if m["tipo"] == "Devolucion") self.assertEqual((m["cantidad"], m["precio"], m["importe"]), (None, None, 1000.0)) self.assertTrue(m["estimado"]) # provisional hasta saber el importe definitivo self.assertEqual(m["actividad"], MINERIA) class TestCuentame(unittest.TestCase): def interpretar(self, frase, datos): return interpretar(frase, datos, HOY)[0] def test_devolucion_del_hosting(self): r = self.interpretar("me han devuelto 200 € del hosting", cartera("Devolucion")) self.assertEqual(r["movimiento"]["tipo"], "Devolucion") self.assertEqual(r["movimiento"]["importe"], 200.0) self.assertIsNone(r["movimiento"]["cantidad"]) def test_devolucion_de_los_aparatos(self): r = self.interpretar("me devolvieron 1.000 euros de los asics", cartera("Devolucion")) self.assertEqual(r["movimiento"]["tipo"], "Devolucion") self.assertEqual(r["movimiento"]["importe"], 1000.0) def test_un_cobro_cualquiera_sigue_siendo_un_reembolso(self): datos = cartera("Devolucion") datos.activos.append({"id": "F1", "nombre": "Un fondo", "tipo": "Fondo", "codigo": "ES0000000000", "unidad": "participaciones", "estado": "abierta", "fecha_inicio": "2025-01-01"}) datos.movimientos.append({"id": "f1", "fecha": "2025-02-01", "activo": "F1", "tipo": "Compra", "cantidad": 100.0, "precio": 10.0, "importe": 1000.0}) datos.precios.append({"fecha": "2026-09-19", "activo": "F1", "precio": 12.0, "fuente": "prueba"}) r = self.interpretar("me han devuelto 500 € de Un fondo", datos) self.assertEqual(r["movimiento"]["tipo"], "Reembolso") def test_pagar_el_hosting_sigue_siendo_un_gasto(self): """El patrón nuevo exige «devuelto»: no puede comerse los gastos.""" r = self.interpretar("he pagado 70 € de hosting", cartera("Devolucion")) self.assertEqual(r["movimiento"]["tipo"], "Gasto") if __name__ == "__main__": unittest.main()