Taxes for the whole of Spain (common territory, Navarre, Álava, Bizkaia and Gipuzkoa); flats, garages and land valued on their own; Treasury bills, bonds and deposits priced on BME; broker accounts; a guided start; a page per investment with its chart and the tax of selling today; net worth at the top; undo when deleting; Woodpecker CI for the tests and the Linux build.
646 lines
37 KiB
Python
646 lines
37 KiB
Python
"""
|
|
fifo.py — Ganancias y pérdidas de cada transmisión por FIFO, año a año.
|
|
|
|
Cómo se cuenta:
|
|
|
|
• Cada compra abre un LOTE: cuánto, cuándo y lo que costó de verdad (el
|
|
importe pagado, con sus comisiones y su prima).
|
|
• Cada venta o reembolso consume lotes empezando por el más antiguo. Su
|
|
ganancia o pérdida es lo cobrado menos el coste de los lotes consumidos.
|
|
• Los valores homogéneos forman una BOLSA y el FIFO va por bolsa. Cada
|
|
activo es la suya, salvo las criptomonedas: todos los activos de la misma
|
|
moneda son una sola bolsa, estén en el monedero, el exchange o el nodo que
|
|
estén.
|
|
|
|
Criterios aplicados (ver CRITERIOS, que es lo que se enseña en pantalla):
|
|
|
|
fifo «Cuando existan valores homogéneos se considerará que los
|
|
transmitidos son aquellos que adquirió en primer lugar»
|
|
(art. 37.2 de la Ley 35/2006, del IRPF).
|
|
cripto La DGT aplica ese mismo FIFO a las criptomonedas, por moneda
|
|
y sin distinguir dónde están (consultas V1604-18, V0975-22).
|
|
OJO: el TSJ del País Vasco (9-ene-2025, contra la Hacienda
|
|
de Gipuzkoa) ha dicho que el FIFO de «valores» no es
|
|
aplicable a las criptomonedas. Aquí se sigue el criterio
|
|
administrativo, que es el prudente.
|
|
traspaso Un traspaso entre fondos de inversión no tributa: las
|
|
participaciones nuevas conservan el coste y la fecha de las
|
|
traspasadas (art. 94.1.a de la Ley 35/2006). Si al apuntar
|
|
el traspaso se dio el coste fiscal de la gestora, manda ese.
|
|
traslado Mover la misma moneda entre lugares tuyos (o entre dos
|
|
activos de la misma moneda, como BTC y un nodo Lightning) no
|
|
es una transmisión.
|
|
comision La comisión de red pagada en la propia moneda se trata como
|
|
una pequeña transmisión a valor de mercado (criterio
|
|
prudente). Alternativa: opciones["comision"] = "coste", que
|
|
la deja sin efecto y reparte su coste entre lo que queda.
|
|
sin_desembolso Lo minado o recibido gratis entra por su valor de mercado
|
|
de ese día: es lo que se declara como ingreso al recibirlo,
|
|
y al venderlo solo tributa lo que haya subido desde
|
|
entonces. Alternativa: opciones["sin_desembolso"] = "cero".
|
|
pago Pagar o regalar con una criptomoneda es transmitirla a su
|
|
valor de mercado: en la cartera ya es una Venta.
|
|
|
|
Lo que NO entra: los planes de pensiones (al rescatarlos son rendimientos del
|
|
trabajo, no ganancias), las cuentas de trading y los activos de los que solo
|
|
se apunta el valor total (sin participaciones no hay lotes). Salen listados
|
|
aparte, con el motivo.
|
|
|
|
La regla de la recompra («de los dos meses»)
|
|
|
|
Una pérdida al vender acciones, ETF o fondos no se puede restar si se
|
|
compraron valores homogéneos dentro del plazo anterior o posterior a la
|
|
venta: dos meses si cotizan, un año si no (art. 33.5.f y g de la Ley
|
|
35/2006). Queda aplazada y se integra a medida que se venden los valores
|
|
recomprados. Aquí:
|
|
• solo bloquea lo recomprado que sigue en cartera tras la venta (si lo
|
|
comprado el mes anterior es justo lo que se vende, no hay recompra);
|
|
• si se recompra menos de lo vendido, se aplaza la parte proporcional;
|
|
• cada valor recomprado bloquea una sola vez;
|
|
• un traspaso a otro fondo no libera la pérdida: viaja con las
|
|
participaciones.
|
|
No se aplica a las criptomonedas, que no son «valores o participaciones»,
|
|
salvo donde la norma del territorio lo diga expresamente (Bizkaia y Álava:
|
|
reglas/<territorio>.json → «recompra_cripto», con su plazo y desde qué año).
|
|
En los fondos el plazo es discutible (no cotizan, pero lo habitual es
|
|
aplicarles los dos meses): opciones["plazo_fondos"] = 2 o 12.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
from .. import spot_btc
|
|
from . import actividad as _actividad
|
|
from .. import rentafija as _rentafija
|
|
from ..calculo import Precios, por_valor
|
|
|
|
CRITERIOS = [
|
|
{"id": "fifo", "titulo": "Se vende lo primero que se compró (FIFO)",
|
|
"texto": "En valores homogéneos, los transmitidos son los adquiridos en primer lugar.",
|
|
"fuente": "Art. 37.2 Ley 35/2006 (IRPF)"},
|
|
{"id": "cripto", "titulo": "Cada criptomoneda, una sola bolsa",
|
|
"texto": "El FIFO va por moneda, sin distinguir monedero, exchange o nodo. Es el criterio de la DGT; "
|
|
"el TSJ del País Vasco lo ha puesto en duda (9-ene-2025), pero aquí se sigue el administrativo.",
|
|
"fuente": "DGT V1604-18, V0975-22"},
|
|
{"id": "traspaso", "titulo": "Los traspasos entre fondos no tributan",
|
|
"texto": "Las participaciones nuevas conservan el coste y la fecha de las traspasadas.",
|
|
"fuente": "Art. 94.1.a Ley 35/2006 (IRPF)"},
|
|
{"id": "traslado", "titulo": "Mover tu bitcoin entre sitios tuyos no es vender",
|
|
"texto": "Los traslados entre lugares, y los traspasos entre activos de la misma moneda, no son transmisiones.",
|
|
"fuente": ""},
|
|
{"id": "comision", "titulo": "La comisión de red es una pequeña transmisión",
|
|
"texto": "Lo que se paga de comisión en la propia moneda se transmite a su valor de mercado de ese día. "
|
|
"Es el criterio prudente; el efecto suele ser de céntimos.",
|
|
"fuente": "Criterio prudente (sin pronunciamiento expreso)"},
|
|
{"id": "sin_desembolso", "titulo": "Lo minado o recibido entra por su valor de ese día",
|
|
"texto": "Ese valor es el ingreso que se declara al recibirlo y pasa a ser su coste; al venderlo solo "
|
|
"tributa lo que haya subido desde entonces.",
|
|
"fuente": ""},
|
|
{"id": "recompra", "titulo": "Una pérdida no se resta si recompras el mismo valor",
|
|
"texto": "En acciones, ETF y fondos, si compraste el mismo valor dentro del plazo anterior o posterior a "
|
|
"la venta, la pérdida queda aplazada hasta que vendas lo recomprado. No se aplica a las criptomonedas.",
|
|
"fuente": "Art. 33.5.f y g Ley 35/2006 (IRPF)"},
|
|
]
|
|
|
|
# Lo que se puede elegir en pantalla. La primera opción de cada una es la de partida.
|
|
OPCIONES = {
|
|
"territorio": {
|
|
"titulo": "Dónde declaras",
|
|
"valores": [["comun", "Territorio común (toda España salvo el País Vasco y Navarra; también Canarias, Ceuta y Melilla)"],
|
|
["alava", "Álava"], ["bizkaia", "Bizkaia"], ["gipuzkoa", "Gipuzkoa"], ["navarra", "Navarra"]]},
|
|
"comision": {
|
|
"titulo": "Comisión de red pagada en bitcoin",
|
|
"valores": [["transmision", "Es una pequeña venta a valor de mercado (prudente)"],
|
|
["coste", "No tributa: su coste se reparte entre lo que queda"]]},
|
|
"sin_desembolso": {
|
|
"titulo": "Lo minado o recibido sin pagar",
|
|
"valores": [["mercado", "Lo declaré como ingreso al recibirlo: su coste es el valor de ese día"],
|
|
["cero", "No lo declaré al recibirlo: coste cero, tributa entero al venderlo"]]},
|
|
"plazo_fondos": {
|
|
"titulo": "Plazo de la recompra en fondos de inversión",
|
|
"valores": [["2", "Dos meses (lo habitual)"],
|
|
["12", "Un año (lectura literal: los fondos no cotizan)"]]},
|
|
"amortizacion": {
|
|
"titulo": "Cómo se amortizan los aparatos de la minería",
|
|
"valores": [["informatica", "Como equipos para procesos de información"],
|
|
["maquinaria", "Como maquinaria (más despacio)"]]},
|
|
}
|
|
PLAZO_COTIZADOS = 2 # meses, acciones y ETF
|
|
|
|
# Cómo se cuenta el criterio cuando se elige la opción que no es la de partida
|
|
ALTERNATIVOS = {
|
|
("comision", "coste"): {
|
|
"titulo": "La comisión de red no tributa",
|
|
"texto": "Lo que se paga de comisión en la propia moneda no se trata como una venta: su coste se reparte "
|
|
"entre lo que queda. Es el criterio menos prudente de los dos.", "fuente": "Elegido por ti"},
|
|
("sin_desembolso", "cero"): {
|
|
"titulo": "Lo minado o recibido entra a coste cero",
|
|
"texto": "No se declaró como ingreso al recibirlo, así que no tiene coste: al venderlo tributa por todo lo "
|
|
"cobrado. Lo que correspondía era declararlo al recibirlo.", "fuente": "Elegido por ti"},
|
|
("plazo_fondos", "12"): {
|
|
"id": "recompra", "titulo": "Una pérdida no se resta si recompras el mismo valor",
|
|
"texto": "En acciones y ETF, dentro de los dos meses anteriores o posteriores a la venta; en fondos de "
|
|
"inversión, dentro de un año (has elegido la lectura literal: no cotizan). La pérdida queda aplazada "
|
|
"hasta que vendas lo recomprado. No se aplica a las criptomonedas.",
|
|
"fuente": "Art. 33.5.f y g Ley 35/2006 (IRPF)"},
|
|
}
|
|
|
|
|
|
def recompra_cripto_en(regla: dict | None, ejercicio: int) -> dict | None:
|
|
"""La regla de la recompra para criptomonedas del territorio, si rige ese año."""
|
|
return regla if regla and ejercicio >= regla["desde"] else None
|
|
|
|
|
|
def criterios_de(opciones: dict, citas: dict | None = None, cripto: dict | None = None) -> list[dict]:
|
|
"""Los criterios tal como se están aplicando con esas opciones. «citas»
|
|
son las del territorio (id del criterio → artículo de su norma), que se
|
|
añaden a la de territorio común. «cripto» es la regla de la recompra para
|
|
criptomonedas, si rige en el territorio ese año."""
|
|
cambios = {}
|
|
for (clave, valor), nuevo in ALTERNATIVOS.items():
|
|
if opciones.get(clave) == valor:
|
|
cambios[nuevo.get("id", clave)] = nuevo
|
|
salida = []
|
|
for c in CRITERIOS:
|
|
c = {**c, **cambios.get(c["id"], {})}
|
|
if c["id"] == "recompra" and cripto:
|
|
c["texto"] = c["texto"].replace(
|
|
"No se aplica a las criptomonedas.",
|
|
f"En las criptomonedas también, con {cripto['meses']} meses antes o después ({cripto['cita']}).")
|
|
propia = (citas or {}).get(c["id"])
|
|
if propia:
|
|
c["fuente"] = f"{propia} · {c['fuente']}" if c["fuente"] else propia
|
|
salida.append(c)
|
|
return salida
|
|
|
|
|
|
def opciones_validas(opciones: dict | None) -> dict:
|
|
"""Las opciones conocidas, con su valor de partida donde falte o no valga."""
|
|
opciones = opciones or {}
|
|
return {k: (str(opciones.get(k)) if str(opciones.get(k)) in [v for v, _ in o["valores"]] else o["valores"][0][0])
|
|
for k, o in OPCIONES.items()}
|
|
|
|
|
|
def _mas_meses(fecha: str, meses: int) -> str:
|
|
d = dt.date.fromisoformat(fecha)
|
|
total = d.year * 12 + d.month - 1 + meses
|
|
año, mes = divmod(total, 12)
|
|
for dia in (d.day, 30, 29, 28):
|
|
try:
|
|
return dt.date(año, mes + 1, dia).isoformat()
|
|
except ValueError:
|
|
continue
|
|
return fecha
|
|
|
|
SIN_CANTIDAD = 1e-9
|
|
ENTRA = {"Compra", "Mineria", "Recompensa"}
|
|
TRANSMITE = {"Venta", "Reembolso"}
|
|
EXCLUIDOS = {"PlanPensiones": "al rescatarlo es un rendimiento del trabajo, no una ganancia patrimonial",
|
|
"CuentaTrading": "las ganancias están dentro de la cuenta: copia el resultado del informe del bróker en «Cuentas de bróker o trading»",
|
|
"Cuenta": None, "Deuda": None}
|
|
CLASES = {"Fondo": "Fondos de inversión", "ETF": "Acciones y ETF", "Accion": "Acciones y ETF",
|
|
"Cripto": "Criptomonedas", "Metal": "Metales", "Tierra": "Inmuebles y tierra",
|
|
"Prestamo": "Préstamos"}
|
|
CONCEPTO_RENDIMIENTO = {"Cuenta": "Intereses de cuentas", "Prestamo": "Intereses de préstamos", "RentaFija": "Cupones e intereses de renta fija",
|
|
"Tierra": "Arrendamientos", "Fondo": "Dividendos", "ETF": "Dividendos",
|
|
"Accion": "Dividendos"}
|
|
|
|
|
|
def _f(v) -> float:
|
|
return float(v) if v else 0.0
|
|
|
|
|
|
def _importe(m: dict) -> float:
|
|
return _f(m.get("importe")) or _f(m.get("cantidad")) * _f(m.get("precio"))
|
|
|
|
|
|
def _bolsa(a: dict) -> str:
|
|
"""Los valores homogéneos se agrupan: cada criptomoneda es una sola bolsa."""
|
|
if a["tipo"] == "Cripto":
|
|
return "cripto:" + ((a.get("codigo") or "").strip().lower() or a["id"])
|
|
return a["id"]
|
|
|
|
|
|
def ejercicios(movimientos: list[dict]) -> list[int]:
|
|
return sorted({int(m["fecha"][:4]) for m in movimientos}, reverse=True)
|
|
|
|
|
|
class _Motor:
|
|
def __init__(self, activos, movimientos, precios, opciones, recompra_cripto=None, coeficientes=None):
|
|
self.activos = {a["id"]: a for a in activos}
|
|
self.coeficientes = coeficientes # País Vasco: el coste se actualiza según el año en que se pagó
|
|
self.recompra_cripto = recompra_cripto # la regla del territorio para criptomonedas, si la tiene
|
|
self.precios = Precios(precios)
|
|
self.opciones = opciones_validas(opciones)
|
|
self.aplazadas: list[dict] = [] # pérdidas bloqueadas por una recompra, aún sin integrar
|
|
self.integradas: list[dict] = [] # pérdidas de antes que se liberan al vender lo recomprado
|
|
self.bloqueando: dict[str, float] = {} # compra → cantidad suya que ya bloquea una pérdida
|
|
self.compras: dict[str, list[dict]] = {} # bolsa → sus adquisiciones, para mirar hacia delante
|
|
self.foto: list[dict] | None = None # existencias el último día del ejercicio
|
|
self.lotes: dict[str, list[dict]] = {} # bolsa → lotes, del más antiguo al más nuevo
|
|
self.transmisiones: list[dict] = []
|
|
self.rendimientos: list[dict] = []
|
|
self.recibido: list[dict] = [] # minado y recibido sin pagar
|
|
self.gastos: list[dict] = []
|
|
self.tareas: list[dict] = []
|
|
self.fuera: dict[str, dict] = {} # activos que no entran, con el motivo
|
|
self.movs = [m for m in movimientos if m["activo"] in self.activos]
|
|
|
|
# ── precios de mercado ──
|
|
def mercado(self, a: dict, fecha: str) -> float | None:
|
|
if a["tipo"] == "Cripto" and (a.get("codigo") or "").lower() == "bitcoin":
|
|
return spot_btc.precio(fecha, self.precios.de(a["id"]))[0]
|
|
r = self.precios.ultimo(a["id"], dt.date.fromisoformat(fecha))
|
|
return r.get("precio") if r else None
|
|
|
|
# ── lotes ──
|
|
def abrir(self, bolsa, fecha, cantidad, coste, m, **extra):
|
|
if cantidad > SIN_CANTIDAD:
|
|
self.lotes.setdefault(bolsa, []).append(
|
|
{"fecha": fecha, "cantidad": cantidad, "coste": coste, "origen": m["id"], "raiz": m["id"],
|
|
"estimado": bool(m.get("estimado") or m.get("pendiente")), **extra})
|
|
|
|
def consumir(self, bolsa, cantidad) -> tuple[list[dict], float]:
|
|
"""Saca «cantidad» de los lotes más antiguos. Devuelve (trozos, lo que no se pudo cubrir)."""
|
|
trozos, falta = [], cantidad
|
|
cola = self.lotes.get(bolsa, [])
|
|
while falta > SIN_CANTIDAD and cola:
|
|
lote = cola[0]
|
|
q = min(lote["cantidad"], falta)
|
|
parte = lote["coste"] * q / lote["cantidad"]
|
|
trozos.append({**lote, "cantidad": q, "coste": parte})
|
|
lote["cantidad"] -= q
|
|
lote["coste"] -= parte
|
|
falta -= q
|
|
if lote["cantidad"] <= SIN_CANTIDAD:
|
|
cola.pop(0)
|
|
return trozos, max(falta, 0.0)
|
|
|
|
def tarea(self, m, texto, clase="aviso"):
|
|
self.tareas.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": m["activo"],
|
|
"texto": texto, "clase": clase})
|
|
|
|
# ── cada operación ──
|
|
def transmitir(self, m, a, bolsa, cantidad, valor, concepto):
|
|
trozos, falta = self.consumir(bolsa, cantidad)
|
|
coste = sum(t["coste"] for t in trozos)
|
|
avisos = []
|
|
if falta > max(cantidad * 1e-6, SIN_CANTIDAD):
|
|
avisos.append("sin_cubrir")
|
|
self.tarea(m, f"{concepto} de {cantidad:g} sin compras anteriores suficientes: faltan {falta:g} "
|
|
"por apuntar (su coste cuenta como cero).", "error")
|
|
if any(t.get("estimado") for t in trozos) or m.get("estimado"):
|
|
avisos.append("estimado")
|
|
self.tarea(m, f"{concepto} que se apoya en un importe estimado: confirma lo que pagaste o cobraste de verdad.")
|
|
if any(t.get("sin_fecha") for t in trozos):
|
|
avisos.append("sin_fecha")
|
|
actualizacion = 0.0
|
|
if self.coeficientes:
|
|
tabla = self.coeficientes["tablas"].get(m["fecha"][:4])
|
|
if tabla:
|
|
primero = min(tabla)
|
|
actualizacion = round(sum(t["coste"] * (tabla.get(max(t["fecha"][:4], primero)) or 1.0) for t in trozos) - coste, 2)
|
|
coste += actualizacion
|
|
else:
|
|
avisos.append("sin_coeficientes")
|
|
self.tarea(m, f"No hay tabla de coeficientes de actualización para {m['fecha'][:4]}: el coste va sin actualizar.")
|
|
ganancia = round(valor - coste, 2)
|
|
self.liberar(m, a, trozos)
|
|
recompras, aplazada = self.recompra(m, a, bolsa, cantidad, ganancia) if ganancia < 0 else ([], 0.0)
|
|
self.transmisiones.append({
|
|
"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"],
|
|
"clase": CLASES.get(a["tipo"], "Otros"), "concepto": concepto, "cantidad": cantidad,
|
|
"valor_transmision": round(valor, 2), "valor_adquisicion": round(coste, 2), "actualizacion": actualizacion,
|
|
"ganancia": ganancia, "aplazada": aplazada, "computable": round(ganancia + aplazada, 2),
|
|
"recompras": recompras, "avisos": avisos,
|
|
"lotes": [{"fecha": t["fecha"], "cantidad": t["cantidad"], "coste": round(t["coste"], 2)} for t in trozos]})
|
|
|
|
# ── la regla de la recompra ──
|
|
def plazo(self, a: dict, fecha: str) -> int | None:
|
|
"""Meses del plazo de recompra para ese activo vendido ese día; None si no le toca la regla."""
|
|
if a["tipo"] == "Cripto":
|
|
regla = recompra_cripto_en(self.recompra_cripto, int(fecha[:4]))
|
|
return regla["meses"] if regla else None
|
|
if a["tipo"] in ("Accion", "ETF"):
|
|
return PLAZO_COTIZADOS
|
|
if a["tipo"] == "Fondo":
|
|
return int(self.opciones["plazo_fondos"])
|
|
return None
|
|
|
|
def recompra(self, m, a, bolsa, cantidad, perdida) -> tuple[list[dict], float]:
|
|
"""Cuánto de esa pérdida queda aplazado por haber comprado el mismo valor
|
|
cerca de la venta. Devuelve (las compras que la bloquean, importe aplazado > 0)."""
|
|
meses = self.plazo(a, m["fecha"])
|
|
if not meses or cantidad <= SIN_CANTIDAD:
|
|
return [], 0.0
|
|
desde, hasta = _mas_meses(m["fecha"], -meses), _mas_meses(m["fecha"], meses)
|
|
candidatas = [] # (id de la compra, fecha, cantidad que puede bloquear)
|
|
# Compradas antes: solo lo que sigue en cartera después de esta venta
|
|
vivas: dict[str, float] = {}
|
|
for lote in self.lotes.get(bolsa, []):
|
|
clave = lote.get("entrada") or lote["raiz"]
|
|
vivas[clave] = vivas.get(clave, 0.0) + lote["cantidad"]
|
|
for c in self.compras.get(bolsa, []):
|
|
if desde <= c["fecha"] <= m["fecha"] and c["id"] in vivas:
|
|
candidatas.append((c["id"], c["fecha"], vivas[c["id"]]))
|
|
elif m["fecha"] < c["fecha"] <= hasta:
|
|
candidatas.append((c["id"], c["fecha"], c["cantidad"]))
|
|
falta, recompras = cantidad, []
|
|
for cid, fecha, disponible in sorted(candidatas, key=lambda x: x[1]):
|
|
libre = disponible - self.bloqueando.get(cid, 0.0)
|
|
q = min(libre, falta)
|
|
if q <= SIN_CANTIDAD:
|
|
continue
|
|
self.bloqueando[cid] = self.bloqueando.get(cid, 0.0) + q
|
|
importe = round(-perdida * q / cantidad, 2)
|
|
self.aplazadas.append({"venta": m["id"], "fecha_venta": m["fecha"], "activo": a["id"], "nombre": a["nombre"],
|
|
"compra": cid, "fecha_compra": fecha, "cantidad": q, "pendiente": importe})
|
|
recompras.append({"fecha": fecha, "cantidad": q, "importe": importe})
|
|
falta -= q
|
|
if falta <= SIN_CANTIDAD:
|
|
break
|
|
return recompras, round(sum(r["importe"] for r in recompras), 2)
|
|
|
|
def mudar(self, trozo, entrada: str, escala: float):
|
|
"""Un traspaso no libera la pérdida aplazada: lo que bloqueaban esas
|
|
participaciones pasa a bloquearlo lo recibido en el fondo de destino."""
|
|
clave = trozo.get("entrada") or trozo["raiz"]
|
|
falta = trozo["cantidad"]
|
|
for x in list(self.aplazadas):
|
|
if x["compra"] != clave or x["cantidad"] <= SIN_CANTIDAD or falta <= SIN_CANTIDAD:
|
|
continue
|
|
q = min(falta, x["cantidad"])
|
|
importe = round(x["pendiente"] * q / x["cantidad"], 2)
|
|
x["cantidad"] -= q
|
|
x["pendiente"] = round(x["pendiente"] - importe, 2)
|
|
self.bloqueando[clave] = max(self.bloqueando.get(clave, 0.0) - q, 0.0)
|
|
self.bloqueando[entrada] = self.bloqueando.get(entrada, 0.0) + q * escala
|
|
self.aplazadas.append({**x, "compra": entrada, "cantidad": q * escala, "pendiente": importe})
|
|
falta -= q
|
|
|
|
def liberar(self, m, a, trozos):
|
|
"""Al vender lo recomprado, la pérdida que bloqueaba se integra, en proporción."""
|
|
for t in trozos:
|
|
clave = t.get("entrada") or t["raiz"]
|
|
for x in self.aplazadas:
|
|
if x["compra"] != clave or x["cantidad"] <= SIN_CANTIDAD or x["pendiente"] <= 0:
|
|
continue
|
|
q = min(t["cantidad"], x["cantidad"])
|
|
importe = round(x["pendiente"] * q / x["cantidad"], 2)
|
|
x["cantidad"] -= q
|
|
x["pendiente"] = round(x["pendiente"] - importe, 2)
|
|
self.bloqueando[clave] = max(self.bloqueando.get(clave, 0.0) - q, 0.0)
|
|
if importe:
|
|
self.integradas.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"],
|
|
"nombre": a["nombre"], "importe": -importe,
|
|
"fecha_venta": x["fecha_venta"], "nombre_venta": x["nombre"]})
|
|
|
|
def comision(self, m, a, bolsa):
|
|
cantidad = _f(m.get("cantidad"))
|
|
if cantidad <= SIN_CANTIDAD:
|
|
return
|
|
precio = self.mercado(a, m["fecha"]) if a["tipo"] == "Cripto" else None
|
|
if self.opciones["comision"] == "transmision" and precio:
|
|
self.transmitir(m, a, bolsa, cantidad, cantidad * precio, "Comisión pagada en la propia moneda")
|
|
return
|
|
# Sin efecto: lo que costó lo que se pierde se reparte entre lo que queda
|
|
trozos, _ = self.consumir(bolsa, cantidad)
|
|
perdido = sum(t["coste"] for t in trozos)
|
|
resto = self.lotes.get(bolsa, [])
|
|
total = sum(x["cantidad"] for x in resto)
|
|
for x in resto:
|
|
x["coste"] += perdido * x["cantidad"] / total if total else 0.0
|
|
|
|
def entrada_gratis(self, m, a, bolsa):
|
|
cantidad = _f(m.get("cantidad"))
|
|
valor = _f(m.get("valor_recibido"))
|
|
if not valor:
|
|
precio = self.mercado(a, m["fecha"])
|
|
valor = cantidad * precio if precio else 0.0
|
|
if not precio:
|
|
self.tarea(m, "No sé cuánto valía ese día lo recibido: pon el valor en la operación.")
|
|
self.recibido.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"],
|
|
"tipo": m["tipo"], "actividad": m.get("actividad") or "",
|
|
"cantidad": cantidad, "valor": round(valor, 2)})
|
|
coste = valor if self.opciones["sin_desembolso"] == "mercado" else 0.0
|
|
self.abrir(bolsa, m["fecha"], cantidad, coste, m)
|
|
|
|
def traspaso(self, sal, ent):
|
|
"""Diferimiento: los lotes pasan del fondo de origen al de destino con su coste y su fecha."""
|
|
a, b = self.activos[sal["activo"]], self.activos[ent["activo"]]
|
|
origen, destino = _bolsa(a), _bolsa(b)
|
|
q_sal, q_ent = _f(sal.get("cantidad")), _f(ent.get("cantidad"))
|
|
if origen == destino:
|
|
# La misma moneda en dos activos: no pasa nada; lo que no llega es la comisión
|
|
if q_sal - q_ent > SIN_CANTIDAD:
|
|
self.comision({**sal, "cantidad": q_sal - q_ent}, a, origen)
|
|
return
|
|
trozos, falta = self.consumir(origen, q_sal)
|
|
if falta > max(q_sal * 1e-6, SIN_CANTIDAD):
|
|
self.tarea(sal, f"Traspaso de {q_sal:g} sin compras anteriores suficientes en «{a['nombre']}».", "error")
|
|
coste = sum(t["coste"] for t in trozos)
|
|
declarado = _f(ent.get("coste_traspaso"))
|
|
# El coste fiscal que dio la gestora en la orden del traspaso manda sobre el reconstruido
|
|
factor = declarado / coste if declarado > 0 and coste > 0 else 1.0
|
|
cubierto = sum(t["cantidad"] for t in trozos) or 1.0
|
|
for t in trozos:
|
|
self.abrir(destino, t["fecha"], q_ent * t["cantidad"] / cubierto, t["coste"] * factor, ent,
|
|
traspasado=True, sin_fecha=t.get("sin_fecha", False))
|
|
nuevo = self.lotes[destino][-1]
|
|
nuevo["raiz"], nuevo["entrada"] = t["raiz"], ent["id"]
|
|
self.mudar(t, ent["id"], q_ent / q_sal if q_sal else 1.0)
|
|
if not trozos:
|
|
self.abrir(destino, ent["fecha"], q_ent, declarado or _importe(ent), ent, traspasado=True, sin_fecha=True)
|
|
|
|
def procesar(self, hasta: str):
|
|
rango = {"Compra": 0, "Mineria": 0, "Recompensa": 0, "Traspaso_Entrada": 0, "Traspaso_Salida": 1}
|
|
# Se procesa TODO, también lo posterior al ejercicio: una compra de enero bloquea una pérdida de diciembre
|
|
movs = sorted(self.movs, key=lambda m: (m["fecha"], rango.get(m["tipo"], 2), m.get("orden") or 0, m["id"]))
|
|
for m in movs:
|
|
a = self.activos[m["activo"]]
|
|
# En criptomonedas cuenta toda adquisición (también lo minado) y no los traslados entre lugares tuyos
|
|
# (una compra de diciembre puede bloquear una venta de enero del primer año de la regla)
|
|
if a["tipo"] == "Cripto":
|
|
cuenta = m["tipo"] in ENTRA and self.recompra_cripto is not None
|
|
else:
|
|
cuenta = m["tipo"] in ("Compra", "Traspaso_Entrada") and self.plazo(a, m["fecha"]) is not None
|
|
if cuenta and _f(m.get("cantidad")) > SIN_CANTIDAD:
|
|
self.compras.setdefault(_bolsa(a), []).append(
|
|
{"id": m["id"], "fecha": m["fecha"], "cantidad": _f(m.get("cantidad"))})
|
|
# Cada traspaso de salida, con su entrada del mismo día y el mismo importe
|
|
entradas = [m for m in movs if m["tipo"] == "Traspaso_Entrada"]
|
|
pareja, usadas = {}, set()
|
|
for s in (m for m in movs if m["tipo"] == "Traspaso_Salida"):
|
|
val = _importe(s)
|
|
candidatas = [e for e in entradas if e["fecha"] == s["fecha"] and e["id"] not in usadas
|
|
and e["activo"] != s["activo"] and abs(_importe(e) - val) <= max(val * 0.01, 1.0)]
|
|
if candidatas:
|
|
e = min(candidatas, key=lambda e: abs(_importe(e) - val))
|
|
pareja[s["id"]] = e
|
|
usadas.add(e["id"])
|
|
for m in movs:
|
|
if self.foto is None and m["fecha"] > hasta:
|
|
self.foto = self.existencias(hasta)
|
|
a = self.activos[m["activo"]]
|
|
if a["tipo"] == "RentaFija":
|
|
# Cupones, intereses y lo que se gana o pierde al devolverse o venderse: rendimientos del capital mobiliario
|
|
if m["tipo"] == "Dividendo":
|
|
self.rendimiento(m, a)
|
|
elif m["tipo"] == "Reembolso":
|
|
suyos = [x for x in self.movs if x["activo"] == a["id"]]
|
|
bruto = _rentafija.rendimiento_reembolso(m, suyos, _f((a.get("renta_fija") or {}).get("nominal")))
|
|
self.rendimientos.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"],
|
|
"concepto": "Renta fija: devolución o venta", "bruto": bruto,
|
|
"retencion": round(_f(m.get("retencion")), 2),
|
|
"neto": round(bruto - _f(m.get("retencion")), 2)})
|
|
continue
|
|
if a["tipo"] == "Inmueble":
|
|
continue # alquiler y venta tienen su cálculo propio (alquiler.py)
|
|
if a["tipo"] in EXCLUIDOS or por_valor(a):
|
|
motivo = EXCLUIDOS.get(a["tipo"], "solo se apunta su valor total: sin participaciones no hay lotes")
|
|
if motivo and m["tipo"] in TRANSMITE | {"Compra"}:
|
|
self.fuera.setdefault(a["id"], {"activo": a["id"], "nombre": a["nombre"], "motivo": motivo,
|
|
"cobrado": {}})
|
|
if m["tipo"] in TRANSMITE:
|
|
año = m["fecha"][:4]
|
|
self.fuera[a["id"]]["cobrado"][año] = round(
|
|
self.fuera[a["id"]]["cobrado"].get(año, 0.0) + _importe(m), 2)
|
|
if m["tipo"] == "Dividendo":
|
|
self.rendimiento(m, a)
|
|
continue
|
|
bolsa, t = _bolsa(a), m["tipo"]
|
|
if t == "Compra":
|
|
self.abrir(bolsa, m["fecha"], _f(m.get("cantidad")), _importe(m), m)
|
|
elif t in ("Mineria", "Recompensa"):
|
|
self.entrada_gratis(m, a, bolsa)
|
|
elif t in TRANSMITE:
|
|
self.transmitir(m, a, bolsa, _f(m.get("cantidad")), _importe(m),
|
|
"Venta" if t == "Venta" else "Reembolso")
|
|
elif t == "Comision":
|
|
self.comision(m, a, bolsa)
|
|
elif t == "Traspaso_Salida":
|
|
if m["id"] in pareja:
|
|
self.traspaso(m, pareja[m["id"]])
|
|
else:
|
|
self.consumir(bolsa, _f(m.get("cantidad")))
|
|
self.tarea(m, "Traspaso de salida sin su entrada: no sé a qué fondo fue.")
|
|
elif t == "Traspaso_Entrada":
|
|
if m["id"] not in usadas:
|
|
# Viene de un fondo que no está en la cartera: se sabe el coste, no las fechas
|
|
self.abrir(bolsa, m["fecha"], _f(m.get("cantidad")),
|
|
_f(m.get("coste_traspaso")) or _importe(m), m, traspasado=True, sin_fecha=True)
|
|
elif t == "Dividendo":
|
|
self.rendimiento(m, a)
|
|
elif t in ("Gasto", "Devolucion"):
|
|
signo = 1 if t == "Gasto" else -1
|
|
self.gastos.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"],
|
|
"actividad": m.get("actividad") or "", "importe": round(signo * _f(m.get("importe")), 2),
|
|
"notas": m.get("notas") or "", "amortizable": bool(m.get("amortizable")),
|
|
"estimado": bool(m.get("estimado") or m.get("pendiente"))})
|
|
|
|
def cerrar(self, hasta: str):
|
|
if self.foto is None:
|
|
self.foto = self.existencias(hasta)
|
|
|
|
def rendimiento(self, m, a):
|
|
if m.get("impagado"):
|
|
return
|
|
neto, retencion = _f(m.get("importe")), _f(m.get("retencion"))
|
|
self.rendimientos.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"],
|
|
"concepto": CONCEPTO_RENDIMIENTO.get(a["tipo"], "Otros rendimientos"),
|
|
"bruto": round(neto + retencion, 2), "retencion": round(retencion, 2),
|
|
"neto": round(neto, 2)})
|
|
|
|
def existencias(self, fecha: str) -> list[dict]:
|
|
"""Lo que queda sin vender ese día, bolsa a bolsa, con su coste y su valor."""
|
|
salida = []
|
|
for bolsa, lotes in self.lotes.items():
|
|
cantidad = sum(x["cantidad"] for x in lotes)
|
|
if cantidad <= SIN_CANTIDAD:
|
|
continue
|
|
suyos = [a for a in self.activos.values() if _bolsa(a) == bolsa and a["tipo"] not in EXCLUIDOS]
|
|
a = suyos[0]
|
|
precio = self.mercado(a, fecha)
|
|
salida.append({"bolsa": bolsa, "nombre": a["nombre"] if len(suyos) == 1 else a["nombre"].split(" en ")[0],
|
|
"clase": CLASES.get(a["tipo"], "Otros"), "cantidad": cantidad,
|
|
"coste": round(sum(x["coste"] for x in lotes), 2),
|
|
"valor": round(cantidad * precio, 2) if precio else None,
|
|
"lotes": len(lotes), "primer_lote": lotes[0]["fecha"]})
|
|
return sorted(salida, key=lambda x: (x["clase"], x["nombre"]))
|
|
|
|
|
|
def informe(activos: list[dict], movimientos: list[dict], precios: list[dict], ejercicio: int,
|
|
opciones: dict | None = None, citas: dict | None = None, actividad: dict | None = None,
|
|
recompra_cripto: dict | None = None, coeficientes: dict | None = None) -> dict:
|
|
"""Lo que hay que mirar para declarar ese ejercicio. «actividad» son las
|
|
reglas del territorio para las actividades económicas (ver actividad.py);
|
|
«recompra_cripto», su regla de la recompra para criptomonedas, si la tiene."""
|
|
motor = _Motor(activos, movimientos, precios, opciones, recompra_cripto, coeficientes)
|
|
fin = f"{ejercicio}-12-31"
|
|
motor.procesar(fin)
|
|
motor.cerrar(fin)
|
|
año = str(ejercicio)
|
|
del_año = lambda lista: [x for x in lista if x["fecha"].startswith(año)] # noqa: E731
|
|
transmisiones = del_año(motor.transmisiones)
|
|
por_clase: dict[str, dict] = {}
|
|
for t in transmisiones:
|
|
c = por_clase.setdefault(t["clase"], {"clase": t["clase"], "transmisiones": 0, "valor_transmision": 0.0,
|
|
"valor_adquisicion": 0.0, "ganancias": 0.0, "perdidas": 0.0})
|
|
c["transmisiones"] += 1
|
|
c["valor_transmision"] += t["valor_transmision"]
|
|
c["valor_adquisicion"] += t["valor_adquisicion"]
|
|
c["ganancias" if t["ganancia"] >= 0 else "perdidas"] += t["ganancia"]
|
|
c["aplazadas"] = c.get("aplazadas", 0.0) + t["aplazada"]
|
|
integradas = del_año(motor.integradas)
|
|
for c in por_clase.values():
|
|
for k in ("valor_transmision", "valor_adquisicion", "ganancias", "perdidas", "aplazadas"):
|
|
c[k] = round(c[k], 2)
|
|
c["neto"] = round(c["ganancias"] + c["perdidas"], 2)
|
|
aplazado = round(sum(t["aplazada"] for t in transmisiones), 2)
|
|
integrado = round(sum(x["importe"] for x in integradas), 2)
|
|
rendimientos = del_año(motor.rendimientos)
|
|
por_concepto: dict[str, dict] = {}
|
|
for r in rendimientos:
|
|
c = por_concepto.setdefault(r["concepto"], {"concepto": r["concepto"], "bruto": 0.0, "retencion": 0.0, "neto": 0.0})
|
|
for k in ("bruto", "retencion", "neto"):
|
|
c[k] = round(c[k] + r[k], 2)
|
|
recibido = del_año(motor.recibido)
|
|
# Lo pendiente que afecta a ESTE ejercicio: sus propias operaciones y lo estimado que sigue sin confirmar
|
|
tareas = del_año(motor.tareas)
|
|
tareas += [{"movimiento": m["id"], "fecha": m["fecha"], "activo": m["activo"], "clase": "aviso",
|
|
"texto": "Operación con cifras estimadas o sin completar."}
|
|
for m in motor.movs if m["fecha"].startswith(año) and (m.get("estimado") or m.get("pendiente"))
|
|
and m["id"] not in {t["movimiento"] for t in tareas}]
|
|
return {
|
|
"ejercicio": ejercicio,
|
|
"ejercicios": ejercicios(motor.movs),
|
|
"transmisiones": transmisiones,
|
|
"por_clase": sorted(por_clase.values(), key=lambda c: c["clase"]),
|
|
"total": {"ganancias": round(sum(c["ganancias"] for c in por_clase.values()), 2),
|
|
"perdidas": round(sum(c["perdidas"] for c in por_clase.values()), 2),
|
|
"neto": round(sum(c["neto"] for c in por_clase.values()), 2),
|
|
# Lo que de verdad se resta o suma este año, con la regla de la recompra
|
|
"aplazadas": aplazado, "integradas": integrado,
|
|
"computable": round(sum(c["neto"] for c in por_clase.values()) + aplazado + integrado, 2)},
|
|
"integradas": integradas,
|
|
# Pérdidas aplazadas que siguen esperando a que se venda lo recomprado (de ventas hasta este ejercicio)
|
|
"pendientes": [{k: x[k] for k in ("fecha_venta", "nombre", "fecha_compra", "pendiente")}
|
|
for x in motor.aplazadas if x["pendiente"] > 0 and x["fecha_venta"] <= fin],
|
|
"rendimientos": rendimientos,
|
|
"por_concepto": sorted(por_concepto.values(), key=lambda c: c["concepto"]),
|
|
"recibido": recibido,
|
|
"total_recibido": round(sum(r["valor"] for r in recibido), 2),
|
|
"gastos": del_año(motor.gastos),
|
|
# El rendimiento de cada actividad económica (la minería): va a la parte general
|
|
"actividades": _actividad.rendimientos(motor.gastos, motor.recibido, ejercicio, motor.opciones, actividad)
|
|
if actividad else [],
|
|
"existencias": motor.foto,
|
|
"fuera": [{**f, "cobrado": f["cobrado"].get(año, 0.0)} for f in motor.fuera.values()],
|
|
"tareas": sorted(tareas, key=lambda t: (t["clase"] != "error", t["fecha"])),
|
|
"opciones": motor.opciones,
|
|
"opciones_posibles": OPCIONES,
|
|
"criterios": criterios_de(motor.opciones, citas, recompra_cripto_en(recompra_cripto, ejercicio))
|
|
+ ([{"id": "actualizacion", "titulo": "El coste se actualiza con coeficientes", "texto": coeficientes["texto"]
|
|
+ ("" if coeficientes["verificado"] else " " + coeficientes["nota"]), "fuente": coeficientes["cita"]}] if coeficientes else []),
|
|
}
|