services.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. from decimal import Decimal
  2. from django.utils import timezone
  3. from .models import Dividendo, Divisa, Operacion, Valor
  4. CENT = Decimal('0.01')
  5. def actualizar_cambios():
  6. """Actualiza el cambio a euros de cada divisa desde Yahoo Finance.
  7. Devuelve (correctos, errores) donde errores es una lista de
  8. (par, motivo).
  9. """
  10. import yfinance as yf
  11. correctos = 0
  12. errores = []
  13. for divisa in Divisa.objects.exclude(codigo='EUR'):
  14. par = f'{divisa.codigo}EUR=X'
  15. try:
  16. cierre = yf.Ticker(par).history(period='5d')['Close']
  17. cambio = Decimal(str(cierre.iloc[-1])).quantize(Decimal('0.000001'))
  18. except Exception as e:
  19. errores.append((par, str(e)[:100]))
  20. continue
  21. divisa.cambio_eur = cambio
  22. divisa.actualizado_en = timezone.now()
  23. divisa.save(update_fields=['cambio_eur', 'actualizado_en'])
  24. correctos += 1
  25. return correctos, errores
  26. def actualizar_precios():
  27. """Obtiene la última cotización de cada valor desde Yahoo Finance.
  28. Devuelve (correctos, errores) donde errores es una lista de
  29. (ticker, motivo).
  30. """
  31. import yfinance as yf
  32. correctos = 0
  33. errores = []
  34. for valor in Valor.objects.all():
  35. try:
  36. cierre = yf.Ticker(valor.ticker).history(period='5d')['Close']
  37. precio = Decimal(str(cierre.iloc[-1])).quantize(Decimal('0.0001'))
  38. except Exception as e:
  39. errores.append((valor.ticker, str(e)[:100]))
  40. continue
  41. valor.precio_actual = precio
  42. valor.actualizado_en = timezone.now()
  43. valor.save(update_fields=['precio_actual', 'actualizado_en'])
  44. correctos += 1
  45. return correctos, errores
  46. def posiciones():
  47. """Calcula la situación de cada valor con operaciones registradas.
  48. Devuelve una lista de diccionarios ordenada por ticker con:
  49. - acciones en cartera, precio medio ponderado e importe invertido
  50. - plusvalías realizadas (ventas) según FIFO
  51. - dividendos netos cobrados
  52. Las posiciones cerradas usan el cambio congelado en cada registro:
  53. no varían al actualizar cotizaciones ni tipos de cambio.
  54. """
  55. resultado = []
  56. for valor in Valor.objects.select_related('divisa'):
  57. ops = valor.operaciones.order_by('fecha', 'pk')
  58. if not ops and not valor.dividendos.exists():
  59. continue
  60. acciones = Decimal('0')
  61. coste = Decimal('0') # coste de las acciones aún en cartera
  62. precio_medio = Decimal('0')
  63. comisiones = Decimal('0')
  64. for op in ops:
  65. comisiones += op.comisiones
  66. if op.tipo == Operacion.COMPRA:
  67. acciones += op.cantidad
  68. coste += op.cantidad * op.precio + op.comisiones
  69. if acciones > 0:
  70. precio_medio = (coste / acciones).quantize(Decimal('0.0001'))
  71. else:
  72. if acciones <= 0:
  73. continue
  74. pm = coste / acciones
  75. vendidas = min(op.cantidad, acciones)
  76. acciones -= vendidas
  77. coste -= vendidas * pm
  78. if acciones > 0:
  79. precio_medio = (coste / acciones).quantize(Decimal('0.0001'))
  80. else:
  81. precio_medio = Decimal('0')
  82. filas_fifo = _fifo_de_operaciones(valor, ops)
  83. plusvalias = sum(
  84. (f['resultado'] for f in filas_fifo if f['resultado'] is not None),
  85. Decimal('0'),
  86. )
  87. plusvalias_eur = sum(
  88. (f['resultado_eur'] for f in filas_fifo if f['resultado_eur'] is not None),
  89. Decimal('0'),
  90. )
  91. cambio = valor.divisa.cambio_eur
  92. dividendos = sum((d.neto for d in valor.dividendos.all()), Decimal('0'))
  93. dividendos_eur = sum(
  94. (d.neto * (d.cambio_eur or cambio) for d in valor.dividendos.all()),
  95. Decimal('0'),
  96. )
  97. valoracion = None
  98. latente = None
  99. if acciones > 0 and valor.precio_actual is not None:
  100. valoracion = (acciones * valor.precio_actual).quantize(CENT)
  101. latente = (valoracion - coste).quantize(CENT)
  102. def a_eur(importe):
  103. return None if importe is None else (importe * cambio).quantize(CENT)
  104. resultado.append({
  105. 'valor': valor,
  106. 'divisa': valor.divisa.codigo,
  107. 'cambio': cambio,
  108. 'acciones': acciones,
  109. 'precio_medio': precio_medio,
  110. 'invertido': coste.quantize(CENT),
  111. 'plusvalias': plusvalias.quantize(CENT),
  112. 'comisiones': comisiones,
  113. 'dividendos': dividendos,
  114. 'valoracion': valoracion,
  115. 'latente': latente,
  116. # abiertas: cambio actual; cerradas: congelado en cada registro
  117. 'invertido_eur': a_eur(coste),
  118. 'plusvalias_eur': plusvalias_eur.quantize(CENT),
  119. 'dividendos_eur': dividendos_eur.quantize(CENT),
  120. 'valoracion_eur': a_eur(valoracion),
  121. 'latente_eur': a_eur(latente),
  122. })
  123. return sorted(resultado, key=lambda p: p['valor'].ticker)
  124. def totales(posiciones_lista=None):
  125. """Agrega los importes globales de la cartera, en euros."""
  126. posiciones_lista = posiciones_lista if posiciones_lista is not None else posiciones()
  127. abiertas = [p for p in posiciones_lista if p['acciones'] > 0]
  128. con_precio = [p for p in abiertas if p['valoracion'] is not None]
  129. def suma(clave, lista):
  130. return sum((p[clave] for p in lista), Decimal('0'))
  131. return {
  132. 'invertido': suma('invertido_eur', abiertas),
  133. 'plusvalias': suma('plusvalias_eur', posiciones_lista),
  134. 'dividendos': suma('dividendos_eur', posiciones_lista),
  135. 'num_abiertas': len(abiertas),
  136. 'valoracion': suma('valoracion_eur', con_precio),
  137. 'latente': (
  138. suma('latente_eur', con_precio)
  139. if len(con_precio) == len(abiertas) else None
  140. ),
  141. }
  142. def _fifo_de_operaciones(valor, ops):
  143. """Desglose FIFO de un valor concreto.
  144. Cada venta convierte su resultado a euros con el cambio congelado
  145. en la propia operación (o el vigente si es antigua y no lo tiene).
  146. """
  147. from collections import deque
  148. lotes = deque() # pares [cantidad disponible, coste unitario efectivo]
  149. filas = []
  150. for op in ops:
  151. if op.tipo == Operacion.COMPRA:
  152. coste_unitario = (op.cantidad * op.precio + op.comisiones) / op.cantidad
  153. lotes.append([op.cantidad, coste_unitario])
  154. elif op.tipo == Operacion.VENTA:
  155. por_vender = op.cantidad
  156. comision_unitaria = op.comisiones / op.cantidad
  157. cambio_venta = op.cambio_eur or valor.divisa.cambio_eur
  158. while por_vender > 0 and lotes:
  159. disponibles, coste_lote = lotes[0]
  160. usadas = min(por_vender, disponibles)
  161. importe = usadas * (op.precio - comision_unitaria)
  162. coste = usadas * coste_lote
  163. resultado = (importe - coste).quantize(CENT)
  164. filas.append({
  165. 'fecha': op.fecha,
  166. 'valor': valor,
  167. 'divisa': valor.divisa.codigo,
  168. 'cantidad': usadas,
  169. 'precio_venta': op.precio,
  170. 'importe': importe.quantize(CENT),
  171. 'coste': coste.quantize(CENT),
  172. 'resultado': resultado,
  173. 'resultado_eur': (
  174. None if resultado is None
  175. else (resultado * cambio_venta).quantize(CENT)
  176. ),
  177. })
  178. por_vender -= usadas
  179. if disponibles > usadas:
  180. lotes[0][0] = disponibles - usadas
  181. else:
  182. lotes.popleft()
  183. if por_vender > 0:
  184. # venta sin acciones suficientes registradas (dato inconsistente)
  185. filas.append({
  186. 'fecha': op.fecha,
  187. 'valor': valor,
  188. 'divisa': valor.divisa.codigo,
  189. 'cantidad': por_vender,
  190. 'precio_venta': op.precio,
  191. 'importe': Decimal('0'),
  192. 'coste': Decimal('0'),
  193. 'resultado': None,
  194. 'resultado_eur': None,
  195. })
  196. return filas
  197. def desglose_fifo():
  198. """Desglosa cada venta con su coste de adquisición según FIFO.
  199. Devuelve una lista de filas (una por lote consumido) ordenada por fecha,
  200. con la plusvalía o pérdida realizada en cada una.
  201. """
  202. filas = []
  203. for valor in Valor.objects.select_related('divisa'):
  204. filas += _fifo_de_operaciones(valor, valor.operaciones.order_by('fecha', 'pk'))
  205. filas.sort(key=lambda f: f['fecha'])
  206. return filas
  207. def resumen_fiscal(filas=None):
  208. """Agrupa el desglose FIFO por año natural, en euros (cambio de cada venta)."""
  209. from collections import defaultdict
  210. filas = filas if filas is not None else desglose_fifo()
  211. por_anyo = defaultdict(lambda: Decimal('0'))
  212. for f in filas:
  213. if f['resultado_eur'] is not None:
  214. por_anyo[f['fecha'].year] += f['resultado_eur']
  215. return {anyo: imp.quantize(CENT) for anyo, imp in sorted(por_anyo.items())}