caudal/programa/cartera/actividades.py
2026-10-13 08:01:06 -05:00

194 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
actividades.py — La rentabilidad de una actividad concreta, sin sacarla del
activo al que pertenece.
El caso que la motiva es la minería: los mineros se pagan en euros (los aparatos
y el hosting de cada mes) y lo que producen son bitcoins que van al mismo sitio
que los comprados. En la cartera todo eso está bien donde está —el bitcoin es
uno solo y su coste incluye esos gastos—, pero así no hay forma de responder a
«¿me compensa minar?».
La solución es una etiqueta: cada operación puede llevar el nombre de una
actividad, y aquí se calculan las cifras usando SOLO las operaciones marcadas.
El cálculo general no mira esta etiqueta, de modo que tu total de bitcoin, su
coste y la rentabilidad global no cambian por esto.
Lo que sale:
invertido euros que han salido (los aparatos, el hosting de cada mes),
menos lo que te hayan devuelto de esos gastos
recuperado euros que han vuelto (devoluciones, ventas de lo producido)
producido cantidad recibida sin pagarla (el bitcoin minado)
valor lo que vale hoy lo que sigues teniendo de esta actividad
ganancia valor + recuperado − invertido
tir rentabilidad anual teniendo en cuenta cuándo salió cada euro
coste_unidad a cuánto te está saliendo cada bitcoin producido
meses gasto y producción de los últimos meses, para ver la tendencia
"""
from __future__ import annotations
import datetime as dt
from .calculo import (CON_TRANSACCION, DESINVIERTE, ENTRADAS_CANTIDAD, SALIDAS_CANTIDAD, SIN_DESEMBOLSO,
Precios, _f, _fecha, _precio_actual, _tir_roi, por_valor)
from .datos import Datos
MESES_SERIE = 6
def clave(nombre: str) -> str:
"""Para agrupar sin que «Minería» y «mineria» sean dos actividades."""
tabla = str.maketrans("áéíóúüñÁÉÍÓÚÜÑ", "aeiouunAEIOUUN")
return " ".join(str(nombre or "").translate(tabla).lower().split())
def nombres(datos: Datos) -> list[str]:
"""Las actividades que existen hoy, por orden alfabético."""
vistas: dict[str, str] = {}
for registros in (datos.vivos("movimientos"), datos.vivos("recurrentes")):
for r in registros:
if r.get("actividad"):
vistas.setdefault(clave(r["actividad"]), r["actividad"])
return sorted(vistas.values(), key=lambda n: clave(n))
def _mes(fecha: dt.date) -> str:
return f"{fecha.year:04d}-{fecha.month:02d}"
def _serie_meses(movs: list[dict], hoy: dt.date, cuantos: int) -> list[dict]:
"""Gasto y producción de los últimos meses, del más antiguo al más reciente."""
etiquetas = []
año, mes = hoy.year, hoy.month
for _ in range(cuantos):
etiquetas.append(f"{año:04d}-{mes:02d}")
mes -= 1
if mes == 0:
año, mes = año - 1, 12
etiquetas.reverse()
filas = {e: {"mes": e, "gasto": 0.0, "producido": 0.0, "cobrado": 0.0} for e in etiquetas}
for m in movs:
fila = filas.get(_mes(_fecha(m["fecha"])))
if fila is None:
continue
if m["tipo"] in ("Gasto", "Compra"):
fila["gasto"] += _f(m.get("importe"))
elif m["tipo"] in SIN_DESEMBOLSO:
fila["producido"] += _f(m.get("cantidad"))
elif m["tipo"] in ("Dividendo", "Venta", "Reembolso"):
fila["cobrado"] += _f(m.get("importe"))
return [filas[e] for e in etiquetas]
def resumen(datos: Datos, hoy: dt.date) -> list[dict]:
"""Una ficha por actividad, ordenadas por lo que tienes metido en ellas."""
movs = sorted((m for m in datos.vivos("movimientos") if m.get("actividad")),
key=lambda m: (m["fecha"], m.get("orden") or 0, m["id"]))
if not movs:
return []
activos = {a["id"]: a for a in datos.vivos("activos")}
movs_de: dict[str, list[dict]] = {}
for m in datos.vivos("movimientos"):
movs_de.setdefault(m["activo"], []).append(m)
precios = Precios(datos.vivos("precios"))
solo_valor = {aid for aid, a in activos.items() if por_valor(a)}
grupos: dict[str, list[dict]] = {}
etiquetas: dict[str, str] = {}
for m in movs:
k = clave(m["actividad"])
etiquetas.setdefault(k, m["actividad"])
grupos.setdefault(k, []).append(m)
fichas = []
for k, ms in grupos.items():
posiciones, produccion = [], []
valor_total = 0.0
for aid in sorted({m["activo"] for m in ms}):
activo = activos.get(aid)
if activo is None or aid in solo_valor:
continue
propios = [m for m in ms if m["activo"] == aid]
cantidad = sum(_f(m.get("cantidad")) * (1 if m["tipo"] in ENTRADAS_CANTIDAD else -1)
for m in propios if m["tipo"] in ENTRADAS_CANTIDAD | SALIDAS_CANTIDAD)
recibido = sum(_f(m.get("cantidad")) for m in propios if m["tipo"] in SIN_DESEMBOLSO)
precio = _precio_actual(activo, movs_de.get(aid, []), precios, hoy) or 0.0
valor = cantidad * precio
valor_total += valor
if cantidad or recibido:
posiciones.append({"activo": aid, "nombre": activo.get("nombre", aid),
"unidad": activo.get("unidad") or "unidades",
"cantidad": cantidad, "precio": precio, "valor": valor,
"recibido": recibido})
if recibido:
produccion.append({"activo": aid, "cantidad": recibido,
"unidad": activo.get("unidad") or "unidades"})
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 ms if m["tipo"] in CON_TRANSACCION]
tir, roi = _tir_roi(txns, valor_total, hoy)
invertido = sum(_f(m.get("importe")) for m in ms if m["tipo"] in ("Gasto", "Compra")) \
- sum(_f(m.get("importe")) for m in ms if m["tipo"] in DESINVIERTE)
recuperado = sum(_f(m.get("importe")) for m in ms
if m["tipo"] in ("Dividendo", "Venta", "Reembolso"))
neto = invertido - recuperado
total_producido = sum(p["cantidad"] for p in produccion)
fichas.append({
"nombre": etiquetas[k],
"clave": k,
"invertido": invertido,
"recuperado": recuperado,
"valor": valor_total,
"ganancia": valor_total + recuperado - invertido,
"rentabilidad": (valor_total + recuperado - invertido) / invertido if invertido else None,
"tir": tir,
"roi": roi,
"produccion": produccion,
# A cuánto sale cada unidad producida: el coste neto repartido entre lo producido
"coste_unidad": (neto / total_producido) if total_producido and neto > 0 else None,
"posiciones": posiciones,
"operaciones": len(ms),
"estimadas": sum(1 for m in ms if m.get("estimado")),
"desde": ms[0]["fecha"],
"meses": _serie_meses(ms, hoy, MESES_SERIE),
})
fichas.sort(key=lambda f: -f["invertido"])
return fichas
def candidatas(datos: Datos, actividad: str, hoy: dt.date) -> list[dict]:
"""Operaciones sin actividad que podrían ser de esta, para marcarlas de una vez.
Se proponen TODAS las operaciones de los activos que ya tienen algo marcado
(en la minería conviven gastos, minerías y devoluciones: filtrar también por
tipo dejaba fuera justo lo que falta por marcar), más las que vienen de una
operación automática ya etiquetada. Las que además coinciden en tipo con lo
ya marcado, o vienen de una automática, salen como «probables»: son las que
la pantalla propone marcadas.
"""
k = clave(actividad)
marcadas = [m for m in datos.vivos("movimientos") if clave(m.get("actividad") or "") == k]
activos = {m["activo"] for m in marcadas}
tipos = {m["tipo"] for m in marcadas}
recurrentes = {r["id"] for r in datos.vivos("recurrentes") if clave(r.get("actividad") or "") == k}
nombres_activo = {a["id"]: a.get("nombre", a["id"]) for a in datos.vivos("activos")}
salida = []
for m in datos.vivos("movimientos"):
if m.get("actividad"):
continue
# Las generadas por una automática ya etiquetada llevan su id en «recurrente»
de_recurrente = any(str(m.get("recurrente") or "").startswith(r) for r in recurrentes)
if not de_recurrente and m["activo"] not in activos:
continue
salida.append({"id": m["id"], "fecha": m["fecha"], "activo": m["activo"],
"nombre_activo": nombres_activo.get(m["activo"], m["activo"]),
"tipo": m["tipo"], "cantidad": m.get("cantidad"), "importe": m.get("importe"),
"notas": m.get("notas") or "", "de_recurrente": de_recurrente,
"probable": de_recurrente or m["tipo"] in tipos})
# Primero las probables, y dentro de cada grupo de la más reciente a la más antigua
salida.sort(key=lambda m: m["fecha"], reverse=True)
salida.sort(key=lambda m: not m["probable"])
return salida