119 lines
4.2 KiB
Python
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"))
|