199 lines
9.3 KiB
Python
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()
|