caudal/programa/pruebas/test_devolucion.py
2026-10-13 08:01:06 -05:00

199 lines
9.3 KiB
Python

"""
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()