5 votos

Configuración recomendada para QuantLib-Python AmortizingFloatingRateBond

Estoy tratando de modelar un préstamo a plazo en QuantLib-Python que hace pagos de intereses trimestrales en CME Term SOFR 3M + 10bps + 525bps pagados atrasados con un fixing de 2 días hábiles.

El calendario de amortización es personalizado en el sentido de que no comienza hasta después de la primera fecha de pago de intereses y solo ocurre en el último día hábil de marzo, junio, septiembre y diciembre. La amortización anual es del 5% del monto principal inicial.

Mi código genera correctamente los flujos de efectivo de principal e interés para una tasa SOFR asumida del 5%. En lugar de envolver estos flujos de efectivo en ql.SimpleCashFlow, preferiría aprovechar la clase ql.AmortizingFloatingRateBond para poder pasar una curva SOFR. ¿Cuál es el enfoque recomendado para configurar este instrumento dados el calendario personalizado?

import QuantLib as ql

def quarter_end_business_days(start_date, end_date, calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond), quarter_end_months = [3,6,9,12]):

    # Inicializar la fecha actual en la fecha de inicio
    current_date = start_date

    # Lista para almacenar los últimos días hábiles de los meses que terminan en cuartos
    last_business_days = []

    # Bucle para iterar a través de los meses y encontrar el último día hábil de los meses que terminan en cuartos
    while current_date <= end_date:
        # Determinar el final del mes actual
        eom_date = ql.Date.endOfMonth(current_date)
        # Verificar si es un mes que termina en cuarto
        if eom_date.month() in quarter_end_months:
            # Ajustar al último día hábil
            last_business_day = calendar.adjust(eom_date, ql.Preceding)
            last_business_days.append(last_business_day)

        # Mover al primer día del mes siguiente, ajustando el año si es necesario
            current_date = ql.Date(1, 1, current_date.year() + 1)
        else:
            current_date = ql.Date(1, current_date.month() + 1, current_date.year())

    # Devolver la lista de últimos días hábiles
    return last_business_days

# Establecer la fecha de evaluación y los parámetros básicos
today = ql.Date(9, 5, 2024)
ql.Settings.instance().evaluationDate = today
calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)

# Convención de días de conteo
day_count = ql.Actual360()

# Configuración del calendario del préstamo
effective_date = ql.Date(10, 5, 2022)
maturity_date = ql.Date(1, 2, 2027)
first_amortization_date = ql.Date(30,9,2022)
tenor = ql.Period(ql.Quarterly)

# Definir manualmente las fechas para asegurarse de que sean finales de trimestres
dates = [effective_date] + quarter_end_business_days(start_date=effective_date, end_date=maturity_date, calendar=calendar) + [maturity_date]

# Crear el calendario directamente con estas fechas
schedule = ql.Schedule(dates, calendar, ql.Unadjusted)

# Definir el índice SOFR diario y configurar la curva forward para SOFR a 3 meses
sofr_index = ql.Sofr()
dates = [schedule[i] for i in range(len(schedule))]
rates = [0.05 + 0.0010 + 0.0525] * len(dates)  # Tasas consistentes para simplificación; SOFR base + 10 bps + 525 bps
day_count = ql.Actual360()
sofr_curve = ql.ZeroCurve(dates, rates, day_count, calendar)
sofr_curve_handle = ql.YieldTermStructureHandle(sofr_curve)

# Crear un índice nocturno vinculado a la curva de rendimiento construida
three_month_sofr = ql.OvernightIndex("3M SOFR", 0, ql.USDCurrency(), calendar, day_count, sofr_curve_handle)

# Configuración de la mecánica del bono (préstamo)
valor_facial = 100  # Principal inicial
pago_principal = valor_facial * 0.05 / 4  # 5% anual, dividido por 4 para pagos trimestrales

# Inicializar los flujos de efectivo del préstamo
principal_restante = valor_facial
flujos_de_efectivo = []

for i in range(1, len(schedule)):
    fecha = schedule[i]
    # Calcular la tasa de interés efectiva con límite inferior
    tasa_limite = 0.005  # Piso de 50 bps
    tasa_tres_meses = max(tasa_limite, sofr_curve_handle.zeroRate(fecha, ql.Actual360(), ql.Continuous).rate())
    pago_interes = principal_restante * tasa_tres_meses * day_count.yearFraction(schedule[i-1], schedule[i])  # Pagos trimestrales

    pago_principal_actual = principal_restante if fecha == maturity_date else (pago_principal if fecha >= first_amortization_date else 0)

    pago_total = pago_principal_actual + pago_interes
    principal_restante -= pago_principal_actual
    principal_restante = max(0, principal_restante)  # Asegurar que no haya principal negativo
    flujos_de_efectivo.append((fecha, pago_total, pago_interes, pago_principal_actual, principal_restante))

# Mostrar el calendario de amortización
for fecha, total, interes, principal, restante in flujos_de_efectivo:
    print(f"Fecha: {fecha.ISO()}, Pago Total: {total:.2f}, Interés: {interes:.2f}, Principal: {principal:.2f}, Restante: {restante:.2f}")

8voto

Brad Tutterow Puntos 5628

Algunas cosas antes de crear el bono:

1) Puedes delegar a la biblioteca el cálculo de las fechas. Tu código es equivalente a:

schedule = ql.MakeSchedule(
    effectiveDate=effective_date,
    firstDate=ql.Date(30,ql.June,2022),
    terminationDate=maturity_date,
    frequency=ql.Quarterly,
    calendar=calendar,
    forwards=True,
    endOfMonth=True,
)

lo cual crea el mismo calendario.

2) No está del todo claro qué índice quieres usar. Cuando escribes

three_month_sofr = ql.OvernightIndex(
    "3M SOFR", 0, ql.USDCurrency(), calendar, day_count, sofr_curve_handle
)

parece que quieres que tu bono pague el término de 3 meses de SOFR fijo por adelantado. Pero en este caso, no quieres usar ql.OvernightIndex, que tiene un plazo de 1 día: quieres algo como

fixing_days = 2
three_month_sofr = ql.IborIndex(
    "3M SOFR", ql.Period(3, ql.Months), fixing_days, ql.USDCurrency(), calendar,
    ql.ModifiedFollowing, True, day_count, sofr_curve_handle
)

lo cual utilizará la curva para predecir una tasa de 3 meses.

Si, en cambio, quieres que tus cupones paguen las fijaciones diarias de SOFR compuestas día a día y finalmente fijadas a posteriori, necesitarás usar

sofr = ql.SOFR(sofr_curve_handle)

y dejar que el cupón pronostique la tasa compuesta.

3) Estás extrayendo fijaciones pasadas de la curva. Tu today es en el 2024, pero estás creando una curva comenzando en el 2022 y extrayendo todas las tasas de ella, incluso de cupones pasados. Las fijaciones pasadas que necesitas deben añadirse al índice a través del método addFixing. Si quieres usar la curva para simplificar el ejemplo, deberás mover today de vuelta al inicio del bono.

También estás calculando las tasas como

sofr_curve_handle.zeroRate(schedule[i], ql.Actual360(), ql.Continuous).rate()

pero la tasa cero es la tasa desde el inicio de la curva hasta la fecha pasada. Solo obtienes los resultados esperados porque la curva es plana. Debería ser la tasa forward entre schedule[i-1] y schedule[i]. Finalmente, la tasa SOFR a un término es una tasa simple, no una tasa continua. Pero en cualquier caso, los cupones se encargarán del cálculo.

Y finalmente, el bono. Primero, calcula la secuencia de los principales para cada cupón, casi como ya estás haciendo; necesitas incluir un valor nominal adicional al principio y excluir el 0 al final:

face_value = 100  # Principal inicial
principal_payment = face_value * 0.05 / 4  # 5% anualmente, dividido por 4 para pagos trimestrales

# Inicializa flujos de caja del préstamo
principal_remaining = face_value
principals = [face_value]

for i in range(1, len(schedule)-1):
    date = schedule[i]
    current_principal_payment = principal_payment if date >= first_amortization_date else 0

    principal_remaining -= current_principal_payment
    principal_remaining = max(0, principal_remaining)  # Asegura que no haya un principal negativo
    principals.append(principal_remaining)

Si los imprimes, verás 100 dos veces al principio, ya que es el principal para ambos el primer y segundo cupón, y no verás el 0 al final, porque 0 no es un principal para ningún cupón.

Finalmente, aquí está el bono:

bond = ql.AmortizingFloatingRateBond(
    settlement_days, principals, schedule, three_month_sofr, day_count,
    spreads=[0.0535], floors=[0.0050]
)

si no tuvieras un piso, eso sería todo; pero dado que lo tienes, también necesitas decirle al bono qué volatilidad de tasa de interés quieres usar para evaluarlo. Si deseas un simple corte determinista en el nivel del piso, puedes pasar una volatilidad constante igual a 0. Tendrás que pasar un desplazamiento, porque un piso de 50 bps en SOFR + 535 bps significa un piso de -485 bps en SOFR solo, y el modelo Black no funcionará con una opción de venta negativa. Alternativamente, podrías usar una volatilidad normal.

pricer = ql.BlackIborCouponPricer(
    ql.OptionletVolatilityStructureHandle(
        ql.ConstantOptionletVolatility(
            today, calendar, ql.Following, 0.0, day_count,
            ql.ShiftedLognormal, 0.05
        )
    )
)
ql.setCouponPricer(bond.cashflows(), pricer)

Aquí está el código completo:

import QuantLib as ql

today = ql.Date(10, 5, 2022)
ql.Settings.instance().evaluationDate = today

calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
day_count = ql.Actual360()

effective_date = ql.Date(10, 5, 2022)
maturity_date = ql.Date(1, 2, 2027)
first_amortization_date = ql.Date(30,9,2022)
frequency = ql.Quarterly

schedule = ql.MakeSchedule(
    effectiveDate=effective_date,
    firstDate=calendar.endOfMonth(ql.Date(1,ql.June,2022)),
    terminationDate=maturity_date,
    frequency=frequency,
    calendar=calendar,
    forwards=True,
    endOfMonth=True,
)

sofr_curve = ql.FlatForward(today, 0.05, ql.Actual360())
sofr_curve_handle = ql.YieldTermStructureHandle(sofr_curve)

fixing_days = 2
three_month_sofr = ql.IborIndex(
    "3M SOFR", ql.Period(3, ql.Months), fixing_days, ql.USDCurrency(), calendar,
    ql.ModifiedFollowing, True, day_count, sofr_curve_handle
)

face_value = 100  # Principal inicial
principal_payment = face_value * 0.05 / 4  # 5% anualmente, dividido por 4 para pagos trimestrales

principal_remaining = face_value
principals = [face_value]

for i in range(1, len(schedule)-1):
    date = schedule[i]
    current_principal_payment = principal_payment if date >= first_amortization_date else 0

    principal_remaining -= current_principal_payment
    principal_remaining = max(0, principal_remaining)  # Asegura que no haya un principal negativo
    principals.append(principal_remaining)

settlement_days = 0

bond = ql.AmortizingFloatingRateBond(
    settlement_days, principals, schedule, three_month_sofr, day_count,
    spreads=[0.0535], floors=[0.0050]
)

pricer = ql.BlackIborCouponPricer(
    ql.OptionletVolatilityStructureHandle(
        ql.ConstantOptionletVolatility(
            today, calendar, ql.Following, 0.0, day_count,
            ql.ShiftedLognormal, 0.05
        )
    )
)
ql.setCouponPricer(bond.cashflows(), pricer)

for cf in bond.cashflows():
    c = ql.as_coupon(cf)
    if c is not None:
        print(f"Fecha: {c.date().ISO()}, Principal: {c.nominal():.2f}, Pago de interés: {c.amount():.2f}, Pago de principal: {0.0:.2f}")
    else:
        print(f"Fecha: {cf.date().ISO()}, Pago de interés: {0.0:.2f}, Pago de principal: {cf.amount():.2f}")

Si deseas pagar las fijaciones diarias compuestas de SOFR, la creación del bono es diferente. Sin embargo, actualmente no admite pisos, por lo que deberás abrir un problema en GitHub si deseas que funcione en una de las próximas versiones. Ignorando el piso, el bono se crearía como:

sofr = ql.Sofr(sofr_curve_handle)
coupons = ql.OvernightLeg(principals, schedule, sofr, day_count)
bond = ql.Bond(settlement_days, calendar, effective_date, coupons)

Finanhelp.com

FinanHelp es una comunidad para personas con conocimientos de economía y finanzas, o quiere aprender. Puedes hacer tus propias preguntas o resolver las de los demás.

Powered by:

X