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

219 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""
exportar_xlsx.py — Los datos de la cartera en una hoja de cálculo.
python3 -m cartera.exportar_xlsx datos/ cartera.xlsx [--fecha AAAA-MM-DD]
Genera un libro con tres hojas —Cartera (lo mismo que ves en el resumen de la
aplicación), Movimientos y Precios— con valores, sin fórmulas y sin macros: se
abre igual en LibreOffice, Excel o Google Sheets, y sirve para mirar los números
fuera del programa, para mandárselos a quien te lleve los impuestos o para
guardarlos en un formato que cualquiera entiende dentro de veinte años.
Es una foto de salida, no un formato de trabajo: lo que se edite aquí no vuelve
al programa. Los datos de verdad son los ficheros de texto de la carpeta de
datos, que se leen con cualquier editor.
No hace peticiones de red. No sobrescribe un fichero existente sin --forzar.
Necesita openpyxl (`pip install openpyxl`); todo lo demás funciona sin él.
"""
from __future__ import annotations
import argparse
import datetime as dt
import sys
from pathlib import Path
from .datos import Datos, leer
from .vista import resumen
GRIS = "FFEFEFEF"
# Los porcentajes salen del programa en tanto por uno (0,60 = 60 %), que es justo
# lo que espera el formato «0.00%» de la hoja.
EUROS, UNIDADES, PCT = "#,##0.00", "#,##0.0000", "0.00%"
COLUMNAS_CARTERA = [
("Activo", 30, None), ("Tipo", 15, None), ("Cantidad", 14, UNIDADES),
("Precio", 12, UNIDADES), ("Valor", 14, EUROS), ("Coste", 14, EUROS),
("Ganancia", 14, EUROS), ("Rentabilidad", 12, PCT), ("TWR", 10, PCT),
("TIR", 10, PCT), ("Año", 10, PCT), ("Peso", 10, PCT),
("Dividendos", 12, EUROS), ("Desde", 11, None),
]
COLUMNAS_MOVIMIENTOS = [
("Fecha", 12, "DD/MM/YYYY"), ("Activo", 26, None), ("Tipo", 17, None),
("Cantidad", 14, UNIDADES), ("Precio", 12, UNIDADES), ("Importe", 13, EUROS),
("Comisiones", 11, EUROS), ("Coste traspaso", 13, EUROS), ("Retención", 11, EUROS),
("Moneda", 8, None), ("Actividad", 16, None), ("Notas", 40, None),
]
COLUMNAS_PRECIOS = [
("Fecha", 12, "DD/MM/YYYY"), ("Activo", 26, None), ("Precio", 14, UNIDADES),
("Valor total", 14, EUROS), ("Fuente", 18, None),
]
def _openpyxl():
try:
import openpyxl # noqa: F401
except ImportError:
raise RuntimeError("Para exportar a Excel hace falta openpyxl: pip install openpyxl") from None
def _fecha(v):
try:
return dt.date.fromisoformat(v)
except (TypeError, ValueError):
return v
class _Hoja:
"""Una hoja con sus columnas; escribe filas de datos, títulos y totales."""
def __init__(self, wb, titulo: str, columnas: list[tuple]):
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
self.ws = wb.create_sheet(titulo)
self.columnas = columnas
self.fila = 1
for i, (nombre, ancho, _) in enumerate(columnas, 1):
c = self.ws.cell(1, i, nombre)
c.font = Font(bold=True)
c.fill = PatternFill("solid", fgColor=GRIS)
c.alignment = Alignment(horizontal="center")
self.ws.column_dimensions[get_column_letter(i)].width = ancho
self.ws.freeze_panes = "A2"
self.fila = 2
def datos(self, valores: list, negrita: bool = False) -> None:
from openpyxl.styles import Font
for i, (v, (_, _, formato)) in enumerate(zip(valores, self.columnas), 1):
c = self.ws.cell(self.fila, i, v)
if formato and isinstance(v, (int, float)) and not isinstance(v, bool):
c.number_format = formato
elif formato == "DD/MM/YYYY" and isinstance(v, dt.date):
c.number_format = formato
if negrita:
c.font = Font(bold=True)
self.fila += 1
def titulo(self, texto: str) -> None:
from openpyxl.styles import Font
self.fila += 1
c = self.ws.cell(self.fila, 1, texto)
c.font = Font(bold=True, size=12)
self.fila += 1
def pareja(self, etiqueta: str, valor, formato: str = EUROS) -> None:
self.ws.cell(self.fila, 1, etiqueta)
c = self.ws.cell(self.fila, 2, valor)
if isinstance(valor, (int, float)) and not isinstance(valor, bool):
c.number_format = formato
self.fila += 1
def _fila_activo(f: dict) -> list:
return [f["nombre"], f["tipo"], f["cantidad"], f["precio"], f["valor"], f["coste"],
f["ganancia"], f["rentabilidad"], f["twr"], f["tir"], f["ytd"], f.get("peso"),
f["dividendos"], f.get("fecha_inicio")]
def exportar(datos: Datos, destino: Path, hoy: dt.date) -> None:
"""Escribe el libro con la cartera a la fecha «hoy»."""
_openpyxl()
from openpyxl import Workbook
r = resumen(datos, hoy)
g = r["global"]
nombres = {a["id"]: (a.get("nombre") or a["id"]) for a in datos.vivos("activos")}
wb = Workbook()
wb.remove(wb.active)
# ── Cartera: lo mismo que el resumen de la aplicación ────────────────────
h = _Hoja(wb, "Cartera", COLUMNAS_CARTERA)
for f in r["abiertos"]:
h.datos(_fila_activo(f))
h.datos(["TOTAL", None, None, None, g["valor"], g["coste"], g["ganancia_latente"],
g["rentabilidad_latente"], None, g["tir"], g["ytd"], None, g["dividendos"], None],
negrita=True)
if r["cerrados"]:
h.titulo("Inversiones cerradas")
for f in r["cerrados"]:
h.datos([f["nombre"], f["tipo"], None, None, None, None, f["ganancia"],
f.get("rentabilidad"), None, f["tir"], None, None, f["dividendos"],
f.get("fecha_cierre")])
if r["liquidez"]:
h.titulo("Cuentas")
for f in r["liquidez"]:
h.datos([f["nombre"], f["tipo"], None, None, f["saldo"], None, None, None,
None, None, None, None, f.get("intereses"), f.get("fecha_inicio")])
if r["deudas"]:
h.titulo("Préstamos recibidos")
for f in r["deudas"]:
h.datos([f["nombre"], f["tipo"], None, None, -f["pendiente"], None, None,
(f.get("tin") or 0) / 100, None, None, None, None, None, f.get("fin")])
h.titulo(f"Resumen a {hoy.strftime('%d/%m/%Y')}")
h.pareja("Valor de las inversiones", g["valor"])
h.pareja("Dinero en cuentas", g["liquidez"])
h.pareja("Patrimonio", g["patrimonio"])
if g["deuda"]:
h.pareja("Deuda pendiente", -g["deuda"])
h.pareja("Patrimonio neto", g["patrimonio_neto"])
h.pareja("Ganancia latente", g["ganancia_latente"])
h.pareja("Ganancia realizada", g["ganancia_realizada"])
h.pareja("Dividendos e intereses cobrados", g["dividendos"])
h.pareja("TIR", g["tir"], PCT)
h.pareja("ROI", g["roi"], PCT)
h.fila += 1
h.ws.cell(h.fila, 1, "Exportado desde la cartera; los datos de verdad están en la carpeta de datos.")
# ── Movimientos ──────────────────────────────────────────────────────────
h = _Hoja(wb, "Movimientos", COLUMNAS_MOVIMIENTOS)
movs = sorted(datos.vivos("movimientos"), key=lambda m: (m["fecha"], m.get("orden") or 0, m["id"]))
for m in movs:
h.datos([_fecha(m["fecha"]), nombres.get(m["activo"], m["activo"]), m["tipo"],
m.get("cantidad"), m.get("precio"), m.get("importe"), m.get("comisiones"),
m.get("coste_traspaso"), m.get("retencion"), m.get("moneda") or "EUR",
m.get("actividad"), m.get("notas")])
h.ws.auto_filter.ref = f"A1:L{max(len(movs) + 1, 2)}"
# ── Precios ──────────────────────────────────────────────────────────────
h = _Hoja(wb, "Precios", COLUMNAS_PRECIOS)
for p in sorted(datos.vivos("precios"), key=lambda p: (p["fecha"], p["activo"])):
h.datos([_fecha(p["fecha"]), nombres.get(p["activo"], p["activo"]), p.get("precio"),
p.get("valor_total"), p.get("fuente")])
destino = Path(destino)
destino.parent.mkdir(parents=True, exist_ok=True)
wb.save(destino)
def main() -> int:
p = argparse.ArgumentParser(description="Datos de la cartera → hoja de cálculo")
p.add_argument("datos")
p.add_argument("xlsx")
p.add_argument("--fecha", help="fecha de la foto (por defecto, hoy)")
p.add_argument("--forzar", action="store_true")
args = p.parse_args()
destino = Path(args.xlsx)
if destino.exists() and not args.forzar:
print(f"ERROR: {destino} ya existe. Usa --forzar para sobrescribir.")
return 1
hoy = dt.date.fromisoformat(args.fecha) if args.fecha else dt.date.today()
exportar(leer(Path(args.datos)), destino, hoy)
print(f"✓ Exportado {args.datos} → {destino} (cartera a {hoy.isoformat()})")
return 0
if __name__ == "__main__":
sys.exit(main())