219 lines
8.8 KiB
Python
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())
|