| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- from decimal import Decimal
- from django.utils import timezone
- 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():
- """Obtiene la última cotización de cada valor desde Yahoo Finance.
- Devuelve (correctos, errores) donde errores es una lista de
- (ticker, motivo).
- """
- import yfinance as yf
- correctos = 0
- errores = []
- for valor in Valor.objects.all():
- try:
- cierre = yf.Ticker(valor.ticker).history(period='5d')['Close']
- precio = Decimal(str(cierre.iloc[-1])).quantize(Decimal('0.0001'))
- except Exception as e:
- errores.append((valor.ticker, str(e)[:100]))
- continue
- valor.precio_actual = precio
- valor.actualizado_en = timezone.now()
- valor.save(update_fields=['precio_actual', 'actualizado_en'])
- correctos += 1
- return correctos, errores
- def posiciones():
- """Calcula la situación de cada valor con operaciones registradas.
- Devuelve una lista de diccionarios ordenada por ticker con:
- - acciones en cartera, precio medio ponderado e importe invertido
- - 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.select_related('divisa'):
- ops = valor.operaciones.order_by('fecha', 'pk')
- if not ops and not valor.dividendos.exists():
- continue
- acciones = Decimal('0')
- coste = Decimal('0') # coste de las acciones aún en cartera
- precio_medio = Decimal('0')
- comisiones = Decimal('0')
- for op in ops:
- comisiones += op.comisiones
- if op.tipo == Operacion.COMPRA:
- acciones += op.cantidad
- coste += op.cantidad * op.precio + op.comisiones
- if acciones > 0:
- precio_medio = (coste / acciones).quantize(Decimal('0.0001'))
- else:
- if acciones <= 0:
- continue
- pm = coste / acciones
- vendidas = min(op.cantidad, acciones)
- acciones -= vendidas
- coste -= vendidas * pm
- if acciones > 0:
- precio_medio = (coste / acciones).quantize(Decimal('0.0001'))
- 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(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(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, 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]
- def suma(clave, lista):
- return sum((p[clave] for p in lista), Decimal('0'))
- return {
- 'invertido': suma('invertido_eur', abiertas),
- 'plusvalias': suma('plusvalias_eur', posiciones_lista),
- 'dividendos': suma('dividendos_eur', posiciones_lista),
- 'num_abiertas': len(abiertas),
- 'valoracion': suma('valoracion_eur', con_precio),
- 'latente': (
- 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.
- """
- filas = []
- 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, 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_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())}
|