""" 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