From 3f609b723bcace94739e47c9962f5084fbba3a6c Mon Sep 17 00:00:00 2001 From: Alec Loudenback Date: Sun, 1 Jan 2023 00:13:17 -0600 Subject: [PATCH] WIP port of Cashflows branch --- Project.toml | 4 +- src/ActuaryUtilities.jl | 5 +- src/cashflow.jl | 32 +++++++++++ src/financial_math.jl | 114 ++++++++++++++++++---------------------- test/runtests.jl | 35 ++++++------ 5 files changed, 109 insertions(+), 81 deletions(-) create mode 100644 src/cashflow.jl diff --git a/Project.toml b/Project.toml index 0c9c8f6..ab34306 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" FinanceCore = "b9b1ffdd-6612-4b69-8227-7663be06e089" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" +Lazy = "50d2b5c4-7a5e-59d5-8109-a42b560f39c0" MuladdMacro = "46d2c3a1-f734-5fdb-9937-b9b9aeba4221" QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" @@ -21,9 +22,10 @@ FinanceCore = "^1" ForwardDiff = "^0.10" MuladdMacro = "^0.2" QuadGK = "^2" +Reexport = "^1.2" +Lazy = "^0.15" SnoopPrecompile = "^1" StatsBase = "^0.33" -Reexport = "^1.2" Yields = "^2,^3" julia = "^1.6" diff --git a/src/ActuaryUtilities.jl b/src/ActuaryUtilities.jl index f43d55e..23a16b8 100644 --- a/src/ActuaryUtilities.jl +++ b/src/ActuaryUtilities.jl @@ -5,12 +5,14 @@ using Dates import FinanceCore @reexport using FinanceCore: internal_rate_of_return, irr using ForwardDiff +import Lazy using QuadGK using MuladdMacro using Yields import StatsBase using SnoopPrecompile +include("cashflow.jl") include("financial_math.jl") include("risk_measures.jl") include("derivatives.jl") @@ -155,6 +157,7 @@ export years_between, duration, accum_offset, Macaulay,Modified,DV01,KeyRatePar,KeyRateZero,KeyRate,duration, convexity, VaR,ValueAtRisk,CTE,ConditionalTailExpectation,ExpectedShortfall, - eurocall, europut + eurocall, europut, + Cashflow end # module diff --git a/src/cashflow.jl b/src/cashflow.jl new file mode 100644 index 0000000..f0b3e67 --- /dev/null +++ b/src/cashflow.jl @@ -0,0 +1,32 @@ +struct Cashflow{A,T} + amount::A + time::T +end + +#TODO Define Cashflow on a vector/iterable? + +@inline function present_value(yield,cf::Cashflow) + return discount(yield,cf.time) * cf.amount +end + +# this method ignores the time argument and instead uses +# the time within the cashflow. This is to allow for `present_value(yield,cfs)` +# to dispatch on the values of `cfs`. Otherwise, if `cfs` is a generator or otherwise +# an iterable of ambiguous types, we lose the ability to dispatch on the right method +# for Cashflow or just a real type where we infer the timepoint +function present_value(yield,cf::Cashflow,time::T) where {T<:Real} + return discount(yield,cf.time) * cf.amount +end + + +# There are places where we want to infer a 1:n range in a lazy way if not Cashflows; if Cashflows, we often want to ignore +# the times given in the function and stay true to the times embedded in the Cashflows, and these utility functions accomplish this +__times(cfs;start=1) = (__time(cf,t) for (t,cf) in zip(Lazy.range(start),cfs)) +__times(cfs,times) = (__time(cf,t) for (t,cf) in zip(times,cfs)) +__time(cf::Cashflow,t) = cf.time +__time(cf,t) = t + +#look through to the cashflow and grab the amount +__cashflows(cfs) = (__cashflow(cf) for cf in cfs) +__cashflow(cf::Cashflow) = cf.amount +__cashflow(cf) = cf \ No newline at end of file diff --git a/src/financial_math.jl b/src/financial_math.jl index 128d361..4f6345a 100644 --- a/src/financial_math.jl +++ b/src/financial_math.jl @@ -1,13 +1,17 @@ """ - present_value(interest, cashflows::Vector, timepoints) - present_value(interest, cashflows::Vector) + present_value(yield, cashflow, timepoints) + present_value(yield, cashflow) -Discount the `cashflows` vector at the given `interest_interestrate`, with the cashflows occurring +Discount the `cashflow` at the given `yield`, with the cashflows occurring at the times specified in `timepoints`. If no `timepoints` given, assumes that cashflows happen at times 1,2,...,n. -The `interest` can be an `InterestCurve`, a single scalar, or a vector wrapped in an `InterestCurve`. +# Arguments +- `yield` can be any valid Yields.jl yield rate or object +- `cashflow` can be a `Real`-valued scalar, a `Cashflow`, or a vector of scalars or `Cashflow` +- `timepoints` is a scalar or vector of time-points that the corresponding cashflows occur. If `cashflow` is a `Cashflow` object, then the time used in determining the discount factor will be the time in the `Cashflow`` data, and the `timepoints` will be ignored. # Examples + ```julia-repl julia> present_value(0.1, [10,20],[0,1]) 28.18181818181818 @@ -16,8 +20,8 @@ julia> present_value(Yields.Forward([0.1,0.2]), [10,20],[0,1]) ``` Example on how to use real dates using the [DayCounts.jl](https://github.com/JuliaFinance/DayCounts.jl) package -```jldoctest +```jldoctest using DayCounts dates = Date(2012,12,31):Year(1):Date(2013,12,31) times = map(d -> yearfrac(dates[1], d, DayCounts.Actual365Fixed()),dates) # [0.0,1.0] @@ -25,13 +29,13 @@ present_value(0.1, [10,20],times) # output 28.18181818181818 - ``` - """ function present_value(yc::T, cashflows, timepoints) where {T <: Yields.AbstractYield} s = 0.0 - for (cf,t) in zip(cashflows,timepoints) + cfs = __cashflows(cashflows) + times = __times(cfs,timepoints) + for (cf,t) in zip(cfs,times) v = discount(yc,t) @muladd s = s + v * cf end @@ -40,37 +44,18 @@ function present_value(yc::T, cashflows, timepoints) where {T <: Yields.Abstract end function present_value(yc::T, cashflows) where {T <: Yields.AbstractYield} - present_value(yc,cashflows,1:length(cashflows)) -end - -function present_value(i, x) - - v = 1.0 - v_factor = discount(i,0,1) - pv = 0.0 - - for (t,cf) in enumerate(x) - v = v * v_factor - @muladd pv = pv + v * cf - end - return pv + times = __times(cashflows) + present_value(yc,cashflows,times) end function present_value(i, v, times) return present_value(Yields.Constant(i), v, times) end -# Interest Given is an array, assume forwards. -function present_value(i::AbstractArray, v) - yc = Yields.Forward(i) - return sum(discount(yc, t) * cf for (t,cf) in enumerate(v)) +function present_value(i, v) + return present_value(Yields.Constant(i), v) end -# Interest Given is an array, assume forwards. -function present_value(i::AbstractArray, v, times) - yc = Yields.Forward(i, times) - return sum(discount(yc, t) * cf for (cf, t) in zip(v,times)) -end """ pv() @@ -156,7 +141,7 @@ Calculate the time when the accumulated cashflows breakeven given the yield. Assumptions: - cashflows occur at the end of the period -- cashflows evenly spaced with the first one occuring at time zero if `times` not given +- cashflows evenly spaced with the first one occurring at time zero if `times` not given Returns `nothing` if cashflow stream never breaks even. @@ -172,22 +157,18 @@ julia> breakeven(0.10, [-10,-15,2,3,4,8]) # returns the `nothing` value ``` """ -function breakeven(y::T, cashflows::Vector, timepoints::Vector) where {T <: Yields.AbstractYield} - accum = zero(eltype(cashflows)) +function breakeven(y::T, cashflows, timepoints) where {T <: Yields.AbstractYield} + accum = 0.0 last_neg = nothing - - accum += cashflows[1] - if accum >= 0 && isnothing(last_neg) - last_neg = timepoints[1] - end - - for i in 2:length(cashflows) + times = __times(cfs,timepoints) + prior_time = first(times) + for (cf, t) in zip(cashflows,times) # accumulate the flow from each timepoint to the next - accum *= Yields.accumulation(y, timepoints[i - 1], timepoints[i]) - accum += cashflows[i] - - if accum >= 0 && isnothing(last_neg) - last_neg = timepoints[i] + accum *= Yields.accumulation(y, prior_time, t) + accum += __cashflow(cf) + prior_time = t + if accum >= 0 && isnothing(last_neg) + last_neg = t elseif accum < 0 last_neg = nothing end @@ -197,16 +178,13 @@ function breakeven(y::T, cashflows::Vector, timepoints::Vector) where {T <: Yiel end -function breakeven(y::T, cfs, times) where {T <: Real} +function breakeven(y, cfs, times) return breakeven(Yields.Constant(y), cfs, times) end -function breakeven(y::Vector{T}, cfs, times) where {T <: Real} - return breakeven(Yields.Forward(y), cfs, times) -end - -function breakeven(i, cashflows::Vector) - return breakeven(i, cashflows, [t for t in 0:length(cashflows) - 1]) +function breakeven(i, cashflows) + times = __times(cashflows;start=0) + return breakeven(i, cashflows, times) end abstract type Duration end @@ -307,9 +285,14 @@ julia> convexity(0.03,my_lump_sum_value) ``` """ function duration(::Macaulay, yield, cfs, times) - return sum(times .* price.(yield, cfs, times) / price(yield, cfs, times)) + a= sum(pv(yield, __multiply_time(cf,t), t) for (t,cf) in zip(times,cfs)) + b = sum(pv(yield, cf, t) for (t,cf) in zip(times,cfs)) + a / b end +__multiply_time(cf::Cashflow,time) = (cf.amount * cf.time,cf.time) +__multiply_time(cf,time) = cf * time + function duration(::Modified, yield, cfs, times) D(i) = price(i, cfs, times) return duration(yield, D) @@ -321,10 +304,10 @@ function duration(yield::Y, valuation_function::T) where {Y<:Yields.AbstractYiel end function duration(yield, cfs, times) - return duration(Modified(), yield, vec(cfs), times) + return duration(Modified(), yield, cfs, times) end function duration(yield::Y, cfs) where {Y <: Yields.AbstractYield} - times = 1:length(cfs) + times = __times(cfs) return duration(Modified(), yield, cfs, times) end @@ -336,8 +319,8 @@ function duration(::DV01, yield, cfs, times) return duration(DV01(), yield, i -> price(i, vec(cfs), times)) end function duration(d::Duration, yield, cfs) - times = 1:length(cfs) - return duration(d, yield, vec(cfs), times) + times = __times(cfs) + return duration(d, yield, cfs, times) end function duration(::DV01, yield, valuation_function::Y) where {Y<:Function} @@ -383,11 +366,12 @@ julia> convexity(0.03,my_lump_sum_value) """ function convexity(yield, cfs, times) - return convexity(yield, i -> price(i, cfs, times)) + ts = __times(cfs,times) + return convexity(yield, i -> price(i, cfs, ts)) end function convexity(yield,cfs) - times = 1:length(cfs) + times = __times(cfs) return convexity(yield, i -> price(i, cfs, times)) end @@ -496,7 +480,7 @@ function duration(keyrate::KeyRateDuration, curve, cashflows, timepoints) end function duration(keyrate::KeyRateDuration, curve, cashflows) - timepoints = eachindex(cashflows) + timepoints = __times(cashflows) krd_points = 1:maximum(timepoints) return duration(keyrate, curve, cashflows, timepoints, krd_points) @@ -507,7 +491,8 @@ end Return the solved-for constant spread to add to `curve1` in order to equate the discounted `cashflows` with `curve2` """ -function spread(curve1,curve2,cashflows,times=eachindex(cashflows)) +function spread(curve1,curve2,cashflows,times=__times(cashflows)) + ts = [0.; Lazy.take(times,length(cashflows))] pv1 = pv(curve1,cashflows,times) pv2 = pv(curve2,cashflows,times) irr1 = irr([-pv1;cashflows], [0.;times]) @@ -518,7 +503,7 @@ function spread(curve1,curve2,cashflows,times=eachindex(cashflows)) end """ - moic(cashflows<:AbstractArray) + moic(cashflows) The multiple on invested capital ("moic") is the un-discounted sum of distributions divided by the sum of the contributions. The function assumes that negative numbers in the array represent contributions and positive numbers represent distributions. @@ -530,7 +515,8 @@ julia> moic([-10,20,30]) ``` """ -function moic(cfs::T) where {T<:AbstractArray} +function moic(cashflows) + cfs = __cashflows(cashflows) returned = sum(cf for cf in cfs if cf > 0) invested = -sum(cf for cf in cfs if cf < 0) return returned / invested diff --git a/test/runtests.jl b/test/runtests.jl index 2de9a0d..6b81d89 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -54,9 +54,12 @@ end @testset "pv" begin cf = [100, 100] - + CF = Cashflow.(cf,[1,2]) + @test pv(0.05, cf) ≈ cf[1] / 1.05 + cf[2] / 1.05^2 + @test pv(0.05, CF) ≈ pv(0.05, cf) @test price(0.05, cf) ≈ pv(0.05, cf) + @test price(0.05, CF) ≈ pv(0.05, CF) # this vector came from Numpy Financial's test suite with target of 122.89, but that assumes payments are begin of period # 117.04 comes from Excel verification with NPV function @@ -92,22 +95,13 @@ end @testset "pv with vector discount rates" begin cf = [100, 100] - @test pv([0.0,0.05], cf) ≈ 100 / 1.0 + 100 / 1.05 @test pv(ActuaryUtilities.Yields.Forward([0.0,0.05]), cf) ≈ 100 / 1.0 + 100 / 1.05 - @test pv([0.05,0.0], cf) ≈ 100 / 1.05 + 100 / 1.05 - @test pv([0.05,0.1], cf) ≈ 100 / 1.05 + 100 / 1.05 / 1.1 ts = [0.5,1] @test pv(ActuaryUtilities.Yields.Forward([0.0,0.05], ts), cf, ts) ≈ 100 / 1.0 + 100 / 1.05^0.5 @test pv(ActuaryUtilities.Yields.Forward([0.05,0.0], ts), cf, ts) ≈ 100 / 1.05^0.5 + 100 / 1.05^0.5 @test pv(ActuaryUtilities.Yields.Forward([0.05,0.1], ts), cf, ts) ≈ 100 / 1.05^0.5 + 100 / (1.05^0.5) / (1.1^0.5) - #without explicit Yields constructor - @test pv([0.0,0.05], cf, ts) ≈ 100 / 1.0 + 100 / 1.05^0.5 - - @test price([0.0,0.05], cf, ts) ≈ pv([0.0,0.05], cf, ts) - @test price([0.0,0.05], -1 .* cf, ts) ≈ abs(pv([0.0,0.05], cf, ts)) - end @@ -118,6 +112,8 @@ end @testset "basic" begin @test breakeven(0.10, [-10,1,2,3,4,8]) == 5 + @test breakeven(0.10, Cashflow.([-10,1,2,3,4,8],0:5)) == 5 + @test breakeven(0.10, Cashflow.([-10,1,2,3,4,8],[0,1,2,3,4,4.5])) == 4.5 @test breakeven(0.10, [-10,15,2,3,4,8]) == 1 @test breakeven(0.10, [-10,15,2,3,4,8]) == 1 @test breakeven(0.10, [10,15,2,3,4,8]) == 0 @@ -126,11 +122,12 @@ end @testset "basic with vector interest" begin @test breakeven(0.0,[-10,1,2,3,4], [1,2,3,4,5]) == 5 + @test breakeven(0.0,[-10,1,2,3,4], [1,2,3,4,4.5]) == 4.5 # - @test isnothing(breakeven([0.0,0.0,0.0,0.0,0.1], [-10,1,2,3,4], [1,2,3,4,5])) - @test breakeven([0.0,0.0,0.0,0.0,-0.5], [-10,1,2,3,4], [1,2,3,4,5]) == 5 - @test breakeven([0.0,0.0,0.0,-0.9,-0.5],[-10,1,2,3,4], [1,2,3,4,5]) == 4 - @test breakeven([0.1,0.1,0.2,0.1,0.1], [-10,1,12,3,4], [1,2,3,4,5]) == 3 + @test isnothing(breakeven(Yields.Forward([0.0,0.0,0.0,0.0,0.1]), [-10,1,2,3,4], [1,2,3,4,5])) + @test breakeven(Yields.Forward([0.0,0.0,0.0,0.0,-0.5]), [-10,1,2,3,4], [1,2,3,4,5]) == 5 + @test breakeven(Yields.Forward([0.0,0.0,0.0,-0.9,-0.5]),[-10,1,2,3,4], [1,2,3,4,5]) == 4 + @test breakeven(Yields.Forward([0.1,0.1,0.2,0.1,0.1]), [-10,1,12,3,4], [1,2,3,4,5]) == 3 end @testset "timepoints" begin @@ -147,7 +144,8 @@ end # https://bankingprep.com/multiple-on-invested-capital/ ex1 = [-100;[t == 200 ? 100 * 1.067^t : 0 for t in 1:200]] @test moic(ex1) ≈ 429421.59914697794 - + ex1_cf = Cashflow.(ex1, 0:200) + @test moic(ex1_cf) ≈ 429421.59914697794 ex2 = ex1[end] *= 0.5 @test moic(ex1) ≈ 429421.59914697794 * 0.5 @@ -174,9 +172,14 @@ end V = present_value(0.04, cfs, times) @test duration(Macaulay(), 0.04, cfs, times) ≈ 1.777570320376649 + @test duration(Macaulay(), 0.04, Cashflow.(cfs, times)) ≈ 1.777570320376649 + @test duration(Modified(), 0.04, cfs, times) ≈ 1.777570320376649 / (1 + 0.04) + @test duration(Modified(), 0.04, Cashflow.(cfs, times)) ≈ 1.777570320376649 / (1 + 0.04) + @test duration(0.04, cfs, times) ≈ 1.777570320376649 / (1 + 0.04) @test duration(DV01(), 0.04, cfs, times) ≈ 1.777570320376649 / (1 + 0.04) * V / 100 + @test duration(DV01(), 0.04, Cashflow.(cfs, times)) ≈ 1.777570320376649 / (1 + 0.04) * V / 100 # test with a Rate r = Yields.Periodic(0.04,1) @@ -223,6 +226,7 @@ end @test isapprox(present_value(0.04, cfs, times), 821927.11, atol = 1e-2) # @test isapprox(duration(0.04,cfs,times),4.76190476,atol=1e-6) @test isapprox(convexity(0.04, cfs, times), 27.7366864, atol = 1e-6) + @test isapprox(convexity(0.04, Cashflow.(cfs, times)), 27.7366864, atol = 1e-6) @test isapprox(convexity(0.04, cfs), 27.7366864, atol = 1e-6) # the same, but with a functional argument value(i) = present_value(i, cfs, times) @@ -291,6 +295,7 @@ end @test duration(KeyRatePar(3),c,bond.cfs,bond.times) ≈ 0.0 atol = 0.01 @test duration(KeyRatePar(4),c,bond.cfs,bond.times) ≈ 0.0 atol = 0.01 @test duration(KeyRatePar(5),c,bond.cfs,bond.times) ≈ 4.45 atol = 0.05 + @test duration(KeyRatePar(5),c,Cashflow.(bond.cfs,bond.times)) ≈ 4.45 atol = 0.05 bond =(times=[1,2,3,4,5],cfs=[0,0,0,0,100]) @test duration(KeyRateZero(1),c,bond.cfs,bond.times) ≈ 0.0