558 lines
24 KiB
Python
558 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
|
|
|
|
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),
|
|
key=lambda a: (a.get("orden") or 0, a["id"]))
|
|
movs = sorted(datos.vivos("movimientos"),
|
|
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
|
|
|
|
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,
|
|
},
|
|
},
|
|
}
|