Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/buntec/derifree
Derivative pricing :heart: free monads
https://github.com/buntec/derifree
derivatives functional-programming quantitative-finance scala
Last synced: 10 days ago
JSON representation
Derivative pricing :heart: free monads
- Host: GitHub
- URL: https://github.com/buntec/derifree
- Owner: buntec
- License: apache-2.0
- Created: 2023-11-29T18:03:50.000Z (about 1 year ago)
- Default Branch: main
- Last Pushed: 2024-03-07T09:52:58.000Z (10 months ago)
- Last Synced: 2024-10-29T20:50:50.318Z (about 2 months ago)
- Topics: derivatives, functional-programming, quantitative-finance, scala
- Language: Scala
- Homepage:
- Size: 4.07 MB
- Stars: 2
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE.txt
Awesome Lists containing this project
README
# Derifree
Derivative pricing for the ~~lactose~~ side-effect intolerant.
Derifree is an experimental library exploring the use of free monads to
implement a contract definition language for (equity) derivatives.*This is work in progress!*
## Intro
A contract is encoded as a value of type `dsl.RV[A]` where
`dsl` is an instance of `derifree.Dsl[T]` for some time-like `T` (e.g,. `java.time.Instant`).
The type `A` is typically `Unit` and we provide a convenient alias:```scala
dsl.ContingentClaim = dsl.RV[Unit]
```Returning `Unit` makes sense if you think of a contract as
having the "side-effect" of exchanging a sequence of cash flows
between the long and the short party over some time horizon.
There isn't a meaningful value to return, in general.
A generic return type `A` can still be useful. In that case `RV[A]`
should be interpreted as a random variable taking value in `A`.
This is used internally, e.g., to compute factors in the Longstaff-Schwartz regression.The type `dsl.RV` is a free monad. In particular, values are sequenced using `flatMap`,
allowing us to write contracts using for-comprehensions (see the examples below).## Rules
When writing contracts using the derifree DSL, some rules need to be followed
to ensure pricing doesn't fail or, worse, return incorrect results.1. The occurrences of any of the keywords
(`cashflow`, `spot`, etc.) should be unconditional.
Note that this does not preclude things like conditional cash flows.
Indeed, to model a conditional cash flow `a` at time `t`,
we write `cashflow(if p then a else 0.0, ccy, t)` instead of
`Monad[RV].whenA(p)(cashflow(a, ccy, t))`.
To give another example, if we need a certain spot observation
`spot("ACME", t)` only conditionally, we simply
ignore its result when it isn't needed.The reason for this rule is that we want to be able to
collect all meta information about the contract
(spot/discount observations, barriers, callability, etc)
by evaluating the contract *once* using
an *arbitrary* realization of the underlying assets.For the read-like keywords (`spot`, `hitProb`, `survivalProb`)
this means simply ignoring their result when it isn't needed.
For write-like keywords (`cashflow`, `callable`, `puttable`)
this means using a neutral value (`0.0` or `None`).(Side note: another approach would be to "discover" this
information at pricing time and restart the pricing
until we arrive at a "fix point". This would
come at a loss in efficiency, however.)2. Causality: the DSL doesn't prevent you from
having a cash flow or call/early exercise at time `t1` depend on
an observation at time `t2 > t1`.
It's the user's responsibility to ensure all relationships are causal.## Examples
```scala
import cats.*
import cats.syntax.all.*
import derifree.*
import derifree.literals.*
import derifree.syntax.*// use any time type with a `TimeLike` instance
val dsl = Dsl[java.time.Instant]
import dsl.*val refTime = i"2023-12-27T18:00:00Z"
val expiry = refTime.plusDays(365)
val settle = expiry.plusDays(2)val europeanCall = for
s0 <- spot("AAPL", refTime)
s <- spot("AAPL", expiry)
_ <- cashflow(max(s / s0 - 1, 0.0), Ccy.USD, settle)
yield ()val europeanUpAndOutCall = for
s0 <- spot("AAPL", refTime)
s <- spot("AAPL", expiry)
p <- survivalProb(
Barrier.Discrete(
Barrier.Direction.Up,
Map("AAPL" -> List((expiry, 1.5 * s0)))
)
)
_ <- cashflow(p * max(s / s0 - 1, 0.0), Ccy.USD, settle)
yield ()val europeanPut = for
s0 <- spot("AAPL", refTime)
s <- spot("AAPL", expiry)
_ <- cashflow(max(1 - s / s0, 0.0), Ccy.USD, settle)
yield ()val bermudanPut = for
s0 <- spot("AAPL", refTime)
_ <- List(90, 180, 270, 360)
.map(refTime.plusDays)
.traverse_(d =>
spot("AAPL", d).flatMap(s =>
exercisable(max(1 - s / s0, 0.0).some.filter(_ > 0.0), Ccy.USD, d)
)
)
s <- spot("AAPL", expiry)
_ <- cashflow(max(1 - s / s0, 0.0), Ccy.USD, settle)
yield ()val quantoEuropeanCall = for
s0 <- spot("AAPL", refTime)
s <- spot("AAPL", expiry)
_ <- cashflow(max(s / s0 - 1, 0.0), Ccy.EUR, settle)
yield ()val worstOfContinuousDownAndInPut = for
s1_0 <- spot("AAPL", refTime)
s2_0 <- spot("MSFT", refTime)
s3_0 <- spot("GOOG", refTime)
s1 <- spot("AAPL", expiry)
s2 <- spot("MSFT", expiry)
s3 <- spot("GOOG", expiry)
p <- hitProb(
Barrier.Continuous(
Barrier.Direction.Down,
Map("AAPL" -> 0.8 * s1_0, "MSFT" -> 0.8 * s2_0, "GOOG" -> 0.8 * s3_0),
from = refTime,
to = expiry
)
)
_ <- cashflow(p * max(0.0, 1 - min(s1 / s1_0, s2 / s2_0, s3 / s3_0)), Ccy.USD, settle)
yield ()val worstOfEuropeanDownAndInPut = for
s1_0 <- spot("AAPL", refTime)
s2_0 <- spot("MSFT", refTime)
s3_0 <- spot("GOOG", refTime)
s1 <- spot("AAPL", expiry)
s2 <- spot("MSFT", expiry)
s3 <- spot("GOOG", expiry)
p <- hitProb(
Barrier.Discrete(
Barrier.Direction.Down,
Map(
"AAPL" -> List((expiry, 0.8 * s1_0)),
"MSFT" -> List((expiry, 0.8 * s2_0)),
"GOOG" -> List((expiry, 0.8 * s3_0))
)
)
)
_ <- cashflow(p * max(0.0, 1 - min(s1 / s1_0, s2 / s2_0, s3 / s3_0)), Ccy.USD, settle)
yield ()val barrierReverseConvertible =
val relBarrier = 0.7
val couponTimes = List(90, 180, 270, 360).map(refTime.plusDays)
for
s1_0 <- spot("AAPL", refTime)
s2_0 <- spot("MSFT", refTime)
s3_0 <- spot("GOOG", refTime)
s1 <- spot("AAPL", expiry)
s2 <- spot("MSFT", expiry)
s3 <- spot("GOOG", expiry)
p <- hitProb(
Barrier.Continuous(
Barrier.Direction.Down,
Map(
"AAPL" -> relBarrier * s1_0,
"MSFT" -> relBarrier * s2_0,
"GOOG" -> relBarrier * s3_0
),
from = refTime,
to = expiry
)
)
_ <- couponTimes.traverse_(t => cashflow(5.0, Ccy.USD, t))
_ <- cashflow(
100 * (1 - p * max(0.0, 1 - min(s1 / s1_0, s2 / s2_0, s3 / s3_0))),
Ccy.USD,
settle
)
yield ()val callableBarrierReverseConvertible =
val relBarrier = 0.7
val callTimes = List(90, 180, 270, 360).map(refTime.plusDays)
val couponTimes = List(90, 180, 270, 360).map(refTime.plusDays)
for
s1_0 <- spot("AAPL", refTime)
s2_0 <- spot("MSFT", refTime)
s3_0 <- spot("GOOG", refTime)
s1 <- spot("AAPL", expiry)
s2 <- spot("MSFT", expiry)
s3 <- spot("GOOG", expiry)
p <- hitProb(
Barrier.Continuous(
Barrier.Direction.Down,
Map(
"AAPL" -> relBarrier * s1_0,
"MSFT" -> relBarrier * s2_0,
"GOOG" -> relBarrier * s3_0
),
from = refTime,
to = expiry
)
)
_ <- callTimes.traverse_(t => callable(100.0.some, Ccy.USD, t))
_ <- couponTimes.traverse_(t => cashflow(5.0, Ccy.USD, t))
_ <- cashflow(
100 * (1 - p * max(0.0, 1 - min(s1 / s1_0, s2 / s2_0, s3 / s3_0))),
Ccy.USD,
settle
)
yield ()val couponBarrier =
val relBarrier = 0.95
val couponAmount = 5.0
val couponTimes = List(90, 180, 270, 360).map(refTime.plusDays)
for
s1_0 <- spot("AAPL", refTime)
s2_0 <- spot("MSFT", refTime)
s3_0 <- spot("GOOG", refTime)
s1 <- spot("AAPL", expiry)
s2 <- spot("MSFT", expiry)
s3 <- spot("GOOG", expiry)
_ <- couponTimes.traverse: t =>
(spot("AAPL", t), spot("MSFT", t), spot("GOOG", t))
.mapN((s1_t, s2_t, s3_t) => min(s1_t / s1_0, s2_t / s2_0, s3_t / s3_0) > relBarrier)
.flatMap: isAbove =>
cashflow(if isAbove then couponAmount else 0.0, Ccy.USD, t)
yield ()val couponBarrierWithMemoryEffect =
val relBarrier = 0.95
val couponAmount = 5.0
val couponTimes = List(90, 180, 270, 360).map(refTime.plusDays)
for
s1_0 <- spot("AAPL", refTime)
s2_0 <- spot("MSFT", refTime)
s3_0 <- spot("GOOG", refTime)
s1 <- spot("AAPL", expiry)
s2 <- spot("MSFT", expiry)
s3 <- spot("GOOG", expiry)
_ <- couponTimes.foldLeftM(0.0): (acc, t) =>
(spot("AAPL", t), spot("MSFT", t), spot("GOOG", t))
.mapN((s1_t, s2_t, s3_t) => min(s1_t / s1_0, s2_t / s2_0, s3_t / s3_0) > relBarrier)
.flatMap: isAbove =>
cashflow(if isAbove then acc + couponAmount else 0.0, Ccy.USD, t)
.as(if isAbove then 0 else acc + couponAmount)
yield ()// let's price using a simple Black-Scholes model
val discount = YieldCurve.fromContinuouslyCompoundedRate(0.05.rate, refTime)
val aapl = models.blackscholes.Asset(
"AAPL",
Ccy.USD,
Forward(195.0, divs = Nil, discount = discount, borrow = YieldCurve.zero),
0.23.vol
)val msft = models.blackscholes.Asset(
"MSFT",
Ccy.USD,
Forward(370.0, divs = Nil, discount = discount, borrow = YieldCurve.zero),
0.25.vol
)val goog = models.blackscholes.Asset(
"GOOG",
Ccy.USD,
Forward(135.0, divs = Nil, discount = discount, borrow = YieldCurve.zero),
0.29.vol
)val correlations =
Map(
("AAPL", "MSFT") -> 0.7,
("AAPL", "GOOG") -> 0.6,
("MSFT", "GOOG") -> 0.65,
("EURUSD", "AAPL") -> -0.2
)// for quantos
val fxVols = Map(CcyPair(Ccy.EUR, Ccy.USD) -> 0.07.vol)val dirNums = Sobol.directionNumbers(10000).toTry.get
val sim = models.blackscholes.simulator(
TimeGrid.Factory.almostEquidistant(YearFraction.oneDay),
NormalGen.Factory.sobol(dirNums),
refTime,
List(aapl, msft, goog),
correlations,
discount,
Map.empty
)// the number of Monte Carlo simulations
val nSims = 32767
``````scala
europeanCall.fairValue(sim, nSims)
// res0: Either[Error, PV] = Right(value = 0.11573966972403645)// should be cheaper than plain European call
europeanUpAndOutCall.fairValue(sim, nSims)
// res1: Either[Error, PV] = Right(value = 0.08550169593261212)europeanPut.fairValue(sim, nSims)
// res2: Either[Error, PV] = Right(value = 0.06699973963703337)// should be more expensive than European put
bermudanPut.fairValue(sim, nSims)
// res3: Either[Error, PV] = Right(value = 0.07095113729727867)// what are the probabilities of early exercise?
bermudanPut.putProbabilities(sim, nSims)
// res4: Either[Error, Map[Instant, Double]] = Right(
// value = Map(
// 2024-09-22T18:00:00Z -> 0.130283516953032,
// 2024-03-26T18:00:00Z -> 0.05578783532212287,
// 2024-12-21T18:00:00Z -> 0.07791375469222084,
// 2024-06-24T18:00:00Z -> 0.11328470717490158
// )
// )quantoEuropeanCall.fairValue(sim, nSims)
// res5: Either[Error, PV] = Left(
// value = Error(message = "missing vol for USDEUR")
// )worstOfContinuousDownAndInPut.fairValue(sim, nSims)
// res6: Either[Error, PV] = Right(value = 0.11952072431608426)// should be cheaper than continuous barrier
worstOfEuropeanDownAndInPut.fairValue(sim, nSims)
// res7: Either[Error, PV] = Right(value = 0.09498951398431003)barrierReverseConvertible.fairValue(sim, nSims)
// res8: Either[Error, PV] = Right(value = 106.09064030012115)// should be cheaper than non-callable BRC
callableBarrierReverseConvertible.fairValue(sim, nSims)
// res9: Either[Error, PV] = Right(value = 105.51905901142916)// what are the probabilities of being called?
callableBarrierReverseConvertible.callProbabilities(sim, nSims)
// res10: Either[Error, Map[Instant, Double]] = Right(
// value = Map(
// 2024-09-22T18:00:00Z -> 0.15588854640339367,
// 2024-03-26T18:00:00Z -> 0.0011597033600878933,
// 2024-12-21T18:00:00Z -> 0.16977446821497238,
// 2024-06-24T18:00:00Z -> 0.003143406476027711
// )
// )// should be cheaper than sum of discounted coupons
couponBarrier.fairValue(sim, nSims)
// res11: Either[Error, PV] = Right(value = 8.314781740288916)// should be more expensive than w/o memory, still cheaper than discounted sum of coupons
couponBarrierWithMemoryEffect.fairValue(sim, nSims)
// res12: Either[Error, PV] = Right(value = 10.370530640687656)
```## Building
`README.md` in the root directory is generated from `./docs/readme.md`
by running `sbt docs/mdoc`. The example code in `./docs/readme.md` is a copy
of the code in `./examples/src/main/scala/examples/readme.scala`
and should be kept in sync.