caudal/programa/cartera/datos.py
2026-11-01 05:56:12 -06:00

673 lines
34 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.

"""
datos.py — Los datos de la cartera en texto plano.
Ficheros JSON Lines (un objeto JSON por línea) en la carpeta de datos:
activos.jsonl un activo por línea
movimientos.jsonl una operación por línea
precios.jsonl un precio por línea (activo + fecha)
recurrentes.jsonl operaciones automáticas (hosting mensual, aportación al plan...)
version.json versión del formato: {"version": N} (si falta, es la 1)
Por qué así:
• Se leen con cualquier editor y git muestra cambios línea a línea.
• Cada registro tiene clave propia (id, o activo+fecha en precios) y fecha
de "modificado", así que juntar dos copias (USB y ordenador) es unir
líneas y, si una clave está en las dos, quedarse con la más reciente
(salvo las operaciones automáticas: ver fusionar).
Un borrado se guarda como "borrado": true para que la fusión no lo resucite.
• Los números se guardan como JSON (punto decimal, precisión completa):
no hay líos de coma/punto ni de redondeo.
Formato de cada registro (los campos opcionales pueden faltar o ser null):
activo: id, nombre, tipo, codigo, unidad, fecha_inicio, visible, orden,
valoracion ("unitaria" por defecto, o "total": solo se sigue el
valor de la posición, sin participaciones),
estado ("abierta" | "cerrada"), fecha_cierre, alias (otros nombres para el asistente),
prestamo {importe_final, vencimiento} (solo tipo Prestamo: dinero que prestas),
deuda {capital, tin, cuotas, primera_cuota} (solo tipo Deuda: préstamo
que te han concedido, cuota constante; ver deuda.py),
lugares [{id, nombre, clase}] (dónde lo tienes: exchanges,
monederos, nodos Lightning; clase monedero|exchange|lightning|otro),
modificado, borrado
movimiento: id, fecha, activo, tipo, cantidad, precio, importe, comisiones,
coste_traspaso, notas, orden, recurrente, estimado, pendiente,
importe_original, moneda, actividad, extracto, adjuntos, txid,
importacion, lugar, lugar_destino, traslado, automatica, modificado, borrado
· automatica: {nivel, modificado}, la pone la app al crear,
completar o afinar una operación automática (nivel 1 pendiente,
2 estimada, 3 exacta). Solo vale mientras su «modificado» sea el
de la operación: en cuanto el usuario la toca, deja de valer.
· lugar: id de uno de los lugares del activo (dónde ocurrió).
· Traslado: mover cantidad entre dos lugares tuyos del MISMO
activo (de un exchange a tu monedero). Una sola línea: lugar = origen,
lugar_destino = destino ("" = aún sin saber), cantidad = lo que
llega. NO cambia ninguna cifra de la cartera ni es fiscal: el
cálculo no lo mira. Su comisión es un «Comision» aparte, en el
lugar de origen, con traslado = id del traslado.
· extracto: de dónde viene la operación («btc:<txid>» si la trajo
el nodo de bitcoin, o la línea del extracto importado).
· txid: transacción de bitcoin ENLAZADA a una operación que ya
estaba apuntada (al importar el historial; no cambia cifras).
· importacion: marca de la importación del historial de bitcoin
que la creó, para poder deshacerla entera (historial_btc.py).
· adjuntos: justificantes, [{id, nombre, tipo, bytes}]; el
fichero está en adjuntos/<id> (ver adjuntos.py).
· actividad: nombre libre («Minería») que agrupa operaciones de
cualquier activo para poder ver su rentabilidad por separado
(ver actividades.py). El cálculo general NO la mira: marcar
operaciones no cambia ninguna cifra de la cartera.
· Gasto: euros sin cantidad (p. ej. hosting de mineros); cuenta
como dinero invertido en el activo.
· amortizable: en un Gasto o Devolucion, que es la compra de un
aparato de la actividad (un minero) y no un gasto del
mes. Solo lo mira el cálculo fiscal de la actividad
(fiscal/actividad.py): se resta poco a poco.
· Devolucion: euros que te devuelven de un gasto ya apuntado;
RESTAN dinero invertido (bajan el coste del activo),
no son un cobro. Espejo de Gasto.
· Mineria: cantidad recibida sin pagarla (BTC minado).
· Recompensa: acciones/participaciones recibidas gratis (bonificaciones del bróker, regalos); valor_recibido informativo.
· Dividendo: dinero cobrado en cuenta sin vender (dividendos,
cupones, intereses); importe neto y retención opcional.
· Amortizacion: pago anticipado de una Deuda (importe) con
modalidad "plazo" o "cuota".
recurrente: id, activo, tipo (Gasto | Compra | Dividendo), importe, moneda (EUR | USD),
dia (1-28), desde, hasta, pausado, notas, actividad, modificado,
borrado (las operaciones que genera heredan su actividad)
precio: fecha, activo, precio | valor_total, fuente, modificado, borrado
(precio null = "sin precio ese día"; valor_total = valoración
total en €, se usa en cuentas de trading)
Cambios de formato (migraciones):
Cuando una versión de la app necesite guardar los datos de otra forma, sube
VERSION y añade a MIGRACIONES la función que pasa de la versión anterior a
la nueva. Al leer datos antiguos se migran en memoria, paso a paso, antes de
validarlos; la primera vez que se escriben se guarda antes una copia de los
ficheros tal como estaban (carpeta «copia_vN» dentro de la de datos).
Lo mismo vale para la copia cifrada que llega del servidor git: se migra al leerla,
antes de fusionarla. Datos de una versión MÁS NUEVA que la app no se abren:
se para con un mensaje claro en vez de estropearlos.
Reglas para escribir una migración:
• Recibe {"activos": [...], "movimientos": [...], ...} y lo cambia en el sitio.
• Debe poder aplicarse dos veces sin daño (idempotente): version.json se
escribe el último, y si algo corta la escritura a medias se vuelve a migrar.
• Nunca se cambia una migración ya publicada: se añade otra.
"""
from __future__ import annotations
import datetime as dt
import json
import os
import re
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from . import almacen
TIPOS_ACTIVO = ("Fondo", "PlanPensiones", "Cripto", "Metal", "ETF", "Accion",
"CuentaTrading", "Prestamo", "Tierra", "Inmueble", "RentaFija", "Cuenta", "Deuda")
TIPOS_LIQUIDEZ = ("Cuenta",) # dinero parado: no entra en la rentabilidad
TIPOS_DEUDA = ("Deuda",) # lo que debes: resta del patrimonio, no es inversión
NO_INVERSION = TIPOS_LIQUIDEZ + TIPOS_DEUDA
TIPOS_MOVIMIENTO = ("Compra", "Venta", "Comision", "Traspaso_Entrada",
"Traspaso_Salida", "Reembolso", "Gasto", "Devolucion", "Mineria", "Dividendo", "Recompensa",
"Amortizacion", "Traslado")
CLASES_LUGAR = ("monedero", "exchange", "lightning", "otro")
TIPOS_RECURRENTE = ("Gasto", "Compra", "Dividendo")
MONEDAS = ("EUR", "USD")
FICHEROS = ("activos", "movimientos", "precios", "recurrentes")
HUELLA_ADJUNTO = re.compile(r"^[0-9a-f]{64}$")
FICHERO_VERSION = "version.json"
# Versión del formato de los datos que escribe esta app, y las migraciones:
# MIGRACIONES[n] pasa los datos de la versión n-1 a la n (ver el principio).
VERSION = 3
def _v2_lugares(tablas: dict) -> None:
"""Versión 2: lugares en los activos y el tipo «Traslado». Todo es nuevo y
opcional, no hay nada que convertir; la versión sube para que una app
anterior no abra datos con traslados (no los entendería)."""
def _v3_reglas_de_sincronizar(tablas: dict) -> None:
"""Versión 3: las operaciones que genera la app llevan la marca «automatica»
y la fusión las trata aparte (ver fusionar). No hay nada que convertir; la
versión sube para que un Caudal anterior, que fusionaría con las reglas
viejas, no pueda sincronizar con este hasta que se actualice."""
MIGRACIONES: dict = {2: _v2_lugares, 3: _v3_reglas_de_sincronizar}
class ErrorDatos(ValueError):
"""Datos mal formados; el mensaje indica fichero y línea."""
@dataclass
class Datos:
activos: list[dict] = field(default_factory=list)
movimientos: list[dict] = field(default_factory=list)
precios: list[dict] = field(default_factory=list)
recurrentes: list[dict] = field(default_factory=list)
def vivos(self, nombre: str) -> list[dict]:
"""Registros no borrados."""
return [r for r in getattr(self, nombre) if not r.get("borrado")]
# ── Validación ────────────────────────────────────────────────────────────────
def _fecha(v, donde: str, obligatoria: bool = True) -> None:
if v is None and not obligatoria:
return
if not isinstance(v, str):
raise ErrorDatos(f"{donde}: fecha ausente o no es texto AAAA-MM-DD")
try:
dt.date.fromisoformat(v)
except ValueError:
raise ErrorDatos(f"{donde}: fecha inválida {v!r} (se espera AAAA-MM-DD)") from None
def _numero(v, donde: str, campo: str) -> None:
if v is not None and (isinstance(v, bool) or not isinstance(v, (int, float))):
raise ErrorDatos(f"{donde}: «{campo}» debe ser un número o null")
def _validar(nombre: str, r: dict, donde: str) -> None:
if not isinstance(r, dict):
raise ErrorDatos(f"{donde}: cada línea debe ser un objeto JSON")
if r.get("borrado"):
return
if nombre == "activos":
if not r.get("id") or not isinstance(r["id"], str):
raise ErrorDatos(f"{donde}: falta «id»")
if r.get("tipo") not in TIPOS_ACTIVO:
raise ErrorDatos(f"{donde}: tipo de activo desconocido {r.get('tipo')!r}")
_fecha(r.get("fecha_inicio"), donde, obligatoria=False)
if r.get("valoracion", "unitaria") not in ("unitaria", "total"):
raise ErrorDatos(f"{donde}: valoracion debe ser «unitaria» o «total»")
if r.get("estado", "abierta") not in ("abierta", "cerrada"):
raise ErrorDatos(f"{donde}: estado debe ser «abierta» o «cerrada»")
_fecha(r.get("fecha_cierre"), donde, obligatoria=r.get("estado") == "cerrada")
p = r.get("prestamo")
if p is not None:
_numero(p.get("importe_final"), donde, "prestamo.importe_final")
_fecha(p.get("vencimiento"), donde)
rf = r.get("renta_fija")
if rf is not None:
from .rentafija import CLASES as CLASES_RF, PAGOS
if not isinstance(rf, dict) or rf.get("clase") not in CLASES_RF or rf.get("pagos", 0) not in PAGOS:
raise ErrorDatos(f"{donde}: «renta_fija» necesita una clase y una frecuencia de pago válidas")
_fecha(rf.get("vencimiento"), donde)
for c in ("nominal", "interes"):
v = rf.get(c)
if v is not None and (not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0):
raise ErrorDatos(f"{donde}: «renta_fija.{c}» debe ser un número no negativo")
f = r.get("inmueble")
if f is not None:
from .inmuebles import CLASES, USOS
if not isinstance(f, dict) or f.get("clase") not in CLASES or f.get("uso") not in USOS:
raise ErrorDatos(f"{donde}: «inmueble» necesita una clase y un uso válidos")
for c in ("precio_compra", "valor_catastral", "porcentaje_construccion", "precio_maximo", "superficie",
"intereses_hipoteca", "coef_vivienda", "coef_anejo"):
v = f.get(c)
if v is not None and (not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0):
raise ErrorDatos(f"{donde}: «inmueble.{c}» debe ser un número no negativo")
cultivos = f.get("cultivos")
if cultivos is not None and (not isinstance(cultivos, list) or
not all(isinstance(x, dict) and isinstance(x.get("cultivo"), str) for x in cultivos)):
raise ErrorDatos(f"{donde}: «inmueble.cultivos» debe ser una lista de cultivos con su fecha")
piezas = f.get("piezas")
if piezas is not None and (not isinstance(piezas, list) or not all(
isinstance(x, dict) and isinstance(x.get("m2"), (int, float)) and not isinstance(x.get("m2"), bool)
and x.get("clase") in ("vivienda", "anejo") for x in piezas)):
raise ErrorDatos(f"{donde}: «inmueble.piezas» debe ser una lista de piezas con sus metros")
modulos = f.get("modulos")
if modulos is not None and (not isinstance(modulos, dict) or not all(
isinstance(k, str) and k.isdigit() and isinstance(v, (int, float)) and not isinstance(v, bool)
for k, v in modulos.items())):
raise ErrorDatos(f"{donde}: «inmueble.modulos» debe ir por años con su importe")
lugares = r.get("lugares")
if lugares is not None:
if not isinstance(lugares, list) or len(lugares) > 200:
raise ErrorDatos(f"{donde}: «lugares» debe ser una lista (máximo 200)")
ids = set()
for x in lugares:
if not isinstance(x, dict) or not isinstance(x.get("id"), str) or not x["id"] \
or not isinstance(x.get("nombre"), str) or x.get("clase") not in CLASES_LUGAR:
raise ErrorDatos(f"{donde}: lugar mal formado")
if x["id"] in ids:
raise ErrorDatos(f"{donde}: lugar repetido {x['id']!r}")
ids.add(x["id"])
d = r.get("deuda")
if r.get("tipo") == "Deuda" or d is not None:
if not isinstance(d, dict):
raise ErrorDatos(f"{donde}: una Deuda necesita «deuda»")
for c in ("capital", "tin"):
if not isinstance(d.get(c), (int, float)) or isinstance(d.get(c), bool) or d[c] < 0:
raise ErrorDatos(f"{donde}: «deuda.{c}» debe ser un número no negativo")
if not isinstance(d.get("cuotas"), int) or isinstance(d.get("cuotas"), bool) or d["cuotas"] < 1:
raise ErrorDatos(f"{donde}: «deuda.cuotas» debe ser un entero positivo")
_fecha(d.get("primera_cuota"), donde)
_fecha(r.get("fecha_inicio"), donde)
elif nombre == "movimientos":
if not r.get("id") or not r.get("activo"):
raise ErrorDatos(f"{donde}: faltan «id» o «activo»")
if r.get("tipo") not in TIPOS_MOVIMIENTO:
raise ErrorDatos(f"{donde}: tipo de movimiento desconocido {r.get('tipo')!r}")
_fecha(r.get("fecha"), donde)
for c in ("cantidad", "precio", "importe", "comisiones", "coste_traspaso", "orden",
"importe_original", "retencion", "valor_recibido"):
_numero(r.get(c), donde, c)
adj = r.get("adjuntos")
if adj is not None:
if not isinstance(adj, list) or len(adj) > 20:
raise ErrorDatos(f"{donde}: «adjuntos» debe ser una lista (máximo 20)")
for x in adj:
if not isinstance(x, dict) or not HUELLA_ADJUNTO.match(str(x.get("id"))) \
or not isinstance(x.get("nombre"), str) or not isinstance(x.get("bytes"), int):
raise ErrorDatos(f"{donde}: adjunto mal formado")
for c in ("lugar", "lugar_destino", "traslado"):
if r.get(c) is not None and not isinstance(r.get(c), str):
raise ErrorDatos(f"{donde}: «{c}» debe ser texto")
if r.get("tipo") == "Amortizacion" and r.get("modalidad") not in ("plazo", "cuota"):
raise ErrorDatos(f"{donde}: «modalidad» debe ser «plazo» o «cuota»")
elif nombre == "precios":
if not r.get("activo"):
raise ErrorDatos(f"{donde}: falta «activo»")
_fecha(r.get("fecha"), donde)
_numero(r.get("precio"), donde, "precio")
_numero(r.get("valor_total"), donde, "valor_total")
elif nombre == "recurrentes":
if not r.get("id") or not r.get("activo"):
raise ErrorDatos(f"{donde}: faltan «id» o «activo»")
if r.get("tipo") not in TIPOS_RECURRENTE:
raise ErrorDatos(f"{donde}: tipo de recurrente debe ser Gasto, Compra o Dividendo")
if r.get("moneda", "EUR") not in MONEDAS:
raise ErrorDatos(f"{donde}: moneda debe ser EUR o USD")
if not isinstance(r.get("importe"), (int, float)) or isinstance(r.get("importe"), bool) or r["importe"] <= 0:
raise ErrorDatos(f"{donde}: «importe» debe ser un número positivo")
if not isinstance(r.get("dia"), int) or not 1 <= r["dia"] <= 28:
raise ErrorDatos(f"{donde}: «dia» debe estar entre 1 y 28")
_fecha(r.get("desde"), donde)
_fecha(r.get("hasta"), donde, obligatoria=False)
# ── Versión y migraciones ─────────────────────────────────────────────────────
def version_de(carpeta: Path) -> int:
"""Versión del formato de una carpeta de datos (1 si no hay version.json)."""
ruta = Path(carpeta) / FICHERO_VERSION
if not ruta.exists():
return 1
try:
v = json.loads(almacen.leer_texto(ruta)).get("version")
except (json.JSONDecodeError, AttributeError, UnicodeDecodeError):
v = None
if isinstance(v, bool) or not isinstance(v, int) or v < 1:
raise ErrorDatos(f"{FICHERO_VERSION}: no se entiende la versión de los datos")
return v
def _comprobar_version(v: int) -> None:
if v > VERSION:
raise ErrorDatos(f"estos datos son de una versión más nueva de la app (formato {v}; esta app "
f"entiende hasta el {VERSION}). Actualiza la app antes de abrirlos.")
def migrar(tablas: dict[str, list[dict]], desde: int) -> None:
"""Aplica en orden las migraciones de «desde» a VERSION (en el sitio)."""
_comprobar_version(desde)
for n in range(desde + 1, VERSION + 1):
MIGRACIONES[n](tablas)
def pendiente_de_migrar(carpeta: Path) -> bool:
"""¿Hay datos en disco en un formato anterior al de esta app?"""
carpeta = Path(carpeta)
return any((carpeta / f"{n}.jsonl").exists() for n in FICHEROS) and version_de(carpeta) < VERSION
def _copia_antes_de_migrar(carpeta: Path) -> None:
"""Antes de escribir en formato nuevo sobre datos antiguos, se guardan
los ficheros tal como estaban. Solo la primera vez: no se pisa."""
if not pendiente_de_migrar(carpeta):
return # al día, o carpeta nueva sin nada que guardar
v = version_de(carpeta)
copia = carpeta / f"copia_v{v}"
if copia.exists():
return
tmp = carpeta / f".copia_v{v}.tmp"
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir()
for nombre in [f"{n}.jsonl" for n in FICHEROS] + [FICHERO_VERSION]:
if (carpeta / nombre).exists():
shutil.copyfile(carpeta / nombre, tmp / nombre)
tmp.rename(copia)
_permisos(copia, 0o700)
# ── Lectura / escritura ───────────────────────────────────────────────────────
def clave(nombre: str, r: dict) -> str:
"""Clave única de un registro (la usa la fusión de copias)."""
if nombre == "precios":
return f"{r['activo']}|{r['fecha']}"
return r["id"]
def leer(carpeta: Path) -> Datos:
"""Lee la carpeta de datos, migrándola en memoria si es de un formato
anterior (en disco no cambia nada hasta que se escribe)."""
carpeta = Path(carpeta)
version = version_de(carpeta)
_comprobar_version(version)
tablas: dict[str, list[dict]] = {nombre: [] for nombre in FICHEROS}
lineas: dict[int, str] = {} # id(registro) → «fichero:línea», para los errores
for nombre in FICHEROS:
ruta = carpeta / f"{nombre}.jsonl"
if not ruta.exists():
continue
for n, linea in enumerate(almacen.leer_texto(ruta).splitlines(), 1):
if not linea.strip():
continue
donde = f"{ruta.name}:{n}"
try:
r = json.loads(linea)
except json.JSONDecodeError as e:
raise ErrorDatos(f"{donde}: JSON inválido ({e.msg})") from None
tablas[nombre].append(r)
lineas[id(r)] = donde
if version < VERSION:
migrar(tablas, version)
datos = Datos()
for nombre in FICHEROS:
vistos: set[str] = set()
for r in tablas[nombre]:
donde = lineas.get(id(r), f"{nombre}.jsonl (migrado)")
_validar(nombre, r, donde)
k = clave(nombre, r)
if k in vistos:
raise ErrorDatos(f"{donde}: clave repetida {k!r}")
vistos.add(k)
getattr(datos, nombre).append(r)
return datos
def _orden_escritura(nombre: str, r: dict):
if nombre in ("activos", "recurrentes"):
return (r.get("orden") or 0, r["id"])
if nombre == "movimientos":
return (r["fecha"], r.get("orden") or 0, r["id"])
return (r["fecha"], r["activo"])
def _permisos(ruta: Path, modo: int) -> None:
"""Solo propietario. En exFAT/FAT (el USB) no hay permisos: se ignora."""
try:
os.chmod(ruta, modo)
except OSError:
pass
def _escribir_atomico(destino: Path, lineas) -> None:
fd, tmp = tempfile.mkstemp(dir=destino.parent, prefix=f".{destino.stem}.", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(almacen.sellar("".join(linea + "\n" for linea in lineas).encode("utf-8")))
f.flush()
os.fsync(f.fileno())
_permisos(Path(tmp), 0o600)
os.replace(tmp, destino)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
def escribir(carpeta: Path, datos: Datos) -> None:
"""Escribe los ficheros de forma atómica (nunca quedan a medias), con
permisos solo para el propietario y en el formato de esta app (VERSION).
Si lo que había era de un formato anterior, antes guarda una copia."""
carpeta = Path(carpeta)
carpeta.mkdir(parents=True, exist_ok=True)
_permisos(carpeta, 0o700)
for nombre in FICHEROS:
for i, r in enumerate(getattr(datos, nombre), 1):
_validar(nombre, r, f"{nombre} (registro {i})")
_comprobar_version(version_de(carpeta)) # no pisar datos de una app más nueva
_copia_antes_de_migrar(carpeta)
for nombre in FICHEROS:
registros = sorted(getattr(datos, nombre), key=lambda r: _orden_escritura(nombre, r))
_escribir_atomico(carpeta / f"{nombre}.jsonl",
(json.dumps(r, ensure_ascii=False, allow_nan=False) for r in registros))
# La versión va la última: si algo corta antes, al releer se vuelve a migrar
_escribir_atomico(carpeta / FICHERO_VERSION, [json.dumps({"version": VERSION})])
# ── Fusión de dos copias ──────────────────────────────────────────────────────
def _huella(r: dict) -> str:
return json.dumps(r, ensure_ascii=False, sort_keys=True)
# Niveles de una operación automática tal como la dejó la app (movimiento["automatica"])
AUTO_PENDIENTE, AUTO_ESTIMADA, AUTO_EXACTA = 1, 2, 3
def nivel_automatica(m: dict) -> int | None:
"""Si esta versión de la operación la dejó así la app (y no el usuario),
su nivel; si no, None. Vale para las automáticas y para los cupones y
vencimientos de renta fija. Una pendiente de una recurrente siempre es de
la app, aunque venga de una versión anterior que aún no ponía la marca."""
if m.get("borrado"):
return None
marca = m.get("automatica")
if isinstance(marca, dict) and marca.get("modificado") and marca.get("modificado") == m.get("modificado"):
return int(marca.get("nivel") or AUTO_PENDIENTE)
return AUTO_PENDIENTE if m.get("recurrente") and m.get("pendiente") else None
def _prioridad(nombre: str, r: dict) -> tuple:
"""Orden para elegir entre dos versiones del mismo registro: primero lo
que hizo el usuario, después lo de la app; dentro de cada grupo, lo más
completo y lo más reciente. En precios, el puesto a mano manda siempre
sobre el descargado o el calculado."""
if nombre == "precios":
return (r.get("fuente") == "manual" and not r.get("borrado"), 0, r.get("modificado") or "", _huella(r))
nivel = nivel_automatica(r) if nombre == "movimientos" else None
return (nivel is None, nivel or 0, r.get("modificado") or "", _huella(r))
def fusionar(local: Datos, remoto: Datos) -> tuple[Datos, dict]:
"""Junta dos copias de los datos (USB y la del servidor git, o dos ordenadores).
Cada registro se identifica por su clave; si está en las dos copias con
contenido distinto gana el de «modificado» más reciente (a igualdad, uno
fijo, para que las dos copias lleguen al mismo resultado). Los borrados son
registros con «borrado»: true, así que un borrado nunca resucita.
Excepción, las operaciones automáticas: cada copia las genera al abrirse,
así que la que se abre la última tiene la versión «más reciente» aunque
la otra ya la tuviera completa o corregida a mano. Por eso una versión que
tocó el usuario gana siempre a una que solo escribió la app, y entre dos
de la app gana la más completa (exacta > estimada > pendiente). Y las
que una copia generó sin saber lo que había pasado en la otra (automática
finalizada, pausada o borrada; inversión cerrada; cupón apuntado a mano)
se retiran (ver sobrantes_de_automaticas). Un precio puesto a mano gana
siempre a uno descargado.
Devuelve (datos fusionados, recuento de lo que ha cambiado en cada lado)."""
resultado = Datos()
recuento = {"de_remoto": 0, "de_local": 0, "actualizados_desde_remoto": 0, "actualizados_desde_local": 0}
for nombre in FICHEROS:
loc = {clave(nombre, r): r for r in getattr(local, nombre)}
rem = {clave(nombre, r): r for r in getattr(remoto, nombre)}
salida = []
for k in sorted(set(loc) | set(rem)):
a, b = loc.get(k), rem.get(k)
if b is None:
salida.append(a)
recuento["de_local"] += 1
elif a is None:
salida.append(b)
recuento["de_remoto"] += 1
elif _huella(a) == _huella(b):
salida.append(a)
else:
gana_remoto = _prioridad(nombre, b) > _prioridad(nombre, a)
gana, pierde = (b, a) if gana_remoto else (a, b)
if nombre == "activos":
gana = _con_todos_los_lugares(gana, pierde)
salida.append(gana)
recuento["actualizados_desde_remoto" if gana_remoto else "actualizados_desde_local"] += 1
setattr(resultado, nombre, salida)
retirar = {m["id"]: m for m in sobrantes_de_automaticas(resultado)}
resultado.movimientos = [retirar.get(m["id"], m) for m in resultado.movimientos]
return resultado, recuento
def sobrantes_de_automaticas(datos: Datos, hoy: dt.date | None = None) -> list[dict]:
"""Operaciones que la app generó sola y que ya no tocan, devueltas como
copias marcadas de borradas (no toca las originales).
Solo las que la app escribió sola: si el usuario tocó una, se queda.
De una automática sobra:
· la de fecha fuera de «desde»–«hasta» (se finalizó o se acortó);
· la posterior al cierre de su inversión;
· la de fecha posterior al día en que se pausó o borró la regla: la
generó una copia que aún no lo sabía (el USB al abrirse, antes de
sincronizar). Las de antes de ese día se quedan: tocaban.
De renta fija sobra el cupón o la devolución que el calendario ya no
daría: se vendió, se cerró o el usuario apuntó el cobro de verdad.
La marca de borrado lleva un «modificado» que no depende de quién limpie:
así las dos copias llegan al mismo registro."""
from . import rentafija
hoy = hoy or dt.date.today()
reglas = {r["id"]: r for r in datos.recurrentes}
activos = {a["id"]: a for a in datos.activos}
vivos = [m for m in datos.movimientos if not m.get("borrado")]
calendarios: dict[str, set] = {}
sobran = []
for m in vivos:
if nivel_automatica(m) is None:
continue
a = activos.get(m.get("activo")) or {}
cierre = a.get("fecha_cierre") if a.get("estado") == "cerrada" else None
momento = m.get("modificado") or ""
r = reglas.get(m.get("recurrente"))
if r is not None:
parada = bool(r.get("borrado") or r.get("pausado")) and bool(r.get("modificado")) \
and m["fecha"] > r["modificado"][:10]
sobra = m["fecha"] < r.get("desde", "") or bool(r.get("hasta") and m["fecha"] > r["hasta"]) \
or bool(cierre and m["fecha"] > cierre) or parada
momento = max(momento, r.get("modificado") or "")
elif str(m.get("id", "")).startswith(f"rf-{m.get('activo')}-") and a.get("tipo") == "RentaFija":
if a["id"] not in calendarios:
suyos = [x for x in vivos if x["activo"] == a["id"]]
calendarios[a["id"]] = {x["id"] for x in rentafija.calendario(a, suyos, hoy)}
sobra = m["fecha"] <= hoy.isoformat() and m["id"] not in calendarios[a["id"]]
else:
continue
if sobra:
momento = max(momento, a.get("modificado") or "")
sobran.append({**m, "borrado": True, "modificado": momento})
return sobran
def _con_todos_los_lugares(gana: dict, pierde: dict) -> dict:
"""Los lugares de una inversión (exchanges, monederos) no se pierden al
fusionar: si en una copia se añadió uno y en la otra otro, quedan los dos.
Si no, las operaciones apuntadas en el que desaparece se quedarían sin sitio."""
suyos = {x.get("id") for x in gana.get("lugares") or []}
faltan = [x for x in pierde.get("lugares") or [] if x.get("id") not in suyos]
if not faltan:
return gana
return {**gana, "lugares": [*(gana.get("lugares") or []), *faltan]}
# ── Antes de fusionar dos copias que han vivido separadas ─────────────────────
#
# Fusionar va registro a registro y se fía de los identificadores. Hay dos
# casos en los que eso solo no basta, y los dos se arreglan ANTES o DESPUÉS de
# fusionar, sin tocar la regla:
#
# · Dos inversiones DISTINTAS con el mismo identificador. Los de las
# inversiones son correlativos (F1, F2…), así que si se da de alta una en
# cada copia sin sincronizar entre medias, chocan. Se distinguen por su
# «uid» (un número al azar que se pone al crearlas); si chocan, a la que
# llega se le da otro identificador y sus operaciones la siguen.
# · La misma operación apuntada en las dos copias (el mismo extracto
# importado en los dos sitios): tienen identificadores distintos y la
# fusión las dejaría duplicadas. Si coinciden en TODO lo que importa y
# vienen de la misma línea de extracto, se queda una.
def choques_de_activos(local: Datos, remoto: Datos, prefijos: dict[str, str]) -> dict[str, str]:
"""{identificador en «remoto»: el nuevo que hay que darle} para las
inversiones de «remoto» que comparten identificador con otra distinta de
«local». Sin «uid» en alguna de las dos, se entiende que son la misma."""
suyos = {a["id"]: a for a in local.activos}
usados = set(suyos) | {a["id"] for a in remoto.activos}
mapa = {}
for a in remoto.activos:
otro = suyos.get(a["id"])
if otro is None or not a.get("uid") or not otro.get("uid") or a["uid"] == otro["uid"]:
continue
prefijo = prefijos.get(a.get("tipo"), "X")
n = 1
while f"{prefijo}{n}" in usados:
n += 1
mapa[a["id"]] = f"{prefijo}{n}"
usados.add(mapa[a["id"]])
return mapa
def renombrar_activos(datos: Datos, mapa: dict[str, str]) -> Datos:
"""Una copia de los datos con esas inversiones bajo su identificador nuevo
y todo lo que las nombra (operaciones, precios, automáticas) siguiéndolas."""
if not mapa:
return datos
cambia = lambda r, campo: {**r, campo: mapa[r[campo]]} if r.get(campo) in mapa else r # noqa: E731
return Datos([cambia(a, "id") for a in datos.activos], [cambia(m, "activo") for m in datos.movimientos],
[cambia(x, "activo") for x in datos.precios], [cambia(r, "activo") for r in datos.recurrentes])
_NO_CUENTAN = ("id", "orden", "modificado", "adjuntos", "notas", "importacion")
def unir_duplicadas(datos: Datos, momento: str) -> list[dict]:
"""Deja una sola de las operaciones que son la misma línea de extracto
apuntada dos veces: mismo extracto y mismos datos. Las demás quedan como
borradas (no se pierden: un borrado es una marca). Devuelve las retiradas."""
grupos: dict[str, list[dict]] = {}
for m in datos.movimientos:
if m.get("borrado") or not m.get("extracto"):
continue
firma = json.dumps({k: v for k, v in m.items() if k not in _NO_CUENTAN}, ensure_ascii=False, sort_keys=True)
grupos.setdefault(firma, []).append(m)
retiradas = []
for iguales in grupos.values():
if len(iguales) < 2:
continue
# Se queda la que más tiene (justificantes, notas) y, a igualdad, la más antigua: siempre la misma en las dos copias
iguales.sort(key=lambda m: (-len(m.get("adjuntos") or []), -len(m.get("notas") or ""), m.get("modificado") or "", m["id"]))
for sobra in iguales[1:]:
sobra["borrado"], sobra["modificado"] = True, momento
retiradas.append(sobra)
return retiradas
def huella_datos(datos: Datos) -> str:
"""Resumen del contenido (para saber si algo ha cambiado desde la última vez)."""
import hashlib
h = hashlib.sha256()
for nombre in FICHEROS:
for r in sorted(getattr(datos, nombre), key=lambda r: clave(nombre, r)):
h.update(_huella(r).encode())
return h.hexdigest()