# ---
# 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]
# # Curve Calibration: Piecewise And Global Fit
#
# We calibrate a stylized USD discount curve with a sequential bootstrapper and a parametric global fit, then compare the shape and residuals.

# %%
from __future__ import annotations

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.bonds import Tenor
from fuggers_py.core import Compounding, Date, Frequency
from fuggers_py.curves import (
    Deposit,
    FitterConfig,
    Fra,
    Future,
    GlobalFitter,
    InstrumentSet,
    Ois,
    ParametricModel,
    SequentialBootstrapper,
    Swap,
    ValueConverter,
    ValueTypeKind,
)

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

apply_research_style()

# %%
reference_date = Date.from_ymd(2026, 1, 2)
instruments = InstrumentSet(
    [
        Deposit(reference_date=reference_date, tenor=Tenor.parse('1M'), quote=Decimal('0.0435')),
        Deposit(reference_date=reference_date, tenor=Tenor.parse('3M'), quote=Decimal('0.0430')),
        Fra(reference_date=reference_date, start_tenor=Tenor.parse('3M'), end_tenor=Tenor.parse('6M'), quote=Decimal('0.0422')),
        Future(reference_date=reference_date, start_tenor=Tenor.parse('1Y'), end_tenor=Tenor.parse('15M'), quote=Decimal('95.95')),
        Ois(reference_date=reference_date, tenor=Tenor.parse('2Y'), quote=Decimal('0.0398'), fixed_frequency=Frequency.ANNUAL),
        Swap(reference_date=reference_date, tenor=Tenor.parse('5Y'), quote=Decimal('0.0386'), fixed_frequency=Frequency.SEMI_ANNUAL),
        Swap(reference_date=reference_date, tenor=Tenor.parse('10Y'), quote=Decimal('0.0390'), fixed_frequency=Frequency.SEMI_ANNUAL),
    ]
)

input_table = pd.DataFrame(
    [
        {
            'instrument_type': type(instrument).__name__,
            'maturity_date': instrument.maturity_date(),
            'quote': instrument.quote,
        }
        for instrument in instruments.instruments
    ]
)

display_table(input_table, date_columns=['maturity_date'], percent_columns=['quote'])

# %%
bootstrap = SequentialBootstrapper(
    reference_date=reference_date,
    instruments=instruments,
    value_type_kind=ValueTypeKind.DISCOUNT_FACTOR,
).bootstrap()

piecewise_curve = bootstrap.curve
piecewise_residuals = pd.DataFrame(
    [
        {
            'instrument_type': type(item.instrument).__name__,
            'maturity_date': item.instrument.maturity_date(),
            'market_quote': item.market_quote,
            'model_quote': item.model_quote,
            'residual': item.residual,
        }
        for item in bootstrap.instrument_results
    ]
)

fit_tenors = np.array([0.25, 0.50, 1.0, 2.0, 5.0, 10.0])
fit_zero_rates = np.array([0.0432, 0.0426, 0.0410, 0.0398, 0.0386, 0.0390])

global_fit = GlobalFitter(
    reference_date,
    config=FitterConfig(model=ParametricModel.NELSON_SIEGEL),
).fit_zero_rates(fit_tenors, fit_zero_rates)

grid = np.array([0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0])
curve_grid = pd.DataFrame(
    {
        'tenor_years': grid,
        'piecewise_zero': [
            ValueConverter.df_to_zero(
                float(piecewise_curve.discount_factor(reference_date.add_days(int(365 * tenor)))),
                float(tenor),
                Compounding.CONTINUOUS,
            )
            for tenor in grid
        ],
        'global_zero': [float(global_fit.curve.curve.value_at(float(tenor))) for tenor in grid],
    }
)
curve_grid['smoothness_gap'] = curve_grid['global_zero'] - curve_grid['piecewise_zero']

display_table(curve_grid, percent_columns=['piecewise_zero', 'global_zero', 'smoothness_gap'], float_columns=['tenor_years'])

# %% [markdown]
# The sequential bootstrapper matches quoted instruments locally. The global fit sacrifices exact local fit in exchange for a smoother research curve.

# %%
figure, axes = plt.subplots(1, 2, figsize=(12, 4.6))
axes[0].plot(curve_grid['tenor_years'], 100 * curve_grid['piecewise_zero'], label='Piecewise', linewidth=2)
axes[0].plot(curve_grid['tenor_years'], 100 * curve_grid['global_zero'], label='Global fit', linewidth=2)
axes[0].scatter(fit_tenors, 100 * fit_zero_rates, color='black', s=20, zorder=3, label='Calibrated nodes')
axes[0].set_title('Calibrated Zero Curves')
axes[0].set_xlabel('Tenor (years)')
axes[0].set_ylabel('Zero rate')
percent_axis(axes[0])
axes[0].legend(title='')

axes[1].bar(piecewise_residuals['instrument_type'], 10000 * piecewise_residuals['residual'])
axes[1].set_title('Bootstrap Residual Diagnostics')
axes[1].set_xlabel('Instrument bucket')
axes[1].set_ylabel('Residual (bps)')
plt.tight_layout()
figure

# %% [markdown]
# ## Cross-Sectional Fitted Bond Curves
#
# Global zero-rate fitting is one research path. Another is to fit a curve directly to a cross section of bond dirty prices while estimating regression coefficients for local richness and liquidity effects at the same time.

# %%
from fuggers_py.bonds import FixedBondBuilder, YieldCalculationRules
from fuggers_py.core import Currency
from fuggers_py.curves import (
    BondFairValueRequest,
    CubicSplineDiscountFactorCurveModel,
    ExponentialSplineCurveModel,
    FittedBondCurveFitter,
    FittedBondFitConfig,
    FittedBondObservation,
    JumpDiffusionCurve,
    RegressionExposure,
    ShadowRateCurve,
    build_notional_benchmark,
    dirty_price_from_curve,
    fair_value_from_fit,
)
from fuggers_py.data import BondReferenceData, BondType, InstrumentId, IssuerType

# %%
fitted_reference_date = Date.from_ymd(2026, 1, 15)


def build_fitted_bond_observations() -> tuple[FittedBondObservation, ...]:
    true_model = ExponentialSplineCurveModel((Decimal("0.40"), Decimal("1.20")))
    true_curve = true_model.build_curve(
        fitted_reference_date,
        np.asarray([0.032, -0.008, 0.004], dtype=float),
        max_t=10.0,
    )
    universe = (
        ("UST2Y", 2, Decimal("0.0200"), Decimal("-1.2"), Decimal("400000000"), Decimal("0.80"), False, Decimal("0.00")),
        ("UST3Y", 3, Decimal("0.0225"), Decimal("-0.8"), Decimal("500000000"), Decimal("0.90"), False, Decimal("0.00")),
        ("UST4Y", 4, Decimal("0.0250"), Decimal("-0.4"), Decimal("700000000"), Decimal("1.00"), True, Decimal("0.90")),
        ("UST5Y", 5, Decimal("0.0275"), Decimal("0.0"), Decimal("900000000"), Decimal("1.10"), True, Decimal("0.00")),
        ("UST6Y", 6, Decimal("0.0300"), Decimal("0.4"), Decimal("1100000000"), Decimal("1.15"), True, Decimal("0.00")),
        ("UST8Y", 8, Decimal("0.0350"), Decimal("0.8"), Decimal("800000000"), Decimal("1.00"), True, Decimal("-1.40")),
        ("UST10Y", 10, Decimal("0.0400"), Decimal("1.2"), Decimal("600000000"), Decimal("0.85"), False, Decimal("-0.20")),
    )
    observations: list[FittedBondObservation] = []
    for instrument_id, maturity_years, coupon_rate, liquidity_exposure, amount_outstanding, liquidity_score, benchmark_flag, residual_shock in universe:
        bond = (
            FixedBondBuilder.new()
            .with_issue_date(Date.from_ymd(2021, 1, 15))
            .with_maturity_date(fitted_reference_date.add_years(maturity_years))
            .with_coupon_rate(coupon_rate)
            .with_frequency(Frequency.SEMI_ANNUAL)
            .with_currency(Currency.USD)
            .with_rules(YieldCalculationRules.us_treasury())
            .build()
        )
        reference_data = BondReferenceData(
            instrument_id=instrument_id,
            bond_type=BondType.FIXED_RATE,
            issuer_type=IssuerType.SOVEREIGN,
            issue_date=Date.from_ymd(2021, 1, 15),
            maturity_date=fitted_reference_date.add_years(maturity_years),
            currency=Currency.USD,
            coupon_rate=coupon_rate,
            frequency=Frequency.SEMI_ANNUAL,
            amount_outstanding=amount_outstanding,
            benchmark_flag=benchmark_flag,
            futures_deliverable_flags=("USTFUT",) if maturity_years in {6, 8} else (),
            liquidity_score=liquidity_score,
        )
        observed_dirty = (
            dirty_price_from_curve(bond, true_curve, fitted_reference_date)
            + Decimal("0.25") * liquidity_exposure
            + residual_shock
        )
        observations.append(
            FittedBondObservation(
                instrument_id=InstrumentId(instrument_id),
                bond=bond,
                clean_price=observed_dirty - bond.accrued_interest(fitted_reference_date),
                settlement_date=fitted_reference_date,
                regression_exposures=(RegressionExposure("liquidity", liquidity_exposure),),
                reference_data=reference_data,
            )
        )
    return tuple(observations)


fitted_observations = build_fitted_bond_observations()
fitted_input_table = pd.DataFrame(
    [
        {
            "instrument_id": observation.instrument_id.as_str(),
            "maturity_date": observation.maturity_date(),
            "observed_clean_price": observation.clean_price,
            "liquidity_score": observation.reference_data.liquidity_score if observation.reference_data is not None else None,
            "benchmark_flag": observation.reference_data.benchmark_flag if observation.reference_data is not None else None,
        }
        for observation in fitted_observations
    ]
)

display_table(
    fitted_input_table,
    date_columns=["maturity_date"],
    float_columns=["liquidity_score"],
)

# %% [markdown]
# We fit the same cross section with an exponential-spline discount curve and a cubic-spline discount-factor curve, both with a liquidity regression term.

# %%
exp_fit = FittedBondCurveFitter(
    curve_model=ExponentialSplineCurveModel((Decimal("0.40"), Decimal("1.20"))),
    config=FittedBondFitConfig(),
).fit(fitted_observations)

cubic_fit = FittedBondCurveFitter(
    curve_model=CubicSplineDiscountFactorCurveModel(
        (
            Decimal("2.0"),
            Decimal("4.0"),
            Decimal("6.0"),
            Decimal("8.0"),
            Decimal("10.0"),
        )
    ),
    config=FittedBondFitConfig(),
).fit(fitted_observations)

fit_summary = pd.DataFrame(
    [
        {
            "curve_family": exp_fit.curve_family.value,
            "objective": exp_fit.objective.value,
            "weighted_mae_price": exp_fit.diagnostics.weighted_mae_price,
            "weighted_mae_bp": exp_fit.diagnostics.weighted_mean_abs_bp_residual,
            "liquidity_coefficient": exp_fit.coefficient_map().get("liquidity", Decimal("0")),
        },
        {
            "curve_family": cubic_fit.curve_family.value,
            "objective": cubic_fit.objective.value,
            "weighted_mae_price": cubic_fit.diagnostics.weighted_mae_price,
            "weighted_mae_bp": cubic_fit.diagnostics.weighted_mean_abs_bp_residual,
            "liquidity_coefficient": cubic_fit.coefficient_map().get("liquidity", Decimal("0")),
        },
    ]
)

display_table(
    fit_summary,
    percent_columns=["liquidity_coefficient"],
    bps_columns=["weighted_mae_bp"],
)

# %%
residual_comparison = pd.DataFrame(
    [
        {
            "instrument_id": point.instrument_id.as_str(),
            "maturity_years": point.maturity_years,
            "observed_yield": point.observed_yield,
            "exp_fitted_yield": point.fitted_yield,
            "exp_bp_residual": point.bp_residual,
            "cubic_fitted_yield": cubic_fit.get_bond(point.instrument_id).fitted_yield,
            "cubic_bp_residual": cubic_fit.get_bond(point.instrument_id).bp_residual,
        }
        for point in exp_fit.bonds
    ]
)

display_table(
    residual_comparison,
    percent_columns=["observed_yield", "exp_fitted_yield", "cubic_fitted_yield"],
    bps_columns=["exp_bp_residual", "cubic_bp_residual"],
    float_columns=["maturity_years"],
)

# %% [markdown]
# The fitted curve gives us an issuer-specific fair-value surface. We can price an off-the-run 7Y bond off the fitted curve and also build a liquidity-weighted notional benchmark around 5Y.

# %%
seven_year_bond = (
    FixedBondBuilder.new()
    .with_issue_date(Date.from_ymd(2026, 1, 15))
    .with_maturity_date(fitted_reference_date.add_years(7))
    .with_coupon_rate(Decimal("0.0330"))
    .with_frequency(Frequency.SEMI_ANNUAL)
    .with_currency(Currency.USD)
    .with_rules(YieldCalculationRules.us_treasury())
    .build()
)
seven_year_reference = BondReferenceData(
    instrument_id="UST7Y_SYNTH",
    bond_type=BondType.FIXED_RATE,
    issuer_type=IssuerType.SOVEREIGN,
    issue_date=fitted_reference_date,
    maturity_date=fitted_reference_date.add_years(7),
    currency=Currency.USD,
    coupon_rate=Decimal("0.0330"),
    frequency=Frequency.SEMI_ANNUAL,
    amount_outstanding=Decimal("950000000"),
    benchmark_flag=True,
    liquidity_score=Decimal("1.05"),
)
seven_year_fair_value = fair_value_from_fit(
    exp_fit,
    BondFairValueRequest(
        instrument_id="UST7Y_SYNTH",
        bond=seven_year_bond,
        settlement_date=fitted_reference_date,
        regression_exposures=(RegressionExposure("liquidity", Decimal("0.45")),),
        reference_data=seven_year_reference,
    ),
)
five_year_benchmark = build_notional_benchmark(exp_fit, Decimal("5.0"))

fair_value_table = pd.DataFrame(
    [
        {
            "instrument_id": "UST7Y_SYNTH",
            "curve_clean_price": seven_year_fair_value.curve_clean_price,
            "fair_value_clean_price": seven_year_fair_value.fair_value_clean_price,
            "fair_value_yield": seven_year_fair_value.fair_value_yield,
            "regression_adjustment": seven_year_fair_value.regression_adjustment,
        }
    ]
)

benchmark_table = pd.DataFrame(
    [
        {
            "instrument_id": component.instrument_id.as_str(),
            "weight": component.weight,
            "maturity_years": component.maturity_years,
            "fair_value_yield": component.fair_value_yield,
            "benchmark_flag": component.benchmark_flag,
        }
        for component in five_year_benchmark.components
    ]
)

display_table(
    fair_value_table,
    percent_columns=["fair_value_yield", "regression_adjustment"],
)

display_table(
    benchmark_table,
    percent_columns=["weight", "fair_value_yield"],
    float_columns=["maturity_years"],
)

# %%
fit_grid = np.linspace(0.5, 10.0, 20)

figure, axes = plt.subplots(1, 2, figsize=(12, 4.8))
axes[0].plot(
    fit_grid,
    100 * np.array([exp_fit.curve.zero_rate_at_tenor(float(tenor)) for tenor in fit_grid]),
    linewidth=2,
    label="Exponential spline",
)
axes[0].plot(
    fit_grid,
    100 * np.array([cubic_fit.curve.zero_rate_at_tenor(float(tenor)) for tenor in fit_grid]),
    linewidth=2,
    label="Cubic spline DF",
)
axes[0].set_title("Fitted Bond Zero-Curve Views")
axes[0].set_xlabel("Tenor (years)")
axes[0].set_ylabel("Zero rate")
percent_axis(axes[0])
axes[0].legend(title="")

axes[1].bar(
    [point.instrument_id.as_str() for point in exp_fit.bonds],
    [float(point.bp_residual) for point in exp_fit.bonds],
    color="#355070",
)
axes[1].axhline(0.0, color="black", linewidth=1)
axes[1].set_title("Exponential-Fit Local RV Residuals")
axes[1].set_xlabel("Instrument")
axes[1].set_ylabel("Residual (bps)")
plt.tight_layout()
figure

# %% [markdown]
# ## Optional Advanced Curve Overlays
#
# The default fitted-curve path stays unchanged. For research overlays, we can wrap the fitted zero curve in an optional shadow-rate floor or a deterministic jump-diffusion adjustment without changing the fitted-bond engine itself.

# %%
shadow_curve = ShadowRateCurve(
    base_curve=exp_fit.curve,
    lower_bound=Decimal("-0.0025"),
    smoothing=Decimal("0.0015"),
)
jump_diffusion_curve = JumpDiffusionCurve(
    base_curve=exp_fit.curve,
    diffusion_volatility=Decimal("0.08"),
    jump_intensity=Decimal("0.15"),
    mean_jump_size=Decimal("-0.03"),
    jump_volatility=Decimal("0.10"),
    risk_premium_adjustment=Decimal("0.0005"),
)

overlay_tenors = np.array([1.0, 2.0, 5.0, 7.0, 10.0])
overlay_table = pd.DataFrame(
    {
        "tenor_years": overlay_tenors,
        "base_zero": [exp_fit.curve.zero_rate_at_tenor(float(tenor)) for tenor in overlay_tenors],
        "shadow_zero": [
            float(shadow_curve.zero_rate(fitted_reference_date.add_days(int(365 * tenor))).value())
            for tenor in overlay_tenors
        ],
        "jump_diffusion_zero": [
            float(jump_diffusion_curve.zero_rate(fitted_reference_date.add_days(int(365 * tenor))).value())
            for tenor in overlay_tenors
        ],
    }
)
overlay_table["shadow_minus_base_bps"] = 10000 * (overlay_table["shadow_zero"] - overlay_table["base_zero"])
overlay_table["jump_minus_base_bps"] = 10000 * (overlay_table["jump_diffusion_zero"] - overlay_table["base_zero"])

display_table(
    overlay_table,
    percent_columns=["base_zero", "shadow_zero", "jump_diffusion_zero"],
    bps_columns=["shadow_minus_base_bps", "jump_minus_base_bps"],
    float_columns=["tenor_years"],
)

# %%
figure, ax = plt.subplots(figsize=(7.6, 4.6))
ax.plot(overlay_table["tenor_years"], 100 * overlay_table["base_zero"], linewidth=2, label="Base fitted curve")
ax.plot(overlay_table["tenor_years"], 100 * overlay_table["shadow_zero"], linewidth=2, label="Shadow-rate overlay")
ax.plot(
    overlay_table["tenor_years"],
    100 * overlay_table["jump_diffusion_zero"],
    linewidth=2,
    label="Jump-diffusion overlay",
)
ax.set_title("Optional Research Overlays On The Fitted Curve")
ax.set_xlabel("Tenor (years)")
ax.set_ylabel("Zero rate")
percent_axis(ax)
ax.legend(title="")
plt.tight_layout()
figure
