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.
212 lines
10 KiB
Python
212 lines
10 KiB
Python
"""
|
||
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)
|