= [10, 10, 10, 10, 110];
cf_bond 1= [0.05, 0.06, 0.05, 0.04, 0.05]; rate
- 1
- The rates are the one year forward rates for time 0, 1, 2, etc.
Alec Loudenback
The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise. - Edsger Dijkstra (1972)
Demonstrate different approaches to a problem which gradually introduce more re-usable or general techniques. These techniques will allow for constructing sophisticated models while maintaining consistency and simplicity. Imperative programming, functional programming, and recursion.
This chapter will center around a simple task: calculate the present value of a portfolio of a single fixed, risk-free, coupon-paying bonds under two different interest rate environments. The focus will be on describing different approaches to this problem, not be adding complexity to the problem (e.g. no getting into credit spreads, settlement timing, etc.).
Mathematically, the problem is to determine the \(\text{Present Value}\), where:
\[ \text{Present Value} = \sum{\text{Cashflow}_t \times \text{Discount Factor}_t} \]
Where
\[ \text{Discount Factor}_t = \prod^t{\frac{1}{1+\text{Discount Rate}_i}} \]
= [10, 10, 10, 10, 110];
cf_bond 1= [0.05, 0.06, 0.05, 0.04, 0.05]; rate
We will focus on this first discount vector, and introduce more scenarios later in the chapter.
We will repeatedly solve the same problem before extending it to more examples. It may feel repetitive but the focus here is not the problem, but rather the variations between the different approaches.
One of the most familiar styles of programming is called imperative (or procedural), where we provide explicit, step-by-step instructions to the computer. The programmer defines the data involved and how that data moves through the program one step at a time. It commonly uses loops to perform tasks repeatedly or across a set of data. The program’s state (assignment and logic of the program’s variables) is defined and managed by the programmer explicitly.
Here’s an imperative style of calculating the present value of the bond.
pv
i
) update the discount factor to account for the prevailing rate and add the discounted cashflow to the running total present value.
121.48888490821489
This style is simple, digestible, and clear. If we were performing the calculation by hand, it would likely follow a pattern very similar to this. Look up the first cashflow and discount rate, compute a discount factor, and subtotal the value. Repeat for the next set of values.
Note that in the prior code example we defined an index variable i
and had to manually define the range over which it would operate (1
through the length
of the bond’s cashflow vector). A couple of reasons this could be sub-optimal:
0
or another arbitrary index.We can solve the first one (partially) by letting Julia return an iterable set of values corresponding to the indices of the cf_bond
vector. This is an example of an iterator which is an object upon which we can repeatedly ask for the next value until it tells us to stop.
By using eachindex
we can get the indices of the vector since Julia already knows what they are:
eachindex(cf_bond)
Base.OneTo(5)
The result, Base.OneTo(5) is a lazy object which represents a collection that does not get fully instantiated until asked to (which may not actually be necessary). Many (most?) iterators are lazy but we can interact with them without fully instantiating the data that they represent. Instantiating means fully loading the values into memory.
An analogy is that we can write the “set of all numbers from 1 to 100” without writing out each of the 100 numbers, but we are referring to the same thing.
An example of operating on a lazy iterator, is that we could find the largest index:
maximum(eachindex(cf_bond))
5
The point is if we have an object that represents a set, we need not actually enumerate each element of the set to interact with it.
We can fully instantiate an iterator with collect
collect(eachindex(cf_bond))
5-element Vector{Int64}:
1
2
3
4
5
Laziness is generally a good thing in programming because sometimes it can be computationally or memory expensive to fully instantiate the collection of interest (this will be discussed further in Chapter 9).
And when used in context:
let
= 0.0
pv = 1.0
discount
for i in eachindex(cf_bond)
= discount / (1 + rate[i])
discount = pv + discount * cf_bond[i]
pv end
pvend
121.48888490821489
Here Julia gave us the index associated with the bond cashflows, but we are still looking up the values (why not just ask for the values instead of their index?) as well as assuming that the indices are the same for the discount rates.
We can get the value and the associated index with enumerate
:
collect(enumerate(cf_bond))
5-element Vector{Tuple{Int64, Int64}}:
(1, 10)
(2, 10)
(3, 10)
(4, 10)
(5, 110)
This would allow us to skip the step of needing to look up the bond’s cashflows. However, we can go even further by just asking for value associated with both collections. With zip
(named because it’s sort of like zipping up two collections together), we get an iterator that provides the values of the underlying collections:
collect(zip(cf_bond, rate))
5-element Vector{Tuple{Int64, Float64}}:
(10, 0.05)
(10, 0.06)
(10, 0.05)
(10, 0.04)
(110, 0.05)
This provides the simplest implementation of the imperative approaches:
let
= 0.0
pv = 1.0
discount
for (cf, r) in zip(cf_bond, rate)
= discount / (1 + r)
discount = pv + discount * cf
pv end
pvend
121.48888490821489
The primary downsides to iterative approaches to algorithms are:
In the imperative style, we mentioned needing to explicitly handle program state. In general, it’s advisable to minimize as many temporary state variables as possible - more mutability tends to produce more complex, difficult to maintain code. An example of maintaining state in the examples above is keeping track of the current index as well as interim pv
and discount
variables.
Avoiding modifying values is often avoidable by restructuring the logic, using functional techniques, or finding the right abstractions. However, sometimes for performance reasons, clarity, or expediency you may find modifying state to be the preferred option and that’s okay.
Functional programming is a paradigm which attempts to minimize state via composing functions together.
Table 6.1 introduces a set of core functional methods to familiarize yourself with. Note that anonymous functions (Section 5.5.6) are used frequently to define intermediary steps.
Function | Description | Example |
---|---|---|
map(f,v) |
Apply function f to each element of the collection v . |
|
reduce(op,v) |
Apply binary
|
|
mapreduce(f,op,v) |
Maps f over collection v and returns a reduced result using op . |
|
foldl(op,v)foldr(op,v) |
Like reduce , but applies op from left to right (foldl ) or right to left (foldr ). Also has mapfoldl and mapfoldr versions. |
|
accumulate(op,v) |
Apply op along v , creating a vector with the cumulative result. |
|
filter(f,v) |
Apply f along v and return a copy of v with elements where f is true |
|
This paradigm is very powerful in a few ways:
portfolio
and calling a value
function” we can more concisely refer to this as mapreduce(value,portfolio)
.reduce
, the compiler could drive the calculation in any order since the operation is associative.Let’s build a version of the present value calculation using the functional building blocks described above. We will work up to it by discussing the core functional programming building blocks, culminating in combining mapreduce
and accumulate
to do the bond valuation.
map
map
is so named for the mathematical concept of mapping an input to an output. Here, it’s effectively the same thing. We take a collection and use the given function to calculate an output. The size of the output equals the size of the input.
First, we will use map
to compute the one-period discount factors:
map(x -> 1 / (1 + x), rate)
5-element Vector{Float64}:
0.9523809523809523
0.9433962264150942
0.9523809523809523
0.9615384615384615
0.9523809523809523
map
transforms the rate
collection by applying the anonymous function x -> 1 / (1 + x)
, which is the single period discount factor. This operation is conveyed visually in Figure 6.1.
map
creates a new collection mirroring the old one, after applying the given function to each element in the original collection.
map
is an absolute workhorse of a function and the authors recommend using it liberally within your code. We find ourselves using map
frequently, usually avoiding defining an explicit loop (unless we are modifying some existing collection).
map
would likely be a better tool for a loop like this:
= []
output for x in collection
= # ... do stuff ...
result push!(output,result)
end
output
Instead, map
simplifies this to:
map(collection) do x
# ... do stuff
end
Not only does this have the advantage of being clearer, more concise, and less work, it also let’s Julia infer the output type of your computation so you don’t have to worry about the type of output
.
accumulate
accumulate
takes an operation and a collection and returns a collection where each element is the cumulative result of applying the operation from the first element to the current one. For example, to calculate the cumulative product of the one-period discount factors:
accumulate(*, map(x -> 1 / (1 + x), rate))
5-element Vector{Float64}:
0.9523809523809523
0.898472596585804
0.8556881872245752
0.822777103100553
0.7835972410481457
This results in a vector of the cumulative discount factors for each point in time corresponding to the given cashflows.
accumulate
creates a new collection where each element is the cumulative result of applying the given operation to all previous elements.
For accumulate
and reduce
, an important, optional value is the init
(an optional keyword argument), which is the initial value to start the accumulation or reduction. For common operations this identity element is already predefined. For example, for +
the identity is 0
while for *
it is 1
. The identity element \(e\) is the one where for a given binary operation \(\bigodot\), that \(x \bigodot e = x\).
Another example is string concatenation. In Julia, two strings are concatenated with *
(like in mathematics, \(a * b\) is also written as \(ab\)). The identity element for strings where the binary operation \(\bigodot = *\) is ""
. For example:
accumulate(*, ["a", "b", "c"], init="")
3-element Vector{String}:
"a"
"ab"
"abc"
This is a taste of a branch of mathematics known as Category Theory, a very rich subject but largely beyond the immediate scope of this book. The category theoretical term for sets of things that work with the binary operator and identity elements as described above is a monoid. There will not be a quiz on this trivia.
reduce
reduce
takes an operation and a collection and applies the operation repeatedly to pairs of elements until there is only a single value left.
For example, we start with the calculation of the vector of discounted cashflows
= accumulate(*, map(x -> 1 / (1 + x), rate))
dfs = map(*, cf_bond, dfs) discounted_cfs
5-element Vector{Float64}:
9.523809523809524
8.98472596585804
8.556881872245752
8.22777103100553
86.19569651529602
Then we can sum them with reduce:
reduce(+, discounted_cfs)
121.48888490821487
reduce
applies the given operation to pairs of elements, ultimately reducing the collection to a single value.
mapreduce
We can combine map
, accumulate
and reduce
to concisely calculate the present value in a functional style. This calculates the discount factors, applies them to the cashflows with map, and sums the result with a reduction:
121.48888490821487
Contrast this example with the earlier imperative styles:
This completes the example of using a functional approach to determine the present value of bond cashflows.
filter
For completeness, we will also cover filter
even though it’s not necessary for the bond cashflow example.
filter
does what you might think - filter a collection based on some criterion that can be determined as true or false.
For example filtering out even numbers using the isodd
function:
filter(isodd, 1:6)
3-element Vector{Int64}:
1
3
5
Or filtering out things that don’t match a criteria:
filter(x -> ~(x == 5), 1:6)
5-element Vector{Int64}:
1
2
3
4
6
While we didn’t need filter
to calculate a bond’s present value in the example above, one can imagine how you may want to filter dates that a bond might pay a cashflow, say last day of a quarter:
using Dates
let d = Date(2024, 01, 01)
filter(d -> lastdayofquarter(d) == d, d:Day(1):lastdayofyear(d))
end
4-element Vector{Date}:
2024-03-31
2024-06-30
2024-09-30
2024-12-31
do
Syntax for Function ArgumentsIn more complex situations such as with multiple collections or multi-line logic, there is a clearer syntax that is often used. do
is a reserved keyword in Julia that creates an anonymous function and passes its arguments to a function like map
. For example, this (terrible) code which decides if a number is prime. The anonymous function requires a begin
block since the logic of the function is extended into multiple lines.
map(x -> begin
if x == 1
"prime"
elseif x == 2
"not prime"
elseif x == 3
"prime"
elseif x > 4
"probably not prime"
end
end,
1, 2, 3, 10]
[ )
This can be written more cleanly with the do
syntax:
map([1, 2, 3, 10]) do x
if x == 1
"prime"
elseif x == 2
"not prime"
elseif x == 3
"prime"
elseif x > 4
"probably not prime"
end)
map
and the other functional operators discussed in this section can take multiple arguments. This is convenient if you have multiple arguments to a function:
= [0.9, 0.81, 0.73]
discounts = [10, 10, 10]
cashflows
map((d, c) -> d * c, discounts, cashflows)
3-element Vector{Float64}:
9.0
8.100000000000001
7.3
Or an example with the do
syntax:
map(discounts, cashflows) do d, c
* c
d end
3-element Vector{Float64}:
9.0
8.100000000000001
7.3
At the risk of sounding obvious, an easy way to make the program more “functional” is to simply use more functions. Do this one thing and it will improve the model’s organization, maintainability, and reduce bugs!
Take the example from earlier:
= 0.0
pv = 1.0
discount
for (cf, r) in zip(cf_bond, rate)
= discount / (1 + r)
discount = pv + discount * cf
pv end
pv
We can easy turn this code into a function so that it can operate on data beyond the single pair of cf_bond
and rate
previously defined:
function pv(rates,cashflows)
= 0.0
pv = 1.0
discount
1for (cf, r) in zip(rates, cashflows)
= discount / (1 + r)
discount = pv + discount * cf
pv end
pvend
cf_bond
and rate
would refer to whatever was passed as arguments to the function instead of any globally defined values.
Now we could use this definition of pv
on other instances of rates and cashflows.
One of the best things about Julia is how natural it can be to mix the different styles Sometimes the best is the mix of both styles and that’s one of the benefits of Julia: use the style that’s most natural to the problem.
Lisp (“list processing”) is another, much older language than Julia (created in the 1950s!). One of it’s claims to fame is how flexible and powerful the tools are within the language to build upon. There’s a couple aspects of this curse that we wish to describe because we can learn from it while Julia is still a relatively young language.
Part of the “curse” is that: because there’s so much freedom in what can be expressed in the language, there’s not an obvious “best” way of doing things. This can lead to decision paralysis where you are trying to over-analyze what’s the best way to write part of your code. Our advice: don’t worry about it! A working implementation of something is better than an over-optimized idea.
The other part of the “curse” is that because is that it’s relatively easy to implement so many things from the building blocks that Julia provides and compose them together to do what you want. This has a downside because the general approach to packages is smaller, standalone pieces that you call as needed. For example, consider Python’s Pandas library, upon which Python’s data science community was built. It came bundled with a CSV reader, Excel reader, Database reader, DataFrame type, visualization library, and statistical functions. In Julia, each of those are separate packages that specialize for the respective topics. This is advantageous in that they can progress independently from one another, you don’t have to include functionality that you don’t need, and you can mix and match libraries depending on your preference.
Another paradigm is array-oriented, which is a style that relies heavily on putting similar data into arrays and operating on the entire array at the same time (as opposed to going element-by-element).
Array-oriented programming is one that is practiced in two main contexts:
The former because GPUs want large blocks of similar data to operate in parallel. The latter is because native Python is too slow for many modeling problems so libraries like NumPy,SciPy,and tensor libraries utilize C++ (or similar) libraries for users to call out to.
Array-oriented programming is not always natural for financial and actuarial applications. Differences in behavior or timing of underling cashflows can make a set of otherwise similar products difficult to capture in nicely gridded arrays. Nonetheless, certain applications (scenario generation, some valuation routines) fit very naturally into this paradigm. Furthermore, for those that work well it’s often a great way to extract additional performance due to the parallelization offered via CPU or GPU array programming.
Table 6.2 shows the bond present value example in this style.
Julia | Python (NumPy) |
---|---|
|
|
The downsides to this style are:
discount_factors
needs to instantiate an entirely new vector even though it’s only temporarily used). See more on allocations in Chapter 9.A recursive function which is a pattern where current steps are defined in a way that depends on previous steps. Typically, an explicit starting condition is also required to be specified.
The Fibbonaci sequence is a classic example of a recursive algorithm, with the starting conditions of \(n\) specified for the first two steps:
\[F(n) = \begin{cases} 0, & \text{if } n = 0\\ 1, & \text{if } n = 1\\ F(n-1) + F(n-2), & \text{if } n > 1 \end{cases}\]
In code, this translates into a function definition that refers to itself:
function fibonacci(n)
if n == 0
return 0
elseif n == 1
return 1
else
return fibonacci(n-1) + fibonacci(n-2)
end
end
How could a recursive pattern be defined for valuing our bond? A possible pattern is defining the present value to be the discounted value of:
the current period’s cashflow, plus
the accumulated cashflows up to that point in time
Here’s how that might be defined:
function pv_recursive(rates,cashflows,accumulated_value=0.0,discount_factor=1.0)
1if isempty(cashflows)
return accumulated_value
else
2= discount_factor / (1+first(rates))
discount_factor 3= first(cashflows) * discount_factor + accumulated_value
av = rates[begin+1:end]
remaining_rates = cashflows[begin+1:end]
remaining_cfs 4return pv_recursive(remaining_rates,remaining_cfs, av,discount_factor)
end
end
pv_recursive (generic function with 3 methods)
And an example of its use:
pv_recursive(rate,cf_bond)
121.48888490821489
The recursive pattern often works very nicely for simpler examples. However, more complex logic and conditionals can make this approach unwieldy. Nonetheless, attempting to distill the desired functionality into a single function can be a beneficial thought exercise.