FinanceRoutines.jl

Financial data routines for Julia
Log | Files | Refs | README | LICENSE

commit 3baa3b7757320f2b6222ebcc14cf44c90e6ce256
parent 98848cbd9132330f6dd6abb80fcbffb6141ad731
Author: Erik Loualiche <[email protected]>
Date:   Sun, 22 Mar 2026 10:59:14 -0500

Split ImportYields.jl into GSW.jl and BondPricing.jl

Pure file split, no logic changes. GSW.jl contains the yield curve
model (GSWParameters, gsw_* functions, DataFrame wrappers).
BondPricing.jl contains generic bond math (bond_yield, bond_yield_excel,
day-count helpers). All 89 Yields tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

Diffstat:
Asrc/BondPricing.jl | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/FinanceRoutines.jl | 3++-
Asrc/GSW.jl | 1368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/ImportYields.jl | 1714-------------------------------------------------------------------------------
4 files changed, 1694 insertions(+), 1715 deletions(-)

diff --git a/src/BondPricing.jl b/src/BondPricing.jl @@ -0,0 +1,324 @@ +# OTHER FUNCTIONS TO WORK WITH BONDS ... NOT DIRECTLY RELATED TO TREASURIES ... +""" + bond_yield_excel(settlement, maturity, rate, price, redemption; + frequency=2, basis=0) -> Float64 + +Calculate the yield to maturity of a bond using Excel-compatible YIELD function interface. + +This function provides an Excel-compatible API for calculating bond yield to maturity, +matching the behavior and parameter conventions of Excel's `YIELD()` function. It +internally converts the date-based inputs to the time-to-maturity format required +by the underlying `bond_yield()` function. + +# Arguments +- `settlement::Date`: Settlement date of the bond (when the bond is purchased) +- `maturity::Date`: Maturity date of the bond (when principal is repaid) +- `rate::Real`: Annual coupon rate as a decimal (e.g., 0.0575 for 5.75%) +- `price::Real`: Bond's price per 100 of face value +- `redemption::Real`: Redemption value per 100 of face value (typically 100) + +# Keyword Arguments +- `frequency::Integer=2`: Number of coupon payments per year + - `1` = Annual + - `2` = Semiannual (default) + - `4` = Quarterly +- `basis::Integer=0`: Day count basis for calculating time periods + - `0` = 30/360 (default) + - `1` = Actual/actual + - `2` = Actual/360 + - `3` = Actual/365 + - `4` = European 30/360 + +# Returns +- `Float64`: Annual yield to maturity as a decimal (e.g., 0.065 for 6.5%) + +# Excel Compatibility +This function replicates Excel's `YIELD(settlement, maturity, rate, price, redemption, frequency, basis)` +function with identical parameter meanings and calculation methodology. + +# Example (Excel Documentation Case) +```julia +using Dates + +# Excel example data: +settlement = Date(2008, 2, 15) # 15-Feb-08 Settlement date +maturity = Date(2016, 11, 15) # 15-Nov-16 Maturity date +rate = 0.0575 # 5.75% Percent coupon +price = 95.04287 # Price per 100 face value +redemption = 100.0 # 100 Redemption value +frequency = 2 # Semiannual frequency +basis = 0 # 30/360 basis + +# Calculate yield (matches Excel YIELD function) +ytm = bond_yield_excel(settlement, maturity, rate, price, redemption, + frequency=frequency, basis=basis) +# Result: 0.065 (6.5%) + +# Equivalent Excel formula: =YIELD(A2,A3,A4,A5,A6,A7,A8) +# where cells contain the values above +``` + +# Additional Examples +```julia +# Corporate bond with quarterly payments +settlement = Date(2024, 1, 15) +maturity = Date(2029, 1, 15) +ytm = bond_yield_excel(settlement, maturity, 0.045, 98.50, 100.0, + frequency=4, basis=1) + +# Government bond with annual payments, actual/365 basis +ytm = bond_yield_excel(Date(2024, 3, 1), Date(2034, 3, 1), + 0.0325, 102.25, 100.0, frequency=1, basis=3) +``` + +# Notes +- Settlement date must be before maturity date +- Price and redemption are typically quoted per 100 of face value +- Uses actual coupon dates and the specified day-count basis, matching Excel's computation +- Results should match Excel's YIELD function within numerical precision + +# Throws +- `ArgumentError`: If settlement ≥ maturity date +- Convergence errors from underlying numerical root-finding + +See also: [`bond_yield`](@ref) +""" +function bond_yield_excel( + settlement::Date, maturity::Date, rate::Real, price::Real, redemption::Real; + frequency = 2, basis = 0) + + if settlement >= maturity + throw(ArgumentError("Settlement ($settlement) must be before maturity ($maturity)")) + end + + # Compute coupon schedule by working backwards from maturity + period_months = div(12, frequency) + + # Find next coupon date after settlement + next_coupon = maturity + while next_coupon - Month(period_months) > settlement + next_coupon -= Month(period_months) + end + prev_coupon = next_coupon - Month(period_months) + + # Count remaining coupons (from next_coupon to maturity, inclusive) + N = 0 + d = next_coupon + while d <= maturity + N += 1 + d += Month(period_months) + end + + # Day count fractions using the specified basis + A = _day_count_days(prev_coupon, settlement, basis) # accrued days + E = _day_count_days(prev_coupon, next_coupon, basis) # days in coupon period + DSC = E - A # Excel defines DSC = E - A to ensure consistency + + α = DSC / E # fraction of period until next coupon + coupon = redemption * rate / frequency + + # Excel's YIELD pricing formula + function price_from_yield(y) + if y <= 0 + return Inf + end + + dr = y / frequency + + if N == 1 + # Special case: single remaining coupon + return (redemption + coupon) / (1 + α * dr) - coupon * A / E + end + + # General case: N > 1 coupons + # PV of coupon annuity: ∑(k=1..N) coupon/(1+dr)^(k-1+α) = coupon*(1+dr)^(1-α)/dr * [1-(1+dr)^(-N)] + pv_coupons = coupon * (1 + dr)^(1 - α) * (1 - (1 + dr)^(-N)) / dr + # PV of redemption + pv_redemption = redemption / (1 + dr)^(N - 1 + α) + # Subtract accrued interest + return pv_coupons + pv_redemption - coupon * A / E + end + + price_diff(y) = price_from_yield(y) - price + + try + return Roots.find_zero(price_diff, (1e-6, 2.0), Roots.Brent()) + catch e + if isa(e, ArgumentError) && occursin("not a bracketing interval", sprint(showerror, e)) + @warn "Brent failed: falling back to Order1" exception=e + return Roots.find_zero(price_diff, rate, Roots.Order1()) + else + rethrow(e) + end + end +end + +""" + bond_yield(price, face_value, coupon_rate, years_to_maturity, frequency; + method=:brent, bracket=(0.001, 1.0)) -> Float64 + +Calculate the yield to maturity (YTM) of a bond given its market price and characteristics. + +This function uses numerical root-finding to determine the discount rate that equates the +present value of all future cash flows (coupon payments and principal repayment) to the +current market price of the bond. The calculation properly handles bonds with fractional +periods remaining until maturity and accounts for accrued interest. + +# Arguments +- `price::Real`: Current market price of the bond +- `face_value::Real`: Par value or face value of the bond (principal amount) +- `coupon_rate::Real`: Annual coupon rate as a decimal (e.g., 0.05 for 5%) +- `years_to_maturity::Real`: Time to maturity in years (can be fractional) +- `frequency::Integer`: Number of coupon payments per year (e.g., 2 for semi-annual, 4 for quarterly) + +# Keyword Arguments +- `method::Symbol=:brent`: Root-finding method (currently only :brent is implemented) +- `bracket::Tuple{Float64,Float64}=(0.001, 1.0)`: Initial bracket for yield search as (lower_bound, upper_bound) + +# Returns +- `Float64`: The yield to maturity as an annual rate (decimal form) + +# Algorithm Details +The function calculates bond price using the standard present value formula: +- For whole coupon periods: discounts each coupon payment to present value +- For fractional periods: applies fractional discounting and adjusts for accrued interest +- Handles the special case where yield approaches zero (no discounting) +- Uses the Brent method for robust numerical root-finding + +The price calculation accounts for: +1. Present value of remaining coupon payments +2. Present value of principal repayment +3. Accrued interest adjustments for fractional periods + +# Examples +```julia +# Calculate YTM for a 5% annual coupon bond, 1000 face value, 3.5 years to maturity, +# semi-annual payments, currently priced at 950 +ytm = bond_yield(950, 1000, 0.05, 3.5, 2) + +# 10-year quarterly coupon bond +ytm = bond_yield(1050, 1000, 0.06, 10.0, 4) + +# Bond very close to maturity (0.25 years) +ytm = bond_yield(998, 1000, 0.04, 0.25, 2) +``` + +# Notes +- The yield returned is the effective annual rate compounded at the specified frequency +- For bonds trading at a premium (price > face_value), expect YTM < coupon_rate +- For bonds trading at a discount (price < face_value), expect YTM > coupon_rate +- The function assumes the next coupon payment occurs exactly one period from now +- Requires the `Roots.jl` package for numerical root-finding + +# Throws +- May throw convergence errors if the root-finding algorithm fails to converge +- Will return `Inf` for invalid yields (≤ 0) + +See also: [`bond_yield_excel`](@ref) +""" +function bond_yield(price, face_value, coupon_rate, years_to_maturity, frequency; + method=:brent, bracket=(0.001, 1.0)) + + total_periods = years_to_maturity * frequency + whole_periods = floor(Int, total_periods) # Complete coupon periods + fractional_period = total_periods - whole_periods # Partial period + + coupon_payment = (face_value * coupon_rate) / frequency + + function price_diff(y) + if y <= 0 + return Inf + end + + discount_rate = y / frequency + calculated_price = 0.0 + + if discount_rate == 0 + # Zero yield case + calculated_price = coupon_payment * whole_periods + face_value + if fractional_period > 0 + # Add accrued interest for partial period + calculated_price += coupon_payment * fractional_period + end + else + # Present value of whole coupon payments + if whole_periods > 0 + pv_coupons = coupon_payment * (1 - (1 + discount_rate)^(-whole_periods)) / discount_rate + calculated_price += pv_coupons / (1 + discount_rate)^fractional_period + end + + # Present value of principal (always discounted by full period) + pv_principal = face_value / (1 + discount_rate)^total_periods + calculated_price += pv_principal + + # Subtract accrued interest (what buyer owes seller) + if fractional_period > 0 + accrued_interest = coupon_payment * fractional_period + calculated_price -= accrued_interest + end + end + + return calculated_price - price + end + + try + return Roots.find_zero(price_diff, bracket, Roots.Brent()) + catch e + if isa(e, ArgumentError) && occursin("not a bracketing interval", sprint(showerror, e)) + # Fall back to a derivative-free method using an initial guess + @warn "Brent failed: falling back to Order1" exception=e + return Roots.find_zero(price_diff, 0.02, Roots.Order1()) + else + rethrow(e) + end + end + +end + + +""" + _day_count_days(d1, d2, basis) -> Int + +Count the number of days between two dates using the specified day-count convention. +Used internally for bond yield calculations. + +- `basis=0`: 30/360 (US) +- `basis=1`: Actual/actual +- `basis=2`: Actual/360 +- `basis=3`: Actual/365 +- `basis=4`: European 30/360 +""" +function _day_count_days(d1::Date, d2::Date, basis::Int) + if basis == 0 # 30/360 US + day1, mon1, yr1 = Dates.day(d1), Dates.month(d1), Dates.year(d1) + day2, mon2, yr2 = Dates.day(d2), Dates.month(d2), Dates.year(d2) + if day1 == 31; day1 = 30; end + if day2 == 31 && day1 >= 30; day2 = 30; end + return 360 * (yr2 - yr1) + 30 * (mon2 - mon1) + (day2 - day1) + elseif basis == 4 # European 30/360 + day1, mon1, yr1 = Dates.day(d1), Dates.month(d1), Dates.year(d1) + day2, mon2, yr2 = Dates.day(d2), Dates.month(d2), Dates.year(d2) + if day1 == 31; day1 = 30; end + if day2 == 31; day2 = 30; end + return 360 * (yr2 - yr1) + 30 * (mon2 - mon1) + (day2 - day1) + else # basis 1, 2, 3: actual days + return Dates.value(d2 - d1) + end +end + +function _date_difference(start_date, end_date; basis=1) + days = _day_count_days(start_date, end_date, basis) + if basis == 0 + return days / 360 + elseif basis == 1 + return days / 365.25 + elseif basis == 2 + return days / 360 + elseif basis == 3 + return days / 365 + else + error("Invalid basis: $basis") + end +end +# -------------------------------------------------------------------------------------------------- + diff --git a/src/FinanceRoutines.jl b/src/FinanceRoutines.jl @@ -32,7 +32,8 @@ import ZipFile include("Utilities.jl") include("betas.jl") include("ImportFamaFrench.jl") -include("ImportYields.jl") +include("GSW.jl") +include("BondPricing.jl") include("ImportCRSP.jl") include("ImportComp.jl") include("Merge_CRSP_Comp.jl") diff --git a/src/GSW.jl b/src/GSW.jl @@ -0,0 +1,1368 @@ +# -------------------------------------------------------------------------------------------------- +# ImportYields.jl + +# Collection of functions that import Treasury Yields data +# -------------------------------------------------------------------------------------------------- + + +# -------------------------------------------------------------------------------------------------- +# GSW Parameter Type Definition +# -------------------------------------------------------------------------------------------------- + +""" + GSWParameters + +Structure to hold Gürkaynak-Sack-Wright Nelson-Siegel-Svensson model parameters. + +# Fields +- `β₀::Float64`: Level parameter (BETA0) +- `β₁::Float64`: Slope parameter (BETA1) +- `β₂::Float64`: Curvature parameter (BETA2) +- `β₃::Float64`: Second curvature parameter (BETA3) - may be missing if model uses 3-factor version +- `τ₁::Float64`: First decay parameter (TAU1, must be positive) +- `τ₂::Float64`: Second decay parameter (TAU2, must be positive) - may be missing if model uses 3-factor version + +# Examples +```julia +# Create GSW parameters manually (4-factor model) +params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) + +# Create GSW parameters for 3-factor model (when τ₂/β₃ are missing) +params_3factor = GSWParameters(5.0, -2.0, 1.5, missing, 2.5, missing) + +# Create from DataFrame row +df = import_gsw_parameters() +params = GSWParameters(df[1, :]) # First row + +# Access individual parameters +println("Level: ", params.β₀) +println("Slope: ", params.β₁) +``` + +# Notes +- Constructor validates that available decay parameters are positive +- Handles missing values for τ₂ and β₃ (common when using 3-factor Nelson-Siegel model) +- When τ₂ or β₃ are missing, the model degenerates to the 3-factor Nelson-Siegel form +- Can be constructed from DataFrameRow for convenience +""" +struct GSWParameters + β₀::Float64 # Level + β₁::Float64 # Slope + β₂::Float64 # Curvature 1 + β₃::Union{Float64, Missing} # Curvature 2 (may be missing for 3-factor model) + τ₁::Float64 # Decay 1 (must be positive) + τ₂::Union{Float64, Missing} # Decay 2 (may be missing for 3-factor model) + + # Inner constructor with validation + # Returns `missing` (not a GSWParameters) when core fields are missing + function GSWParameters(β₀, β₁, β₂, β₃, τ₁, τ₂) + + # Check if core parameters are missing — return missing instead of constructing + if ismissing(β₀) || ismissing(β₁) || ismissing(β₂) || ismissing(τ₁) + return missing + end + + # Validate that decay parameters are positive + if τ₁ <= 0 + throw(ArgumentError("First decay parameter τ₁ must be positive, got τ₁=$τ₁")) + end + if !ismissing(τ₂) && τ₂ <= 0 + throw(ArgumentError("Second decay parameter τ₂ must be positive when present, got τ₂=$τ₂")) + end + + new( + Float64(β₀), Float64(β₁), Float64(β₂), + ismissing(β₃) ? missing : Float64(β₃), + Float64(τ₁), + ismissing(τ₂) ? missing : Float64(τ₂) + ) + end +end + +# Convenience constructors +""" + GSWParameters(row::DataFrameRow) + +Create GSWParameters from a DataFrame row containing BETA0, BETA1, BETA2, BETA3, TAU1, TAU2 columns. +Handles missing values (including -999 flags) gracefully. +""" +function GSWParameters(row::DataFrameRow) + return GSWParameters(row.BETA0, row.BETA1, row.BETA2, row.BETA3, row.TAU1, row.TAU2) +end + +""" + GSWParameters(row::NamedTuple) + +Create GSWParameters from a NamedTuple containing the required fields. +Handles missing values (including -999 flags) gracefully. +""" +function GSWParameters(row::NamedTuple) + return GSWParameters(row.BETA0, row.BETA1, row.BETA2, row.BETA3, row.TAU1, row.TAU2) +end + + +""" + is_three_factor_model(params::GSWParameters) + +Check if GSW parameters represent a 3-factor Nelson-Siegel model (missing β₃ and τ₂). + +# Returns +- `Bool`: true if this is a 3-factor model, false if 4-factor Svensson model +""" +function is_three_factor_model(params::GSWParameters) + return ismissing(params.β₃) || ismissing(params.τ₂) +end + +# Helper function to extract parameters as tuple, handling missing values +""" + _extract_params(params::GSWParameters) + +Extract parameters as tuple for use in calculation functions. +For 3-factor models, uses τ₁ for both decay parameters and sets β₃=0. +""" +function _extract_params(params::GSWParameters) + # Handle 3-factor vs 4-factor models + if is_three_factor_model(params) + # For 3-factor model: set β₃=0 and use τ₁ for both decay parameters + β₃ = 0.0 + τ₂ = ismissing(params.τ₂) ? params.τ₁ : params.τ₂ + else + β₃ = params.β₃ + τ₂ = params.τ₂ + end + + return (params.β₀, params.β₁, params.β₂, β₃, params.τ₁, τ₂) +end +# -------------------------------------------------------------------------------------------------- + + + +# -------------------------------------------------------------------------------------------------- +""" + import_gsw_parameters(; date_range=nothing, validate=true) + +Import Gürkaynak-Sack-Wright (GSW) yield curve parameters from the Federal Reserve. + +Downloads the daily GSW yield curve parameter estimates from the Fed's website and returns +a cleaned DataFrame with the Nelson-Siegel-Svensson model parameters. + +# Arguments +- `date_range::Union{Nothing, Tuple{Date, Date}}`: Optional date range for filtering data. + If `nothing`, returns all available data. Default: `nothing` +- `validate::Bool`: Whether to validate input parameters and data quality. Default: `true` + +# Returns +- `DataFrame`: Contains columns `:date`, `:BETA0`, `:BETA1`, `:BETA2`, `:BETA3`, `:TAU1`, `:TAU2` + +# Throws +- `ArgumentError`: If date range is invalid +- `HTTP.ExceptionRequest.StatusError`: If download fails +- `Exception`: If data parsing fails + +# Examples +```julia +# Import all available data +df = import_gsw_parameters() + +# Import data for specific date range +df = import_gsw_parameters(date_range=(Date("2020-01-01"), Date("2023-12-31"))) + +# Import without validation (faster, but less safe) +df = import_gsw_parameters(validate=false) +``` + +# Notes +- Data source: Federal Reserve Economic Data (FRED) +- The GSW model uses the Nelson-Siegel-Svensson functional form +- Missing values in the original data are converted to `missing` +- Data is automatically sorted by date +- Additional variables: + - Zero-coupon yield,Continuously Compounded,SVENYXX + - Par yield,Coupon-Equivalent,SVENPYXX + - Instantaneous forward rate,Continuously Compounded,SVENFXX + - One-year forward rate,Coupon-Equivalent,SVEN1FXX + +""" +function import_gsw_parameters(; + date_range::Union{Nothing, Tuple{Date, Date}} = nothing, + additional_variables::Vector{Symbol}=Symbol[], + validate::Bool = true) + + + # Download data with error handling + @info "Downloading GSW Yield Curve Parameters from Federal Reserve" + + try + url_gsw = "https://www.federalreserve.gov/data/yield-curve-tables/feds200628.csv" + temp_file = Downloads.download(url_gsw) + + # Parse CSV with proper error handling + df_gsw = CSV.read(temp_file, DataFrame, + skipto=11, + header=10, + silencewarnings=true) + + # Clean up temporary file + rm(temp_file, force=true) + + # Clean and process the data + df_clean = _clean_gsw_data(df_gsw, date_range; additional_variables=additional_variables) + + + if validate + _validate_gsw_data(df_clean) + end + + @info "Successfully imported $(nrow(df_clean)) rows of GSW parameters" + return df_clean + + catch e + if e isa Downloads.RequestError + throw(ArgumentError("Failed to download GSW data from Federal Reserve. Check internet connection.")) + elseif e isa CSV.Error + throw(ArgumentError("Failed to parse GSW data. The file format may have changed.")) + else + rethrow(e) + end + end +end + + + +""" + _clean_gsw_data(df_raw, date_range) + +Clean and format the raw GSW data from the Federal Reserve. +""" +function _clean_gsw_data(df_raw::DataFrame, + date_range::Union{Nothing, Tuple{Date, Date}}; + additional_variables::Vector{Symbol}=Symbol[]) + + + # Make a copy to avoid modifying original + df = copy(df_raw) + # Standardize column names + rename!(df, "Date" => "date") + + # Apply date filtering if specified + if !isnothing(date_range) + start_date, end_date = date_range + if start_date > end_date + @warn "starting date posterior to end date ... shuffling them around" + start_date, end_date = min(start_date, end_date), max(start_date, end_date) + end + filter!(row -> start_date <= row.date <= end_date, df) + end + + # Select and order relevant columns + parameter_cols = vcat( + [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2], + intersect(additional_variables, propertynames(df)) + ) |> unique + select!(df, :date, parameter_cols...) + + # Convert parameter columns to Float64, handling missing values + for col in parameter_cols + transform!(df, col => ByRow(_safe_parse_float) => col) + end + + # Sort by date for consistency + sort!(df, :date) + + return df +end + +""" + _safe_parse_float(value) + +Safely parse a value to Float64, returning missing for unparseable values. +Handles common flag values for missing data in economic datasets. +""" +function _safe_parse_float(value) + if ismissing(value) || value == "" + return missing + end + + # Handle string values + if value isa AbstractString + parsed = tryparse(Float64, strip(value)) + if isnothing(parsed) + return missing + end + value = parsed + end + + # Handle numeric values and check for common missing data flags + try + numeric_value = Float64(value) + + # Common missing data flags in economic/financial datasets + if numeric_value in (-999.99, -999.0, -9999.0, -99.99) + return missing + end + + return numeric_value + catch + return missing + end +end + +""" + _validate_gsw_data(df) + +Validate the cleaned GSW data for basic quality checks. +""" +function _validate_gsw_data(df::DataFrame) + if nrow(df) == 0 + throw(ArgumentError("No data found for the specified date range")) + end + + # Check for required columns + required_cols = [:date, :BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] + missing_cols = setdiff(required_cols, propertynames(df)) + if !isempty(missing_cols) + throw(ArgumentError("Missing required columns: $(missing_cols)")) + end + + # Check for reasonable parameter ranges (basic sanity check) + param_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] + for col in param_cols + col_data = skipmissing(df[!, col]) |> collect + if length(col_data) == 0 + @warn "Column $col contains only missing values" + end + end + + # Check date continuity (warn if there are large gaps) + if nrow(df) > 1 + date_diffs = diff(df.date) + large_gaps = findall(x -> x > Day(7), date_diffs) + if !isempty(large_gaps) + @warn "Found $(length(large_gaps)) gaps larger than 7 days in the data" + end + end +end +# -------------------------------------------------------------------------------------------------- + + + +# -------------------------------------------------------------------------------------------------- +# GSW Core Calculation Functions + +# Method 1: Using GSWParameters struct (preferred for clean API) +""" + gsw_yield(maturity, params::GSWParameters) + +Calculate yield from GSW Nelson-Siegel-Svensson parameters using parameter struct. + +# Arguments +- `maturity::Real`: Time to maturity in years (must be positive) +- `params::GSWParameters`: GSW parameter struct + +# Returns +- `Float64`: Yield in percent (e.g., 5.0 for 5%) + +# Examples +```julia +params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +yield = gsw_yield(10.0, params) +``` +""" +function gsw_yield(maturity::Real, params::GSWParameters) + return gsw_yield(maturity, _extract_params(params)...) +end + +# Method 2: Using individual parameters (for flexibility and backward compatibility) +""" + gsw_yield(maturity, β₀, β₁, β₂, β₃, τ₁, τ₂) + +Calculate yield from Gürkaynak-Sack-Wright Nelson-Siegel-Svensson parameters. + +Computes the yield for a given maturity using the Nelson-Siegel-Svensson functional form +with the GSW parameter estimates. Automatically handles 3-factor vs 4-factor models. + +# Arguments +- `maturity::Real`: Time to maturity in years (must be positive) +- `β₀::Real`: Level parameter (BETA0) +- `β₁::Real`: Slope parameter (BETA1) +- `β₂::Real`: Curvature parameter (BETA2) +- `β₃::Real`: Second curvature parameter (BETA3) - set to 0 or missing for 3-factor model +- `τ₁::Real`: First decay parameter +- `τ₂::Real`: Second decay parameter - can equal τ₁ for 3-factor model + +# Returns +- `Float64`: Yield in percent (e.g., 5.0 for 5%) + +# Throws +- `ArgumentError`: If maturity is non-positive or τ parameters are non-positive + +# Examples +```julia +# Calculate 1-year yield (4-factor model) +yield = gsw_yield(1.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) + +# Calculate 10-year yield (3-factor model, β₃=0) +yield = gsw_yield(10.0, 5.0, -2.0, 1.5, 0.0, 2.5, 2.5) +``` + +# Notes +- Based on the Nelson-Siegel-Svensson functional form +- When β₃=0 or τ₂=τ₁, degenerates to 3-factor Nelson-Siegel model +- Returns yield in percentage terms (not decimal) +- Function is vectorizable: use `gsw_yield.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂)` +""" +function gsw_yield(maturity::Real, + β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real) + + # Input validation + if maturity <= 0 + throw(ArgumentError("Maturity must be positive, got $maturity")) + end + + # For 3-factor model compatibility: if β₃ is 0 or very small, skip the fourth term + use_four_factor = abs(β₃) > 1e-10 && τ₂ > 0 + + # Nelson-Siegel-Svensson formula + t = Float64(maturity) + + # Calculate decay terms + exp_t_τ₁ = exp(-t/τ₁) + + # yield terms + term1 = β₀ # Level + term2 = β₁ * (1.0 - exp_t_τ₁) / (t/τ₁) # Slope + term3 = β₂ * ((1.0 - exp_t_τ₁) / (t/τ₁) - exp_t_τ₁) # First curvature + + # Fourth term only for 4-factor Svensson model + term4 = if use_four_factor + exp_t_τ₂ = exp(-t/τ₂) + β₃ * ((1.0 - exp_t_τ₂) / (t/τ₂) - exp_t_τ₂) # Second curvature + else + 0.0 + end + + yield = term1 + term2 + term3 + term4 + + return Float64(yield) +end + +# Method 1: Using GSWParameters struct +""" + gsw_price(maturity, params::GSWParameters; face_value=1.0) + +Calculate zero-coupon bond price from GSW parameters using parameter struct. + +# Arguments +- `maturity::Real`: Time to maturity in years (must be positive) +- `params::GSWParameters`: GSW parameter struct +- `face_value::Real`: Face value of the bond (default: 1.0) + +# Returns +- `Float64`: Bond price + +# Examples +```julia +params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +price = gsw_price(10.0, params) +``` +""" +function gsw_price(maturity::Real, params::GSWParameters; face_value::Real = 1.0) + return gsw_price(maturity, _extract_params(params)..., face_value=face_value) +end + +# Method 2: Using individual parameters +""" + gsw_price(maturity, β₀, β₁, β₂, β₃, τ₁, τ₂; face_value=1.0) + +Calculate zero-coupon bond price from GSW Nelson-Siegel-Svensson parameters. + +Computes the price of a zero-coupon bond using the yield derived from GSW parameters. + +# Arguments +- `maturity::Real`: Time to maturity in years (must be positive) +- `β₀::Real`: Level parameter (BETA0) +- `β₁::Real`: Slope parameter (BETA1) +- `β₂::Real`: Curvature parameter (BETA2) +- `β₃::Real`: Second curvature parameter (BETA3) +- `τ₁::Real`: First decay parameter +- `τ₂::Real`: Second decay parameter +- `face_value::Real`: Face value of the bond (default: 1.0) + +# Returns +- `Float64`: Bond price + +# Throws +- `ArgumentError`: If maturity is non-positive, τ parameters are non-positive, or face_value is non-positive + +# Examples +```julia +# Calculate price of 1-year zero-coupon bond +price = gsw_price(1.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) + +# Calculate price with different face value +price = gsw_price(1.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5, face_value=1000.0) +``` + +# Notes +- Uses continuous compounding: P = F * exp(-r * t) +- Yield is converted from percentage to decimal for calculation +- Function is vectorizable: use `gsw_price.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂)` +""" +function gsw_price(maturity::Real, β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real; + face_value::Real = 1.0) + + # Input validation + if maturity <= 0 + throw(ArgumentError("Maturity must be positive, got $maturity")) + end + if face_value <= 0 + throw(ArgumentError("Face value must be positive, got $face_value")) + end + + # Handle any missing values + if any(ismissing, [β₀, β₁, β₂, β₃, τ₁, τ₂, maturity, face_value]) + return missing + end + + # Get yield in percentage terms + yield_percent = gsw_yield(maturity, β₀, β₁, β₂, β₃, τ₁, τ₂) + + if ismissing(yield_percent) + return missing + end + + # Convert to decimal and calculate price using continuous compounding + continuous_rate = log(1.0 + yield_percent / 100.0) + price = face_value * exp(-continuous_rate * maturity) + + return Float64(price) +end + +# Method 1: Using GSWParameters struct +""" + gsw_forward_rate(maturity₁, maturity₂, params::GSWParameters) + +Calculate instantaneous forward rate between two maturities using GSW parameter struct. + +# Arguments +- `maturity₁::Real`: Start maturity in years (must be positive and < maturity₂) +- `maturity₂::Real`: End maturity in years (must be positive and > maturity₁) +- `params::GSWParameters`: GSW parameter struct + +# Returns +- `Float64`: Forward rate (decimal rate) + +# Examples +```julia +params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +fwd_rate = gsw_forward_rate(2.0, 3.0, params) +``` +""" +function gsw_forward_rate(maturity₁::Real, maturity₂::Real, params::GSWParameters) + return gsw_forward_rate(maturity₁, maturity₂, _extract_params(params)...) +end + +# Method 2: Using individual parameters +""" + gsw_forward_rate(maturity₁, maturity₂, β₀, β₁, β₂, β₃, τ₁, τ₂) + +Calculate instantaneous forward rate between two maturities using GSW parameters. + +# Arguments +- `maturity₁::Real`: Start maturity in years (must be positive and < maturity₂) +- `maturity₂::Real`: End maturity in years (must be positive and > maturity₁) +- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters + +# Returns +- `Float64`: Forward rate (decimal rate) + +# Examples +```julia +# Calculate 1-year forward rate starting in 2 years +fwd_rate = gsw_forward_rate(2.0, 3.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +``` +""" +function gsw_forward_rate(maturity₁::Real, maturity₂::Real, + β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real) + + if maturity₁ <= 0 || maturity₂ <= maturity₁ + throw(ArgumentError("Must have 0 < maturity₁ < maturity₂, got maturity₁=$maturity₁, maturity₂=$maturity₂")) + end + + # Handle missing values + if any(ismissing, [β₀, β₁, β₂, β₃, τ₁, τ₂, maturity₁, maturity₂]) + return missing + end + + # Get prices at both maturities + p₁ = gsw_price(maturity₁, β₀, β₁, β₂, β₃, τ₁, τ₂) + p₂ = gsw_price(maturity₂, β₀, β₁, β₂, β₃, τ₁, τ₂) + + if ismissing(p₁) || ismissing(p₂) + return missing + end + + # Calculate forward rate: f = -ln(P₂/P₁) / (T₂ - T₁) + forward_rate_decimal = -log(p₂ / p₁) / (maturity₂ - maturity₁) + + # Convert to percentage + return Float64(forward_rate_decimal) +end + +# ------------------------------------------------------------------------------------------ +# Vectorized convenience functions +# ------------------------------------------------------------------------------------------ + +""" + gsw_yield_curve(maturities, params::GSWParameters) + +Calculate yields for multiple maturities using GSW parameter struct. + +# Arguments +- `maturities::AbstractVector{<:Real}`: Vector of maturities in years +- `params::GSWParameters`: GSW parameter struct + +# Returns +- `Vector{Float64}`: Vector of yields in percent + +# Examples +```julia +params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +maturities = [0.25, 0.5, 1, 2, 5, 10, 30] +yields = gsw_yield_curve(maturities, params) +``` +""" +function gsw_yield_curve(maturities::AbstractVector{<:Real}, params::GSWParameters) + return gsw_yield.(maturities, Ref(params)) +end + +""" + gsw_yield_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) + +Calculate yields for multiple maturities using GSW parameters. + +# Arguments +- `maturities::AbstractVector{<:Real}`: Vector of maturities in years +- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters + +# Returns +- `Vector{Float64}`: Vector of yields in percent + +# Examples +```julia +maturities = [0.25, 0.5, 1, 2, 5, 10, 30] +yields = gsw_yield_curve(maturities, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +``` +""" +function gsw_yield_curve(maturities::AbstractVector{<:Real}, β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real) + return gsw_yield.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) +end + +""" + gsw_price_curve(maturities, params::GSWParameters; face_value=1.0) + +Calculate zero-coupon bond prices for multiple maturities using GSW parameter struct. + +# Arguments +- `maturities::AbstractVector{<:Real}`: Vector of maturities in years +- `params::GSWParameters`: GSW parameter struct +- `face_value::Real`: Face value of bonds (default: 1.0) + +# Returns +- `Vector{Float64}`: Vector of bond prices + +# Examples +```julia +params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +maturities = [0.25, 0.5, 1, 2, 5, 10, 30] +prices = gsw_price_curve(maturities, params) +``` +""" +function gsw_price_curve(maturities::AbstractVector{<:Real}, params::GSWParameters; face_value::Real = 1.0) + return gsw_price.(maturities, Ref(params), face_value=face_value) +end + +""" + gsw_price_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂; face_value=1.0) + +Calculate zero-coupon bond prices for multiple maturities using GSW parameters. + +# Arguments +- `maturities::AbstractVector{<:Real}`: Vector of maturities in years +- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters +- `face_value::Real`: Face value of bonds (default: 1.0) + +# Returns +- `Vector{Float64}`: Vector of bond prices + +# Examples +```julia +maturities = [0.25, 0.5, 1, 2, 5, 10, 30] +prices = gsw_price_curve(maturities, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +``` +""" +function gsw_price_curve(maturities::AbstractVector{<:Real}, β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real; + face_value::Real = 1.0) + return gsw_price.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂, face_value=face_value) +end +# -------------------------------------------------------------------------------------------------- + + + + +# -------------------------------------------------------------------------------------------------- +# Return calculation functions +# ------------------------------------------------------------------------------------------ + +# Method 1: Using individual parameters +""" + gsw_return(maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, + β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁; + frequency=:daily, return_type=:log) + +Calculate bond return between two periods using GSW parameters. + +Computes the return on a zero-coupon bond between two time periods by comparing +the price today (with aged maturity) to the price in the previous period. + +# Arguments +- `maturity::Real`: Original maturity of the bond in years +- `β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t`: GSW parameters at time t +- `β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁`: GSW parameters at time t-1 +- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) +- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns + +# Returns +- `Float64`: Bond return + +# Examples +```julia +# Daily log return on 10-year bond +ret = gsw_return(10.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5, # today's params + 4.9, -1.9, 1.4, 0.9, 2.4, 0.6) # yesterday's params + +# Monthly arithmetic return +ret = gsw_return(5.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5, + 4.9, -1.9, 1.4, 0.9, 2.4, 0.6, + frequency=:monthly, return_type=:arithmetic) +``` +""" +function gsw_return(maturity::Real, + β₀_t::Real, β₁_t::Real, β₂_t::Real, β₃_t::Real, τ₁_t::Real, τ₂_t::Real, + β₀_t₋₁::Real, β₁_t₋₁::Real, β₂_t₋₁::Real, β₃_t₋₁::Real, τ₁_t₋₁::Real, τ₂_t₋₁::Real; + frequency::Symbol = :daily, + return_type::Symbol = :log) + + # Input validation + if maturity <= 0 + throw(ArgumentError("Maturity must be positive, got $maturity")) + end + + valid_frequencies = [:daily, :monthly, :annual] + if frequency ∉ valid_frequencies + throw(ArgumentError("frequency must be one of $valid_frequencies, got $frequency")) + end + + valid_return_types = [:log, :arithmetic] + if return_type ∉ valid_return_types + throw(ArgumentError("return_type must be one of $valid_return_types, got $return_type")) + end + + # Handle missing values + all_params = [β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁] + if any(ismissing, all_params) + return missing + end + + # Determine time step based on frequency + Δt = if frequency == :daily + 1/360 # Using 360-day year convention + elseif frequency == :monthly + 1/12 + elseif frequency == :annual + 1.0 + end + + # Calculate prices + # P_t: Price today of bond with remaining maturity (maturity - Δt) + aged_maturity = max(maturity - Δt, 0.001) # Avoid zero maturity + price_today = gsw_price(aged_maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t) + + # P_t₋₁: Price yesterday of bond with original maturity + price_previous = gsw_price(maturity, β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁) + + if ismissing(price_today) || ismissing(price_previous) + return missing + end + + # Calculate return + if return_type == :log + return log(price_today / price_previous) + else # arithmetic + return (price_today - price_previous) / price_previous + end +end + + +# Method 2: Using GSWParameters structs +""" + gsw_return(maturity, params_t::GSWParameters, params_t₋₁::GSWParameters; frequency=:daily, return_type=:log) + +Calculate bond return between two periods using GSW parameter structs. + +# Arguments +- `maturity::Real`: Original maturity of the bond in years +- `params_t::GSWParameters`: GSW parameters at time t +- `params_t₋₁::GSWParameters`: GSW parameters at time t-1 +- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) +- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns + +# Returns +- `Float64`: Bond return + +# Examples +```julia +params_today = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +params_yesterday = GSWParameters(4.9, -1.9, 1.4, 0.9, 2.4, 0.6) +ret = gsw_return(10.0, params_today, params_yesterday) +``` +""" +function gsw_return(maturity::Real, params_t::GSWParameters, params_t₋₁::GSWParameters; + frequency::Symbol = :daily, return_type::Symbol = :log) + return gsw_return(maturity, _extract_params(params_t)..., _extract_params(params_t₋₁)..., + frequency=frequency, return_type=return_type) +end +# -------------------------------------------------------------------------------------------------- + + + +# Method 1: Using GSWParameters structs +""" + gsw_excess_return(maturity, params_t::GSWParameters, params_t₋₁::GSWParameters; + risk_free_maturity=0.25, frequency=:daily, return_type=:log) + +Calculate excess return of a bond over the risk-free rate using GSW parameter structs. + +# Arguments +- `maturity::Real`: Original maturity of the bond in years +- `params_t::GSWParameters`: GSW parameters at time t +- `params_t₋₁::GSWParameters`: GSW parameters at time t-1 +- `risk_free_maturity::Real`: Maturity for risk-free rate calculation (default: 0.25 for 3-month) +- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) +- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns + +# Returns +- `Float64`: Excess return (bond return - risk-free return) + +# Examples +```julia +params_today = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +params_yesterday = GSWParameters(4.9, -1.9, 1.4, 0.9, 2.4, 0.6) +excess_ret = gsw_excess_return(10.0, params_today, params_yesterday) +``` +""" +function gsw_excess_return(maturity::Real, params_t::GSWParameters, params_t₋₁::GSWParameters; + risk_free_maturity::Real = 0.25, + frequency::Symbol = :daily, + return_type::Symbol = :log) + return gsw_excess_return(maturity, _extract_params(params_t)..., _extract_params(params_t₋₁)..., + risk_free_maturity=risk_free_maturity, frequency=frequency, return_type=return_type) +end + +# Method 2: Using individual parameters +""" + gsw_excess_return(maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, + β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁; + risk_free_maturity=0.25, frequency=:daily, return_type=:log) + +Calculate excess return of a bond over the risk-free rate. + +# Arguments +- Same as `gsw_return` plus: +- `risk_free_maturity::Real`: Maturity for risk-free rate calculation (default: 0.25 for 3-month) + +# Returns +- `Float64`: Excess return (bond return - risk-free return) +""" +function gsw_excess_return(maturity::Real, + β₀_t::Real, β₁_t::Real, β₂_t::Real, β₃_t::Real, τ₁_t::Real, τ₂_t::Real, + β₀_t₋₁::Real, β₁_t₋₁::Real, β₂_t₋₁::Real, β₃_t₋₁::Real, τ₁_t₋₁::Real, τ₂_t₋₁::Real; + risk_free_maturity::Real = 0.25, + frequency::Symbol = :daily, + return_type::Symbol = :log) + + # Calculate bond return + bond_return = gsw_return(maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, + β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁, + frequency=frequency, return_type=return_type) + + # Calculate risk-free return + rf_return = gsw_return(risk_free_maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, + β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁, + frequency=frequency, return_type=return_type) + + if ismissing(bond_return) || ismissing(rf_return) + return missing + end + + return bond_return - rf_return +end +# -------------------------------------------------------------------------------------------------- + + +# -------------------------------------------------------------------------------------------------- +# -------------------------------------------------------------------------------------------------- +# GSW DataFrame Wrapper Functions +# ------------------------------------------------------------------------------------------ +""" + add_yields!(df, maturities; validate=true) + +Add yield calculations to a DataFrame containing GSW parameters. + +Adds columns with yields for specified maturities using the Nelson-Siegel-Svensson +model parameters in the DataFrame. + +# Arguments +- `df::DataFrame`: DataFrame containing GSW parameters (must have columns: BETA0, BETA1, BETA2, BETA3, TAU1, TAU2) +- `maturities::Union{Real, AbstractVector{<:Real}}`: Maturity or vector of maturities in years +- `validate::Bool`: Whether to validate DataFrame structure (default: true) + +# Returns +- `DataFrame`: Modified DataFrame with additional yield columns named `yield_Xy` (e.g., `yield_1y`, `yield_10y`) + +# Examples +```julia +df = import_gsw_parameters() + +# Add single maturity +add_yields!(df, 10.0) + +# Add multiple maturities +add_yields!(df, [1, 2, 5, 10, 30]) + +# Add with custom maturity (fractional) +add_yields!(df, [0.25, 0.5, 1.0]) +``` + +# Notes +- Modifies the DataFrame in place +- Column names use format: `yield_Xy` where X is the maturity +- Handles missing parameter values gracefully +- Validates required columns are present +""" +function add_yields!(df::DataFrame, maturities::Union{Real, AbstractVector{<:Real}}; + validate::Bool = true) + + if validate + _validate_gsw_dataframe(df) + end + + # Ensure maturities is a vector + mat_vector = maturities isa Real ? [maturities] : collect(maturities) + + # Validate maturities + if any(m -> m <= 0, mat_vector) + throw(ArgumentError("All maturities must be positive")) + end + + # Add yield columns using GSWParameters struct + for maturity in mat_vector + col_name = _maturity_to_column_name("yield", maturity) + + transform!(df, + AsTable([:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2]) => + ByRow(function(params) + gsw_params = GSWParameters(params) + if ismissing(gsw_params) + return missing + else + return gsw_yield(maturity, gsw_params) + end + end) => col_name) + end + + return df +end +# -------------------------------------------------------------------------------------------------- + + +# -------------------------------------------------------------------------------------------------- +""" + add_prices!(df, maturities; face_value=100.0, validate=true) + +Add zero-coupon bond price calculations to a DataFrame containing GSW parameters. + +# Arguments +- `df::DataFrame`: DataFrame containing GSW parameters +- `maturities::Union{Real, AbstractVector{<:Real}}`: Maturity or vector of maturities in years +- `face_value::Real`: Face value of bonds (default: 100.0) +- `validate::Bool`: Whether to validate DataFrame structure (default: true) + +# Returns +- `DataFrame`: Modified DataFrame with additional price columns named `price_Xy` + +# Examples +```julia +df = import_gsw_parameters() + +# Add prices for multiple maturities +add_prices!(df, [1, 5, 10]) + +# Add prices with different face value +add_prices!(df, 10.0, face_value=1000.0) +``` +""" +function add_prices!(df::DataFrame, maturities::Union{Real, AbstractVector{<:Real}}; + face_value::Real = 100.0, validate::Bool = true) + + if validate + _validate_gsw_dataframe(df) + end + + if face_value <= 0 + throw(ArgumentError("Face value must be positive, got $face_value")) + end + + # Ensure maturities is a vector + mat_vector = maturities isa Real ? [maturities] : collect(maturities) + + # Validate maturities + if any(m -> m <= 0, mat_vector) + throw(ArgumentError("All maturities must be positive")) + end + + # Add price columns using GSWParameters struct + for maturity in mat_vector + col_name = _maturity_to_column_name("price", maturity) + + transform!(df, + AsTable([:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2]) => + ByRow(function(params) + gsw_params = GSWParameters(params) + if ismissing(gsw_params) + return missing + else + return gsw_price(maturity, gsw_params, face_value=face_value) + end + end) => col_name) + end + + return df +end +# -------------------------------------------------------------------------------------------------- + + +# -------------------------------------------------------------------------------------------------- +""" + add_returns!(df, maturity; frequency=:daily, return_type=:log, validate=true) + +Add bond return calculations to a DataFrame containing GSW parameters. + +Calculates returns by comparing bond prices across time periods. Requires DataFrame +to be sorted by date and contain consecutive time periods. + +# Arguments +- `df::DataFrame`: DataFrame containing GSW parameters and dates (must have :date column) +- `maturity::Real`: Bond maturity in years +- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) +- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns +- `validate::Bool`: Whether to validate DataFrame structure (default: true) + +# Returns +- `DataFrame`: Modified DataFrame with return column named `ret_Xy_frequency` + (e.g., `ret_10y_daily`, `ret_5y_monthly`) + +# Examples +```julia +df = import_gsw_parameters() + +# Add daily log returns for 10-year bond +add_returns!(df, 10.0) + +# Add monthly arithmetic returns for 5-year bond +add_returns!(df, 5.0, frequency=:monthly, return_type=:arithmetic) +``` + +# Notes +- Requires DataFrame to be sorted by date +- First row will have missing return (no previous period) +- Uses lag of parameters to calculate returns properly +""" +function add_returns!(df::DataFrame, maturity::Real; + frequency::Symbol = :daily, + return_type::Symbol = :log, + validate::Bool = true) + + if validate + _validate_gsw_dataframe(df, check_date=true) + end + + if maturity <= 0 + throw(ArgumentError("Maturity must be positive, got $maturity")) + end + + valid_frequencies = [:daily, :monthly, :annual] + if frequency ∉ valid_frequencies + throw(ArgumentError("frequency must be one of $valid_frequencies, got $frequency")) + end + + valid_return_types = [:log, :arithmetic] + if return_type ∉ valid_return_types + throw(ArgumentError("return_type must be one of $valid_return_types, got $return_type")) + end + + # Sort by date to ensure proper time series order + sort!(df, :date) + + # Determine time step based on frequency + time_step = if frequency == :daily + Day(1) + elseif frequency == :monthly + Day(30) # Approximate + elseif frequency == :annual + Day(360) # Using 360-day year + end + + # Create lagged parameter columns using PanelShift.jl + param_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] + for col in param_cols + lag_col = Symbol("lag_$col") + transform!(df, [:date, col] => + ((dates, values) -> tlag(values, dates; n=time_step)) => + lag_col) + end + + # Calculate returns using current and lagged parameters + col_name = Symbol(string(_maturity_to_column_name("ret", maturity)) * "_" * string(frequency)) + + transform!(df, + AsTable(vcat(param_cols, [Symbol("lag_$col") for col in param_cols])) => + ByRow(params -> begin + current_params = GSWParameters(params.BETA0, params.BETA1, params.BETA2, + params.BETA3, params.TAU1, params.TAU2) + lagged_params = GSWParameters(params.lag_BETA0, params.lag_BETA1, params.lag_BETA2, + params.lag_BETA3, params.lag_TAU1, params.lag_TAU2) + if ismissing(current_params) || ismissing(lagged_params) + missing + else + gsw_return(maturity, current_params, lagged_params, + frequency=frequency, return_type=return_type) + end + end + ) => col_name) + + # Clean up temporary lagged columns + select!(df, Not([Symbol("lag_$col") for col in param_cols])) + + # Reorder columns to put return column first (after date) + if :date in names(df) + other_cols = filter(col -> col ∉ [:date, col_name], names(df)) + select!(df, :date, col_name, other_cols...) + end + + return df +end +# -------------------------------------------------------------------------------------------------- + + +# -------------------------------------------------------------------------------------------------- +""" + add_excess_returns!(df, maturity; risk_free_maturity=0.25, frequency=:daily, return_type=:log, validate=true) + +Add excess return calculations (bond return - risk-free return) to DataFrame. + +# Arguments +- Same as `add_returns!` plus: +- `risk_free_maturity::Real`: Maturity for risk-free rate (default: 0.25 for 3-month) + +# Returns +- `DataFrame`: Modified DataFrame with excess return column named `excess_ret_Xy_frequency` +""" +function add_excess_returns!(df::DataFrame, maturity::Real; + risk_free_maturity::Real = 0.25, + frequency::Symbol = :daily, + return_type::Symbol = :log, + validate::Bool = true) + + if validate + _validate_gsw_dataframe(df, check_date=true) + end + + if maturity <= 0 + throw(ArgumentError("Maturity must be positive, got $maturity")) + end + + valid_frequencies = [:daily, :monthly, :annual] + if frequency ∉ valid_frequencies + throw(ArgumentError("frequency must be one of $valid_frequencies, got $frequency")) + end + + valid_return_types = [:log, :arithmetic] + if return_type ∉ valid_return_types + throw(ArgumentError("return_type must be one of $valid_return_types, got $return_type")) + end + + # Sort by date to ensure proper time series order + sort!(df, :date) + + # Determine time step based on frequency + time_step = if frequency == :daily + Day(1) + elseif frequency == :monthly + Day(30) + elseif frequency == :annual + Day(360) + end + + # Create lagged parameter columns once (shared for both bond and rf returns) + param_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] + for col in param_cols + lag_col = Symbol("lag_$col") + transform!(df, [:date, col] => + ((dates, values) -> tlag(values, dates; n=time_step)) => + lag_col) + end + + # Calculate excess return directly in a single pass + excess_col = Symbol(string(_maturity_to_column_name("excess_ret", maturity)) * "_" * string(frequency)) + + transform!(df, + AsTable(vcat(param_cols, [Symbol("lag_$col") for col in param_cols])) => + ByRow(params -> begin + current = GSWParameters(params.BETA0, params.BETA1, params.BETA2, + params.BETA3, params.TAU1, params.TAU2) + lagged = GSWParameters(params.lag_BETA0, params.lag_BETA1, params.lag_BETA2, + params.lag_BETA3, params.lag_TAU1, params.lag_TAU2) + if ismissing(current) || ismissing(lagged) + missing + else + gsw_excess_return(maturity, current, lagged; + risk_free_maturity=risk_free_maturity, + frequency=frequency, return_type=return_type) + end + end) => excess_col) + + # Clean up temporary lagged columns + select!(df, Not([Symbol("lag_$col") for col in param_cols])) + + return df +end +# -------------------------------------------------------------------------------------------------- + + + +# -------------------------------------------------------------------------------------------------- +# Convenience functions +# -------------------------------------------------------------------------------------------------- +""" + gsw_curve_snapshot(params::GSWParameters; maturities=[0.25, 0.5, 1, 2, 5, 10, 30]) + +Create a snapshot DataFrame of yields and prices for GSW parameters using parameter struct. + +# Arguments +- `params::GSWParameters`: GSW parameter struct +- `maturities::AbstractVector`: Vector of maturities to calculate (default: standard curve) + +# Returns +- `DataFrame`: Contains columns :maturity, :yield, :price + +# Examples +```julia +params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) +curve = gsw_curve_snapshot(params) + +# Custom maturities +curve = gsw_curve_snapshot(params, maturities=[0.5, 1, 3, 5, 7, 10, 20, 30]) +``` +""" +function gsw_curve_snapshot(params::GSWParameters; + maturities::AbstractVector = [0.25, 0.5, 1, 2, 5, 10, 30]) + + yields = gsw_yield_curve(maturities, params) + prices = gsw_price_curve(maturities, params) + + return DataFrame( + maturity = maturities, + yield = yields, + price = prices + ) +end + +""" + gsw_curve_snapshot(β₀, β₁, β₂, β₃, τ₁, τ₂; maturities=[0.25, 0.5, 1, 2, 5, 10, 30]) + +Create a snapshot DataFrame of yields and prices for a single date's GSW parameters. + +# Arguments +- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters for a single date +- `maturities::AbstractVector`: Vector of maturities to calculate (default: standard curve) + +# Returns +- `DataFrame`: Contains columns :maturity, :yield, :price + +# Examples +```julia +# Create yield curve snapshot +curve = gsw_curve_snapshot(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) + +# Custom maturities +curve = gsw_curve_snapshot(5.0, -2.0, 1.5, 0.8, 2.5, 0.5, + maturities=[0.5, 1, 3, 5, 7, 10, 20, 30]) +``` +""" +function gsw_curve_snapshot(β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real; + maturities::AbstractVector = [0.25, 0.5, 1, 2, 5, 10, 30]) + + yields = gsw_yield_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) + prices = gsw_price_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) + + return DataFrame( + maturity = maturities, + yield = yields, + price = prices + ) +end + +# ------------------------------------------------------------------------------------------ +# Internal helper functions +# ------------------------------------------------------------------------------------------ +""" + _validate_gsw_dataframe(df; check_date=false) + +Validate that DataFrame has required GSW parameter columns. +""" +function _validate_gsw_dataframe(df::DataFrame; check_date::Bool = false) + required_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] + missing_cols = setdiff(required_cols, propertynames(df)) + + if !isempty(missing_cols) + throw(ArgumentError("DataFrame missing required GSW parameter columns: $missing_cols")) + end + + if check_date && :date ∉ propertynames(df) + throw(ArgumentError("DataFrame must contain :date column for return calculations")) + end + + if nrow(df) == 0 + throw(ArgumentError("DataFrame is empty")) + end +end + +""" + _maturity_to_column_name(prefix, maturity) + +Convert maturity to standardized column name. +""" +function _maturity_to_column_name(prefix::String, maturity::Real) + # Handle fractional maturities nicely + if maturity == floor(maturity) + return Symbol("$(prefix)_$(Int(maturity))y") + else + # For fractional, use decimal but clean up trailing zeros + maturity_str = string(maturity) + maturity_str = replace(maturity_str, r"\.?0+$" => "") # Remove trailing zeros + return Symbol("$(prefix)_$(maturity_str)y") + end +end +# -------------------------------------------------------------------------------------------------- + diff --git a/src/ImportYields.jl b/src/ImportYields.jl @@ -1,1714 +0,0 @@ -# -------------------------------------------------------------------------------------------------- -# ImportYields.jl - -# Collection of functions that import Treasury Yields data -# -------------------------------------------------------------------------------------------------- - - -# -------------------------------------------------------------------------------------------------- -# GSW Parameter Type Definition -# -------------------------------------------------------------------------------------------------- - -""" - GSWParameters - -Structure to hold Gürkaynak-Sack-Wright Nelson-Siegel-Svensson model parameters. - -# Fields -- `β₀::Float64`: Level parameter (BETA0) -- `β₁::Float64`: Slope parameter (BETA1) -- `β₂::Float64`: Curvature parameter (BETA2) -- `β₃::Float64`: Second curvature parameter (BETA3) - may be missing if model uses 3-factor version -- `τ₁::Float64`: First decay parameter (TAU1, must be positive) -- `τ₂::Float64`: Second decay parameter (TAU2, must be positive) - may be missing if model uses 3-factor version - -# Examples -```julia -# Create GSW parameters manually (4-factor model) -params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) - -# Create GSW parameters for 3-factor model (when τ₂/β₃ are missing) -params_3factor = GSWParameters(5.0, -2.0, 1.5, missing, 2.5, missing) - -# Create from DataFrame row -df = import_gsw_parameters() -params = GSWParameters(df[1, :]) # First row - -# Access individual parameters -println("Level: ", params.β₀) -println("Slope: ", params.β₁) -``` - -# Notes -- Constructor validates that available decay parameters are positive -- Handles missing values for τ₂ and β₃ (common when using 3-factor Nelson-Siegel model) -- When τ₂ or β₃ are missing, the model degenerates to the 3-factor Nelson-Siegel form -- Can be constructed from DataFrameRow for convenience -""" -struct GSWParameters - β₀::Float64 # Level - β₁::Float64 # Slope - β₂::Float64 # Curvature 1 - β₃::Union{Float64, Missing} # Curvature 2 (may be missing for 3-factor model) - τ₁::Float64 # Decay 1 (must be positive) - τ₂::Union{Float64, Missing} # Decay 2 (may be missing for 3-factor model) - - # Inner constructor with validation - # Returns `missing` (not a GSWParameters) when core fields are missing - function GSWParameters(β₀, β₁, β₂, β₃, τ₁, τ₂) - - # Check if core parameters are missing — return missing instead of constructing - if ismissing(β₀) || ismissing(β₁) || ismissing(β₂) || ismissing(τ₁) - return missing - end - - # Validate that decay parameters are positive - if τ₁ <= 0 - throw(ArgumentError("First decay parameter τ₁ must be positive, got τ₁=$τ₁")) - end - if !ismissing(τ₂) && τ₂ <= 0 - throw(ArgumentError("Second decay parameter τ₂ must be positive when present, got τ₂=$τ₂")) - end - - new( - Float64(β₀), Float64(β₁), Float64(β₂), - ismissing(β₃) ? missing : Float64(β₃), - Float64(τ₁), - ismissing(τ₂) ? missing : Float64(τ₂) - ) - end -end - -# Convenience constructors -""" - GSWParameters(row::DataFrameRow) - -Create GSWParameters from a DataFrame row containing BETA0, BETA1, BETA2, BETA3, TAU1, TAU2 columns. -Handles missing values (including -999 flags) gracefully. -""" -function GSWParameters(row::DataFrameRow) - return GSWParameters(row.BETA0, row.BETA1, row.BETA2, row.BETA3, row.TAU1, row.TAU2) -end - -""" - GSWParameters(row::NamedTuple) - -Create GSWParameters from a NamedTuple containing the required fields. -Handles missing values (including -999 flags) gracefully. -""" -function GSWParameters(row::NamedTuple) - return GSWParameters(row.BETA0, row.BETA1, row.BETA2, row.BETA3, row.TAU1, row.TAU2) -end - - -""" - is_three_factor_model(params::GSWParameters) - -Check if GSW parameters represent a 3-factor Nelson-Siegel model (missing β₃ and τ₂). - -# Returns -- `Bool`: true if this is a 3-factor model, false if 4-factor Svensson model -""" -function is_three_factor_model(params::GSWParameters) - return ismissing(params.β₃) || ismissing(params.τ₂) -end - -# Helper function to extract parameters as tuple, handling missing values -""" - _extract_params(params::GSWParameters) - -Extract parameters as tuple for use in calculation functions. -For 3-factor models, uses τ₁ for both decay parameters and sets β₃=0. -""" -function _extract_params(params::GSWParameters) - # Handle 3-factor vs 4-factor models - if is_three_factor_model(params) - # For 3-factor model: set β₃=0 and use τ₁ for both decay parameters - β₃ = 0.0 - τ₂ = ismissing(params.τ₂) ? params.τ₁ : params.τ₂ - else - β₃ = params.β₃ - τ₂ = params.τ₂ - end - - return (params.β₀, params.β₁, params.β₂, β₃, params.τ₁, τ₂) -end -# -------------------------------------------------------------------------------------------------- - - - -# -------------------------------------------------------------------------------------------------- -""" - import_gsw_parameters(; date_range=nothing, validate=true) - -Import Gürkaynak-Sack-Wright (GSW) yield curve parameters from the Federal Reserve. - -Downloads the daily GSW yield curve parameter estimates from the Fed's website and returns -a cleaned DataFrame with the Nelson-Siegel-Svensson model parameters. - -# Arguments -- `date_range::Union{Nothing, Tuple{Date, Date}}`: Optional date range for filtering data. - If `nothing`, returns all available data. Default: `nothing` -- `validate::Bool`: Whether to validate input parameters and data quality. Default: `true` - -# Returns -- `DataFrame`: Contains columns `:date`, `:BETA0`, `:BETA1`, `:BETA2`, `:BETA3`, `:TAU1`, `:TAU2` - -# Throws -- `ArgumentError`: If date range is invalid -- `HTTP.ExceptionRequest.StatusError`: If download fails -- `Exception`: If data parsing fails - -# Examples -```julia -# Import all available data -df = import_gsw_parameters() - -# Import data for specific date range -df = import_gsw_parameters(date_range=(Date("2020-01-01"), Date("2023-12-31"))) - -# Import without validation (faster, but less safe) -df = import_gsw_parameters(validate=false) -``` - -# Notes -- Data source: Federal Reserve Economic Data (FRED) -- The GSW model uses the Nelson-Siegel-Svensson functional form -- Missing values in the original data are converted to `missing` -- Data is automatically sorted by date -- Additional variables: - - Zero-coupon yield,Continuously Compounded,SVENYXX - - Par yield,Coupon-Equivalent,SVENPYXX - - Instantaneous forward rate,Continuously Compounded,SVENFXX - - One-year forward rate,Coupon-Equivalent,SVEN1FXX - -""" -function import_gsw_parameters(; - date_range::Union{Nothing, Tuple{Date, Date}} = nothing, - additional_variables::Vector{Symbol}=Symbol[], - validate::Bool = true) - - - # Download data with error handling - @info "Downloading GSW Yield Curve Parameters from Federal Reserve" - - try - url_gsw = "https://www.federalreserve.gov/data/yield-curve-tables/feds200628.csv" - temp_file = Downloads.download(url_gsw) - - # Parse CSV with proper error handling - df_gsw = CSV.read(temp_file, DataFrame, - skipto=11, - header=10, - silencewarnings=true) - - # Clean up temporary file - rm(temp_file, force=true) - - # Clean and process the data - df_clean = _clean_gsw_data(df_gsw, date_range; additional_variables=additional_variables) - - - if validate - _validate_gsw_data(df_clean) - end - - @info "Successfully imported $(nrow(df_clean)) rows of GSW parameters" - return df_clean - - catch e - if e isa Downloads.RequestError - throw(ArgumentError("Failed to download GSW data from Federal Reserve. Check internet connection.")) - elseif e isa CSV.Error - throw(ArgumentError("Failed to parse GSW data. The file format may have changed.")) - else - rethrow(e) - end - end -end - - - -""" - _clean_gsw_data(df_raw, date_range) - -Clean and format the raw GSW data from the Federal Reserve. -""" -function _clean_gsw_data(df_raw::DataFrame, - date_range::Union{Nothing, Tuple{Date, Date}}; - additional_variables::Vector{Symbol}=Symbol[]) - - - # Make a copy to avoid modifying original - df = copy(df_raw) - # Standardize column names - rename!(df, "Date" => "date") - - # Apply date filtering if specified - if !isnothing(date_range) - start_date, end_date = date_range - if start_date > end_date - @warn "starting date posterior to end date ... shuffling them around" - start_date, end_date = min(start_date, end_date), max(start_date, end_date) - end - filter!(row -> start_date <= row.date <= end_date, df) - end - - # Select and order relevant columns - parameter_cols = vcat( - [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2], - intersect(additional_variables, propertynames(df)) - ) |> unique - select!(df, :date, parameter_cols...) - - # Convert parameter columns to Float64, handling missing values - for col in parameter_cols - transform!(df, col => ByRow(_safe_parse_float) => col) - end - - # Sort by date for consistency - sort!(df, :date) - - return df -end - -""" - _safe_parse_float(value) - -Safely parse a value to Float64, returning missing for unparseable values. -Handles common flag values for missing data in economic datasets. -""" -function _safe_parse_float(value) - if ismissing(value) || value == "" - return missing - end - - # Handle string values - if value isa AbstractString - parsed = tryparse(Float64, strip(value)) - if isnothing(parsed) - return missing - end - value = parsed - end - - # Handle numeric values and check for common missing data flags - try - numeric_value = Float64(value) - - # Common missing data flags in economic/financial datasets - if numeric_value in (-999.99, -999.0, -9999.0, -99.99) - return missing - end - - return numeric_value - catch - return missing - end -end - -""" - _validate_gsw_data(df) - -Validate the cleaned GSW data for basic quality checks. -""" -function _validate_gsw_data(df::DataFrame) - if nrow(df) == 0 - throw(ArgumentError("No data found for the specified date range")) - end - - # Check for required columns - required_cols = [:date, :BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] - missing_cols = setdiff(required_cols, propertynames(df)) - if !isempty(missing_cols) - throw(ArgumentError("Missing required columns: $(missing_cols)")) - end - - # Check for reasonable parameter ranges (basic sanity check) - param_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] - for col in param_cols - col_data = skipmissing(df[!, col]) |> collect - if length(col_data) == 0 - @warn "Column $col contains only missing values" - end - end - - # Check date continuity (warn if there are large gaps) - if nrow(df) > 1 - date_diffs = diff(df.date) - large_gaps = findall(x -> x > Day(7), date_diffs) - if !isempty(large_gaps) - @warn "Found $(length(large_gaps)) gaps larger than 7 days in the data" - end - end -end -# -------------------------------------------------------------------------------------------------- - - - -# -------------------------------------------------------------------------------------------------- -# GSW Core Calculation Functions - -# Method 1: Using GSWParameters struct (preferred for clean API) -""" - gsw_yield(maturity, params::GSWParameters) - -Calculate yield from GSW Nelson-Siegel-Svensson parameters using parameter struct. - -# Arguments -- `maturity::Real`: Time to maturity in years (must be positive) -- `params::GSWParameters`: GSW parameter struct - -# Returns -- `Float64`: Yield in percent (e.g., 5.0 for 5%) - -# Examples -```julia -params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -yield = gsw_yield(10.0, params) -``` -""" -function gsw_yield(maturity::Real, params::GSWParameters) - return gsw_yield(maturity, _extract_params(params)...) -end - -# Method 2: Using individual parameters (for flexibility and backward compatibility) -""" - gsw_yield(maturity, β₀, β₁, β₂, β₃, τ₁, τ₂) - -Calculate yield from Gürkaynak-Sack-Wright Nelson-Siegel-Svensson parameters. - -Computes the yield for a given maturity using the Nelson-Siegel-Svensson functional form -with the GSW parameter estimates. Automatically handles 3-factor vs 4-factor models. - -# Arguments -- `maturity::Real`: Time to maturity in years (must be positive) -- `β₀::Real`: Level parameter (BETA0) -- `β₁::Real`: Slope parameter (BETA1) -- `β₂::Real`: Curvature parameter (BETA2) -- `β₃::Real`: Second curvature parameter (BETA3) - set to 0 or missing for 3-factor model -- `τ₁::Real`: First decay parameter -- `τ₂::Real`: Second decay parameter - can equal τ₁ for 3-factor model - -# Returns -- `Float64`: Yield in percent (e.g., 5.0 for 5%) - -# Throws -- `ArgumentError`: If maturity is non-positive or τ parameters are non-positive - -# Examples -```julia -# Calculate 1-year yield (4-factor model) -yield = gsw_yield(1.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) - -# Calculate 10-year yield (3-factor model, β₃=0) -yield = gsw_yield(10.0, 5.0, -2.0, 1.5, 0.0, 2.5, 2.5) -``` - -# Notes -- Based on the Nelson-Siegel-Svensson functional form -- When β₃=0 or τ₂=τ₁, degenerates to 3-factor Nelson-Siegel model -- Returns yield in percentage terms (not decimal) -- Function is vectorizable: use `gsw_yield.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂)` -""" -function gsw_yield(maturity::Real, - β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real) - - # Input validation - if maturity <= 0 - throw(ArgumentError("Maturity must be positive, got $maturity")) - end - - # For 3-factor model compatibility: if β₃ is 0 or very small, skip the fourth term - use_four_factor = abs(β₃) > 1e-10 && τ₂ > 0 - - # Nelson-Siegel-Svensson formula - t = Float64(maturity) - - # Calculate decay terms - exp_t_τ₁ = exp(-t/τ₁) - - # yield terms - term1 = β₀ # Level - term2 = β₁ * (1.0 - exp_t_τ₁) / (t/τ₁) # Slope - term3 = β₂ * ((1.0 - exp_t_τ₁) / (t/τ₁) - exp_t_τ₁) # First curvature - - # Fourth term only for 4-factor Svensson model - term4 = if use_four_factor - exp_t_τ₂ = exp(-t/τ₂) - β₃ * ((1.0 - exp_t_τ₂) / (t/τ₂) - exp_t_τ₂) # Second curvature - else - 0.0 - end - - yield = term1 + term2 + term3 + term4 - - return Float64(yield) -end - -# Method 1: Using GSWParameters struct -""" - gsw_price(maturity, params::GSWParameters; face_value=1.0) - -Calculate zero-coupon bond price from GSW parameters using parameter struct. - -# Arguments -- `maturity::Real`: Time to maturity in years (must be positive) -- `params::GSWParameters`: GSW parameter struct -- `face_value::Real`: Face value of the bond (default: 1.0) - -# Returns -- `Float64`: Bond price - -# Examples -```julia -params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -price = gsw_price(10.0, params) -``` -""" -function gsw_price(maturity::Real, params::GSWParameters; face_value::Real = 1.0) - return gsw_price(maturity, _extract_params(params)..., face_value=face_value) -end - -# Method 2: Using individual parameters -""" - gsw_price(maturity, β₀, β₁, β₂, β₃, τ₁, τ₂; face_value=1.0) - -Calculate zero-coupon bond price from GSW Nelson-Siegel-Svensson parameters. - -Computes the price of a zero-coupon bond using the yield derived from GSW parameters. - -# Arguments -- `maturity::Real`: Time to maturity in years (must be positive) -- `β₀::Real`: Level parameter (BETA0) -- `β₁::Real`: Slope parameter (BETA1) -- `β₂::Real`: Curvature parameter (BETA2) -- `β₃::Real`: Second curvature parameter (BETA3) -- `τ₁::Real`: First decay parameter -- `τ₂::Real`: Second decay parameter -- `face_value::Real`: Face value of the bond (default: 1.0) - -# Returns -- `Float64`: Bond price - -# Throws -- `ArgumentError`: If maturity is non-positive, τ parameters are non-positive, or face_value is non-positive - -# Examples -```julia -# Calculate price of 1-year zero-coupon bond -price = gsw_price(1.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) - -# Calculate price with different face value -price = gsw_price(1.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5, face_value=1000.0) -``` - -# Notes -- Uses continuous compounding: P = F * exp(-r * t) -- Yield is converted from percentage to decimal for calculation -- Function is vectorizable: use `gsw_price.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂)` -""" -function gsw_price(maturity::Real, β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real; - face_value::Real = 1.0) - - # Input validation - if maturity <= 0 - throw(ArgumentError("Maturity must be positive, got $maturity")) - end - if face_value <= 0 - throw(ArgumentError("Face value must be positive, got $face_value")) - end - - # Handle any missing values - if any(ismissing, [β₀, β₁, β₂, β₃, τ₁, τ₂, maturity, face_value]) - return missing - end - - # Get yield in percentage terms - yield_percent = gsw_yield(maturity, β₀, β₁, β₂, β₃, τ₁, τ₂) - - if ismissing(yield_percent) - return missing - end - - # Convert to decimal and calculate price using continuous compounding - continuous_rate = log(1.0 + yield_percent / 100.0) - price = face_value * exp(-continuous_rate * maturity) - - return Float64(price) -end - -# Method 1: Using GSWParameters struct -""" - gsw_forward_rate(maturity₁, maturity₂, params::GSWParameters) - -Calculate instantaneous forward rate between two maturities using GSW parameter struct. - -# Arguments -- `maturity₁::Real`: Start maturity in years (must be positive and < maturity₂) -- `maturity₂::Real`: End maturity in years (must be positive and > maturity₁) -- `params::GSWParameters`: GSW parameter struct - -# Returns -- `Float64`: Forward rate (decimal rate) - -# Examples -```julia -params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -fwd_rate = gsw_forward_rate(2.0, 3.0, params) -``` -""" -function gsw_forward_rate(maturity₁::Real, maturity₂::Real, params::GSWParameters) - return gsw_forward_rate(maturity₁, maturity₂, _extract_params(params)...) -end - -# Method 2: Using individual parameters -""" - gsw_forward_rate(maturity₁, maturity₂, β₀, β₁, β₂, β₃, τ₁, τ₂) - -Calculate instantaneous forward rate between two maturities using GSW parameters. - -# Arguments -- `maturity₁::Real`: Start maturity in years (must be positive and < maturity₂) -- `maturity₂::Real`: End maturity in years (must be positive and > maturity₁) -- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters - -# Returns -- `Float64`: Forward rate (decimal rate) - -# Examples -```julia -# Calculate 1-year forward rate starting in 2 years -fwd_rate = gsw_forward_rate(2.0, 3.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -``` -""" -function gsw_forward_rate(maturity₁::Real, maturity₂::Real, - β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real) - - if maturity₁ <= 0 || maturity₂ <= maturity₁ - throw(ArgumentError("Must have 0 < maturity₁ < maturity₂, got maturity₁=$maturity₁, maturity₂=$maturity₂")) - end - - # Handle missing values - if any(ismissing, [β₀, β₁, β₂, β₃, τ₁, τ₂, maturity₁, maturity₂]) - return missing - end - - # Get prices at both maturities - p₁ = gsw_price(maturity₁, β₀, β₁, β₂, β₃, τ₁, τ₂) - p₂ = gsw_price(maturity₂, β₀, β₁, β₂, β₃, τ₁, τ₂) - - if ismissing(p₁) || ismissing(p₂) - return missing - end - - # Calculate forward rate: f = -ln(P₂/P₁) / (T₂ - T₁) - forward_rate_decimal = -log(p₂ / p₁) / (maturity₂ - maturity₁) - - # Convert to percentage - return Float64(forward_rate_decimal) -end - -# ------------------------------------------------------------------------------------------ -# Vectorized convenience functions -# ------------------------------------------------------------------------------------------ - -""" - gsw_yield_curve(maturities, params::GSWParameters) - -Calculate yields for multiple maturities using GSW parameter struct. - -# Arguments -- `maturities::AbstractVector{<:Real}`: Vector of maturities in years -- `params::GSWParameters`: GSW parameter struct - -# Returns -- `Vector{Float64}`: Vector of yields in percent - -# Examples -```julia -params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -maturities = [0.25, 0.5, 1, 2, 5, 10, 30] -yields = gsw_yield_curve(maturities, params) -``` -""" -function gsw_yield_curve(maturities::AbstractVector{<:Real}, params::GSWParameters) - return gsw_yield.(maturities, Ref(params)) -end - -""" - gsw_yield_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) - -Calculate yields for multiple maturities using GSW parameters. - -# Arguments -- `maturities::AbstractVector{<:Real}`: Vector of maturities in years -- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters - -# Returns -- `Vector{Float64}`: Vector of yields in percent - -# Examples -```julia -maturities = [0.25, 0.5, 1, 2, 5, 10, 30] -yields = gsw_yield_curve(maturities, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -``` -""" -function gsw_yield_curve(maturities::AbstractVector{<:Real}, β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real) - return gsw_yield.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) -end - -""" - gsw_price_curve(maturities, params::GSWParameters; face_value=1.0) - -Calculate zero-coupon bond prices for multiple maturities using GSW parameter struct. - -# Arguments -- `maturities::AbstractVector{<:Real}`: Vector of maturities in years -- `params::GSWParameters`: GSW parameter struct -- `face_value::Real`: Face value of bonds (default: 1.0) - -# Returns -- `Vector{Float64}`: Vector of bond prices - -# Examples -```julia -params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -maturities = [0.25, 0.5, 1, 2, 5, 10, 30] -prices = gsw_price_curve(maturities, params) -``` -""" -function gsw_price_curve(maturities::AbstractVector{<:Real}, params::GSWParameters; face_value::Real = 1.0) - return gsw_price.(maturities, Ref(params), face_value=face_value) -end - -""" - gsw_price_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂; face_value=1.0) - -Calculate zero-coupon bond prices for multiple maturities using GSW parameters. - -# Arguments -- `maturities::AbstractVector{<:Real}`: Vector of maturities in years -- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters -- `face_value::Real`: Face value of bonds (default: 1.0) - -# Returns -- `Vector{Float64}`: Vector of bond prices - -# Examples -```julia -maturities = [0.25, 0.5, 1, 2, 5, 10, 30] -prices = gsw_price_curve(maturities, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -``` -""" -function gsw_price_curve(maturities::AbstractVector{<:Real}, β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real; - face_value::Real = 1.0) - return gsw_price.(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂, face_value=face_value) -end -# -------------------------------------------------------------------------------------------------- - - - - -# -------------------------------------------------------------------------------------------------- -# Return calculation functions -# ------------------------------------------------------------------------------------------ - -# Method 1: Using individual parameters -""" - gsw_return(maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, - β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁; - frequency=:daily, return_type=:log) - -Calculate bond return between two periods using GSW parameters. - -Computes the return on a zero-coupon bond between two time periods by comparing -the price today (with aged maturity) to the price in the previous period. - -# Arguments -- `maturity::Real`: Original maturity of the bond in years -- `β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t`: GSW parameters at time t -- `β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁`: GSW parameters at time t-1 -- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) -- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns - -# Returns -- `Float64`: Bond return - -# Examples -```julia -# Daily log return on 10-year bond -ret = gsw_return(10.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5, # today's params - 4.9, -1.9, 1.4, 0.9, 2.4, 0.6) # yesterday's params - -# Monthly arithmetic return -ret = gsw_return(5.0, 5.0, -2.0, 1.5, 0.8, 2.5, 0.5, - 4.9, -1.9, 1.4, 0.9, 2.4, 0.6, - frequency=:monthly, return_type=:arithmetic) -``` -""" -function gsw_return(maturity::Real, - β₀_t::Real, β₁_t::Real, β₂_t::Real, β₃_t::Real, τ₁_t::Real, τ₂_t::Real, - β₀_t₋₁::Real, β₁_t₋₁::Real, β₂_t₋₁::Real, β₃_t₋₁::Real, τ₁_t₋₁::Real, τ₂_t₋₁::Real; - frequency::Symbol = :daily, - return_type::Symbol = :log) - - # Input validation - if maturity <= 0 - throw(ArgumentError("Maturity must be positive, got $maturity")) - end - - valid_frequencies = [:daily, :monthly, :annual] - if frequency ∉ valid_frequencies - throw(ArgumentError("frequency must be one of $valid_frequencies, got $frequency")) - end - - valid_return_types = [:log, :arithmetic] - if return_type ∉ valid_return_types - throw(ArgumentError("return_type must be one of $valid_return_types, got $return_type")) - end - - # Handle missing values - all_params = [β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁] - if any(ismissing, all_params) - return missing - end - - # Determine time step based on frequency - Δt = if frequency == :daily - 1/360 # Using 360-day year convention - elseif frequency == :monthly - 1/12 - elseif frequency == :annual - 1.0 - end - - # Calculate prices - # P_t: Price today of bond with remaining maturity (maturity - Δt) - aged_maturity = max(maturity - Δt, 0.001) # Avoid zero maturity - price_today = gsw_price(aged_maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t) - - # P_t₋₁: Price yesterday of bond with original maturity - price_previous = gsw_price(maturity, β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁) - - if ismissing(price_today) || ismissing(price_previous) - return missing - end - - # Calculate return - if return_type == :log - return log(price_today / price_previous) - else # arithmetic - return (price_today - price_previous) / price_previous - end -end - - -# Method 2: Using GSWParameters structs -""" - gsw_return(maturity, params_t::GSWParameters, params_t₋₁::GSWParameters; frequency=:daily, return_type=:log) - -Calculate bond return between two periods using GSW parameter structs. - -# Arguments -- `maturity::Real`: Original maturity of the bond in years -- `params_t::GSWParameters`: GSW parameters at time t -- `params_t₋₁::GSWParameters`: GSW parameters at time t-1 -- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) -- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns - -# Returns -- `Float64`: Bond return - -# Examples -```julia -params_today = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -params_yesterday = GSWParameters(4.9, -1.9, 1.4, 0.9, 2.4, 0.6) -ret = gsw_return(10.0, params_today, params_yesterday) -``` -""" -function gsw_return(maturity::Real, params_t::GSWParameters, params_t₋₁::GSWParameters; - frequency::Symbol = :daily, return_type::Symbol = :log) - return gsw_return(maturity, _extract_params(params_t)..., _extract_params(params_t₋₁)..., - frequency=frequency, return_type=return_type) -end -# -------------------------------------------------------------------------------------------------- - - - -# Method 1: Using GSWParameters structs -""" - gsw_excess_return(maturity, params_t::GSWParameters, params_t₋₁::GSWParameters; - risk_free_maturity=0.25, frequency=:daily, return_type=:log) - -Calculate excess return of a bond over the risk-free rate using GSW parameter structs. - -# Arguments -- `maturity::Real`: Original maturity of the bond in years -- `params_t::GSWParameters`: GSW parameters at time t -- `params_t₋₁::GSWParameters`: GSW parameters at time t-1 -- `risk_free_maturity::Real`: Maturity for risk-free rate calculation (default: 0.25 for 3-month) -- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) -- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns - -# Returns -- `Float64`: Excess return (bond return - risk-free return) - -# Examples -```julia -params_today = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -params_yesterday = GSWParameters(4.9, -1.9, 1.4, 0.9, 2.4, 0.6) -excess_ret = gsw_excess_return(10.0, params_today, params_yesterday) -``` -""" -function gsw_excess_return(maturity::Real, params_t::GSWParameters, params_t₋₁::GSWParameters; - risk_free_maturity::Real = 0.25, - frequency::Symbol = :daily, - return_type::Symbol = :log) - return gsw_excess_return(maturity, _extract_params(params_t)..., _extract_params(params_t₋₁)..., - risk_free_maturity=risk_free_maturity, frequency=frequency, return_type=return_type) -end - -# Method 2: Using individual parameters -""" - gsw_excess_return(maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, - β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁; - risk_free_maturity=0.25, frequency=:daily, return_type=:log) - -Calculate excess return of a bond over the risk-free rate. - -# Arguments -- Same as `gsw_return` plus: -- `risk_free_maturity::Real`: Maturity for risk-free rate calculation (default: 0.25 for 3-month) - -# Returns -- `Float64`: Excess return (bond return - risk-free return) -""" -function gsw_excess_return(maturity::Real, - β₀_t::Real, β₁_t::Real, β₂_t::Real, β₃_t::Real, τ₁_t::Real, τ₂_t::Real, - β₀_t₋₁::Real, β₁_t₋₁::Real, β₂_t₋₁::Real, β₃_t₋₁::Real, τ₁_t₋₁::Real, τ₂_t₋₁::Real; - risk_free_maturity::Real = 0.25, - frequency::Symbol = :daily, - return_type::Symbol = :log) - - # Calculate bond return - bond_return = gsw_return(maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, - β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁, - frequency=frequency, return_type=return_type) - - # Calculate risk-free return - rf_return = gsw_return(risk_free_maturity, β₀_t, β₁_t, β₂_t, β₃_t, τ₁_t, τ₂_t, - β₀_t₋₁, β₁_t₋₁, β₂_t₋₁, β₃_t₋₁, τ₁_t₋₁, τ₂_t₋₁, - frequency=frequency, return_type=return_type) - - if ismissing(bond_return) || ismissing(rf_return) - return missing - end - - return bond_return - rf_return -end -# -------------------------------------------------------------------------------------------------- - - -# -------------------------------------------------------------------------------------------------- -# -------------------------------------------------------------------------------------------------- -# GSW DataFrame Wrapper Functions -# ------------------------------------------------------------------------------------------ -""" - add_yields!(df, maturities; validate=true) - -Add yield calculations to a DataFrame containing GSW parameters. - -Adds columns with yields for specified maturities using the Nelson-Siegel-Svensson -model parameters in the DataFrame. - -# Arguments -- `df::DataFrame`: DataFrame containing GSW parameters (must have columns: BETA0, BETA1, BETA2, BETA3, TAU1, TAU2) -- `maturities::Union{Real, AbstractVector{<:Real}}`: Maturity or vector of maturities in years -- `validate::Bool`: Whether to validate DataFrame structure (default: true) - -# Returns -- `DataFrame`: Modified DataFrame with additional yield columns named `yield_Xy` (e.g., `yield_1y`, `yield_10y`) - -# Examples -```julia -df = import_gsw_parameters() - -# Add single maturity -add_yields!(df, 10.0) - -# Add multiple maturities -add_yields!(df, [1, 2, 5, 10, 30]) - -# Add with custom maturity (fractional) -add_yields!(df, [0.25, 0.5, 1.0]) -``` - -# Notes -- Modifies the DataFrame in place -- Column names use format: `yield_Xy` where X is the maturity -- Handles missing parameter values gracefully -- Validates required columns are present -""" -function add_yields!(df::DataFrame, maturities::Union{Real, AbstractVector{<:Real}}; - validate::Bool = true) - - if validate - _validate_gsw_dataframe(df) - end - - # Ensure maturities is a vector - mat_vector = maturities isa Real ? [maturities] : collect(maturities) - - # Validate maturities - if any(m -> m <= 0, mat_vector) - throw(ArgumentError("All maturities must be positive")) - end - - # Add yield columns using GSWParameters struct - for maturity in mat_vector - col_name = _maturity_to_column_name("yield", maturity) - - transform!(df, - AsTable([:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2]) => - ByRow(function(params) - gsw_params = GSWParameters(params) - if ismissing(gsw_params) - return missing - else - return gsw_yield(maturity, gsw_params) - end - end) => col_name) - end - - return df -end -# -------------------------------------------------------------------------------------------------- - - -# -------------------------------------------------------------------------------------------------- -""" - add_prices!(df, maturities; face_value=100.0, validate=true) - -Add zero-coupon bond price calculations to a DataFrame containing GSW parameters. - -# Arguments -- `df::DataFrame`: DataFrame containing GSW parameters -- `maturities::Union{Real, AbstractVector{<:Real}}`: Maturity or vector of maturities in years -- `face_value::Real`: Face value of bonds (default: 100.0) -- `validate::Bool`: Whether to validate DataFrame structure (default: true) - -# Returns -- `DataFrame`: Modified DataFrame with additional price columns named `price_Xy` - -# Examples -```julia -df = import_gsw_parameters() - -# Add prices for multiple maturities -add_prices!(df, [1, 5, 10]) - -# Add prices with different face value -add_prices!(df, 10.0, face_value=1000.0) -``` -""" -function add_prices!(df::DataFrame, maturities::Union{Real, AbstractVector{<:Real}}; - face_value::Real = 100.0, validate::Bool = true) - - if validate - _validate_gsw_dataframe(df) - end - - if face_value <= 0 - throw(ArgumentError("Face value must be positive, got $face_value")) - end - - # Ensure maturities is a vector - mat_vector = maturities isa Real ? [maturities] : collect(maturities) - - # Validate maturities - if any(m -> m <= 0, mat_vector) - throw(ArgumentError("All maturities must be positive")) - end - - # Add price columns using GSWParameters struct - for maturity in mat_vector - col_name = _maturity_to_column_name("price", maturity) - - transform!(df, - AsTable([:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2]) => - ByRow(function(params) - gsw_params = GSWParameters(params) - if ismissing(gsw_params) - return missing - else - return gsw_price(maturity, gsw_params, face_value=face_value) - end - end) => col_name) - end - - return df -end -# -------------------------------------------------------------------------------------------------- - - -# -------------------------------------------------------------------------------------------------- -""" - add_returns!(df, maturity; frequency=:daily, return_type=:log, validate=true) - -Add bond return calculations to a DataFrame containing GSW parameters. - -Calculates returns by comparing bond prices across time periods. Requires DataFrame -to be sorted by date and contain consecutive time periods. - -# Arguments -- `df::DataFrame`: DataFrame containing GSW parameters and dates (must have :date column) -- `maturity::Real`: Bond maturity in years -- `frequency::Symbol`: Return frequency (:daily, :monthly, :annual) -- `return_type::Symbol`: :log for log returns, :arithmetic for simple returns -- `validate::Bool`: Whether to validate DataFrame structure (default: true) - -# Returns -- `DataFrame`: Modified DataFrame with return column named `ret_Xy_frequency` - (e.g., `ret_10y_daily`, `ret_5y_monthly`) - -# Examples -```julia -df = import_gsw_parameters() - -# Add daily log returns for 10-year bond -add_returns!(df, 10.0) - -# Add monthly arithmetic returns for 5-year bond -add_returns!(df, 5.0, frequency=:monthly, return_type=:arithmetic) -``` - -# Notes -- Requires DataFrame to be sorted by date -- First row will have missing return (no previous period) -- Uses lag of parameters to calculate returns properly -""" -function add_returns!(df::DataFrame, maturity::Real; - frequency::Symbol = :daily, - return_type::Symbol = :log, - validate::Bool = true) - - if validate - _validate_gsw_dataframe(df, check_date=true) - end - - if maturity <= 0 - throw(ArgumentError("Maturity must be positive, got $maturity")) - end - - valid_frequencies = [:daily, :monthly, :annual] - if frequency ∉ valid_frequencies - throw(ArgumentError("frequency must be one of $valid_frequencies, got $frequency")) - end - - valid_return_types = [:log, :arithmetic] - if return_type ∉ valid_return_types - throw(ArgumentError("return_type must be one of $valid_return_types, got $return_type")) - end - - # Sort by date to ensure proper time series order - sort!(df, :date) - - # Determine time step based on frequency - time_step = if frequency == :daily - Day(1) - elseif frequency == :monthly - Day(30) # Approximate - elseif frequency == :annual - Day(360) # Using 360-day year - end - - # Create lagged parameter columns using PanelShift.jl - param_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] - for col in param_cols - lag_col = Symbol("lag_$col") - transform!(df, [:date, col] => - ((dates, values) -> tlag(values, dates; n=time_step)) => - lag_col) - end - - # Calculate returns using current and lagged parameters - col_name = Symbol(string(_maturity_to_column_name("ret", maturity)) * "_" * string(frequency)) - - transform!(df, - AsTable(vcat(param_cols, [Symbol("lag_$col") for col in param_cols])) => - ByRow(params -> begin - current_params = GSWParameters(params.BETA0, params.BETA1, params.BETA2, - params.BETA3, params.TAU1, params.TAU2) - lagged_params = GSWParameters(params.lag_BETA0, params.lag_BETA1, params.lag_BETA2, - params.lag_BETA3, params.lag_TAU1, params.lag_TAU2) - if ismissing(current_params) || ismissing(lagged_params) - missing - else - gsw_return(maturity, current_params, lagged_params, - frequency=frequency, return_type=return_type) - end - end - ) => col_name) - - # Clean up temporary lagged columns - select!(df, Not([Symbol("lag_$col") for col in param_cols])) - - # Reorder columns to put return column first (after date) - if :date in names(df) - other_cols = filter(col -> col ∉ [:date, col_name], names(df)) - select!(df, :date, col_name, other_cols...) - end - - return df -end -# -------------------------------------------------------------------------------------------------- - - -# -------------------------------------------------------------------------------------------------- -""" - add_excess_returns!(df, maturity; risk_free_maturity=0.25, frequency=:daily, return_type=:log, validate=true) - -Add excess return calculations (bond return - risk-free return) to DataFrame. - -# Arguments -- Same as `add_returns!` plus: -- `risk_free_maturity::Real`: Maturity for risk-free rate (default: 0.25 for 3-month) - -# Returns -- `DataFrame`: Modified DataFrame with excess return column named `excess_ret_Xy_frequency` -""" -function add_excess_returns!(df::DataFrame, maturity::Real; - risk_free_maturity::Real = 0.25, - frequency::Symbol = :daily, - return_type::Symbol = :log, - validate::Bool = true) - - if validate - _validate_gsw_dataframe(df, check_date=true) - end - - if maturity <= 0 - throw(ArgumentError("Maturity must be positive, got $maturity")) - end - - valid_frequencies = [:daily, :monthly, :annual] - if frequency ∉ valid_frequencies - throw(ArgumentError("frequency must be one of $valid_frequencies, got $frequency")) - end - - valid_return_types = [:log, :arithmetic] - if return_type ∉ valid_return_types - throw(ArgumentError("return_type must be one of $valid_return_types, got $return_type")) - end - - # Sort by date to ensure proper time series order - sort!(df, :date) - - # Determine time step based on frequency - time_step = if frequency == :daily - Day(1) - elseif frequency == :monthly - Day(30) - elseif frequency == :annual - Day(360) - end - - # Create lagged parameter columns once (shared for both bond and rf returns) - param_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] - for col in param_cols - lag_col = Symbol("lag_$col") - transform!(df, [:date, col] => - ((dates, values) -> tlag(values, dates; n=time_step)) => - lag_col) - end - - # Calculate excess return directly in a single pass - excess_col = Symbol(string(_maturity_to_column_name("excess_ret", maturity)) * "_" * string(frequency)) - - transform!(df, - AsTable(vcat(param_cols, [Symbol("lag_$col") for col in param_cols])) => - ByRow(params -> begin - current = GSWParameters(params.BETA0, params.BETA1, params.BETA2, - params.BETA3, params.TAU1, params.TAU2) - lagged = GSWParameters(params.lag_BETA0, params.lag_BETA1, params.lag_BETA2, - params.lag_BETA3, params.lag_TAU1, params.lag_TAU2) - if ismissing(current) || ismissing(lagged) - missing - else - gsw_excess_return(maturity, current, lagged; - risk_free_maturity=risk_free_maturity, - frequency=frequency, return_type=return_type) - end - end) => excess_col) - - # Clean up temporary lagged columns - select!(df, Not([Symbol("lag_$col") for col in param_cols])) - - return df -end -# -------------------------------------------------------------------------------------------------- - - - -# -------------------------------------------------------------------------------------------------- -# Convenience functions -# -------------------------------------------------------------------------------------------------- -""" - gsw_curve_snapshot(params::GSWParameters; maturities=[0.25, 0.5, 1, 2, 5, 10, 30]) - -Create a snapshot DataFrame of yields and prices for GSW parameters using parameter struct. - -# Arguments -- `params::GSWParameters`: GSW parameter struct -- `maturities::AbstractVector`: Vector of maturities to calculate (default: standard curve) - -# Returns -- `DataFrame`: Contains columns :maturity, :yield, :price - -# Examples -```julia -params = GSWParameters(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) -curve = gsw_curve_snapshot(params) - -# Custom maturities -curve = gsw_curve_snapshot(params, maturities=[0.5, 1, 3, 5, 7, 10, 20, 30]) -``` -""" -function gsw_curve_snapshot(params::GSWParameters; - maturities::AbstractVector = [0.25, 0.5, 1, 2, 5, 10, 30]) - - yields = gsw_yield_curve(maturities, params) - prices = gsw_price_curve(maturities, params) - - return DataFrame( - maturity = maturities, - yield = yields, - price = prices - ) -end - -""" - gsw_curve_snapshot(β₀, β₁, β₂, β₃, τ₁, τ₂; maturities=[0.25, 0.5, 1, 2, 5, 10, 30]) - -Create a snapshot DataFrame of yields and prices for a single date's GSW parameters. - -# Arguments -- `β₀, β₁, β₂, β₃, τ₁, τ₂`: GSW parameters for a single date -- `maturities::AbstractVector`: Vector of maturities to calculate (default: standard curve) - -# Returns -- `DataFrame`: Contains columns :maturity, :yield, :price - -# Examples -```julia -# Create yield curve snapshot -curve = gsw_curve_snapshot(5.0, -2.0, 1.5, 0.8, 2.5, 0.5) - -# Custom maturities -curve = gsw_curve_snapshot(5.0, -2.0, 1.5, 0.8, 2.5, 0.5, - maturities=[0.5, 1, 3, 5, 7, 10, 20, 30]) -``` -""" -function gsw_curve_snapshot(β₀::Real, β₁::Real, β₂::Real, β₃::Real, τ₁::Real, τ₂::Real; - maturities::AbstractVector = [0.25, 0.5, 1, 2, 5, 10, 30]) - - yields = gsw_yield_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) - prices = gsw_price_curve(maturities, β₀, β₁, β₂, β₃, τ₁, τ₂) - - return DataFrame( - maturity = maturities, - yield = yields, - price = prices - ) -end - -# ------------------------------------------------------------------------------------------ -# Internal helper functions -# ------------------------------------------------------------------------------------------ -""" - _validate_gsw_dataframe(df; check_date=false) - -Validate that DataFrame has required GSW parameter columns. -""" -function _validate_gsw_dataframe(df::DataFrame; check_date::Bool = false) - required_cols = [:BETA0, :BETA1, :BETA2, :BETA3, :TAU1, :TAU2] - missing_cols = setdiff(required_cols, propertynames(df)) - - if !isempty(missing_cols) - throw(ArgumentError("DataFrame missing required GSW parameter columns: $missing_cols")) - end - - if check_date && :date ∉ propertynames(df) - throw(ArgumentError("DataFrame must contain :date column for return calculations")) - end - - if nrow(df) == 0 - throw(ArgumentError("DataFrame is empty")) - end -end - -""" - _maturity_to_column_name(prefix, maturity) - -Convert maturity to standardized column name. -""" -function _maturity_to_column_name(prefix::String, maturity::Real) - # Handle fractional maturities nicely - if maturity == floor(maturity) - return Symbol("$(prefix)_$(Int(maturity))y") - else - # For fractional, use decimal but clean up trailing zeros - maturity_str = string(maturity) - maturity_str = replace(maturity_str, r"\.?0+$" => "") # Remove trailing zeros - return Symbol("$(prefix)_$(maturity_str)y") - end -end -# -------------------------------------------------------------------------------------------------- - - -# -------------------------------------------------------------------------------------------------- -# OTHER FUNCTIONS TO WORK WITH BONDS ... NOT DIRECTLY RELATED TO TREASURIES ... -""" - bond_yield_excel(settlement, maturity, rate, price, redemption; - frequency=2, basis=0) -> Float64 - -Calculate the yield to maturity of a bond using Excel-compatible YIELD function interface. - -This function provides an Excel-compatible API for calculating bond yield to maturity, -matching the behavior and parameter conventions of Excel's `YIELD()` function. It -internally converts the date-based inputs to the time-to-maturity format required -by the underlying `bond_yield()` function. - -# Arguments -- `settlement::Date`: Settlement date of the bond (when the bond is purchased) -- `maturity::Date`: Maturity date of the bond (when principal is repaid) -- `rate::Real`: Annual coupon rate as a decimal (e.g., 0.0575 for 5.75%) -- `price::Real`: Bond's price per 100 of face value -- `redemption::Real`: Redemption value per 100 of face value (typically 100) - -# Keyword Arguments -- `frequency::Integer=2`: Number of coupon payments per year - - `1` = Annual - - `2` = Semiannual (default) - - `4` = Quarterly -- `basis::Integer=0`: Day count basis for calculating time periods - - `0` = 30/360 (default) - - `1` = Actual/actual - - `2` = Actual/360 - - `3` = Actual/365 - - `4` = European 30/360 - -# Returns -- `Float64`: Annual yield to maturity as a decimal (e.g., 0.065 for 6.5%) - -# Excel Compatibility -This function replicates Excel's `YIELD(settlement, maturity, rate, price, redemption, frequency, basis)` -function with identical parameter meanings and calculation methodology. - -# Example (Excel Documentation Case) -```julia -using Dates - -# Excel example data: -settlement = Date(2008, 2, 15) # 15-Feb-08 Settlement date -maturity = Date(2016, 11, 15) # 15-Nov-16 Maturity date -rate = 0.0575 # 5.75% Percent coupon -price = 95.04287 # Price per 100 face value -redemption = 100.0 # 100 Redemption value -frequency = 2 # Semiannual frequency -basis = 0 # 30/360 basis - -# Calculate yield (matches Excel YIELD function) -ytm = bond_yield_excel(settlement, maturity, rate, price, redemption, - frequency=frequency, basis=basis) -# Result: 0.065 (6.5%) - -# Equivalent Excel formula: =YIELD(A2,A3,A4,A5,A6,A7,A8) -# where cells contain the values above -``` - -# Additional Examples -```julia -# Corporate bond with quarterly payments -settlement = Date(2024, 1, 15) -maturity = Date(2029, 1, 15) -ytm = bond_yield_excel(settlement, maturity, 0.045, 98.50, 100.0, - frequency=4, basis=1) - -# Government bond with annual payments, actual/365 basis -ytm = bond_yield_excel(Date(2024, 3, 1), Date(2034, 3, 1), - 0.0325, 102.25, 100.0, frequency=1, basis=3) -``` - -# Notes -- Settlement date must be before maturity date -- Price and redemption are typically quoted per 100 of face value -- Uses actual coupon dates and the specified day-count basis, matching Excel's computation -- Results should match Excel's YIELD function within numerical precision - -# Throws -- `ArgumentError`: If settlement ≥ maturity date -- Convergence errors from underlying numerical root-finding - -See also: [`bond_yield`](@ref) -""" -function bond_yield_excel( - settlement::Date, maturity::Date, rate::Real, price::Real, redemption::Real; - frequency = 2, basis = 0) - - if settlement >= maturity - throw(ArgumentError("Settlement ($settlement) must be before maturity ($maturity)")) - end - - # Compute coupon schedule by working backwards from maturity - period_months = div(12, frequency) - - # Find next coupon date after settlement - next_coupon = maturity - while next_coupon - Month(period_months) > settlement - next_coupon -= Month(period_months) - end - prev_coupon = next_coupon - Month(period_months) - - # Count remaining coupons (from next_coupon to maturity, inclusive) - N = 0 - d = next_coupon - while d <= maturity - N += 1 - d += Month(period_months) - end - - # Day count fractions using the specified basis - A = _day_count_days(prev_coupon, settlement, basis) # accrued days - E = _day_count_days(prev_coupon, next_coupon, basis) # days in coupon period - DSC = E - A # Excel defines DSC = E - A to ensure consistency - - α = DSC / E # fraction of period until next coupon - coupon = redemption * rate / frequency - - # Excel's YIELD pricing formula - function price_from_yield(y) - if y <= 0 - return Inf - end - - dr = y / frequency - - if N == 1 - # Special case: single remaining coupon - return (redemption + coupon) / (1 + α * dr) - coupon * A / E - end - - # General case: N > 1 coupons - # PV of coupon annuity: ∑(k=1..N) coupon/(1+dr)^(k-1+α) = coupon*(1+dr)^(1-α)/dr * [1-(1+dr)^(-N)] - pv_coupons = coupon * (1 + dr)^(1 - α) * (1 - (1 + dr)^(-N)) / dr - # PV of redemption - pv_redemption = redemption / (1 + dr)^(N - 1 + α) - # Subtract accrued interest - return pv_coupons + pv_redemption - coupon * A / E - end - - price_diff(y) = price_from_yield(y) - price - - try - return Roots.find_zero(price_diff, (1e-6, 2.0), Roots.Brent()) - catch e - if isa(e, ArgumentError) && occursin("not a bracketing interval", sprint(showerror, e)) - @warn "Brent failed: falling back to Order1" exception=e - return Roots.find_zero(price_diff, rate, Roots.Order1()) - else - rethrow(e) - end - end -end - -""" - bond_yield(price, face_value, coupon_rate, years_to_maturity, frequency; - method=:brent, bracket=(0.001, 1.0)) -> Float64 - -Calculate the yield to maturity (YTM) of a bond given its market price and characteristics. - -This function uses numerical root-finding to determine the discount rate that equates the -present value of all future cash flows (coupon payments and principal repayment) to the -current market price of the bond. The calculation properly handles bonds with fractional -periods remaining until maturity and accounts for accrued interest. - -# Arguments -- `price::Real`: Current market price of the bond -- `face_value::Real`: Par value or face value of the bond (principal amount) -- `coupon_rate::Real`: Annual coupon rate as a decimal (e.g., 0.05 for 5%) -- `years_to_maturity::Real`: Time to maturity in years (can be fractional) -- `frequency::Integer`: Number of coupon payments per year (e.g., 2 for semi-annual, 4 for quarterly) - -# Keyword Arguments -- `method::Symbol=:brent`: Root-finding method (currently only :brent is implemented) -- `bracket::Tuple{Float64,Float64}=(0.001, 1.0)`: Initial bracket for yield search as (lower_bound, upper_bound) - -# Returns -- `Float64`: The yield to maturity as an annual rate (decimal form) - -# Algorithm Details -The function calculates bond price using the standard present value formula: -- For whole coupon periods: discounts each coupon payment to present value -- For fractional periods: applies fractional discounting and adjusts for accrued interest -- Handles the special case where yield approaches zero (no discounting) -- Uses the Brent method for robust numerical root-finding - -The price calculation accounts for: -1. Present value of remaining coupon payments -2. Present value of principal repayment -3. Accrued interest adjustments for fractional periods - -# Examples -```julia -# Calculate YTM for a 5% annual coupon bond, 1000 face value, 3.5 years to maturity, -# semi-annual payments, currently priced at 950 -ytm = bond_yield(950, 1000, 0.05, 3.5, 2) - -# 10-year quarterly coupon bond -ytm = bond_yield(1050, 1000, 0.06, 10.0, 4) - -# Bond very close to maturity (0.25 years) -ytm = bond_yield(998, 1000, 0.04, 0.25, 2) -``` - -# Notes -- The yield returned is the effective annual rate compounded at the specified frequency -- For bonds trading at a premium (price > face_value), expect YTM < coupon_rate -- For bonds trading at a discount (price < face_value), expect YTM > coupon_rate -- The function assumes the next coupon payment occurs exactly one period from now -- Requires the `Roots.jl` package for numerical root-finding - -# Throws -- May throw convergence errors if the root-finding algorithm fails to converge -- Will return `Inf` for invalid yields (≤ 0) - -See also: [`bond_yield_excel`](@ref) -""" -function bond_yield(price, face_value, coupon_rate, years_to_maturity, frequency; - method=:brent, bracket=(0.001, 1.0)) - - total_periods = years_to_maturity * frequency - whole_periods = floor(Int, total_periods) # Complete coupon periods - fractional_period = total_periods - whole_periods # Partial period - - coupon_payment = (face_value * coupon_rate) / frequency - - function price_diff(y) - if y <= 0 - return Inf - end - - discount_rate = y / frequency - calculated_price = 0.0 - - if discount_rate == 0 - # Zero yield case - calculated_price = coupon_payment * whole_periods + face_value - if fractional_period > 0 - # Add accrued interest for partial period - calculated_price += coupon_payment * fractional_period - end - else - # Present value of whole coupon payments - if whole_periods > 0 - pv_coupons = coupon_payment * (1 - (1 + discount_rate)^(-whole_periods)) / discount_rate - calculated_price += pv_coupons / (1 + discount_rate)^fractional_period - end - - # Present value of principal (always discounted by full period) - pv_principal = face_value / (1 + discount_rate)^total_periods - calculated_price += pv_principal - - # Subtract accrued interest (what buyer owes seller) - if fractional_period > 0 - accrued_interest = coupon_payment * fractional_period - calculated_price -= accrued_interest - end - end - - return calculated_price - price - end - - try - return Roots.find_zero(price_diff, bracket, Roots.Brent()) - catch e - if isa(e, ArgumentError) && occursin("not a bracketing interval", sprint(showerror, e)) - # Fall back to a derivative-free method using an initial guess - @warn "Brent failed: falling back to Order1" exception=e - return Roots.find_zero(price_diff, 0.02, Roots.Order1()) - else - rethrow(e) - end - end - -end - - -""" - _day_count_days(d1, d2, basis) -> Int - -Count the number of days between two dates using the specified day-count convention. -Used internally for bond yield calculations. - -- `basis=0`: 30/360 (US) -- `basis=1`: Actual/actual -- `basis=2`: Actual/360 -- `basis=3`: Actual/365 -- `basis=4`: European 30/360 -""" -function _day_count_days(d1::Date, d2::Date, basis::Int) - if basis == 0 # 30/360 US - day1, mon1, yr1 = Dates.day(d1), Dates.month(d1), Dates.year(d1) - day2, mon2, yr2 = Dates.day(d2), Dates.month(d2), Dates.year(d2) - if day1 == 31; day1 = 30; end - if day2 == 31 && day1 >= 30; day2 = 30; end - return 360 * (yr2 - yr1) + 30 * (mon2 - mon1) + (day2 - day1) - elseif basis == 4 # European 30/360 - day1, mon1, yr1 = Dates.day(d1), Dates.month(d1), Dates.year(d1) - day2, mon2, yr2 = Dates.day(d2), Dates.month(d2), Dates.year(d2) - if day1 == 31; day1 = 30; end - if day2 == 31; day2 = 30; end - return 360 * (yr2 - yr1) + 30 * (mon2 - mon1) + (day2 - day1) - else # basis 1, 2, 3: actual days - return Dates.value(d2 - d1) - end -end - -function _date_difference(start_date, end_date; basis=1) - days = _day_count_days(start_date, end_date, basis) - if basis == 0 - return days / 360 - elseif basis == 1 - return days / 365.25 - elseif basis == 2 - return days / 360 - elseif basis == 3 - return days / 365 - else - error("Invalid basis: $basis") - end -end -# -------------------------------------------------------------------------------------------------- - - - - - - - - - - - - - - - - - - - - -