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.
565 lines
24 KiB
Python
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,
|
|
},
|
|
},
|
|
}
|