caudal/programa/cartera/calculo.py
esetheyers a240b7c672 Caudal 0.3.1
Fixed: an investment tracked by value kept its last value after being closed, which lowered the Weight column (it no longer added up to 100 %), made the Allocation chart disagree with it and inflated the overall return. The chart now says it includes your accounts and the Weight column explains its total. On laptops, the More menu stays on screen.
2026-10-18 16:27:54 -05:00

565 lines
24 KiB
Python

"""
calculo.py — Motor de cálculo de la cartera.
Estas cifras vienen de una hoja de cálculo que estuvo años en uso, y se
comprobaron una a una contra ella antes de sustituirla: `pruebas/cartera_ficticia/`
con `pruebas/cifras_esperadas.json` son esa vara de medir y siguen ahí. Alguna
rareza heredada se conserva a propósito, señalada donde aparece; se corregirán
de una en una y de forma explícita, nunca sin querer.
Tipos añadidos después (no alteran las cifras de referencia):
Gasto → euros sin cantidad: flujo de dinero invertido (TIR, ROI, ganancia
y coste fiscal); no participa en el TWR.
Devolucion → lo contrario de un Gasto: euros que vuelven porque se anula
parte de un gasto ya apuntado (una devolución del hosting). NO es
un cobro: en vez de sumar a lo recuperado, RESTA de lo invertido,
así que baja el coste del activo. La TIR y la ganancia total salen
igual que si fuera un cobro —el euro entra el mismo día—, pero el
coste medio baja y el ROI sube, que es lo que lo distingue de un
Dividendo. Tampoco entra en el TWR.
Mineria → cantidad recibida sin pagarla: suma cantidad (baja el coste medio),
no es flujo de dinero y no marca precio en el TWR.
Recompensa → como Mineria (bonificaciones del bróker, regalos): cantidad
gratis, todo su valor es ganancia.
Las cuentas (liquidez) no entran aquí: no tienen coste ni rentabilidad, solo
saldo. Se ven aparte, en vista.resumen().
Dividendo → dinero cobrado sin vender: cuenta como dinero recuperado (TIR, ROI,
ganancia total); no cambia cantidad ni coste; no entra en el TWR.
calcular(datos, fecha) devuelve dos bloques —los nombres vienen de la hoja de
cálculo original y se conservan porque son los de la vara de medir:
"motor": cómo ha ido la inversión (cantidad, precio, valor, ganancia por
flujos de dinero, TWR, CAGR, TIR, ROI, y los mismos totales).
"hoja": cómo está la posición hoy (coste fiscal, precio medio, ganancia
sobre ese coste, diferencia fiscal, peso, año y mes por activo).
"""
from __future__ import annotations
import bisect
import datetime as dt
from .datos import NO_INVERSION, Datos
from .inmuebles import es_propio
ENTRADAS = {"Compra", "Traspaso_Entrada"}
SALIDAS = {"Venta", "Traspaso_Salida", "Reembolso"} # flujos de dinero
SALIDAS_CANTIDAD = SALIDAS | {"Comision"} # restan participaciones
SIN_DESEMBOLSO = {"Mineria", "Recompensa"} # entra cantidad gratis
ENTRADAS_CANTIDAD = ENTRADAS | SIN_DESEMBOLSO # suman participaciones
COBROS = SALIDAS | {"Dividendo"} # dinero que vuelve
DESINVIERTE = {"Devolucion"} # dinero que vuelve restando coste
CON_TRANSACCION = ENTRADAS | SALIDAS | SIN_DESEMBOLSO | {"Gasto", "Dividendo"} | DESINVIERTE
def por_valor(a: dict) -> bool:
"""Activo del que solo se sabe el valor total (algunos planes de aseguradoras): se
apuntan las aportaciones en € y, de vez en cuando, el valor de la posición."""
return a.get("valoracion") == "total"
def flujo(t: dict) -> float:
"""Importe en € que mueve una transacción (positivo; el signo lo da el tipo)."""
if t.get("por_valor"):
return t["importe"]
if t["tipo"] in ("Gasto", "Dividendo") or t["tipo"] in DESINVIERTE:
return t["importe"]
if t["tipo"] in SIN_DESEMBOLSO:
return 0.0
return t["cantidad"] * t["precio"]
def invierte(t: dict) -> bool:
return t["tipo"] in ENTRADAS or t["tipo"] == "Gasto"
def _f(v) -> float:
"""Número o 0."""
return float(v) if v else 0.0
def _fecha(s: str) -> dt.date:
return dt.date.fromisoformat(s)
# ── Precios ──────────────────────────────────────────────────────────────────
class Precios:
"""Serie de precios por activo; consulta del último precio ≤ fecha."""
def __init__(self, registros: list[dict]):
self._series: dict[str, tuple[list[dt.date], list[dict]]] = {}
por_activo: dict[str, list[dict]] = {}
for r in registros:
por_activo.setdefault(r["activo"], []).append(r)
for aid, rs in por_activo.items():
rs.sort(key=lambda r: r["fecha"])
self._series[aid] = ([_fecha(r["fecha"]) for r in rs], rs)
def ultimo(self, activo: str, fecha: dt.date) -> dict | None:
serie = self._series.get(activo)
if not serie:
return None
i = bisect.bisect_right(serie[0], fecha)
return serie[1][i - 1] if i else None
def de(self, activo: str) -> list[dict]:
"""Todos los registros de un activo, por fecha."""
serie = self._series.get(activo)
return list(serie[1]) if serie else []
# ── Matemática financiera ───────────────────────────────────────────────────
def coste_restante(ms: list[dict]) -> tuple[float, float, float]:
"""(cantidad, coste fiscal, coste a valor de mercado) de lo que sigue en
cartera, con los movimientos de UN activo en orden.
Coste medio ponderado móvil: cada venta se lleva su parte proporcional del
coste, así que al venderlo todo el coste vuelve a cero y una compra
posterior empieza de nuevo. (Antes se promediaban todas las entradas de la
historia, también las ya vendidas.) La comisión resta cantidad sin restar
coste: el coste se reatribuye a lo que queda."""
cantidad = fiscal = mercado = 0.0
for m in ms:
t, c, importe = m["tipo"], _f(m.get("cantidad")), _f(m.get("importe"))
if t == "Compra":
cantidad += c
fiscal += importe
mercado += importe
elif t == "Gasto":
fiscal += importe
mercado += importe
elif t in DESINVIERTE:
fiscal -= importe
mercado -= importe
elif t in SIN_DESEMBOLSO:
cantidad += c
elif t == "Traspaso_Entrada":
cantidad += c
fiscal += m["coste_traspaso"] if (m.get("coste_traspaso") or 0) > 0 else importe
mercado += importe
elif t == "Comision":
cantidad -= c
elif t in SALIDAS:
if cantidad > 0:
parte = min(c / cantidad, 1.0)
fiscal -= fiscal * parte
mercado -= mercado * parte
cantidad -= c
return cantidad, fiscal, mercado
def _xnpv(rate: float, flujos: list[tuple]) -> float:
if rate <= -1:
return float("inf")
t0 = flujos[0][0]
return sum(cf / (1 + rate) ** ((d - t0).days / 365.0) for d, cf in flujos)
def xirr(flujos: list[tuple]) -> float:
"""TIR anualizada de flujos (fecha, importe). Bisección en [-0.999, 100]."""
if len(flujos) < 2:
return 0.0
if not (any(cf > 0 for _, cf in flujos) and any(cf < 0 for _, cf in flujos)):
return 0.0
flujos = sorted(flujos, key=lambda x: x[0])
lo, hi = -0.999, 100.0
f_lo, f_hi = _xnpv(lo, flujos), _xnpv(hi, flujos)
if f_lo * f_hi > 0:
return 0.0
for _ in range(200):
mid = (lo + hi) / 2
f_mid = _xnpv(mid, flujos)
if abs(f_mid) < 1e-7 or (hi - lo) < 1e-9:
return mid
if f_lo * f_mid < 0:
hi, f_hi = mid, f_mid
else:
lo, f_lo = mid, f_mid
return (lo + hi) / 2
def _pares_traspaso(txns: list[dict]) -> dict:
"""Empareja cada Traspaso_Entrada con el Traspaso_Salida del mismo día y
valor (tolerancia 1 % o 1 €). Clave (destino, fecha, valor) → origen."""
salidas: dict = {}
for t in txns:
if t["tipo"] == "Traspaso_Salida":
salidas.setdefault(t["fecha"], []).append(t)
pares = {}
for t in txns:
if t["tipo"] != "Traspaso_Entrada":
continue
val = t["cantidad"] * t["precio"]
candidatos = salidas.get(t["fecha"], [])
if not candidatos:
continue
mejor = min(candidatos, key=lambda s: abs(s["cantidad"] * s["precio"] - val))
if abs(mejor["cantidad"] * mejor["precio"] - val) <= max(val * 0.01, 1.0):
pares[(t["activo"], t["fecha"], round(val, 2))] = mejor["activo"]
return pares
def _cadena(activo: str, por_activo: dict, pares: dict, visitados: frozenset = frozenset()) -> list[dict]:
"""Movimientos del activo más la historia de los fondos de los que viene
por traspaso (hasta el día del traspaso)."""
if activo in visitados:
return []
visitados = visitados | {activo}
propios = por_activo.get(activo, [])
cadena = list(propios)
for t in propios:
if t["tipo"] != "Traspaso_Entrada":
continue
origen = pares.get((activo, t["fecha"], round(t["cantidad"] * t["precio"], 2)))
if not origen or origen in visitados:
continue
for s in _cadena(origen, por_activo, pares, visitados):
if s["fecha"] < t["fecha"] or (s["fecha"] == t["fecha"]
and s["tipo"] == "Traspaso_Salida"
and s["activo"] == origen):
cadena.append(s)
return sorted(cadena, key=lambda x: (x["fecha"], 0 if x["tipo"] == "Traspaso_Salida" else 1))
def _twr_cagr(cadena: list[dict], precio_actual: float, hoy: dt.date) -> tuple[float, float]:
if not cadena:
return 0.0, 0.0
tenencia, producto = 0.0, 1.0
nav_prev, tenencia_prev, fondo_prev = None, 0.0, None
cadena = [t for t in cadena if t["tipo"] not in ("Gasto", "Dividendo") and t["tipo"] not in DESINVIERTE]
if not cadena:
return 0.0, 0.0
for t in cadena:
fondo = t["activo"]
if t["tipo"] in SIN_DESEMBOLSO:
# Entra cantidad sin precio: no hay revalorización que medir
if fondo_prev is not None and fondo_prev != fondo:
tenencia = 0.0
nav_prev = None
tenencia += t["cantidad"]
tenencia_prev, fondo_prev = tenencia, fondo
continue
nav = t["precio"] or precio_actual or 1.0
if fondo_prev == fondo and nav_prev and tenencia_prev > 0 and nav_prev > 0:
producto *= (tenencia_prev * nav) / (tenencia_prev * nav_prev)
if fondo_prev is not None and fondo_prev != fondo:
tenencia = 0.0
if t["tipo"] in ENTRADAS:
tenencia += t["cantidad"]
elif t["tipo"] in SALIDAS:
tenencia -= t["cantidad"]
nav_prev, tenencia_prev, fondo_prev = nav, tenencia, fondo
if tenencia_prev > 0 and nav_prev and nav_prev > 0 and precio_actual > 0:
producto *= (tenencia_prev * precio_actual) / (tenencia_prev * nav_prev)
twr = producto - 1.0
dias = (hoy - cadena[0]["fecha"]).days
cagr = (1 + twr) ** (365.0 / dias) - 1 if dias > 0 and (1 + twr) > 0 else 0.0
return twr, cagr
def _tir_roi(txns: list[dict], valor: float, hoy: dt.date) -> tuple[float, float]:
flujos, invertido, recuperado = [], 0.0, 0.0
for t in txns:
importe = flujo(t)
if importe <= 0:
continue
if invierte(t):
flujos.append((t["fecha"], -importe))
invertido += importe
elif t["tipo"] in DESINVIERTE:
# Mismo flujo que un cobro para la TIR, pero resta de lo puesto:
# por eso baja el coste y sube el ROI (el denominador es menor)
flujos.append((t["fecha"], importe))
invertido -= importe
elif t["tipo"] in COBROS:
flujos.append((t["fecha"], importe))
recuperado += importe
if valor > 0:
flujos.append((hoy, valor))
tir = xirr(flujos)
roi = (valor + recuperado - invertido) / invertido if invertido - recuperado > 0 else 0.0
return tir, roi
# ── Precio actual por tipo de activo ─────────────────────────────────────────
def _precio_actual(activo: dict, movs: list[dict], precios: Precios, hoy: dt.date) -> float | None:
aid, tipo = activo["id"], activo["tipo"]
reg = precios.ultimo(aid, hoy)
if tipo == "CuentaTrading":
# Participaciones = movimientos con cantidad (las comisiones no cuentan)
partes = 0.0
for m in movs:
if not m.get("cantidad"):
continue
if m["tipo"] in ENTRADAS:
partes += m["cantidad"]
elif m["tipo"] in SALIDAS:
partes -= m["cantidad"]
if partes <= 0 or reg is None:
return None
if reg.get("valor_total"):
return reg["valor_total"] / partes if reg["valor_total"] > 0 else None
v = reg.get("precio")
if not v or v <= 0:
return None
return v / partes if v > 10 else v # > 10 = equity (heredado del viejo)
if tipo == "Prestamo":
devengado = _devengo_prestamo(activo, movs, hoy)
if devengado is not None:
return devengado
# Sin datos para calcularlo: el último precio guardado (como la hoja vieja)
v = reg.get("precio") if reg else None
if v and v > 0:
return v
if tipo == "Tierra":
# Sin precio: coste medio de sus movimientos
cant = sum(_f(m.get("cantidad")) for m in movs)
coste = sum(_f(m.get("cantidad")) * _f(m.get("precio")) for m in movs)
return coste / cant if cant > 0 else None
return None
def _devengo_prestamo(activo: dict, movs: list[dict], hoy: dt.date) -> float | None:
"""Valor del préstamo interpolando linealmente entre principal e importe final."""
p = activo.get("prestamo") or {}
inicio = activo.get("fecha_inicio")
if p.get("importe_final") is None or not p.get("vencimiento") or not inicio:
return None
principal = sum(_f(m.get("importe")) or _f(m.get("cantidad"))
for m in movs if m["tipo"] == "Compra")
if principal <= 0:
return None
inicio, fin = _fecha(inicio), _fecha(p["vencimiento"])
total = (fin - inicio).days
if total <= 0:
return None
transcurrido = max(0, min((hoy - inicio).days, total))
return principal + (p["importe_final"] - principal) * transcurrido / total
def _refs_por_valor(aid: str, precios: Precios, ene1: dt.date, mes1: dt.date) -> tuple:
def valor(fecha):
reg = precios.ultimo(aid, fecha)
return (reg or {}).get("valor_total") or (reg or {}).get("precio") if reg else None
return valor(ene1), valor(mes1)
def _precio_referencia(activo: dict, precios: Precios, fecha: dt.date, partes: float) -> float | None:
"""Precio de referencia (01/ENE, 01/MES): último ≤ fecha."""
reg = precios.ultimo(activo["id"], fecha)
if reg is None:
return None
if reg.get("valor_total"):
return reg["valor_total"] / partes if partes > 0 else None
return reg.get("precio")
# ── Cálculo principal ────────────────────────────────────────────────────────
def calcular(datos: Datos, hoy: dt.date) -> dict:
activos = sorted((a for a in datos.vivos("activos")
if a.get("visible", True) and a["tipo"] not in NO_INVERSION and not es_propio(a)),
key=lambda a: (a.get("orden") or 0, a["id"]))
# Un cobro marcado como impagado no es dinero recibido
movs = sorted((m for m in datos.vivos("movimientos") if not m.get("impagado")),
key=lambda m: (m["fecha"], m.get("orden") or 0, m["id"]))
# Nota: los movimientos con fecha futura cuentan (rareza heredada, a propósito).
precios = Precios([p for p in datos.vivos("precios")])
movs_de: dict[str, list[dict]] = {}
for m in movs:
movs_de.setdefault(m["activo"], []).append(m)
# Transacciones con flujo de dinero (todas, también de activos ocultos)
solo_valor = {a["id"] for a in datos.vivos("activos") if por_valor(a)}
txns = [{"fecha": _fecha(m["fecha"]), "activo": m["activo"], "tipo": m["tipo"],
"cantidad": _f(m.get("cantidad")), "precio": _f(m.get("precio")),
"importe": _f(m.get("importe")), "por_valor": m["activo"] in solo_valor}
for m in movs if m["tipo"] in CON_TRANSACCION]
txns_de: dict[str, list[dict]] = {}
for t in txns:
txns_de.setdefault(t["activo"], []).append(t)
pares = _pares_traspaso(txns)
ene1 = dt.date(hoy.year, 1, 1)
mes1 = dt.date(hoy.year, hoy.month, 1)
motor_activos, hoja_activos = {}, {}
flujos_globales, inv_total, rec_total = [], 0.0, 0.0
twr_val = [] # (twr, valor) para el TWR global ponderado
for a in activos:
aid, ms = a["id"], movs_de.get(a["id"], [])
solo = por_valor(a)
if solo:
reg = precios.ultimo(aid, hoy)
valor = (reg or {}).get("valor_total") or (reg or {}).get("precio") or 0.0
cantidad, precio, p = None, None, 0.0
else:
cantidad = sum(_f(m.get("cantidad")) * (1 if m["tipo"] in ENTRADAS_CANTIDAD else -1)
for m in ms if m["tipo"] in ENTRADAS_CANTIDAD | SALIDAS_CANTIDAD)
precio = _precio_actual(a, ms, precios, hoy)
p = precio or 0.0
valor = cantidad * p
if a.get("estado") == "cerrada":
# Cerrada no vale nada hoy, aunque su último valor apuntado no fuera 0 (al cerrar
# algo llevado por valor no hay participaciones que vender): si no, ese valor
# seguía sumando en los pesos, en el valor global y en la rentabilidad
valor = 0.0
twr, cagr = (0.0, 0.0) if solo else _twr_cagr(_cadena(aid, txns_de, pares), p, hoy)
propios = txns_de.get(aid, [])
tir, roi = _tir_roi(propios, valor, hoy)
inv = rec = 0.0
for t in propios:
importe = flujo(t)
if importe <= 0:
continue
if invierte(t):
flujos_globales.append((t["fecha"], -importe))
inv += importe
elif t["tipo"] in DESINVIERTE:
flujos_globales.append((t["fecha"], importe))
inv -= importe # una devolución no es un cobro: es menos puesto
else:
flujos_globales.append((t["fecha"], importe))
rec += importe
inv_total += inv
rec_total += rec
vivo = valor > 0
twr_val.append((twr if vivo else 0.0, valor))
motor_activos[aid] = {
"nombre": a.get("nombre", ""), "tipo": a["tipo"],
"cantidad": cantidad, "precio": p, "valor": valor,
"ganancia": (valor + rec - inv) if vivo or inv > 0 else 0.0,
"twr": twr if vivo else 0.0, "cagr": cagr if vivo else 0.0,
"irr": tir if vivo else 0.0, "roi": roi if vivo else None,
}
if solo:
# Sin participaciones: el coste es el dinero neto que sigue dentro
coste = max(inv - rec, 0.0)
hoja_activos[aid] = {
"cantidad": None, "coste": coste, "precio_medio": None, "precio": None, "valor": valor,
"ganancia": valor - coste, "ganancia_pct": (valor - coste) / coste if coste else 0.0,
"dif_fiscal": None, "_refs": _refs_por_valor(aid, precios, ene1, mes1), "_aportado": (inv, rec),
}
continue
# ── Hoja: coste fiscal y derivados (fórmulas de Posiciones) ──
_, coste, mercado = coste_restante(ms)
if abs(cantidad) <= 1e-9:
coste = mercado = 0.0
precio_hoja = precio if precio is not None else 0.0
dif = mercado - coste
ganancia_hoja = valor - coste
hoja_activos[aid] = {
"cantidad": cantidad,
"coste": coste,
"precio_medio": coste / cantidad if cantidad else 0.0,
"precio": precio_hoja,
"valor": valor,
"ganancia": ganancia_hoja,
"ganancia_pct": ganancia_hoja / coste if coste else 0.0,
"dif_fiscal": dif if dif is not None and abs(dif) >= 0.01 else None,
}
partes = cantidad
ref_ene = _precio_referencia(a, precios, ene1, partes)
ref_mes = _precio_referencia(a, precios, mes1, partes)
hoja_activos[aid]["_refs"] = (ref_ene, ref_mes)
# ── Globales del motor ──
valor_total = sum(v for _, v in twr_val)
twr_global = sum(t * v / valor_total for t, v in twr_val) if valor_total > 0 else 0.0
if txns:
dias = (hoy - min(t["fecha"] for t in txns)).days
cagr_global = (1 + twr_global) ** (365.0 / dias) - 1 if dias > 0 and (1 + twr_global) > 0 else 0.0
else:
cagr_global = 0.0
if valor_total > 0:
flujos_globales.append((hoy, valor_total))
tir_global = xirr(flujos_globales) if flujos_globales else 0.0
roi_global = (valor_total + rec_total - inv_total) / inv_total if inv_total > 0 else 0.0
ytd_w = mes_w = base = 0.0
for a in activos:
m = motor_activos[a["id"]]
h = hoja_activos[a["id"]]
if m["valor"] <= 0:
continue
ref_ene, ref_mes = h["_refs"]
if por_valor(a):
# Sin precio unitario: se descuenta lo aportado en el periodo
for ref, fecha_ref, acumulador in ((ref_ene, ene1, "ytd"), (ref_mes, mes1, "mes")):
if not ref or ref <= 0:
continue
neto = sum(flujo(t) * (-1 if invierte(t) else 1)
for t in txns_de.get(a["id"], []) if t["fecha"] >= fecha_ref)
variacion = (m["valor"] + neto) / ref - 1
if acumulador == "ytd":
ytd_w += variacion * m["valor"]
else:
mes_w += variacion * m["valor"]
base += m["valor"]
continue
if ref_ene and ref_ene > 0:
ytd_w += (m["precio"] / ref_ene - 1) * m["valor"]
if ref_mes and ref_mes > 0:
mes_w += (m["precio"] / ref_mes - 1) * m["valor"]
base += m["valor"]
# ── Hoja: pesos, YTD y mes por activo (fórmulas de Rentabilidad) ──
total_hoja = sum(h["valor"] for h in hoja_activos.values())
for aid, h in hoja_activos.items():
ref_ene, ref_mes = h.pop("_refs")
aportado = h.pop("_aportado", None)
h["peso"] = h["valor"] / total_hoja if total_hoja else 0.0
cero = h["valor"] == 0
h["rent_valor"] = h["valor"]
h["rent_ganancia"] = None if cero else h["ganancia"]
h["rent_ganancia_pct"] = None if cero else h["ganancia_pct"]
def variacion(referencia, desde):
"""Cuánto ha variado desde esa fecha, descontando lo aportado."""
if cero or not referencia:
return None
if aportado is None: # con participaciones: por precio
return h["precio"] / referencia - 1 if h["precio"] else None
neto = sum(flujo(t) * (-1 if invierte(t) else 1)
for t in txns_de.get(aid, []) if t["fecha"] >= desde)
return (h["valor"] + neto) / referencia - 1
h["rent_ytd"] = variacion(ref_ene, ene1)
h["rent_mes"] = variacion(ref_mes, mes1)
h["rent_peso"] = h["valor"] / total_hoja if total_hoja else 0.0
coste_total = sum(h["coste"] for h in hoja_activos.values())
return {
"fecha_calculo": hoy.isoformat(),
"motor": {
"activos": motor_activos,
"global": {
"twr": twr_global, "cagr": cagr_global, "irr": tir_global, "roi": roi_global,
"ytd": ytd_w / base if base > 0 else 0.0,
"mes": mes_w / base if base > 0 else 0.0,
"valor_total": valor_total,
},
},
"hoja": {
"activos": hoja_activos,
"global": {
"valor_total": total_hoja,
"coste_total": coste_total,
"ganancia": total_hoja - coste_total,
"ganancia_pct": (total_hoja - coste_total) / coste_total if coste_total else 0.0,
},
},
}