caudal/programa/cartera/deuda.py
2026-10-13 08:01:06 -05:00

207 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
deuda.py — Préstamos recibidos (deudas) con cuota constante: sistema francés.
El préstamo se describe desde un punto de partida (lo que dice el banco en
una fecha) y el cuadro se calcula, no se guarda cuota a cuota:
activo["deuda"] = {
"capital": capital pendiente en la fecha de partida,
"tin": tipo nominal anual en % (fijo),
"cuotas": cuotas que quedan por pagar desde esa fecha,
"primera_cuota": fecha de la próxima cuota (AAAA-MM-DD),
}
fecha_inicio del activo = fecha de partida.
Sobre ese punto de partida actúan, por orden de fecha:
· movimientos «Amortizacion»: pago anticipado de capital (importe) con
modalidad «plazo» (misma cuota, menos cuotas) o «cuota» (mismas cuotas,
cuota menor). Si el importe cubre todo, el préstamo queda cancelado.
· precios con «precio» = capital pendiente según el banco en esa fecha:
corrigen las pequeñas diferencias de redondeo o de cómputo de días.
Cada cuota: intereses = pendiente × TIN/12 (redondeado al céntimo),
capital = cuota − intereses. La última salda lo que quede. Una amortización
a mitad de mes reduce el capital sobre el que se calculan los intereses de la
cuota siguiente; si el banco cuenta los días de otra forma, la diferencia se
corrige con el saldo del banco.
"""
from __future__ import annotations
import calendar
import datetime as dt
import math
MODALIDADES = ("plazo", "cuota")
def _fecha(s) -> dt.date:
return s if isinstance(s, dt.date) else dt.date.fromisoformat(s)
def _r(x: float) -> float:
return round(x + 0.0, 2)
def sumar_meses(f: dt.date, meses: int, dia: int) -> dt.date:
m = f.month - 1 + meses
y, m = f.year + m // 12, m % 12 + 1
return dt.date(y, m, min(dia, calendar.monthrange(y, m)[1]))
def cuota_francesa(capital: float, tin: float, cuotas: int) -> float:
if cuotas <= 0:
return 0.0
i = tin / 100 / 12
if i == 0:
return _r(capital / cuotas)
return _r(capital * i / (1 - (1 + i) ** -cuotas))
def cuotas_para(capital: float, tin: float, cuota: float) -> int:
"""Cuotas que hacen falta para devolver el capital con esa cuota."""
if capital <= 0.005:
return 0
i = tin / 100 / 12
if i == 0:
return math.ceil(capital / cuota - 1e-9)
if cuota <= capital * i:
raise ValueError("La cuota no cubre ni los intereses.")
return math.ceil(-math.log(1 - capital * i / cuota) / math.log(1 + i) - 1e-9)
def parametros(activo: dict) -> dict:
d = activo.get("deuda") or {}
return {"capital": float(d["capital"]), "tin": float(d["tin"]), "cuotas": int(d["cuotas"]),
"primera": _fecha(d["primera_cuota"]), "inicio": _fecha(activo["fecha_inicio"])}
def cuadro(activo: dict, movimientos: list[dict], saldos: list[dict], extra: list[dict] = ()) -> dict:
"""Cuadro completo: cuotas pasadas y futuras con los eventos aplicados.
movimientos: los del activo (se usan los «Amortizacion» vivos).
saldos: [{fecha, precio}] capital pendiente según el banco.
extra: amortizaciones hipotéticas (para simular), mismo formato."""
p = parametros(activo)
tin, i = p["tin"], p["tin"] / 100 / 12
dia = p["primera"].day
eventos = []
for m in list(movimientos) + list(extra):
if m.get("tipo") == "Amortizacion" and not m.get("borrado"):
eventos.append((_fecha(m["fecha"]), 0, "amortizacion", m))
for s in saldos:
if s.get("precio") is not None and not s.get("borrado") and _fecha(s["fecha"]) > p["inicio"]:
eventos.append((_fecha(s["fecha"]), 1, "saldo", s))
eventos.sort(key=lambda e: (e[0], e[1]))
pendiente, restantes = p["capital"], p["cuotas"]
cuota = cuota_francesa(pendiente, tin, restantes)
filas, aplicados, n = [], [], 0
k = 0
while restantes > 0 and pendiente > 0.005:
vence = sumar_meses(p["primera"], n, dia)
# Eventos anteriores o del mismo día que la cuota se aplican antes (el
# saldo del banco de ese día ya suele incluir la cuota: va después)
while k < len(eventos) and (eventos[k][0] < vence or (eventos[k][0] == vence and eventos[k][2] == "amortizacion")):
pendiente, cuota, restantes = _aplicar(eventos[k], pendiente, cuota, restantes, tin, aplicados)
k += 1
if restantes <= 0 or pendiente <= 0.005:
break
intereses = _r(pendiente * i)
amortizado = _r(cuota - intereses)
if restantes == 1 or amortizado >= pendiente:
amortizado = _r(pendiente)
pendiente = _r(pendiente - amortizado)
restantes -= 1
filas.append({"fecha": vence.isoformat(), "cuota": _r(intereses + amortizado), "intereses": intereses,
"capital": amortizado, "pendiente": pendiente})
n += 1
while k < len(eventos) and eventos[k][0] == vence:
pendiente, cuota, restantes = _aplicar(eventos[k], pendiente, cuota, restantes, tin, aplicados)
k += 1
while k < len(eventos): # eventos tras la última cuota (p. ej. cancelación)
pendiente, cuota, restantes = _aplicar(eventos[k], pendiente, cuota, restantes, tin, aplicados)
k += 1
return {"filas": filas, "eventos": aplicados}
def _aplicar(evento, pendiente, cuota, restantes, tin, aplicados):
fecha, _, clase, r = evento
antes = pendiente
if clase == "saldo":
pendiente = _r(r["precio"])
aplicados.append({"fecha": fecha.isoformat(), "tipo": "saldo", "pendiente": pendiente,
"diferencia": _r(pendiente - antes)})
if pendiente <= 0.005:
restantes = 0
return pendiente, cuota, restantes
importe = min(float(r.get("importe") or 0), pendiente)
pendiente = _r(pendiente - importe)
modalidad = r.get("modalidad") if r.get("modalidad") in MODALIDADES else "plazo"
if pendiente <= 0.005:
restantes, pendiente = 0, 0.0
elif modalidad == "cuota":
cuota = cuota_francesa(pendiente, tin, restantes)
else:
restantes = min(restantes, cuotas_para(pendiente, tin, cuota))
aplicados.append({"fecha": fecha.isoformat(), "tipo": "amortizacion", "importe": _r(importe),
"modalidad": modalidad, "id": r.get("id"), "pendiente": pendiente,
"cuota": cuota if restantes else 0.0, "cuotas": restantes})
return pendiente, cuota, restantes
def estado(activo: dict, movimientos: list[dict], saldos: list[dict], hoy: dt.date) -> dict:
"""Situación del préstamo en una fecha."""
c = cuadro(activo, movimientos, saldos)
p = parametros(activo)
pagadas = [f for f in c["filas"] if _fecha(f["fecha"]) <= hoy]
futuras = [f for f in c["filas"] if _fecha(f["fecha"]) > hoy]
# Capital pendiente hoy: el último punto de la línea de tiempo, con el mismo
# orden que el cuadro (amortización del día → cuota → saldo del banco)
# (a igualdad de fecha y clase gana el último aplicado: dos amortizaciones el mismo día)
linea = [(p["inicio"], -1, 0, p["capital"])]
linea += [(_fecha(f["fecha"]), 1, 0, f["pendiente"]) for f in c["filas"]]
linea += [(_fecha(e["fecha"]), 0 if e["tipo"] == "amortizacion" else 2, n, e["pendiente"])
for n, e in enumerate(c["eventos"])]
pendiente = max(x for x in linea if x[0] <= hoy)[3]
amortizado_extra = sum(e["importe"] for e in c["eventos"]
if e["tipo"] == "amortizacion" and _fecha(e["fecha"]) <= hoy)
año = str(hoy.year)
return {
"pendiente": _r(pendiente),
"cuota": futuras[0]["cuota"] if futuras else 0.0,
"proxima": futuras[0]["fecha"] if futuras else None,
"cuotas_restantes": len(futuras),
"fin": c["filas"][-1]["fecha"] if c["filas"] else None,
"intereses_pagados": _r(sum(f["intereses"] for f in pagadas)),
"intereses_año": _r(sum(f["intereses"] for f in pagadas if f["fecha"][:4] == año)),
"intereses_pendientes": _r(sum(f["intereses"] for f in futuras)),
"amortizado_anticipado": _r(amortizado_extra),
"cancelado": pendiente <= 0.005,
"futuras": futuras,
"pagadas": pagadas,
"eventos": c["eventos"],
}
def simular(activo: dict, movimientos: list[dict], saldos: list[dict], fecha, importe: float) -> dict:
"""Qué pasa si amortizas «importe» en «fecha» con cada modalidad."""
f = _fecha(fecha)
base = cuadro(activo, movimientos, saldos)
desde = [x for x in base["filas"] if _fecha(x["fecha"]) >= f]
intereses_base = sum(x["intereses"] for x in desde)
res = {}
for modalidad in MODALIDADES:
extra = [{"tipo": "Amortizacion", "fecha": f.isoformat(), "importe": importe, "modalidad": modalidad}]
c = cuadro(activo, movimientos, saldos, extra)
filas = [x for x in c["filas"] if _fecha(x["fecha"]) >= f]
res[modalidad] = {
"ahorro_intereses": _r(intereses_base - sum(x["intereses"] for x in filas)),
"cuota": filas[0]["cuota"] if filas else 0.0,
"fin": filas[-1]["fecha"] if filas else None,
"cuotas": len(filas),
}
res["actual"] = {"cuota": desde[0]["cuota"] if desde else 0.0, "fin": desde[-1]["fecha"] if desde else None,
"cuotas": len(desde), "intereses": _r(intereses_base)}
return res