# ---
# jupyter:
#   jupytext:
#     formats: ipynb,py:percent
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.1
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Numerical Methods For Term Structures
#
# This notebook ties the math layer to familiar curve-building tasks: interpolation on sparse pillars, long-end extrapolation, yield solving, and a compact parametric fit.

# %%
from __future__ import annotations

import math
from decimal import Decimal

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from fuggers_py.math import (
    CubicSpline,
    LinearInterpolator,
    LogLinearInterpolator,
    MonotoneConvex,
    OptimizationConfig,
    SmithWilson,
    brent,
    gauss_newton,
)

from examples._shared.plotting import apply_research_style, percent_axis
from examples._shared.tables import display_table

apply_research_style()

# %% [markdown]
# ## Sparse Market Pillars
#
# The inputs below are a stylized zero-rate strip. The curve is intentionally sparse so interpolation choices remain visible.

# %%
pillars = np.array([0.5, 1.0, 2.0, 5.0, 10.0])
zero_rates = np.array([0.0410, 0.0402, 0.0388, 0.0375, 0.0385])
discount_factors = np.exp(-pillars * zero_rates)
plot_grid = np.linspace(0.5, 10.0, 120)

linear = LinearInterpolator(pillars, zero_rates, allow_extrapolation=True)
log_linear = LogLinearInterpolator(pillars, discount_factors, allow_extrapolation=True)
cubic = CubicSpline(pillars, zero_rates, allow_extrapolation=True)
monotone = MonotoneConvex(pillars, zero_rates, allow_extrapolation=True)

comparison = pd.DataFrame(
    {
        'tenor_years': np.linspace(0.5, 10.0, 12),
    }
)
comparison['linear_zero'] = comparison['tenor_years'].map(lambda tenor: linear.interpolate(float(tenor)))
comparison['log_linear_df'] = comparison['tenor_years'].map(lambda tenor: log_linear.interpolate(float(tenor)))
comparison['log_linear_zero'] = -np.log(comparison['log_linear_df']) / comparison['tenor_years']
comparison['cubic_zero'] = comparison['tenor_years'].map(lambda tenor: cubic.interpolate(float(tenor)))
comparison['monotone_zero'] = comparison['tenor_years'].map(lambda tenor: monotone.interpolate(float(tenor)))

display_table(
    comparison[['tenor_years', 'linear_zero', 'log_linear_zero', 'cubic_zero', 'monotone_zero']],
    percent_columns=['linear_zero', 'log_linear_zero', 'cubic_zero', 'monotone_zero'],
    float_columns=['tenor_years'],
)

# %% [markdown]
# Linear and cubic interpolation are familiar baselines, but the monotone-convex curve is often easier to defend in rates work because it suppresses implausible oscillations while preserving shape.

# %%
figure, axis = plt.subplots(figsize=(10.5, 4.8))
axis.plot(plot_grid, [100 * linear.interpolate(float(t)) for t in plot_grid], label='Linear', linewidth=2)
axis.plot(plot_grid, [100 * (-math.log(log_linear.interpolate(float(t))) / float(t)) for t in plot_grid], label='Log-linear on DF', linewidth=2)
axis.plot(plot_grid, [100 * cubic.interpolate(float(t)) for t in plot_grid], label='Cubic spline', linewidth=2)
axis.plot(plot_grid, [100 * monotone.interpolate(float(t)) for t in plot_grid], label='Monotone-convex', linewidth=2)
axis.scatter(pillars, 100 * zero_rates, color='black', s=24, zorder=3, label='Market pillars')
axis.set_title('Interpolation Comparison On Sparse Zero-Rate Pillars')
axis.set_xlabel('Tenor (years)')
axis.set_ylabel('Zero rate')
percent_axis(axis)
axis.legend(title='')
plt.tight_layout()
figure

# %% [markdown]
# ## Long-End Extrapolation
#
# For long liability or balance-sheet work, extrapolation assumptions matter as much as interpolation. We compare a flat-last-zero tail to a Smith-Wilson curve that converges to an ultimate forward rate.

# %%
smith_wilson = SmithWilson(pillars, discount_factors, ufr=0.0375, alpha=0.12)
long_grid = np.linspace(0.5, 40.0, 160)
flat_tail_df = np.array([math.exp(-float(zero_rates[-1]) * float(t)) for t in long_grid])
smith_wilson_df = np.array([smith_wilson.discount_factor(float(t)) for t in long_grid])
flat_tail_zero = -np.log(flat_tail_df) / long_grid
smith_wilson_zero = -np.log(smith_wilson_df) / long_grid

figure, axis = plt.subplots(figsize=(10.5, 4.8))
axis.plot(long_grid, 100 * flat_tail_zero, label='Flat terminal zero', linewidth=2)
axis.plot(long_grid, 100 * smith_wilson_zero, label='Smith-Wilson', linewidth=2)
axis.scatter(pillars, 100 * zero_rates, color='black', s=20, zorder=3)
axis.set_title('Long-End Extrapolation Choices')
axis.set_xlabel('Tenor (years)')
axis.set_ylabel('Zero rate')
percent_axis(axis)
axis.legend(title='')
plt.tight_layout()
figure

# %% [markdown]
# ## Root Solver: Implied Yield From Price
#
# We solve a two-cashflow pricing problem with Brent. This is the numerical core of a large part of fixed-income quoting.

# %%
dirty_price = 99.35
cashflows = [(0.5, 2.5), (1.0, 102.5)]

def price_error(yield_rate: float) -> float:
    present_value = sum(amount / (1.0 + yield_rate / 2.0) ** (2.0 * tenor) for tenor, amount in cashflows)
    return present_value - dirty_price

solver_result = brent(price_error, -0.20, 0.30)
implied_yield = solver_result.root

solver_grid = np.linspace(-0.02, 0.12, 80)
solver_frame = pd.DataFrame(
    {
        'yield_rate': solver_grid,
        'pricing_error': [price_error(float(rate)) for rate in solver_grid],
    }
)

# %%
fit_tenors = np.array([1.0, 2.0, 3.0, 5.0, 7.0, 10.0])
fit_observed = np.array([0.041, 0.0398, 0.0387, 0.0377, 0.0379, 0.0386])


def residuals(params: np.ndarray) -> np.ndarray:
    beta0, beta1 = float(params[0]), float(params[1])
    return beta0 + beta1 * fit_tenors - fit_observed

fit_result = gauss_newton(
    residuals,
    [0.04, -0.0002],
    config=OptimizationConfig(tolerance=1e-12, max_iterations=40, step_size=1e-6),
)
fit_curve = fit_result.parameters[0] + fit_result.parameters[1] * fit_tenors

solver_summary = pd.DataFrame(
    [
        {
            'metric': 'implied_yield',
            'value': implied_yield,
        },
        {
            'metric': 'objective_value',
            'value': fit_result.objective_value,
        },
        {
            'metric': 'slope_parameter',
            'value': fit_result.parameters[1],
        },
        {
            'metric': 'converged',
            'value': fit_result.converged,
        },
    ]
)
solver_summary

# %% [markdown]
# Brent is robust for one-dimensional quoting problems. The least-squares fit is intentionally simple, but it shows the role of optimization in turning noisy market strips into a smooth research curve.

# %%
figure, axes = plt.subplots(1, 2, figsize=(12, 4.5))
axes[0].plot(100 * solver_frame['yield_rate'], solver_frame['pricing_error'], linewidth=2)
axes[0].axhline(0.0, color='black', linewidth=1)
axes[0].axvline(100 * implied_yield, color='#b7410e', linestyle='--', linewidth=1.5)
axes[0].set_title('Root-Solver Objective')
axes[0].set_xlabel('Yield (%)')
axes[0].set_ylabel('Price error')

axes[1].scatter(fit_tenors, 100 * fit_observed, label='Observed zeros', color='black', s=24)
axes[1].plot(fit_tenors, 100 * fit_curve, label='Least-squares fit', linewidth=2)
axes[1].set_title('Compact Parametric Fit')
axes[1].set_xlabel('Tenor (years)')
axes[1].set_ylabel('Zero rate')
percent_axis(axes[1])
axes[1].legend(title='')
plt.tight_layout()
figure
