Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//| mill-jvm-opts: [ "--add-modules", "jdk.incubator.vector", -XX:TrimNativeHeapInterval=60000]
//| mvnDeps:
//| - io.github.quafadas:millSite_mill1_3.8:0.0.57
//| - com.goyeau:mill-scalafix_mill1_3:0.6.0
//| - com.goyeau:mill-scalafix_mill1_3:0.6.2
//| - com.lihaoyi::mill-contrib-jmh:$MILL_VERSION
//| - com.lihaoyi::mill-contrib-scoverage:$MILL_VERSION

Expand Down
8 changes: 8 additions & 0 deletions vecxt_re/src/CurrencyAmount.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package vecxt_re

import scala.annotation.targetName

import com.github.plokhotnyuk.jsoniter_scala.core.*
import com.github.plokhotnyuk.jsoniter_scala.macros.*

Expand Down Expand Up @@ -29,12 +31,18 @@ final case class CurrencyAmount private (amount: Double, ccy: Ccy) derives CanEq
def unary_- : CurrencyAmount = new CurrencyAmount(-amount, ccy)

/** premium = notional * coupon */
@targetName("timesRel")
def *(r: Rel): CurrencyAmount =
new CurrencyAmount(amount * r.canonical, ccy)

def *(k: Double): CurrencyAmount =
new CurrencyAmount(amount * k, ccy)

/** market value = notional * price */
@targetName("timesPrice")
def *(p: Price): CurrencyAmount =
new CurrencyAmount(amount * p.canonical, ccy)

/** Ratio of two same-currency amounts, e.g. a loss ratio. */
def /(y: CurrencyAmount): Rel =
sameCcy(y); Rel(amount / y.amount, PriceUnit.One)
Expand Down
55 changes: 24 additions & 31 deletions vecxt_re/src/PortfolioCalc.scala
Original file line number Diff line number Diff line change
Expand Up @@ -142,35 +142,26 @@ object PortfolioCalc:
holdings.length == lossMatrix.cols,
s"Each entry in the lossMatrix should have a holdings entry. Loss matrix width ${lossMatrix.cols}, got ${holdings.length} holdings"
)
val oneYearYield = for h <- holdings yield
val priceT1 = PositionCalculations.priceForecast1Year(h.price, t0Date, h.maturity)
val notionals = holdings.map { h =>
val ccy = h.notional.ccy
val xcRate =
if ccy == portfolioCurrency then Right[String, Double](1.0)
else
xcRates
.get(ccy)
.toRight(
s"Position id=${h.id} name=${h.name}: no FX rate from $ccy to $portfolioCurrency"
)

xcRate.map { xc =>
val notionalInPortfolioCurrency = xc * h.notional.amount
((priceT1 + h.riskFree + h.spread).canonical, notionalInPortfolioCurrency)
}

val errors = oneYearYield.collect { case Left(e) => e }
val xcRate = if ccy == portfolioCurrency then Some(1.0) else xcRates.get(ccy)
xcRate
.map(_ * h.notional.amount)
.toRight(s"Position id=${h.id} name=${h.name}: no FX rate from $ccy to $portfolioCurrency")
}

val errors = notionals.collect { case Left(e) => e }
if errors.nonEmpty then
throw new IllegalArgumentException(
s"Failed to compute 1 year market value forecast for ${errors.size} position(s):\n${errors.mkString("\n")}"
)
end if

val (unitValueT1, notionals) = oneYearYield.collect { case Right(r) => r }.unzip
val n = notionals.toArray
val n = notionals.collect { case Right(v) => v }.toArray
val unitValueT1 = valueT1(holdings, holdings.map(_.price).toArray, t0Date).canonical

// MV(i, j) = n(j) * (unitValueT1(j) - loss(i, j)) = loss(i, j) * -n(j) + unitValueT1(j) * n(j), in one pass
lossMatrix.fmaCols(multiply = n * -1.0, add = unitValueT1.toArray * n)
lossMatrix.fmaCols(multiply = n * -1.0, add = unitValueT1 * n)
end marketValueForecast1Year

/** The one year relative return of each position in each loss scenario, in canonical units (`0.05` is 5%).
Expand Down Expand Up @@ -199,25 +190,27 @@ object PortfolioCalc:
holdings.length == lossMatrix.cols,
s"Each entry in the lossMatrix should have a holdings entry. Loss matrix width ${lossMatrix.cols}, got ${holdings.length} holdings"
)
val perHolding = holdings.map { h =>
val price0 = h.price.canonical
if price0 > 0.0 then
val unitValueT1 =
(PositionCalculations.priceForecast1Year(h.price, t0Date, h.maturity) + h.riskFree + h.spread).canonical
Right((-1.0 / price0, unitValueT1 / price0 - 1.0))
else Left(s"Position id=${h.id} name=${h.name}: price ${h.price} must be positive to compute a relative return")
end if
val errors = holdings.collect {
case h if !(h.price.canonical > 0.0) =>
s"Position id=${h.id} name=${h.name}: price ${h.price} must be positive to compute a relative return"
}
val errors = perHolding.collect { case Left(e) => e }
if errors.nonEmpty then
throw new IllegalArgumentException(
s"Failed to compute 1 year relative return for ${errors.size} position(s):\n${errors.mkString("\n")}"
)
end if

val (multiply, add) = perHolding.collect { case Right(r) => r }.unzip
val price0 = holdings.map(_.price).toArray
// r(i, j) = (c(j) - loss(i, j)) / p0(j) - 1 = loss(i, j) * (-1 / p0(j)) + (c(j) / p0(j) - 1), in one pass
lossMatrix.fmaCols(multiply.toArray, add.toArray)
lossMatrix.fmaCols(multiply = -1.0 / price0.canonical, add = valueT1(holdings, price0, t0Date) / price0 - 1.0)
end relativeReturn1Year

/** Each holding's value per unit of notional after one year: its forecast price plus one year of risk free and spread
* carry.
*/
private def valueT1(holdings: IndexedSeq[PositionT1], price0: Array[Price], t0Date: LocalDate): Array[Price] =
PositionCalculations.priceForecast1Year(price0, t0Date, holdings.map(_.maturity).toArray)
+ holdings.map(_.riskFree).toArray
+ holdings.map(_.spread).toArray

end PortfolioCalc
38 changes: 28 additions & 10 deletions vecxt_re/src/PositionCalculations.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ object PositionCalculations:
* @param maturity
* the maturity date of the bond
* @return
* the forecast price one year after `priceDate`, in `priceUnit`. If the bond matures within the year, this is par.
* the forecast price one year after `priceDate`. If the bond matures within the year, this is par.
*/
def priceForecast1Year(
price: Price,
Expand All @@ -26,6 +26,27 @@ object PositionCalculations:
): Price =
price + pull2Parity1Year(price, priceDate, maturity)

/** As [[priceForecast1Year]], elementwise over positions observed on the same date. */
def priceForecast1Year(
prices: Array[Price],
priceDate: LocalDate,
maturities: Array[LocalDate]
): Array[Price] =
prices + pull2Parity1Year(prices, priceDate, maturities)

/** As [[pull2Parity1Year]], elementwise over positions observed on the same date. */
def pull2Parity1Year(
prices: Array[Price],
priceDate: LocalDate,
maturities: Array[LocalDate]
): Array[Rel] =
require(
prices.length == maturities.length,
s"Got ${prices.length} prices and ${maturities.length} maturities"
)
Array.tabulate(prices.length)(i => pull2Parity1Year(prices(i), priceDate, maturities(i)))
end pull2Parity1Year

/** The change in price over one year of a bond which is being pulled linearly (by calendar days) to parity at
* maturity.
*
Expand All @@ -34,35 +55,32 @@ object PositionCalculations:
* Uses calendar days, not yield accretion.
*
* @param price
* the current price, expressed in `priceUnit`
* @param priceUnit
* the unit `price` is quoted in
* the current price
* @param priceDate
* the date on which `price` was observed; the projection is for one year after this date
* @param maturity
* the maturity date of the bond
* @return
* the forecast price change (forecast price minus `price`) in `priceUnit`. Positive for a bond priced below par,
* negative above par. If the bond matures within the year, this is the full distance to par.
* the forecast price change (forecast price minus `price`). Positive for a bond priced below par, negative above
* par. If the bond matures within the year, this is the full distance to par.
*/
def pull2Parity1Year(
price: Price,
priceDate: LocalDate,
maturity: LocalDate
): Price =
): Rel =
import scala.math.Ordered.orderingToOrdered
assert(
maturity >= priceDate,
s"Maturity $maturity and pricing date $priceDate suggest this bond has already matured. It should not pull to parity"
)

val one = Rel(1.0, PriceUnit.One)
val projectionDate = priceDate.plusYears(1L)

val days2Maturity = ChronoUnit.DAYS.between(priceDate, maturity)
val days2Project = ChronoUnit.DAYS.between(priceDate, projectionDate)
if maturity < projectionDate then -(price - Rel.one)
else days2Project.toDouble / days2Maturity.toDouble * -(price - Rel.one)
if maturity < projectionDate then Price.par - price
else days2Project.toDouble / days2Maturity.toDouble * (Price.par - price)
end if
end pull2Parity1Year

Expand Down
63 changes: 63 additions & 0 deletions vecxt_re/src/Price.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package vecxt_re

import scala.annotation.targetName

/** A price level relative to par, stored as a canonical Double where `1.0` is par.
*
* A Price is a point on a scale rather than an amount, so it is deliberately not a [[Rel]]: the difference of two
* prices is a Rel, a Rel can be added to or subtracted from a Price, and two prices cannot be added together.
*
* As with Rel, [[PriceUnit]] only affects construction (`Price(102, PriceUnit.Pts)`) and display
* (`p.in(PriceUnit.Pts)`).
*/
opaque type Price = Double

object Price:
/** e.g. `Price(102, PriceUnit.Pts)` is 1.02 */
def apply(v: Double, u: PriceUnit): Price = u.convert(v, PriceUnit.One)

val par: Price = 1.0

extension (x: Price)
/** The canonical value, where `1.0` is par. */
inline def canonical: Double = x

/** The price expressed in unit `u`, e.g. `Price(1.02, PriceUnit.One).in(PriceUnit.Pts) == 102.0` */
def in(u: PriceUnit): Double = PriceUnit.One.convert(x, u)

def approxEq(y: Price, tol: Double = 1e-12): Boolean = math.abs(x - y) <= tol

def +(r: Rel): Price = x + r.canonical
def -(r: Rel): Price = x - r.canonical

/** The change in price from `y` to `x`. */
@targetName("minusPrice")
def -(y: Price): Rel = Rel.fromCanonical(x - y)

/** The ratio of two prices, e.g. a gross return. */
def /(y: Price): Double = x / y
end extension

// Elementwise array versions, delegating to vecxt's Array[Double] operations. An Array[Price] is a double[] at
// runtime, so `canonical` is a cast, not a copy. vecxt is called by its qualified name: importing its operators here
// would clash with the ones defined above.
extension (xs: Array[Price])
@targetName("canonicalArr")
def canonical: Array[Double] = xs

@targetName("plusArr")
def +(ys: Array[? <: Rel]): Array[Price] = vecxt.all.+(xs)(ys.canonical)

@targetName("minusArr")
def -(ys: Array[? <: Rel]): Array[Price] = vecxt.all.-(xs)(ys.canonical)

@targetName("minusPriceArr")
def -(ys: Array[Price]): Array[Rel] = Rel.fromCanonical[Rel](vecxt.all.-(xs)(ys))

@targetName("divPriceArr")
def /(ys: Array[Price]): Array[Double] = vecxt.all./(xs)(ys)
end extension

given CanEqual[Price, Price] = CanEqual.derived
given Ordering[Price] = Ordering.Double.TotalOrdering
end Price
97 changes: 63 additions & 34 deletions vecxt_re/src/Rel.scala
Original file line number Diff line number Diff line change
@@ -1,61 +1,90 @@
package vecxt_re

// TODO: RelVec(values: Array[Double], unit) for bulk ops, avoid Array[Rel]
import scala.annotation.targetName

/** Relative Units that have a unit attached.
/** A relative quantity: a change in price, a return, a ratio, or a one year rate (see [[Spread]], [[RiskFreeRate]]).
* Stored as a canonical Double where `1.0` is 100%.
*
* @param value
* @param unit
* [[PriceUnit]] is a boundary concern only: it is used to construct a Rel from a quote (`Rel(50, PriceUnit.Bps)`) and
* to read one back out (`r.in(PriceUnit.Bps)`). It is not stored, so equality, ordering and arithmetic are all on the
* canonical value, and an `Array[Rel]` is an `Array[Double]` at runtime.
*
* A price level is not a Rel; see [[Price]]. `Price - Price` is a Rel and `Price + Rel` is a Price.
*/
final case class Rel(relativeToPriceUnit: Double, unit: PriceUnit):
def canonical: Double = unit.convert(relativeToPriceUnit, PriceUnit.One)
def to(u: PriceUnit): Rel = Rel(unit.convert(relativeToPriceUnit, u), u)
inline def value = canonical
inline def in(u: PriceUnit) = to(u)

def approxEq(y: Rel, tol: Double = 1e-12): Boolean =
math.abs(canonical - y.canonical) <= tol
end Rel
opaque type Rel = Double

extension (k: Double) def *[R <: Rel](r: R): R = r * k
extension (k: Double) def *[R <: Rel](r: R): R = Rel.scale(r, k)
end extension

object Rel:
// Arithmetic preserves the static type of the left operand, so Price + Rel is a Price.
// Rel is final, so every R <: Rel is a Rel (or an opaque alias of it) at runtime and the cast is safe.
/** e.g. `Rel(102, PriceUnit.Pts)` is 1.02 and `Rel(50, PriceUnit.Bps)` is 0.005 */
def apply(v: Double, u: PriceUnit): Rel = u.convert(v, PriceUnit.One)

val one: Rel = 1.0

inline private[vecxt_re] def fromCanonical(d: Double): Rel = d
inline private[vecxt_re] def fromCanonical[R <: Rel](ds: Array[Double]): Array[R] = ds.asInstanceOf[Array[R]]
inline private[vecxt_re] def scale[R <: Rel](r: R, k: Double): R = (r * k).asInstanceOf[R]

extension (x: Rel)
/** The canonical value, where `1.0` is 100%. */
inline def canonical: Double = x

/** The value expressed in unit `u`, e.g. `Rel(0.005, PriceUnit.One).in(PriceUnit.Bps) == 50.0` */
def in(u: PriceUnit): Double = PriceUnit.One.convert(x, u)

def approxEq(y: Rel, tol: Double = 1e-12): Boolean = math.abs(x - y) <= tol
end extension

// Combining two values of the same type keeps that type (Spread + Spread is a Spread); mixing types widens to Rel
// (Spread + RiskFreeRate is a Rel). Every R <: Rel is a Double at runtime, so the casts are free.
extension [R <: Rel](x: R)
def +(y: Rel): R =
Rel(x.relativeToPriceUnit + y.unit.convert(y.relativeToPriceUnit, x.unit), x.unit).asInstanceOf[R]
def -(y: Rel): R =
Rel(x.relativeToPriceUnit - y.unit.convert(y.relativeToPriceUnit, x.unit), x.unit).asInstanceOf[R]
def unary_- : R = x.copy(relativeToPriceUnit = -x.relativeToPriceUnit).asInstanceOf[R]
def *(k: Double): R = x.copy(relativeToPriceUnit = x.relativeToPriceUnit * k).asInstanceOf[R]
def +(y: R): R = (x + y).asInstanceOf[R]
def -(y: R): R = (x - y).asInstanceOf[R]
def unary_- : R = (-x).asInstanceOf[R]
def *(k: Double): R = (x * k).asInstanceOf[R]
def /(k: Double): R = (x / k).asInstanceOf[R]
// The ratio of two Rels is dimensionless, so a plain Double is returned.
@targetName("divRel")
def /(y: Rel): Double = x / y
end extension

// Elementwise array versions of the above, delegating to vecxt's Array[Double] operations. An Array[R] is a double[] at
// runtime, so `canonical` is a cast, not a copy, and the same-type / mixed-type rules match the scalar operations.
// vecxt is called by its qualified name: importing its operators here would clash with the ones defined above.
extension (xs: Array[? <: Rel])
@targetName("canonicalArr")
inline def canonical: Array[Double] = xs.asInstanceOf[Array[Double]]
end extension

extension [R <: Rel](xs: Array[? <: R])
@targetName("plusArr")
def +(ys: Array[? <: R]): Array[R] = fromCanonical[R](vecxt.all.+(xs.canonical)(ys.canonical))

@targetName("minusArr")
def -(ys: Array[? <: R]): Array[R] = fromCanonical[R](vecxt.all.-(xs.canonical)(ys.canonical))

@targetName("timesArr")
def *(k: Double): Array[R] = fromCanonical[R](vecxt.all.*(xs.canonical)(k))
end extension

given Ordering[Rel] = Ordering.by[Rel, Double](_.canonical)
lazy val one = Rel(1.0, PriceUnit.One)
given CanEqual[Rel, Rel] = CanEqual.derived
// One generic instance for Rel and all its subtypes; per-subtype instances are ambiguous for e.g. `sorted[B >: Spread]`.
given [R <: Rel]: Ordering[R] = Ordering.Double.TotalOrdering.asInstanceOf[Ordering[R]]
end Rel

/** An annual credit spread. Over the one year horizon it is the return earned for bearing credit risk. */
opaque type Spread <: Rel = Rel
object Spread:
def apply(v: Double, u: PriceUnit): Spread = Rel(v, u)
def from(r: Rel): Spread = r
given CanEqual[Spread, Spread] = CanEqual.derived
given Ordering[Spread] = summon[Ordering[Rel]]
end Spread

/** An annual risk free rate. Over the one year horizon it is the risk free return. */
opaque type RiskFreeRate <: Rel = Rel
object RiskFreeRate:
def apply(v: Double, u: PriceUnit): RiskFreeRate = Rel(v, u)
def from(r: Rel): RiskFreeRate = r
given CanEqual[RiskFreeRate, RiskFreeRate] = CanEqual.derived
given Ordering[RiskFreeRate] = summon[Ordering[Rel]]
end RiskFreeRate

opaque type Price <: Rel = Rel
object Price:
def apply(v: Double, u: PriceUnit): Price = Rel(v, u)
def from(r: Rel): Price = r
given CanEqual[Price, Price] = CanEqual.derived
given Ordering[Price] = summon[Ordering[Rel]]
end Price
Loading
Loading