From fc75dec77d055c1914c1f55022faa9871175bb93 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Mon, 19 Oct 2026 17:14:33 +0200 Subject: [PATCH 1/3] make rel an opaque type --- build.mill | 4 +- vecxt_re/src/CurrencyValue.scala | 3 ++ vecxt_re/src/PositionCalculations.scala | 1 - vecxt_re/src/Rel.scala | 63 +++++++++++++--------- vecxt_re/test/src/priceForecast.test.scala | 2 +- vecxt_re/test/src/rel.test.scala | 50 +++++++++++++++++ vecxt_re/test/src/vecEquals.scala | 4 +- 7 files changed, 95 insertions(+), 32 deletions(-) create mode 100644 vecxt_re/test/src/rel.test.scala diff --git a/build.mill b/build.mill index 8ef5d489..0315cb51 100644 --- a/build.mill +++ b/build.mill @@ -1,9 +1,9 @@ -//| mill-version: 1.3.0-M1 +//| mill-version: 1.3.0 //| mill-jvm-version: 25 //| 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 diff --git a/vecxt_re/src/CurrencyValue.scala b/vecxt_re/src/CurrencyValue.scala index 7e47b652..e5e07061 100644 --- a/vecxt_re/src/CurrencyValue.scala +++ b/vecxt_re/src/CurrencyValue.scala @@ -1,5 +1,7 @@ package vecxt_re +import scala.annotation.targetName + enum Ccy derives CanEqual: case USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD end Ccy @@ -38,6 +40,7 @@ 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) diff --git a/vecxt_re/src/PositionCalculations.scala b/vecxt_re/src/PositionCalculations.scala index 450ddcf5..93453ccb 100644 --- a/vecxt_re/src/PositionCalculations.scala +++ b/vecxt_re/src/PositionCalculations.scala @@ -56,7 +56,6 @@ object PositionCalculations: 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) diff --git a/vecxt_re/src/Rel.scala b/vecxt_re/src/Rel.scala index 75641766..008b0443 100644 --- a/vecxt_re/src/Rel.scala +++ b/vecxt_re/src/Rel.scala @@ -1,39 +1,53 @@ 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 price relative to par, a rate, a spread, a ratio), stored as a canonical Double where `1.0` + * is par / 100%. * - * @param value - * @param unit + * [[PriceUnit]] is a boundary concern only: it is used to construct a Rel from a quote (`Rel(102, PriceUnit.Pts)`) 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. */ -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: + /** 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 scale[R <: Rel](r: R, k: Double): R = (r * k).asInstanceOf[R] + + extension (x: Rel) + /** The canonical value, where `1.0` is par / 100%. */ + inline def canonical: Double = x + + /** The value expressed in unit `u`, e.g. `Rel(102, PriceUnit.Pts).in(PriceUnit.Bps) == 10200.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 + // 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. + // 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: Rel): R = (x + y).asInstanceOf[R] + def -(y: Rel): 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 - 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 >: Price]`. + given [R <: Rel]: Ordering[R] = Ordering.Double.TotalOrdering.asInstanceOf[Ordering[R]] end Rel opaque type Spread <: Rel = Rel @@ -41,7 +55,6 @@ 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 opaque type RiskFreeRate <: Rel = Rel @@ -49,7 +62,6 @@ 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 @@ -57,5 +69,4 @@ 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 diff --git a/vecxt_re/test/src/priceForecast.test.scala b/vecxt_re/test/src/priceForecast.test.scala index b2aeb129..f874da69 100644 --- a/vecxt_re/test/src/priceForecast.test.scala +++ b/vecxt_re/test/src/priceForecast.test.scala @@ -40,7 +40,7 @@ class PositionCalculationsSuite extends munit.FunSuite: PositionCalculations.priceForecast1Year(Price(10200, PriceUnit.Bps), reportDate, reportDate.plusYears(2)) assertEquals(forecastPrice, Price(10100.0, PriceUnit.Bps)) - assertEquals(forecastPrice.unit, PriceUnit.Bps) + assertEqualsDouble(forecastPrice.in(PriceUnit.Bps), 10100.0, 1e-9) test("pnlForecast"): val reportDate = LocalDate.of(2026, 1, 1) diff --git a/vecxt_re/test/src/rel.test.scala b/vecxt_re/test/src/rel.test.scala new file mode 100644 index 00000000..7e433ae7 --- /dev/null +++ b/vecxt_re/test/src/rel.test.scala @@ -0,0 +1,50 @@ +package vecxt_re + +import PriceUnit.* + +class RelSuite extends munit.FunSuite: + + test("construction converts to canonical"): + assertEqualsDouble(Rel(102, Pts).canonical, 1.02, 1e-12) + assertEqualsDouble(Rel(50, Bps).canonical, 0.005, 1e-12) + assertEqualsDouble(Rel(0.7, One).canonical, 0.7, 1e-12) + + test("in reads the value back out in any unit"): + val p = Price(102, Pts) + assertEqualsDouble(p.in(Pts), 102.0, 1e-9) + assertEqualsDouble(p.in(Bps), 10200.0, 1e-9) + assertEqualsDouble(p.in(One), 1.02, 1e-12) + + test("equality and ordering ignore the quoted unit"): + assert(Price(100, Pts) == Price(1.0, One)) + assert(Spread(100, Bps) == Spread(1, Pts)) + assertEquals(List(Price(101, Pts), Price(9900, Bps), Price(1.0, One)).sorted.map(_.in(Pts)), List(99.0, 100.0, 101.0)) + assertEquals(List(Spread(50, Bps), Spread(1, Pts)).max, Spread(1, Pts)) + + test("arithmetic mixes quoted units and preserves the left operand's type"): + val p: Price = Price(98, Pts) + Spread(200, Bps) + assertEquals(p, Price(1.0, One)) + val s: Spread = Spread(300, Bps) - Rel(1, Pts) + assertEqualsDouble(s.in(Bps), 200.0, 1e-9) + val n: Price = -Price(2, Pts) + assertEqualsDouble(n.in(Pts), -2.0, 1e-12) + + test("scaling by a Double from either side preserves type"): + val a: Spread = Spread(100, Bps) * 3 + val b: Spread = 3.0 * Spread(100, Bps) + assertEqualsDouble(a.in(Bps), 300.0, 1e-9) + assertEquals(a, b) + + test("Rel / Rel is a unit-free ratio"): + assertEqualsDouble(Rel(500, Bps) / Rel(2.5, Pts), 2.0, 1e-12) + assertEqualsDouble(Rel(0.05, One) / Rel(500, Bps), 1.0, 1e-12) + + test("Rel / Double scales and preserves type"): + val s: Spread = Spread(500, Bps) / 4 + assertEqualsDouble(s.in(Bps), 125.0, 1e-9) + + test("approxEq respects the tolerance"): + assert((Rel(0.1, One) + Rel(0.2, One)).approxEq(Rel(0.3, One))) + assert(!Rel(1, Bps).approxEq(Rel(2, Bps))) + assert(Rel(1, Bps).approxEq(Rel(2, Bps), tol = 1e-4)) +end RelSuite diff --git a/vecxt_re/test/src/vecEquals.scala b/vecxt_re/test/src/vecEquals.scala index f42f098f..e59c49e8 100644 --- a/vecxt_re/test/src/vecEquals.scala +++ b/vecxt_re/test/src/vecEquals.scala @@ -30,8 +30,8 @@ def assertVecEquals(v1: Array[Long], v2: Array[Long])(implicit loc: munit.Locati end while end assertVecEquals -/** Makes `assertEquals` on Rel (and Price / Spread / RiskFreeRate) compare canonical values within a tolerance, so - * `Price(100, Pts)` equals `Price(1.0, One)` and floating point noise doesn't fail tests. +/** Makes `assertEquals` on Rel (and Price / Spread / RiskFreeRate) compare within a tolerance, so floating point noise + * doesn't fail tests. */ given relCompare[R <: Rel]: munit.Compare[R, R] with def isEqual(obtained: R, expected: R): Boolean = obtained.approxEq(expected, 1e-9) From d1ada938c3ebed8b61e6f591e47454023cde7a81 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Mon, 19 Oct 2026 17:36:28 +0200 Subject: [PATCH 2/3] improve rel / price --- vecxt_re/src/CurrencyValue.scala | 5 ++ vecxt_re/src/PortfolioCalc.scala | 55 +++++++--------- vecxt_re/src/PositionCalculations.scala | 37 ++++++++--- vecxt_re/src/Price.scala | 62 ++++++++++++++++++ vecxt_re/src/Rel.scala | 52 ++++++++++----- vecxt_re/test/src/priceForecast.test.scala | 14 ++++ vecxt_re/test/src/rel.test.scala | 75 ++++++++++++++++++++-- vecxt_re/test/src/vecEquals.scala | 9 ++- 8 files changed, 243 insertions(+), 66 deletions(-) create mode 100644 vecxt_re/src/Price.scala diff --git a/vecxt_re/src/CurrencyValue.scala b/vecxt_re/src/CurrencyValue.scala index e5e07061..1bc02906 100644 --- a/vecxt_re/src/CurrencyValue.scala +++ b/vecxt_re/src/CurrencyValue.scala @@ -47,6 +47,11 @@ final case class CurrencyAmount private (amount: Double, ccy: Ccy) derives CanEq 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) diff --git a/vecxt_re/src/PortfolioCalc.scala b/vecxt_re/src/PortfolioCalc.scala index 38233f45..8f2b59f4 100644 --- a/vecxt_re/src/PortfolioCalc.scala +++ b/vecxt_re/src/PortfolioCalc.scala @@ -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%). @@ -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 diff --git a/vecxt_re/src/PositionCalculations.scala b/vecxt_re/src/PositionCalculations.scala index 93453ccb..2cef53db 100644 --- a/vecxt_re/src/PositionCalculations.scala +++ b/vecxt_re/src/PositionCalculations.scala @@ -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, @@ -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. * @@ -34,22 +55,20 @@ 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, @@ -60,8 +79,8 @@ object PositionCalculations: 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 diff --git a/vecxt_re/src/Price.scala b/vecxt_re/src/Price.scala new file mode 100644 index 00000000..8d402e42 --- /dev/null +++ b/vecxt_re/src/Price.scala @@ -0,0 +1,62 @@ +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 diff --git a/vecxt_re/src/Rel.scala b/vecxt_re/src/Rel.scala index 008b0443..59190d55 100644 --- a/vecxt_re/src/Rel.scala +++ b/vecxt_re/src/Rel.scala @@ -2,12 +2,14 @@ package vecxt_re import scala.annotation.targetName -/** A relative quantity (a price relative to par, a rate, a spread, a ratio), stored as a canonical Double where `1.0` - * is par / 100%. +/** 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%. * - * [[PriceUnit]] is a boundary concern only: it is used to construct a Rel from a quote (`Rel(102, PriceUnit.Pts)`) and + * [[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. */ opaque type Rel = Double @@ -20,23 +22,25 @@ object Rel: 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 par / 100%. */ + /** The canonical value, where `1.0` is 100%. */ inline def canonical: Double = x - /** The value expressed in unit `u`, e.g. `Rel(102, PriceUnit.Pts).in(PriceUnit.Bps) == 10200.0` */ + /** 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 - // Arithmetic preserves the static type of the left operand, so Price + Rel is a Price. - // Every R <: Rel is a Double at runtime, so the casts are free. + // 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 = (x + y).asInstanceOf[R] - def -(y: Rel): R = (x - y).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] @@ -45,11 +49,31 @@ object Rel: 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 CanEqual[Rel, Rel] = CanEqual.derived - // One generic instance for Rel and all its subtypes; per-subtype instances are ambiguous for e.g. `sorted[B >: Price]`. + // 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) @@ -57,16 +81,10 @@ object Spread: given CanEqual[Spread, Spread] = CanEqual.derived 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 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 -end Price diff --git a/vecxt_re/test/src/priceForecast.test.scala b/vecxt_re/test/src/priceForecast.test.scala index f874da69..1d285d83 100644 --- a/vecxt_re/test/src/priceForecast.test.scala +++ b/vecxt_re/test/src/priceForecast.test.scala @@ -66,4 +66,18 @@ class PositionCalculationsSuite extends munit.FunSuite: Spread(100.0, PriceUnit.Bps) ) assertVecEquals(forecastPriceWSpread, forecast + 0.01) + + test("array priceForecast1Year matches the scalar version elementwise"): + val reportDate = LocalDate.of(2026, 1, 1) + val prices = Array(Price(102, PriceUnit.Pts), Price(9500, PriceUnit.Bps), Price(0.99, PriceUnit.One)) + val maturities = Array(reportDate.plusMonths(6), reportDate.plusYears(2), reportDate.plusYears(5)) + val forecast = PositionCalculations.priceForecast1Year(prices, reportDate, maturities) + for i <- prices.indices do + assertEquals(forecast(i), PositionCalculations.priceForecast1Year(prices(i), reportDate, maturities(i))) + end for + + test("array pull2Parity1Year rejects mismatched lengths"): + intercept[IllegalArgumentException]( + PositionCalculations.pull2Parity1Year(Array(Price.par), LocalDate.of(2026, 1, 1), Array.empty[LocalDate]) + ) end PositionCalculationsSuite diff --git a/vecxt_re/test/src/rel.test.scala b/vecxt_re/test/src/rel.test.scala index 7e433ae7..6e3c27bf 100644 --- a/vecxt_re/test/src/rel.test.scala +++ b/vecxt_re/test/src/rel.test.scala @@ -7,28 +7,56 @@ class RelSuite extends munit.FunSuite: test("construction converts to canonical"): assertEqualsDouble(Rel(102, Pts).canonical, 1.02, 1e-12) assertEqualsDouble(Rel(50, Bps).canonical, 0.005, 1e-12) - assertEqualsDouble(Rel(0.7, One).canonical, 0.7, 1e-12) + assertEqualsDouble(Price(0.7, One).canonical, 0.7, 1e-12) test("in reads the value back out in any unit"): val p = Price(102, Pts) assertEqualsDouble(p.in(Pts), 102.0, 1e-9) assertEqualsDouble(p.in(Bps), 10200.0, 1e-9) assertEqualsDouble(p.in(One), 1.02, 1e-12) + assertEqualsDouble(Spread(0.005, One).in(Bps), 50.0, 1e-9) test("equality and ordering ignore the quoted unit"): assert(Price(100, Pts) == Price(1.0, One)) assert(Spread(100, Bps) == Spread(1, Pts)) - assertEquals(List(Price(101, Pts), Price(9900, Bps), Price(1.0, One)).sorted.map(_.in(Pts)), List(99.0, 100.0, 101.0)) + assertEquals(List(Price(101, Pts), Price(9900, Bps), Price.par).sorted.map(_.in(Pts)), List(99.0, 100.0, 101.0)) assertEquals(List(Spread(50, Bps), Spread(1, Pts)).max, Spread(1, Pts)) - test("arithmetic mixes quoted units and preserves the left operand's type"): - val p: Price = Price(98, Pts) + Spread(200, Bps) - assertEquals(p, Price(1.0, One)) - val s: Spread = Spread(300, Bps) - Rel(1, Pts) + test("Price - Price is a Rel"): + val r: Rel = Price(102, Pts) - Price(98, Pts) + assertEqualsDouble(r.in(Pts), 4.0, 1e-9) + + test("Price +/- Rel is a Price"): + val up: Price = Price(98, Pts) + Spread(200, Bps) + assertEquals(up, Price.par) + val down: Price = Price(98, Pts) - Rel(3, Pts) + assertEqualsDouble(down.in(Pts), 95.0, 1e-9) + + test("Price / Price is a ratio"): + assertEqualsDouble(Price(102, Pts) / Price(100, Pts), 1.02, 1e-12) + + test("same-type arithmetic keeps the type, mixed widens to Rel"): + val s: Spread = Spread(300, Bps) - Spread(100, Bps) assertEqualsDouble(s.in(Bps), 200.0, 1e-9) - val n: Price = -Price(2, Pts) + val y: Rel = RiskFreeRate(325, Bps) + Spread(100, Bps) + assertEqualsDouble(y.in(Bps), 425.0, 1e-9) + val n: Spread = -Spread(2, Pts) assertEqualsDouble(n.in(Pts), -2.0, 1e-12) + test("price levels cannot be combined like returns"): + // controls: the snippets compile in this scope when the types line up + assertEquals(compileErrors("Price(1, One) - Price(1, One)"), "") + assertEquals(compileErrors("val s: Spread = Spread(1, Bps) + Spread(1, Bps)"), "") + assert(compileErrors("Price(1, One) + Price(1, One)").nonEmpty) + assert(compileErrors("val r: Rel = Price(1, One)").nonEmpty) + assert(compileErrors("-Price(1, One)").nonEmpty) + assert(compileErrors("Price(1, One) * 2.0").nonEmpty) + assert(compileErrors("Rel.one + Price(1, One)").nonEmpty) + + test("mixed rates do not keep the left operand's type"): + assert(compileErrors("val s: Spread = Spread(1, Bps) + RiskFreeRate(1, Bps)").nonEmpty) + assert(compileErrors("val s: Spread = Spread(1, Bps) - Rel(1, Bps)").nonEmpty) + test("scaling by a Double from either side preserves type"): val a: Spread = Spread(100, Bps) * 3 val b: Spread = 3.0 * Spread(100, Bps) @@ -47,4 +75,37 @@ class RelSuite extends munit.FunSuite: assert((Rel(0.1, One) + Rel(0.2, One)).approxEq(Rel(0.3, One))) assert(!Rel(1, Bps).approxEq(Rel(2, Bps))) assert(Rel(1, Bps).approxEq(Rel(2, Bps), tol = 1e-4)) + test("typed arrays are double[] at runtime and canonical does not copy"): + val ps = Array(Price(102, Pts), Price(98, Pts)) + val ss = Array(Spread(100, Bps)) + assertEquals[Any, Any](ps.getClass, classOf[Array[Double]]) + assertEquals[Any, Any](ss.getClass, classOf[Array[Double]]) + assert(ps.canonical eq ps.asInstanceOf[AnyRef]) + assert(ss.canonical eq ss.asInstanceOf[AnyRef]) + + test("array Price - Price is Array[Rel], Price +/- Rel is Array[Price]"): + val p0 = Array(Price(98, Pts), Price(102, Pts)) + val p1 = Array(Price(100, Pts), Price(100, Pts)) + val change: Array[Rel] = p1 - p0 + assertVecEquals(change.canonical, Array(0.02, -0.02)) + val carried: Array[Price] = + p0 + Array(Spread(200, Bps), Spread(100, Bps)) + Array(RiskFreeRate(1, Pts), Rel(0, One)) + assertVecEquals(carried.canonical, Array(1.01, 1.03)) + val back: Array[Price] = carried - Array(Rel(3, Pts), Rel(5, Pts)) + assertVecEquals(back.canonical, Array(0.98, 0.98)) + assertVecEquals(p1 / p0, Array(1.0 / 0.98, 1.0 / 1.02)) + + test("array Rel arithmetic follows the scalar type rules"): + val s: Array[Spread] = Array(Spread(100, Bps)) + Array(Spread(50, Bps)) + assertVecEquals(s.canonical, Array(0.015)) + val scaled: Array[Spread] = s * 2.0 + assertVecEquals(scaled.canonical, Array(0.03)) + val mixed: Array[Rel] = Array(RiskFreeRate(300, Bps)) - Array(Spread(100, Bps)) + assertVecEquals(mixed.canonical, Array(0.02)) + assertEquals(compileErrors("Array(Price.par) + Array(Price.par)").nonEmpty, true) + assertEquals( + compileErrors("val s: Array[Spread] = Array(Spread(1, Bps)) + Array(RiskFreeRate(1, Bps))").nonEmpty, + true + ) + assertEquals(compileErrors("val s: Array[Spread] = Array(Spread(1, Bps)) + Array(Spread(1, Bps))"), "") end RelSuite diff --git a/vecxt_re/test/src/vecEquals.scala b/vecxt_re/test/src/vecEquals.scala index e59c49e8..80f37f1b 100644 --- a/vecxt_re/test/src/vecEquals.scala +++ b/vecxt_re/test/src/vecEquals.scala @@ -30,9 +30,14 @@ def assertVecEquals(v1: Array[Long], v2: Array[Long])(implicit loc: munit.Locati end while end assertVecEquals -/** Makes `assertEquals` on Rel (and Price / Spread / RiskFreeRate) compare within a tolerance, so floating point noise - * doesn't fail tests. +/** Makes `assertEquals` on Rel (and Spread / RiskFreeRate) compare within a tolerance, so floating point noise doesn't + * fail tests. */ given relCompare[R <: Rel]: munit.Compare[R, R] with def isEqual(obtained: R, expected: R): Boolean = obtained.approxEq(expected, 1e-9) end relCompare + +/** As [[relCompare]], for Price. */ +given priceCompare: munit.Compare[Price, Price] with + def isEqual(obtained: Price, expected: Price): Boolean = obtained.approxEq(expected, 1e-9) +end priceCompare From 29eed296b14c88e32f814ce8e12ac6a6e481145e Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 20 Oct 2026 09:48:42 +0200 Subject: [PATCH 3/3] . --- vecxt_re/src/Price.scala | 3 ++- vecxt_re/test/src/priceForecast.test.scala | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/vecxt_re/src/Price.scala b/vecxt_re/src/Price.scala index 8d402e42..002192db 100644 --- a/vecxt_re/src/Price.scala +++ b/vecxt_re/src/Price.scala @@ -7,7 +7,8 @@ import scala.annotation.targetName * 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)`). + * As with Rel, [[PriceUnit]] only affects construction (`Price(102, PriceUnit.Pts)`) and display + * (`p.in(PriceUnit.Pts)`). */ opaque type Price = Double diff --git a/vecxt_re/test/src/priceForecast.test.scala b/vecxt_re/test/src/priceForecast.test.scala index 1d285d83..242507da 100644 --- a/vecxt_re/test/src/priceForecast.test.scala +++ b/vecxt_re/test/src/priceForecast.test.scala @@ -66,7 +66,7 @@ class PositionCalculationsSuite extends munit.FunSuite: Spread(100.0, PriceUnit.Bps) ) assertVecEquals(forecastPriceWSpread, forecast + 0.01) - + test("array priceForecast1Year matches the scalar version elementwise"): val reportDate = LocalDate.of(2026, 1, 1) val prices = Array(Price(102, PriceUnit.Pts), Price(9500, PriceUnit.Bps), Price(0.99, PriceUnit.One))