""" 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)