194 lines
9.2 KiB
Python
194 lines
9.2 KiB
Python
"""
|
||
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
|