Renta Fija Local: Valorización#
En este notebook construiremos un valorizador de renta fija local similar al de la Bolsa de Comercio o RiskAmerica. Dado un nemotécnico y su TIR de mercado, obtendremos el precio, valor par, duración, convexidad y monto a pagar. Adicionalmente, con este valorizador podremos construir una funcionalidad adicional, valorizar un forward de renta fija.
Librerías#
from datetime import date
import pandas as pd
import numpy as np
from autograd import grad
import my_functions as fn
Data#
bonos = pd.read_excel('data/bonos_empresa_carga_inicial.xlsx')
bonos = bonos.append(pd.read_excel('data/bonos_estado_carga_inicial.xlsx'))
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
/tmp/ipykernel_2098/3609385080.py in ?()
----> 1 bonos = bonos.append(pd.read_excel('data/bonos_estado_carga_inicial.xlsx'))
/opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/pandas/core/generic.py in ?(self, name)
6295 and name not in self._accessors
6296 and self._info_axis._can_hold_identifiers_and_holds_name(name)
6297 ):
6298 return self[name]
-> 6299 return object.__getattribute__(self, name)
AttributeError: 'DataFrame' object has no attribute 'append'
tablas_desarrollo = pd.read_csv('data/tablas_desarrollo.csv')
tablas_desarrollo
nemotecnico | numero_cupon | fecha_vcto_cupon | interes | amortizacion | saldo_insoluto | |
---|---|---|---|---|---|---|
0 | BC18-A0719 | 1 | 2019-10-31 | 0.98534 | 0.00 | 100.00 |
1 | BC18-A0719 | 2 | 2020-01-31 | 0.98534 | 0.00 | 100.00 |
2 | BC18-A0719 | 3 | 2020-04-30 | 0.98534 | 0.00 | 100.00 |
3 | BC18-A0719 | 4 | 2020-07-31 | 0.98534 | 4.65 | 100.00 |
4 | BC18-A0719 | 5 | 2020-10-31 | 0.93952 | 0.00 | 95.35 |
... | ... | ... | ... | ... | ... | ... |
3277320 | BWATT-Q | 18 | 2028-09-01 | 1.93130 | 0.00 | 100.00 |
3277321 | BWATT-Q | 19 | 2029-03-01 | 1.93130 | 0.00 | 100.00 |
3277322 | BWATT-Q | 20 | 2029-09-01 | 1.93130 | 100.00 | 100.00 |
3277323 | XERO | 0 | 0001-01-01 | 0.00000 | 0.00 | 0.00 |
3277324 | ZERO | 0 | 0001-01-01 | 0.00000 | 0.00 | 0.00 |
3277325 rows × 6 columns
Valorizador#
Nos gustaría poder reciclar las funciones que calculan el valor par y el valor presente, pero, nos damos cuenta, que las últimas versiones se han adaptado mucho al formato de la data disponible y ahora tenemos que hacer algunos cambios.
Vamos a definir funciones valor_presente
y valor_par
que representen de la forma más general posible ambos cálculos. De esta manera, el formato particular de la data disponible se transforma en un problema totalmente separado del proceso de cálculo en sí.
Función valor_presente
#
def valor_presente(fecha_valor, fechas, flujos, tasa):
"""
Calcula el valor presente de un conjunto de flujos utilizando una única tasa de descuento.
Parameters
----------
fecha_valor: datetime.date
Fecha a la cual se quiere obtener el valor presente.
fechas: List[datetime.date]
Fechas de pago de los flujos.
flujos: List[float]
Flujos a traer a valor presente. Deben corresponder a las fechas en el parámetro `fechas`.
Los flujos cuyas fechas sean iguales o previas a `fecha_valor` no serán incluidos en el cálculo.
tasa: float
Tasa de descuento a utilizar. Debe estar en convención Com Act/365.
Returns
-------
Un `float` que corresponde al valor presente de los flujos.
"""
result = 0.0
for fec, fl in zip(fechas, flujos):
p = (fec - fecha_valor).days
if p > 0:
result += fl * (1 + tasa)**(-p / 365.0)
return result
Realicemos una prueba con la data que tenemos disponible en este notebook. Utilizaremos el bono BBNS-W0414.
tabla = tablas_desarrollo[tablas_desarrollo.nemotecnico == 'BBNS-W0414']
tabla
nemotecnico | numero_cupon | fecha_vcto_cupon | interes | amortizacion | saldo_insoluto | |
---|---|---|---|---|---|---|
57683 | BBNS-W0414 | 1 | 2014-10-01 | 1.5 | 0.0 | 100.0 |
57684 | BBNS-W0414 | 2 | 2015-04-01 | 1.5 | 0.0 | 100.0 |
57685 | BBNS-W0414 | 3 | 2015-10-01 | 1.5 | 0.0 | 100.0 |
57686 | BBNS-W0414 | 4 | 2016-04-01 | 1.5 | 0.0 | 100.0 |
57687 | BBNS-W0414 | 5 | 2016-10-01 | 1.5 | 0.0 | 100.0 |
57688 | BBNS-W0414 | 6 | 2017-04-01 | 1.5 | 0.0 | 100.0 |
57689 | BBNS-W0414 | 7 | 2017-10-01 | 1.5 | 0.0 | 100.0 |
57690 | BBNS-W0414 | 8 | 2018-04-01 | 1.5 | 0.0 | 100.0 |
57691 | BBNS-W0414 | 9 | 2018-10-01 | 1.5 | 0.0 | 100.0 |
57692 | BBNS-W0414 | 10 | 2019-04-01 | 1.5 | 0.0 | 100.0 |
57693 | BBNS-W0414 | 11 | 2019-10-01 | 1.5 | 0.0 | 100.0 |
57694 | BBNS-W0414 | 12 | 2020-04-01 | 1.5 | 0.0 | 100.0 |
57695 | BBNS-W0414 | 13 | 2020-10-01 | 1.5 | 0.0 | 100.0 |
57696 | BBNS-W0414 | 14 | 2021-04-01 | 1.5 | 0.0 | 100.0 |
57697 | BBNS-W0414 | 15 | 2021-10-01 | 1.5 | 0.0 | 100.0 |
57698 | BBNS-W0414 | 16 | 2022-04-01 | 1.5 | 0.0 | 100.0 |
57699 | BBNS-W0414 | 17 | 2022-10-01 | 1.5 | 0.0 | 100.0 |
57700 | BBNS-W0414 | 18 | 2023-04-01 | 1.5 | 0.0 | 100.0 |
57701 | BBNS-W0414 | 19 | 2023-10-01 | 1.5 | 0.0 | 100.0 |
57702 | BBNS-W0414 | 20 | 2024-04-01 | 1.5 | 100.0 | 100.0 |
Vemos que los flujos corresponden a la suma de las columnas interes
y amortizacion
y las fechas están en la columna fecha_vcto_cupon
.
flujos = tabla['interes'] + tabla['amortizacion']
El tipo de la variables flujos
es:
type(flujos)
pandas.core.series.Series
Para tener un np.array
con los valores aplicaremos:
flujos.values
array([ 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5,
1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5,
1.5, 101.5])
Obtenemos un numpy.array
que es un tipo similar a una List
, pero optimizado para cálculos.
Para las fechas aplicamos un procedimiento similar:
fechas = tabla['fecha_vcto_cupon'].values
fechas
array(['2014-10-01', '2015-04-01', '2015-10-01', '2016-04-01',
'2016-10-01', '2017-04-01', '2017-10-01', '2018-04-01',
'2018-10-01', '2019-04-01', '2019-10-01', '2020-04-01',
'2020-10-01', '2021-04-01', '2021-10-01', '2022-04-01',
'2022-10-01', '2023-04-01', '2023-10-01', '2024-04-01'],
dtype=object)
Vemos que tenemos que obtenemos un numpy.array
de str
, pero queremos datetime.date
.
import datetime
fechas = np.asarray([datetime.datetime.strptime(f, "%Y-%m-%d").date() for f in fechas])
fechas
array([datetime.date(2014, 10, 1), datetime.date(2015, 4, 1),
datetime.date(2015, 10, 1), datetime.date(2016, 4, 1),
datetime.date(2016, 10, 1), datetime.date(2017, 4, 1),
datetime.date(2017, 10, 1), datetime.date(2018, 4, 1),
datetime.date(2018, 10, 1), datetime.date(2019, 4, 1),
datetime.date(2019, 10, 1), datetime.date(2020, 4, 1),
datetime.date(2020, 10, 1), datetime.date(2021, 4, 1),
datetime.date(2021, 10, 1), datetime.date(2022, 4, 1),
datetime.date(2022, 10, 1), datetime.date(2023, 4, 1),
datetime.date(2023, 10, 1), datetime.date(2024, 4, 1)],
dtype=object)
Ahora aplicamos la función:
valor_presente(date(2014,4,1), fechas, flujos, .0302)
99.9997566755515
Función valor_par
#
Para el cálculo del valor_par
necesitamos la fecha valor, las fechas iniciales de los cupones y la tera.
def valor_par(fecha_valor, fechas_iniciales, saldos_insolutos, tera):
"""
Calcula el valor par en base 100 para un bono de renta fija local.
Parameters
----------
fecha_valor: datetime.date
Fecha a la cual se quiere obtener el valor par.
fechas_iniciales: List[datetime.date]
Fechas de inicio de devengo de los flujos del bono.
saldos_insolutos: List[float]
Saldos insolutos de cada cupón del bono.
tera: float
Tasa efectiva real anual del bono según las convenciones de la Bolsa de Comercio de Santiago.
Returns
-------
Un `float` que corresponde al valor par del bono.
"""
fecha = fn.find_le(fechas_iniciales, fecha_valor)
i = fechas_iniciales.index(fecha)
saldo = saldos_insolutos[i]
d = (fecha_valor - fecha).days
return saldo * (1 + tera)**(d / 365.0)
Probemos. La data que ahora estamos utilizando no está en el formato astuto de RiskAmerica que agrega un cupón 0 a la tabla de desarrollo. Vamos a tomar las fechas de la tabla de desarrollo y luego vamos a insertar al inicio la fecha de emisión desde la tabla de características.
Para este ejemplo vamos a usar el nemotécnico BCCA-F0919.
fechas_iniciales = tablas_desarrollo[tablas_desarrollo.nemotecnico == 'BCCA-F0919']['fecha_vcto_cupon'].values
fecha_emision = bonos[bonos.nemotecnico == 'BCCA-F0919']['fecha_emision'].iloc[0]
np.insert(fechas_iniciales, 0, [str(fecha_emision)])
array(['2019-09-27 00:00:00', '2019-12-27', '2020-03-27', '2020-06-27',
'2020-09-27', '2020-12-27', '2021-03-27', '2021-06-27',
'2021-09-27', '2021-12-27', '2022-03-27', '2022-06-27',
'2022-09-27', '2022-12-27', '2023-03-27', '2023-06-27',
'2023-09-27', '2023-12-27', '2024-03-27'], dtype=object)
fechas_iniciales = [datetime.datetime.strptime(f, "%Y-%m-%d").date() for f in fechas_iniciales]
fechas_iniciales
[datetime.date(2019, 12, 27),
datetime.date(2020, 3, 27),
datetime.date(2020, 6, 27),
datetime.date(2020, 9, 27),
datetime.date(2020, 12, 27),
datetime.date(2021, 3, 27),
datetime.date(2021, 6, 27),
datetime.date(2021, 9, 27),
datetime.date(2021, 12, 27),
datetime.date(2022, 3, 27),
datetime.date(2022, 6, 27),
datetime.date(2022, 9, 27),
datetime.date(2022, 12, 27),
datetime.date(2023, 3, 27),
datetime.date(2023, 6, 27),
datetime.date(2023, 9, 27),
datetime.date(2023, 12, 27),
datetime.date(2024, 3, 27)]
saldos_insolutos = list(tablas_desarrollo[tablas_desarrollo.nemotecnico == 'BCCA-F0919']['saldo_insoluto'].values)
tera = bonos[bonos.nemotecnico == 'BCCA-F0919']['tasa_descuento'].iloc[0] / 100.0
tera
0.08092
valor_par(date(2019, 12, 31), fechas_iniciales, saldos_insolutos, tera)
100.08531037450017
Primera Versión#
Con las definiciones anteriores estamos en condiciones de construir el valorizador, al menos la funcionalidad relacionada con el valor presente y valor par.
Vamos a definir una función que aproveche la funcionalidad definida y abstraiga los detalles relacionados con la obtención de la data específica de un bono.
IMPORTANTE: Esta función captura desde el contexto global los DataFrame
bonos
y tablas
, evitando así tener que pasar estos argumentos a cada llamada de la función. Al finalizar veremos una manera más elegante y explícita de lograr el mismo efecto.
def valorizador_rf(fecha_valor, nemotecnico, tir, monto):
"""
Valoriza un instrumento de renta fija local a una fecha, a partir de su nemotécnico y la tir de mercado.
Arguments
---------
nemotecnico: str
Es un nemotécnico válido de renta fija local.
fecha_valor: datetime.date
Fecha a la cual se requiere el cálculo.
tir: float
Tir de mercado del bono.
monto: float
Monto total del bono.
Returns
-------
El precio, el valor par, el valor presente y el valor de pago del bono.
"""
# Busca las características del bono
caracteristicas = bonos[bonos.nemotecnico == nemotecnico]
# Busca la tabla de desarrollo
tabla = tablas_desarrollo[tablas_desarrollo.nemotecnico == nemotecnico]
# Establece el valor de la TERA,
tera = round(caracteristicas['tasa_descuento'].iloc[0] / 100.0, 6)
# la fecha de inicio del primer cupón,
primera_fecha = caracteristicas['fecha_emision'].iloc[0]
primera_fecha = datetime.datetime.strptime(str(primera_fecha)[0:10], "%Y-%m-%d").date()
# las fechas de pago de cupón
fechas_pago = tabla['fecha_vcto_cupon'].values
fechas_pago = [datetime.datetime.strptime(f, "%Y-%m-%d").date() for f in fechas_pago]
# y los cupones.
flujos = (tabla['interes'] + tabla['amortizacion']).values
# Se calcula el valor presente
vpresente = valor_presente(fecha_valor, fechas_pago, flujos, tir)
# Se establecen las fechas de inicio de los cupones.
np.insert(fechas_pago, 0, [primera_fecha])
# Se establecen los saldos insolutos
saldos_insolutos = list(tabla['saldo_insoluto'])
# Se calcula el valor par.
vpar = valor_par(fecha_valor, fechas_pago, saldos_insolutos, tera)
# Se calcula el precio a 4 decimales
precio = round(vpresente / vpar, 6)
# Se calcula el valor a pagar en unidades monetarias.
valor_pago = precio * vpar * monto / 100.0
return {
'precio': precio,
'valor_par': vpar,
'valor_presente': vpresente,
'valor_pago': valor_pago
}
valorizador_rf(date(2021, 8, 13), 'BWATT-Q', 0.0673, 1000)
{'precio': 0.832537,
'valor_par': 101.74312935262013,
'valor_presente': 84.70490433903439,
'valor_pago': 847.049196818423}
Duración y Convexidad#
¿Qué es la duración de un bono? Típicamente la primera respuesta es el promedio ponderado por el valor presente de los flujos de los plazos residuales de los flujos. Una segunda forma, más robusta y que se extiende a otros contextos es pensar en la duración como la derivada primera del valor del bono respecto a la TIR de mercado.
En esta convención de tasa, la duración es, a menos del valor presente del bono y el signo, exactamente igual a la derivada del valor del bono. Si usamos la convención de la TIR de mercado de la renta fija local tenemos:
Vemos que, en esta convención de tasa, la derivada del valor del bono coincide con la duración modificada.
Para la convexidad se puede hacer un razonamiento del todo análogo al anterior:
Podríamos utilizar las últimas fórmulas y escribir funciones que calculen la duración y convexidad de un bono, pero vamos a adoptar un acercamiento al problema más extendible a situaciones futuras de mayor compeljidad.
Vamos a utilizar la librería autograd
que es capaz de calcular la derivada de una función. No es una aproximación por diferencias finitas, autograd
calcula la derivada del código Python que define la función.
dvdtir = grad(valor_presente, 3) # Derivada de la función valor_presente respecto a la TIR (3 variable)
type(dvdtir)
function
dur = -(1 + .0002) * dvdtir(date(2021,8,13), fechas, flujos, .0002)
dur /= valor_presente(date(2021,8,13), fechas, flujos, .0002)
dur
2.5322376795337695
d2vdtir2 = grad(dvdtir, 3)
conv = d2vdtir2(date(2021,8,13), fechas, flujos, .0002)
conv /= valor_presente(date(2021,8,13), fechas, flujos, .0002) * (1 + .0002)**2
conv
9.116173104913129
Dado lo anterior, podemos hacer una segunda versión de la función valorizador_rf
que retorne también la duración y la convexidad.
def valorizador_rf(fecha_valor, nemotecnico, tir, monto):
"""
Valoriza un instrumento de renta fija local a una fecha, a partir de su nemotécnico y la tir de mercado.
Arguments
---------
nemotecnico: str
Es un nemotécnico válido de renta fija local.
fecha_valor: datetime.date
Fecha a la cual se requiere el cálculo.
tir: float
Tir de mercado del bono.
monto: float
Monto total del bono.
Returns
-------
El precio, el valor par, el valor presente, el valor de pago, la duración y la convexidad del bono.
"""
# Busca las características del bono
caracteristicas = bonos[bonos.nemotecnico == nemotecnico]
# Busca la tabla de desarrollo
tabla = tablas_desarrollo[tablas_desarrollo.nemotecnico == nemotecnico]
# Establece el valor de la TERA,
tera = round(caracteristicas['tasa_descuento'].iloc[0] / 100.0, 6)
# la fecha de inicio del primer cupón,
primera_fecha = caracteristicas['fecha_emision'].iloc[0]
primera_fecha = datetime.datetime.strptime(
str(primera_fecha)[0:10], "%Y-%m-%d").date()
# las fechas de pago de cupón
fechas_pago = tabla['fecha_vcto_cupon'].values
fechas_pago = [datetime.datetime.strptime(
f, "%Y-%m-%d").date() for f in fechas_pago]
# y los cupones.
flujos = (tabla['interes'] + tabla['amortizacion']).values
# Se calcula el valor presente
vpresente = valor_presente(fecha_valor, fechas_pago, flujos, tir)
# Se calcula la duración.
dvdtir = grad(valor_presente, 3)
dur = -(1 + tir) * dvdtir(fecha_valor,
fechas_pago, flujos, tir) / vpresente
# Se calcula la convexidad
d2dvdtir2 = grad(dvdtir, 3)
conv = d2dvdtir2(fecha_valor, fechas_pago, flujos, tir)
conv /= vpresente
# Se establecen las fechas de inicio de los cupones.
np.insert(fechas_pago, 0, [primera_fecha])
# Se establecen los saldos insolutos
saldos_insolutos = list(tabla['saldo_insoluto'])
# Se calcula el valor par.
vpar = valor_par(fecha_valor, fechas_pago, saldos_insolutos, tera)
# Se calcula el precio a 4 decimales
precio = round(vpresente / vpar, 6)
# Se calcula el valor a pagar en unidades monetarias.
valor_pago = precio * vpar * monto / 100.0
return {
'precio': precio,
'valor_par': vpar,
'valor_presente': vpresente,
'valor_pago': valor_pago,
'duracion': dur,
'convexidad': conv
}
Probemos con los mismos valores anteriores.
valorizador_rf(date(2021, 8, 13), 'BWATT-Q', 0.0673, 1000000)
{'precio': 0.832537,
'valor_par': 101.74312935262013,
'valor_presente': 84.70490433903439,
'valor_pago': 847049.196818423,
'duracion': 6.732718905495722,
'convexidad': 50.83936352905741}
Segunda Versión#
Sin captura global de la data, pero con la misma usabilidad.
def valorizador_rf(fecha_valor, nemotecnico, tir, monto, bonos, tablas_desarrollo):
"""
Valoriza un instrumento de renta fija local a una fecha, a partir de su nemotécnico y la tir de mercado.
Arguments
---------
nemotecnico: str
Es un nemotécnico válido de renta fija local.
fecha_valor: datetime.date
Fecha a la cual se requiere el cálculo.
tir: float
Tir de mercado del bono.
monto: float
Monto total del bono.
bonos: pandas.DataFrame
Contiene la información de cabecera de los bonos. En particular tiene las columnas `nemotecnico`,
`tasa_descuento` que representa la TERA del bono y `fecha_emision`.
tablas_desarrollo: pandas.DataFrame
Contiene las columnas `nemotecnico`, `interes`, `amortizacion` y `fecha_vcto_cupon`.
Returns
-------
El precio, el valor par, el valor presente, el valor de pago, la duración y la convexidad del bono.
"""
# Busca las características del bono
caracteristicas = bonos[bonos.nemotecnico == nemotecnico]
# Busca la tabla de desarrollo
tabla = tablas_desarrollo[tablas_desarrollo.nemotecnico == nemotecnico]
# Establece el valor de la TERA,
tera = round(caracteristicas['tasa_descuento'].iloc[0] / 100.0, 6)
# la fecha de inicio del primer cupón,
primera_fecha = caracteristicas['fecha_emision'].iloc[0]
primera_fecha = datetime.datetime.strptime(str(primera_fecha)[0:10], "%Y-%m-%d").date()
# las fechas de pago de cupón
fechas_pago = tabla['fecha_vcto_cupon'].values
fechas_pago = [datetime.datetime.strptime(f, "%Y-%m-%d").date() for f in fechas_pago]
# y los cupones.
flujos = (tabla['interes'] + tabla['amortizacion']).values
# Se calcula el valor presente
vpresente = valor_presente(fecha_valor, fechas_pago, flujos, tir)
# Se calcula la duración.
dvdtir = grad(valor_presente, 3)
dur = -(1 + tir) * dvdtir(fecha_valor, fechas_pago, flujos, tir) / vpresente
# Se calcula la convexidad
d2dvdtir2 = grad(dvdtir, 3)
conv = d2dvdtir2(fecha_valor, fechas_pago, flujos, tir)
conv /= vpresente
# Se establecen las fechas de inicio de los cupones.
np.insert(fechas_pago, 0, [primera_fecha])
# Se establecen los saldos insolutos
saldos_insolutos = list(tabla['saldo_insoluto'])
# Se calcula el valor par.
vpar = valor_par(fecha_valor, fechas_pago, saldos_insolutos, tera)
# Se calcula el precio a 4 decimales
precio = round(vpresente / vpar, 6)
# Se calcula el valor a pagar en unidades monetarias.
valor_pago = precio * vpar * monto / 100.0
return {
'precio': precio,
'valor_par': vpar,
'valor_presente': vpresente,
'valor_pago': valor_pago,
'duracion': dur,
'convexidad': conv
}
Est función hace explícita la dependencia de la data relacionada con los bonos y permite, por lo tanto, que sea reutilizable en otros contextos. Sin embargo, el código se hace más verboso si tenemos que pasar los DataFrames
con la data cada vez que llamamos la función. Sin embargo, esto último se puede solucionar con la siguiente captura.
import this
def get_valorizador_with_data(bonos, tablas_desarrollo):
def wrapper(fecha_valor, nemotecnico, tir, monto):
return valorizador_rf(
fecha_valor,
nemotecnico,
tir, monto,
bonos,
tablas_desarrollo
)
return wrapper
val_rf = get_valorizador_with_data(bonos, tablas_desarrollo)
val_rf(date(2021, 8, 13), 'BWATT-Q', 0.0673, 1000000)
{'precio': 0.832537,
'valor_par': 101.74312935262013,
'valor_presente': 84.70490433903439,
'valor_pago': 847049.196818423,
'duracion': 6.732718905495722,
'convexidad': 50.83936352905741}
Un Ejemplo Típico#
Como fijar el valor de una variable de una función.
def suma(a, b):
return a + b
def suma_x(x):
def wrapper(a):
return suma(a, x)
return wrapper
suma2 = suma_x(2)
suma2(10)
12