caudal/programa/cartera/rentafija.py
esetheyers 7e18576f86 Caudal 0.2.0
Taxes for the whole of Spain (common territory, Navarre, Álava, Bizkaia and Gipuzkoa); flats, garages and land valued on their own; Treasury bills, bonds and deposits priced on BME; broker accounts; a guided start; a page per investment with its chart and the tax of selling today; net worth at the top; undo when deleting; Woodpecker CI for the tests and the Linux build.
2026-10-15 16:49:15 -05:00

212 lines
10 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.

"""
rentafija.py — Depósitos a plazo, Letras del Tesoro y bonos u obligaciones.
Un activo de tipo «RentaFija» se sigue por su valor total y lleva su ficha en
activo["renta_fija"]:
clase deposito, letra o bono
nominal lo que se cobra al vencer (en un depósito, el capital)
interes el tipo de interés nominal anual, en % (en una Letra no hay:
se compra por debajo del nominal)
vencimiento la fecha en que se devuelve
pagos cada cuánto paga intereses o cupones: 0 = todo al vencer,
1 = anual, 2 = semestral, 4 = trimestral, 12 = mensual
emisor quién lo emite (el Tesoro, un banco, una empresa)
Lo que se apunta: la compra (lo pagado, con comisiones; la cantidad es el
nominal comprado), los cupones o intereses cobrados (Dividendo) y la
devolución al vencer o la venta (Reembolso).
El valor no hace falta apuntarlo: cada fin de mes se estima lo que vale
hoy, que va de lo pagado al nominal en línea recta hasta el vencimiento,
más los intereses ganados desde el último pago. Se guardan como precios con
la fuente «devengo», igual que los inmuebles con su índice del INE.
"""
from __future__ import annotations
import datetime as dt
CLASES = {"deposito": "Depósito a plazo", "letra": "Letra del Tesoro", "bono": "Bono u obligación"}
PAGOS = {0: "Todo al vencimiento", 1: "Una vez al año", 2: "Cada seis meses", 4: "Cada tres meses", 12: "Cada mes"}
FUENTE = "devengo"
def _f(v) -> float:
try:
return float(v or 0)
except (TypeError, ValueError):
return 0.0
def _fin_de_mes(d: dt.date) -> dt.date:
siguiente = dt.date(d.year + (d.month == 12), d.month % 12 + 1, 1)
return siguiente - dt.timedelta(days=1)
def _meses_antes(d: dt.date, meses: int) -> dt.date:
total = d.year * 12 + d.month - 1 - meses
año, mes = divmod(total, 12)
dia = min(d.day, _fin_de_mes(dt.date(año, mes + 1, 1)).day)
return dt.date(año, mes + 1, dia)
def ultimo_pago(inicio: dt.date, vencimiento: dt.date, pagos: int, dia: dt.date) -> dt.date:
"""La fecha del último cupón antes de «dia», contando hacia atrás desde el vencimiento."""
if not pagos:
return inicio
paso, n, fecha = 12 // pagos, 0, vencimiento
while fecha > dia:
n += 1
fecha = _meses_antes(vencimiento, paso * n)
return max(fecha, inicio)
def valor_en(a: dict, movs: list[dict], dia: dt.date) -> float | None:
"""Lo que vale ese día: lo pagado acercándose al nominal, más los intereses ganados."""
f = a.get("renta_fija") or {}
compras = sorted((m for m in movs if m["tipo"] == "Compra" and m["fecha"] <= dia.isoformat()), key=lambda m: m["fecha"])
if not compras or not f.get("vencimiento"):
return None
inicio = dt.date.fromisoformat(compras[0]["fecha"])
venc = dt.date.fromisoformat(f["vencimiento"])
pagado = sum(_f(m.get("importe")) for m in compras)
nominal = _f(f.get("nominal")) or sum(_f(m.get("cantidad")) for m in compras) or pagado
devuelto = sum(_f(m.get("cantidad")) for m in movs if m["tipo"] == "Reembolso" and m["fecha"] <= dia.isoformat())
if devuelto and nominal:
if devuelto >= nominal - 0.005:
return 0.0
pagado, nominal = pagado * (1 - devuelto / nominal), nominal - devuelto
total = max((venc - inicio).days, 1)
hecho = min(max((dia - inicio).days, 0), total)
valor = pagado + (nominal - pagado) * hecho / total
interes = _f(f.get("interes"))
if interes and f.get("clase") != "letra":
desde = ultimo_pago(inicio, venc, int(f.get("pagos") or 0), min(dia, venc))
valor += nominal * interes / 100 * max((min(dia, venc) - desde).days, 0) / 365
return round(valor, 2)
def estimaciones(a: dict, movs: list[dict], hoy: dt.date) -> list[dict]:
"""El valor de cada fin de mes desde la compra hasta el vencimiento, el
reembolso total o hoy: [{fecha, valor_total}]."""
if a.get("tipo") != "RentaFija":
return []
compras = sorted(m["fecha"] for m in movs if m["tipo"] == "Compra")
f = a.get("renta_fija") or {}
if not compras or not f.get("vencimiento"):
return []
fin = min(hoy, dt.date.fromisoformat(f["vencimiento"]))
salida, dia = [], _fin_de_mes(dt.date.fromisoformat(compras[0]))
while dia <= fin:
v = valor_en(a, movs, dia)
if v is not None:
salida.append({"fecha": dia.isoformat(), "valor_total": v})
if v == 0.0:
break
dia = _fin_de_mes(dia + dt.timedelta(days=1))
return salida
def coste_vivo(movs: list[dict], hasta: str, nominal_ficha: float = 0.0) -> tuple[float, float]:
"""(coste, nominal) de lo que queda sin devolver antes de esa fecha. Si las
compras no llevan el nominal (cantidad), se reparte el de la ficha entre
ellas en proporción a lo pagado."""
compras = [m for m in movs if m["tipo"] == "Compra"]
pagado = sum(_f(m.get("importe")) for m in compras)
sin_cantidad = nominal_ficha and not any(_f(m.get("cantidad")) for m in compras)
coste = nominal = 0.0
for m in sorted(movs, key=lambda m: (m["fecha"], m.get("orden") or 0)):
if m["fecha"] >= hasta:
break
if m["tipo"] == "Compra":
coste += _f(m.get("importe"))
nominal += (nominal_ficha * _f(m.get("importe")) / pagado if sin_cantidad and pagado
else _f(m.get("cantidad")) or _f(m.get("importe")))
elif m["tipo"] == "Reembolso" and nominal > 0:
parte = min((_f(m.get("cantidad")) or nominal) / nominal, 1.0)
coste, nominal = coste * (1 - parte), nominal * (1 - parte)
return coste, nominal
def rendimiento_reembolso(m: dict, movs: list[dict], nominal_ficha: float = 0.0) -> float:
"""Lo que tributa al devolverse o venderse: lo cobrado menos lo que costó esa parte."""
coste, nominal = coste_vivo(movs, m["fecha"], nominal_ficha)
if nominal <= 0:
return round(_f(m.get("importe")), 2)
parte = min((_f(m.get("cantidad")) or nominal) / nominal, 1.0)
return round(_f(m.get("importe")) + _f(m.get("retencion")) - coste * parte, 2)
RETENCION = 19.0 # % que retiene el banco a cuenta del impuesto (las Letras no llevan)
def calendario(a: dict, movs: list[dict], hoy: dt.date) -> list[dict]:
"""Los cobros que tocan hasta hoy según la ficha: cada cupón o los intereses
del depósito y, al vencer, la devolución del nominal. Con id fijo (como las
operaciones automáticas), marcados como estimados para confirmarlos con el
extracto. Se para si ya se vendió todo, y no repite lo que el usuario ya
apuntó a mano por esas fechas."""
f = a.get("renta_fija") or {}
compras = sorted((m for m in movs if m["tipo"] == "Compra"), key=lambda m: m["fecha"])
if a.get("tipo") != "RentaFija" or not compras or not f.get("vencimiento"):
return []
inicio, venc = dt.date.fromisoformat(compras[0]["fecha"]), dt.date.fromisoformat(f["vencimiento"])
interes, pagos = _f(f.get("interes")), int(f.get("pagos") or 0)
propios = [m for m in movs if not str(m.get("id", "")).startswith(f"rf-{a['id']}-")]
def ya_apuntado(tipo: str, dia: dt.date) -> bool:
return any(m["tipo"] == tipo and abs((dt.date.fromisoformat(m["fecha"]) - dia).days) <= 7 for m in propios)
def vivo(dia: dt.date) -> float:
return coste_vivo(propios, (dia + dt.timedelta(days=1)).isoformat(), _f(f.get("nominal")))[1]
def cobro(dia: dt.date, bruto: float, nota: str) -> dict:
retencion = round(bruto * RETENCION / 100, 2)
return {"id": f"rf-{a['id']}-{dia.isoformat()}-cupon", "fecha": dia.isoformat(), "activo": a["id"], "tipo": "Dividendo",
"importe": round(bruto - retencion, 2), "retencion": retencion, "cantidad": None, "precio": None,
"estimado": True, "notas": f"{nota} (automático: confírmalo con el extracto)"}
salida = []
if interes and f.get("clase") != "letra":
if pagos:
fechas, n = [], 0
while True:
dia = _meses_antes(venc, 12 // pagos * n)
if dia <= inicio:
break
fechas.append(dia)
n += 1
for dia in sorted(fechas):
if dia <= min(hoy, venc) and vivo(dia) > 0.005 and not ya_apuntado("Dividendo", dia):
salida.append(cobro(dia, vivo(dia) * interes / 100 / pagos, "Cupón"))
elif venc <= hoy and vivo(venc) > 0.005 and not ya_apuntado("Dividendo", venc):
salida.append(cobro(venc, vivo(venc) * interes / 100 * (venc - inicio).days / 365, "Intereses al vencer"))
if venc <= hoy and vivo(venc) > 0.005 and not ya_apuntado("Reembolso", venc):
nominal = round(vivo(venc), 2)
salida.append({"id": f"rf-{a['id']}-{venc.isoformat()}-reembolso", "fecha": venc.isoformat(), "activo": a["id"],
"tipo": "Reembolso", "importe": nominal, "cantidad": nominal, "precio": None, "estimado": True,
"notas": "Devolución al vencer (automática: confírmala con el extracto)"})
return salida
FUENTE_MERCADO = "BME"
def valor_de_mercado(a: dict, movs: list[dict], dia: dt.date, precio_pct: float) -> float | None:
"""Lo que vale a precio de mercado: el nominal que queda × el precio (en %
del nominal, sin cupón) más el cupón corrido desde el último pago."""
f = a.get("renta_fija") or {}
compras = sorted((m for m in movs if m["tipo"] == "Compra" and m["fecha"] <= dia.isoformat()), key=lambda m: m["fecha"])
if not compras or not f.get("vencimiento"):
return None
nominal = coste_vivo(movs, (dia + dt.timedelta(days=1)).isoformat(), _f(f.get("nominal")))[1]
if nominal <= 0.005:
return None
valor = nominal * precio_pct / 100
interes = _f(f.get("interes"))
if interes and f.get("clase") != "letra":
inicio, venc = dt.date.fromisoformat(compras[0]["fecha"]), dt.date.fromisoformat(f["vencimiento"])
desde = ultimo_pago(inicio, venc, int(f.get("pagos") or 0), min(dia, venc))
valor += nominal * interes / 100 * max((min(dia, venc) - desde).days, 0) / 365
return round(valor, 2)