207 lines
9.2 KiB
Python
207 lines
9.2 KiB
Python
"""
|
||
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
|