|
|
@@ -2,7 +2,34 @@ from decimal import Decimal
|
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
|
-from .models import Dividendo, Operacion, Valor
|
|
|
+from .models import Dividendo, Divisa, Operacion, Valor
|
|
|
+
|
|
|
+CENT = Decimal('0.01')
|
|
|
+
|
|
|
+
|
|
|
+def actualizar_cambios():
|
|
|
+ """Actualiza el cambio a euros de cada divisa desde Yahoo Finance.
|
|
|
+
|
|
|
+ Devuelve (correctos, errores) donde errores es una lista de
|
|
|
+ (par, motivo).
|
|
|
+ """
|
|
|
+ import yfinance as yf
|
|
|
+
|
|
|
+ correctos = 0
|
|
|
+ errores = []
|
|
|
+ for divisa in Divisa.objects.exclude(codigo='EUR'):
|
|
|
+ par = f'{divisa.codigo}EUR=X'
|
|
|
+ try:
|
|
|
+ cierre = yf.Ticker(par).history(period='5d')['Close']
|
|
|
+ cambio = Decimal(str(cierre.iloc[-1])).quantize(Decimal('0.000001'))
|
|
|
+ except Exception as e:
|
|
|
+ errores.append((par, str(e)[:100]))
|
|
|
+ continue
|
|
|
+ divisa.cambio_eur = cambio
|
|
|
+ divisa.actualizado_en = timezone.now()
|
|
|
+ divisa.save(update_fields=['cambio_eur', 'actualizado_en'])
|
|
|
+ correctos += 1
|
|
|
+ return correctos, errores
|
|
|
|
|
|
|
|
|
def actualizar_precios():
|
|
|
@@ -34,11 +61,14 @@ def posiciones():
|
|
|
|
|
|
Devuelve una lista de diccionarios ordenada por ticker con:
|
|
|
- acciones en cartera, precio medio ponderado e importe invertido
|
|
|
- - plusvalías realizadas (ventas) según precio medio ponderado
|
|
|
+ - plusvalías realizadas (ventas) según FIFO
|
|
|
- dividendos netos cobrados
|
|
|
+
|
|
|
+ Las posiciones cerradas usan el cambio congelado en cada registro:
|
|
|
+ no varían al actualizar cotizaciones ni tipos de cambio.
|
|
|
"""
|
|
|
resultado = []
|
|
|
- for valor in Valor.objects.all():
|
|
|
+ for valor in Valor.objects.select_related('divisa'):
|
|
|
ops = valor.operaciones.order_by('fecha', 'pk')
|
|
|
if not ops and not valor.dividendos.exists():
|
|
|
continue
|
|
|
@@ -46,7 +76,6 @@ def posiciones():
|
|
|
acciones = Decimal('0')
|
|
|
coste = Decimal('0') # coste de las acciones aún en cartera
|
|
|
precio_medio = Decimal('0')
|
|
|
- plusvalias = Decimal('0') # realizadas en ventas
|
|
|
comisiones = Decimal('0')
|
|
|
|
|
|
for op in ops:
|
|
|
@@ -61,8 +90,6 @@ def posiciones():
|
|
|
continue
|
|
|
pm = coste / acciones
|
|
|
vendidas = min(op.cantidad, acciones)
|
|
|
- ingresos = op.cantidad * op.precio - op.comisiones
|
|
|
- plusvalias += ingresos - (vendidas * pm)
|
|
|
acciones -= vendidas
|
|
|
coste -= vendidas * pm
|
|
|
if acciones > 0:
|
|
|
@@ -70,107 +97,156 @@ def posiciones():
|
|
|
else:
|
|
|
precio_medio = Decimal('0')
|
|
|
|
|
|
+ filas_fifo = _fifo_de_operaciones(valor, ops)
|
|
|
+ plusvalias = sum(
|
|
|
+ (f['resultado'] for f in filas_fifo if f['resultado'] is not None),
|
|
|
+ Decimal('0'),
|
|
|
+ )
|
|
|
+ plusvalias_eur = sum(
|
|
|
+ (f['resultado_eur'] for f in filas_fifo if f['resultado_eur'] is not None),
|
|
|
+ Decimal('0'),
|
|
|
+ )
|
|
|
+
|
|
|
+ cambio = valor.divisa.cambio_eur
|
|
|
dividendos = sum((d.neto for d in valor.dividendos.all()), Decimal('0'))
|
|
|
+ dividendos_eur = sum(
|
|
|
+ (d.neto * (d.cambio_eur or cambio) for d in valor.dividendos.all()),
|
|
|
+ Decimal('0'),
|
|
|
+ )
|
|
|
|
|
|
valoracion = None
|
|
|
latente = None
|
|
|
if acciones > 0 and valor.precio_actual is not None:
|
|
|
- valoracion = (acciones * valor.precio_actual).quantize(Decimal('0.01'))
|
|
|
- latente = (valoracion - coste).quantize(Decimal('0.01'))
|
|
|
+ valoracion = (acciones * valor.precio_actual).quantize(CENT)
|
|
|
+ latente = (valoracion - coste).quantize(CENT)
|
|
|
+
|
|
|
+ def a_eur(importe):
|
|
|
+ return None if importe is None else (importe * cambio).quantize(CENT)
|
|
|
|
|
|
resultado.append({
|
|
|
'valor': valor,
|
|
|
+ 'divisa': valor.divisa.codigo,
|
|
|
+ 'cambio': cambio,
|
|
|
'acciones': acciones,
|
|
|
'precio_medio': precio_medio,
|
|
|
- 'invertido': coste.quantize(Decimal('0.01')),
|
|
|
- 'plusvalias': plusvalias.quantize(Decimal('0.01')),
|
|
|
+ 'invertido': coste.quantize(CENT),
|
|
|
+ 'plusvalias': plusvalias.quantize(CENT),
|
|
|
'comisiones': comisiones,
|
|
|
'dividendos': dividendos,
|
|
|
'valoracion': valoracion,
|
|
|
'latente': latente,
|
|
|
+ # abiertas: cambio actual; cerradas: congelado en cada registro
|
|
|
+ 'invertido_eur': a_eur(coste),
|
|
|
+ 'plusvalias_eur': plusvalias_eur.quantize(CENT),
|
|
|
+ 'dividendos_eur': dividendos_eur.quantize(CENT),
|
|
|
+ 'valoracion_eur': a_eur(valoracion),
|
|
|
+ 'latente_eur': a_eur(latente),
|
|
|
})
|
|
|
|
|
|
return sorted(resultado, key=lambda p: p['valor'].ticker)
|
|
|
|
|
|
|
|
|
def totales(posiciones_lista=None):
|
|
|
- """Agrega los importes globales de la cartera."""
|
|
|
+ """Agrega los importes globales de la cartera, en euros."""
|
|
|
posiciones_lista = posiciones_lista if posiciones_lista is not None else posiciones()
|
|
|
abiertas = [p for p in posiciones_lista if p['acciones'] > 0]
|
|
|
con_precio = [p for p in abiertas if p['valoracion'] is not None]
|
|
|
- invertido_con_precio = sum((p['invertido'] for p in con_precio), Decimal('0'))
|
|
|
+
|
|
|
+ def suma(clave, lista):
|
|
|
+ return sum((p[clave] for p in lista), Decimal('0'))
|
|
|
+
|
|
|
return {
|
|
|
- 'invertido': sum((p['invertido'] for p in abiertas), Decimal('0')),
|
|
|
- 'plusvalias': sum((p['plusvalias'] for p in posiciones_lista), Decimal('0')),
|
|
|
- 'dividendos': sum((p['dividendos'] for p in posiciones_lista), Decimal('0')),
|
|
|
+ 'invertido': suma('invertido_eur', abiertas),
|
|
|
+ 'plusvalias': suma('plusvalias_eur', posiciones_lista),
|
|
|
+ 'dividendos': suma('dividendos_eur', posiciones_lista),
|
|
|
'num_abiertas': len(abiertas),
|
|
|
- 'valoracion': sum((p['valoracion'] for p in con_precio), Decimal('0')),
|
|
|
+ 'valoracion': suma('valoracion_eur', con_precio),
|
|
|
'latente': (
|
|
|
- sum((p['latente'] for p in con_precio), Decimal('0'))
|
|
|
+ suma('latente_eur', con_precio)
|
|
|
if len(con_precio) == len(abiertas) else None
|
|
|
),
|
|
|
}
|
|
|
|
|
|
|
|
|
+def _fifo_de_operaciones(valor, ops):
|
|
|
+ """Desglose FIFO de un valor concreto.
|
|
|
+
|
|
|
+ Cada venta convierte su resultado a euros con el cambio congelado
|
|
|
+ en la propia operación (o el vigente si es antigua y no lo tiene).
|
|
|
+ """
|
|
|
+ from collections import deque
|
|
|
+
|
|
|
+ lotes = deque() # pares [cantidad disponible, coste unitario efectivo]
|
|
|
+ filas = []
|
|
|
+ for op in ops:
|
|
|
+ if op.tipo == Operacion.COMPRA:
|
|
|
+ coste_unitario = (op.cantidad * op.precio + op.comisiones) / op.cantidad
|
|
|
+ lotes.append([op.cantidad, coste_unitario])
|
|
|
+ elif op.tipo == Operacion.VENTA:
|
|
|
+ por_vender = op.cantidad
|
|
|
+ comision_unitaria = op.comisiones / op.cantidad
|
|
|
+ cambio_venta = op.cambio_eur or valor.divisa.cambio_eur
|
|
|
+ while por_vender > 0 and lotes:
|
|
|
+ disponibles, coste_lote = lotes[0]
|
|
|
+ usadas = min(por_vender, disponibles)
|
|
|
+ importe = usadas * (op.precio - comision_unitaria)
|
|
|
+ coste = usadas * coste_lote
|
|
|
+ resultado = (importe - coste).quantize(CENT)
|
|
|
+ filas.append({
|
|
|
+ 'fecha': op.fecha,
|
|
|
+ 'valor': valor,
|
|
|
+ 'divisa': valor.divisa.codigo,
|
|
|
+ 'cantidad': usadas,
|
|
|
+ 'precio_venta': op.precio,
|
|
|
+ 'importe': importe.quantize(CENT),
|
|
|
+ 'coste': coste.quantize(CENT),
|
|
|
+ 'resultado': resultado,
|
|
|
+ 'resultado_eur': (
|
|
|
+ None if resultado is None
|
|
|
+ else (resultado * cambio_venta).quantize(CENT)
|
|
|
+ ),
|
|
|
+ })
|
|
|
+ por_vender -= usadas
|
|
|
+ if disponibles > usadas:
|
|
|
+ lotes[0][0] = disponibles - usadas
|
|
|
+ else:
|
|
|
+ lotes.popleft()
|
|
|
+ if por_vender > 0:
|
|
|
+ # venta sin acciones suficientes registradas (dato inconsistente)
|
|
|
+ filas.append({
|
|
|
+ 'fecha': op.fecha,
|
|
|
+ 'valor': valor,
|
|
|
+ 'divisa': valor.divisa.codigo,
|
|
|
+ 'cantidad': por_vender,
|
|
|
+ 'precio_venta': op.precio,
|
|
|
+ 'importe': Decimal('0'),
|
|
|
+ 'coste': Decimal('0'),
|
|
|
+ 'resultado': None,
|
|
|
+ 'resultado_eur': None,
|
|
|
+ })
|
|
|
+ return filas
|
|
|
+
|
|
|
+
|
|
|
def desglose_fifo():
|
|
|
"""Desglosa cada venta con su coste de adquisición según FIFO.
|
|
|
|
|
|
Devuelve una lista de filas (una por lote consumido) ordenada por fecha,
|
|
|
con la plusvalía o pérdida realizada en cada una.
|
|
|
"""
|
|
|
- from collections import deque
|
|
|
-
|
|
|
filas = []
|
|
|
- for valor in Valor.objects.all():
|
|
|
- lotes = deque() # pares [cantidad disponible, coste unitario efectivo]
|
|
|
- for op in valor.operaciones.order_by('fecha', 'pk'):
|
|
|
- if op.tipo == Operacion.COMPRA:
|
|
|
- coste_unitario = (op.cantidad * op.precio + op.comisiones) / op.cantidad
|
|
|
- lotes.append([op.cantidad, coste_unitario])
|
|
|
- elif op.tipo == Operacion.VENTA:
|
|
|
- por_vender = op.cantidad
|
|
|
- comision_unitaria = op.comisiones / op.cantidad
|
|
|
- while por_vender > 0 and lotes:
|
|
|
- disponibles, coste_lote = lotes[0]
|
|
|
- usadas = min(por_vender, disponibles)
|
|
|
- importe = usadas * (op.precio - comision_unitaria)
|
|
|
- coste = usadas * coste_lote
|
|
|
- filas.append({
|
|
|
- 'fecha': op.fecha,
|
|
|
- 'valor': valor,
|
|
|
- 'cantidad': usadas,
|
|
|
- 'precio_venta': op.precio,
|
|
|
- 'importe': importe.quantize(Decimal('0.01')),
|
|
|
- 'coste': coste.quantize(Decimal('0.01')),
|
|
|
- 'resultado': (importe - coste).quantize(Decimal('0.01')),
|
|
|
- })
|
|
|
- por_vender -= usadas
|
|
|
- if disponibles > usadas:
|
|
|
- lotes[0][0] = disponibles - usadas
|
|
|
- else:
|
|
|
- lotes.popleft()
|
|
|
- if por_vender > 0:
|
|
|
- # venta sin acciones suficientes registradas (dato inconsistente)
|
|
|
- filas.append({
|
|
|
- 'fecha': op.fecha,
|
|
|
- 'valor': valor,
|
|
|
- 'cantidad': por_vender,
|
|
|
- 'precio_venta': op.precio,
|
|
|
- 'importe': Decimal('0'),
|
|
|
- 'coste': Decimal('0'),
|
|
|
- 'resultado': None,
|
|
|
- })
|
|
|
+ for valor in Valor.objects.select_related('divisa'):
|
|
|
+ filas += _fifo_de_operaciones(valor, valor.operaciones.order_by('fecha', 'pk'))
|
|
|
filas.sort(key=lambda f: f['fecha'])
|
|
|
return filas
|
|
|
|
|
|
|
|
|
def resumen_fiscal(filas=None):
|
|
|
- """Agrupa el desglose FIFO por año natural."""
|
|
|
+ """Agrupa el desglose FIFO por año natural, en euros (cambio de cada venta)."""
|
|
|
from collections import defaultdict
|
|
|
|
|
|
filas = filas if filas is not None else desglose_fifo()
|
|
|
por_anyo = defaultdict(lambda: Decimal('0'))
|
|
|
for f in filas:
|
|
|
- if f['resultado'] is not None:
|
|
|
- por_anyo[f['fecha'].year] += f['resultado']
|
|
|
- return {anyo: imp.quantize(Decimal('0.01')) for anyo, imp in sorted(por_anyo.items())}
|
|
|
+ if f['resultado_eur'] is not None:
|
|
|
+ por_anyo[f['fecha'].year] += f['resultado_eur']
|
|
|
+ return {anyo: imp.quantize(CENT) for anyo, imp in sorted(por_anyo.items())}
|