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

119 lines
4.2 KiB
Python

"""
lugares.py — Dónde tienes cada cosa dentro de un mismo activo.
Tu bitcoin es UNA bolsa para Hacienda y para la rentabilidad, esté donde esté.
Dónde está (un exchange, un monedero, un nodo Lightning…) es un «lugar» del
activo, no otro activo:
• cada operación puede llevar su lugar («Compra · un exchange»);
• mover entre lugares tuyos es un «Traslado»: una sola línea, origen →
destino, que no cambia ninguna cifra ni es fiscal. Su comisión es un
«Comision» aparte en el lugar de origen (esa sí baja la cantidad);
• el saldo de cada lugar sirve para cuadrarlo con el nodo, con el exchange o
con la app del nodo Lightning.
Un traslado puede tener un extremo sin saber todavía ("" en lugar o en
lugar_destino): lo deja así el extracto de un exchange (la retirada sabe de
dónde sale, no a qué monedero va) y lo completa el historial de bitcoin al ver
llegar esa cantidad a uno de tus monederos.
"""
from __future__ import annotations
import re
import unicodedata
from .calculo import ENTRADAS_CANTIDAD, SALIDAS_CANTIDAD
SIN_LUGAR = ""
NOMBRES_CLASE = {"monedero": "Monedero", "exchange": "Exchange", "lightning": "Nodo Lightning", "otro": "Otro"}
def _f(v) -> float:
try:
return float(v or 0)
except (TypeError, ValueError):
return 0.0
def clave(texto: str) -> str:
"""Nombre sin tildes ni mayúsculas, para comparar."""
t = unicodedata.normalize("NFD", (texto or "").lower())
return "".join(c for c in t if unicodedata.category(c) != "Mn").strip()
def de(activo: dict) -> list[dict]:
return list(activo.get("lugares") or [])
def por_id(activo: dict) -> dict[str, dict]:
return {x["id"]: x for x in de(activo)}
def buscar(activo: dict, nombre: str) -> dict | None:
k = clave(nombre)
return next((x for x in de(activo) if clave(x["nombre"]) == k), None)
def buscar_exchange(activo: dict, exchange: str) -> dict | None:
"""El lugar de un exchange aunque se llame «BTC en Binance» o «Binance BTC»."""
ya = buscar(activo, exchange)
if ya:
return ya
k = clave(exchange)
for x in de(activo):
palabras = re.sub(r"[^a-z0-9]+", " ", clave(x["nombre"])).split()
resto = [p for p in palabras if p not in ("btc", "bitcoin", "en", "de", "in")]
if resto and " ".join(resto) == re.sub(r"[^a-z0-9]+", " ", k).strip():
return x
return None
def nuevo_id(nombre: str, existentes) -> str:
base = re.sub(r"[^a-z0-9]+", "-", clave(nombre)).strip("-")[:24] or "lugar"
ids, i, lid = set(existentes), 2, "l-" + base
while lid in ids:
lid, i = f"l-{base}-{i}", i + 1
return lid
def nombre(activo: dict, lid: str | None) -> str:
if not lid:
return "sin lugar"
x = por_id(activo).get(lid)
return x["nombre"] if x else lid
def saldos(movs: list[dict], activo: str, hasta: str | None = None) -> dict[str, float]:
"""Cantidad en cada lugar (clave "" = sin lugar). hasta: fecha AAAA-MM-DD incluida."""
s: dict[str, float] = {}
def suma(lid, q):
s[lid or SIN_LUGAR] = s.get(lid or SIN_LUGAR, 0.0) + q
for m in movs:
if m.get("borrado") or m["activo"] != activo or (hasta and m["fecha"] > hasta):
continue
q, t = _f(m.get("cantidad")), m["tipo"]
if t == "Traslado":
suma(m.get("lugar"), -q)
suma(m.get("lugar_destino"), q)
elif t in ENTRADAS_CANTIDAD:
suma(m.get("lugar"), q)
elif t in SALIDAS_CANTIDAD:
suma(m.get("lugar"), -q)
# 9 decimales: cabe el medio satoshi de la mitad de un monedero compartido
return {k: round(v, 9) for k, v in s.items() if abs(v) > 1e-12}
def activo_bitcoin(activos: list[dict], movs: list[dict]) -> dict | None:
"""El activo donde va el bitcoin: el Cripto «bitcoin» con más operaciones."""
candidatos = [a for a in activos if a["tipo"] == "Cripto" and (a.get("codigo") or "").lower() == "bitcoin"
and a.get("estado", "abierta") != "cerrada"]
if not candidatos:
return None
cuenta = {a["id"]: 0 for a in candidatos}
for m in movs:
if m["activo"] in cuenta:
cuenta[m["activo"]] += 1
return max(candidatos, key=lambda a: (cuenta[a["id"]], a.get("nombre") == "Bitcoin"))