diff --git a/build.mill b/build.mill index 842a4489..29da45a8 100644 --- a/build.mill +++ b/build.mill @@ -1,6 +1,6 @@ //| mill-version: 1.1.9 //| mill-jvm-version: 25 -//| mill-jvm-opts: [ "--add-modules", "jdk.incubator.vector"] +//| 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 @@ -37,7 +37,7 @@ object V: val spire: Dep = mvn"org.typelevel::spire::0.18.0" val laminar: Dep = mvn"com.raquo::laminar::17.2.1" val scalaTags: Dep = mvn"com.lihaoyi::scalatags::0.13.1" - val scalaVersion = "3.9.0" + val scalaVersion = "3.8.4" val munitVersion = "1.3.6" val blas: Dep = mvn"dev.ludovic.netlib:blas:3.2.0" val lapack: Dep = mvn"dev.ludovic.netlib:lapack:3.2.0" diff --git a/jitAudit/package.mill b/jitAudit/package.mill index d881a073..e9811081 100644 --- a/jitAudit/package.mill +++ b/jitAudit/package.mill @@ -28,8 +28,8 @@ import mill.*, scalalib.* * * ==What was deliberately not built: D2 and D5== * - * The plan specified two further checks in this tier, both of which work by running with - * {@code -XX:+LogCompilation} and parsing the XML: D2 confirms Vector API intrinsics were applied by looking for + * The plan specified two further checks in this tier, both of which work by running with {@code -XX:+LogCompilation} + * and parsing the XML: D2 confirms Vector API intrinsics were applied by looking for * {@code } entries, and D5 counts {@code } and recompilation events. Neither * is implemented, and the decision is not "not yet" — it is that the cost/benefit does not work. Recorded here rather * than left as an absence, because an unexplained gap in a checklist reads as an oversight. @@ -101,8 +101,8 @@ object `package` extends ScalaModule: * ==How this relates to the unbuilt D2== * * It covers part of the same ground, from an observable consequence and a product-grade {@code -XX:} flag rather - * than from a JDK-internal XML vocabulary. D1 with EA on cannot distinguish "the SIMD intrinsics were applied" - * from "they were not, and EA scalarised the software-path objects instead" — both read as zero. This scope can. + * than from a JDK-internal XML vocabulary. D1 with EA on cannot distinguish "the SIMD intrinsics were applied" from + * "they were not, and EA scalarised the software-path objects instead" — both read as zero. This scope can. * * Only part, though, and the earlier framing of it as "what replaces D2" was too strong. It relies on EA being what * rescues a software-path kernel, and that is not always so: D6's canary is a software-path kernel that allocates diff --git a/vecxt/src/BooleanMatrix.scala b/vecxt/src/BooleanMatrix.scala new file mode 100644 index 00000000..7fa60a2d --- /dev/null +++ b/vecxt/src/BooleanMatrix.scala @@ -0,0 +1,132 @@ +package vecxt + +import vecxt.matrix.* + +/** Logical operations on `Matrix[Boolean]`, mirroring [[BooleanArrays]]. + * + * Each op has the same two branches as the other matrix element-wise ops: when the backing array holds exactly the + * matrix's elements in order, it delegates to the (SIMD on the JVM) array op and reuses `m.layout`; otherwise it walks + * the view through `linearIndex`, and any new result is dense column-major. + */ +object BooleanMatrix: + + extension (m: Matrix[Boolean]) + + /** True if every element of the matrix is true. True for an empty matrix. */ + def allTrue: Boolean = + if m.hasSimpleContiguousMemoryLayout then BooleanArrays.allTrue(m.raw) + else + var out = true + var j = 0 + while out && j < m.cols do + var i = 0 + while out && i < m.rows do + if !m.raw(m.layout.linearIndex(i, j)) then out = false + end if + i += 1 + end while + j += 1 + end while + out + end allTrue + + /** True if at least one element of the matrix is true. */ + def any: Boolean = + if m.hasSimpleContiguousMemoryLayout then BooleanArrays.any(m.raw) + else + var out = false + var j = 0 + while !out && j < m.cols do + var i = 0 + while !out && i < m.rows do + if m.raw(m.layout.linearIndex(i, j)) then out = true + end if + i += 1 + end while + j += 1 + end while + out + end any + + /** The number of true elements in the matrix. */ + def trues: Int = + if m.hasSimpleContiguousMemoryLayout then BooleanArrays.trues(m.raw) + else + var sum = 0 + m.layout.foreach2D { (i, j) => + if m.raw(m.layout.linearIndex(i, j)) then sum += 1 + end if + } + sum + end trues + + /** Element-wise negation, returning a new matrix. */ + def not: Matrix[Boolean] = + if m.hasSimpleContiguousMemoryLayout then Matrix[Boolean](BooleanArrays.not(m.raw), m.layout) + else + val newArr = Array.ofDim[Boolean](m.numel) + m.layout.foreach2D { (i, j) => + newArr(i + j * m.rows) = !m.raw(m.layout.linearIndex(i, j)) + } + Matrix[Boolean](newArr, m.rows, m.cols) + end not + + /** Element-wise negation in place, writing through to the backing array (so any other view sharing it sees the + * change). + * + * Throws [[UnsupportedLayoutException]] for a view whose strides alias one backing element to several positions + * (e.g. a broadcast with a zero stride): each alias would flip the same element again, so the result would depend + * on how many times it is repeated. + */ + def `not!`: Unit = + if m.hasSimpleContiguousMemoryLayout then BooleanArrays.`not!`(m.raw) + else + if (m.rowStride == 0 && m.rows > 1) || (m.colStride == 0 && m.cols > 1) then + throw UnsupportedLayoutException( + s"not! cannot be applied in place to a view that aliases elements, got layout: ${m.layoutString}" + ) + end if + m.layout.foreach2D { (i, j) => + val idx = m.layout.linearIndex(i, j) + m.raw(idx) = !m.raw(idx) + } + end `not!` + + /** Element-wise logical and. Both matrices must have the same shape. */ + def &&(that: Matrix[Boolean]): Matrix[Boolean] = + sameDimMatCheck(m, that) + if sameDenseElementWiseMemoryLayoutCheck(m, that) then + Matrix[Boolean](BooleanArrayOps.and(m.raw, that.raw), m.layout) + else + val newArr = Array.ofDim[Boolean](m.numel) + m.layout.foreach2D { (i, j) => + newArr(i + j * m.rows) = m.raw(m.layout.linearIndex(i, j)) && that.raw(that.layout.linearIndex(i, j)) + } + Matrix[Boolean](newArr, m.rows, m.cols) + end if + end && + + /** Element-wise logical or. Both matrices must have the same shape. */ + def ||(that: Matrix[Boolean]): Matrix[Boolean] = + sameDimMatCheck(m, that) + if sameDenseElementWiseMemoryLayoutCheck(m, that) then + Matrix[Boolean](BooleanArrayOps.or(m.raw, that.raw), m.layout) + else + val newArr = Array.ofDim[Boolean](m.numel) + m.layout.foreach2D { (i, j) => + newArr(i + j * m.rows) = m.raw(m.layout.linearIndex(i, j)) || that.raw(that.layout.linearIndex(i, j)) + } + Matrix[Boolean](newArr, m.rows, m.cols) + end if + end || + end extension +end BooleanMatrix + +/** The array `&&` / `||` live in `BooleanArrays` on the JVM but in `arrayUtil` on JS and Native, so resolve them + * through `all` rather than naming either. Kept outside [[BooleanMatrix]], whose own `&&` / `||` would shadow them. + */ +private object BooleanArrayOps: + import vecxt.all.* + def and(a: Array[Boolean], b: Array[Boolean]): Array[Boolean] = a && b + def or(a: Array[Boolean], b: Array[Boolean]): Array[Boolean] = a || b +end BooleanArrayOps diff --git a/vecxt/src/all.scala b/vecxt/src/all.scala index 04db2bf4..b4c636fc 100644 --- a/vecxt/src/all.scala +++ b/vecxt/src/all.scala @@ -32,6 +32,7 @@ object all: export vecxt.JsFloatMatrix.* export vecxt.NativeFloatMatrix.* export vecxt.JvmIntMatrix.* + export vecxt.BooleanMatrix.* export vecxt.JsDoubleMatrix.* export vecxt.NativeDoubleMatrix.* export vecxt.DoubleMatrix.* diff --git a/vecxt/src/doublematrix.scala b/vecxt/src/doublematrix.scala index 4ddb6277..a513edd9 100644 --- a/vecxt/src/doublematrix.scala +++ b/vecxt/src/doublematrix.scala @@ -538,6 +538,11 @@ object DoubleMatrix: reduceAlongDimension(dim, _ * _, 1.0) end product + def mean(dim: DimensionExtender): Matrix[Double] = + val s = sum(dim) + s / (m.numel / s.numel).toDouble + end mean + // inline def - : Matrix[Double] = // Matrix(vecxt.doublearrays.*(m.raw)(-1), m.shape) diff --git a/vecxt/test/src/MatrixBoolean.test.scala b/vecxt/test/src/MatrixBoolean.test.scala index 9afb71df..50fcc07b 100644 --- a/vecxt/test/src/MatrixBoolean.test.scala +++ b/vecxt/test/src/MatrixBoolean.test.scala @@ -81,4 +81,150 @@ class MatrixBooleanSuite extends FunSuite: assertVecEquals[Double](calc.raw, result.raw) } + private def assertLogical(actual: Matrix[Boolean], expectedRows: Array[Boolean]*)(implicit + loc: munit.Location + ): Unit = + assertEquals(actual.rows, expectedRows.length) + for i <- 0 until actual.rows do + for j <- 0 until actual.cols do assertEquals(actual(i, j), expectedRows(i)(j), clue = s"at ($i, $j)") + end for + end for + end assertLogical + + // 2 x 2 view on the middle of a 3 x 3 col-major array (rows 1-2, cols 1-2). Non-contiguous: offset 4, colStride 3. + // col-major raw: col0 = (T, F, F), col1 = (T, T, F), col2 = (F, T, F) + // view: [ T, T ] + // [ F, F ] + private def strided: Matrix[Boolean] = + Matrix(Array[Boolean](true, false, false, true, true, false, false, true, false), 2, 2, 1, 3, 4) + + private def rowMajor: Matrix[Boolean] = + // [ T, F, T ] + // [ F, F, T ] + Matrix(Array[Boolean](true, false, true, false, false, true), 2, 3, 3, 1, 0) + + test("allTrue") { + assert(Matrix.fromRows[Boolean](Array(true, true), Array(true, true)).allTrue) + assert(!Matrix.fromRows[Boolean](Array(true, true), Array(false, true)).allTrue) + assert(!rowMajor.allTrue) + assert(!strided.allTrue) + + val big = Matrix(Array.fill(1025 * 3)(true), 1025, 3) + assert(big.allTrue) + big.raw(2000) = false + assert(!big.allTrue) + + val allTrueView = Matrix(Array(false, true, true, false, true, true), 2, 2, 1, 3, 1) + assert(allTrueView.allTrue) + } + + test("any") { + assert(!Matrix.zeros[Boolean]((3, 3)).any) + assert(Matrix.eye[Boolean](3).any) + assert(rowMajor.any) + assert(strided.any) + + val big = Matrix(Array.fill(1025 * 3)(false), 1025, 3) + assert(!big.any) + big.raw(2000) = true + assert(big.any) + + // Only elements outside the view are true. + val noneInView = Matrix(Array(true, false, false, true, false, false), 2, 2, 1, 3, 1) + assert(!noneInView.any) + } + + test("trues") { + assertEquals(Matrix.eye[Boolean](4).trues, 4) + assertEquals(Matrix.zeros[Boolean]((2, 5)).trues, 0) + assertEquals(rowMajor.trues, 3) + assertEquals(strided.trues, 2) + + val big = Matrix(Array.fill(1025 * 3)(true), 1025, 3) + assertEquals(big.trues, 3075) + } + + test("not - dense") { + val m = Matrix.fromRows[Boolean](Array(true, false, true), Array(false, true, false)) + val n = m.not + assertLogical(n, Array(false, true, false), Array(true, false, true)) + assertLogical(m, Array(true, false, true), Array(false, true, false)) // original untouched + + assertLogical(rowMajor.not, Array(false, true, false), Array(true, true, false)) + } + + test("not - strided view") { + val v = strided + val n = v.not + assertLogical(n, Array(false, false), Array(true, true)) + assert(n.isDenseColMajor) + assertLogical(v, Array(true, true), Array(false, false)) // view untouched + } + + test("not! - dense") { + val m = Matrix.fromRows[Boolean](Array(true, false), Array(false, false)) + m.`not!` + assertLogical(m, Array(false, true), Array(true, true)) + + val r = rowMajor + r.`not!` + assertLogical(r, Array(false, true, false), Array(true, true, false)) + } + + test("not! - strided view writes through and leaves the rest of the array alone") { + val v = strided + v.`not!` + assertLogical(v, Array(false, false), Array(true, true)) + assertVecEquals[Boolean](v.raw, Array(true, false, false, true, false, true, false, false, true)) + } + + test("not! - aliased (broadcast) view throws") { + val broadcastCol = Matrix(Array(true, false), 2, 3, 1, 0, 0) + intercept[UnsupportedLayoutException](broadcastCol.`not!`) + } + + test("&& and || - dense col-major") { + val a = Matrix.fromRows[Boolean](Array(true, true, false), Array(false, true, false)) + val b = Matrix.fromRows[Boolean](Array(true, false, false), Array(true, true, true)) + assertLogical(a && b, Array(true, false, false), Array(false, true, false)) + assertLogical(a || b, Array(true, true, false), Array(true, true, true)) + } + + test("&& and || - large dense exercises the SIMD path") { + val n = 1025 + val a = Matrix(Array.tabulate(n * 2)(_ % 2 == 0), n, 2) + val b = Matrix(Array.tabulate(n * 2)(_ % 3 == 0), n, 2) + val and = a && b + val or = a || b + for k <- 0 until n * 2 do + assertEquals(and.raw(k), k % 6 == 0, clue = s"and at $k") + assertEquals(or.raw(k), k % 2 == 0 || k % 3 == 0, clue = s"or at $k") + end for + } + + test("&& and || - dense row-major") { + val other = Matrix(Array(true, true, false, true, false, true), 2, 3, 3, 1, 0) + // other: [ T, T, F ] + // [ T, F, T ] + assertLogical(rowMajor && other, Array(true, false, false), Array(false, false, true)) + assertLogical(rowMajor || other, Array(true, true, true), Array(true, false, true)) + } + + test("&& and || - mixed layouts") { + val colMajor = Matrix.fromRows[Boolean](Array(true, true, false), Array(true, false, true)) + assertLogical(rowMajor && colMajor, Array(true, false, false), Array(false, false, true)) + assertLogical(colMajor || rowMajor, Array(true, true, true), Array(true, false, true)) + + val dense = Matrix.fromRows[Boolean](Array(false, true), Array(true, false)) + assertLogical(strided && dense, Array(false, true), Array(false, false)) + assertLogical(dense || strided, Array(true, true), Array(true, false)) + } + + test("&& and || - dimension mismatch throws") { + val a = Matrix.zeros[Boolean]((2, 2)) + val b = Matrix.zeros[Boolean]((2, 3)) + intercept[MatrixDimensionMismatch](a && b) + intercept[MatrixDimensionMismatch](a || b) + } + end MatrixBooleanSuite diff --git a/vecxt/test/src/matrix.test.scala b/vecxt/test/src/matrix.test.scala index eb5aa254..0fdb305a 100644 --- a/vecxt/test/src/matrix.test.scala +++ b/vecxt/test/src/matrix.test.scala @@ -145,6 +145,45 @@ class MatrixExtensionSuite extends FunSuite: assertMatrixEquals(prodC, Matrix[Double](Array[Double](8.0, 90.0), (1, 2))) } + // mean(dim) divides sum(dim) by the length of the collapsed axis. Non-square matrices throughout, so dividing by + // the kept axis instead (rows vs cols) would give a different answer. + + test("mean along dimension, column-major") { + // [[1,5],[4,3],[2,6]] + val mat1 = Matrix[Double](Array(1.0, 4.0, 2.0, 5.0, 3.0, 6.0), (3, 2)) + + assertMatrixEquals(mat1.mean(Rows), Matrix[Double](Array(3.0, 3.5, 4.0), (3, 1))) + assertMatrixEquals(mat1.mean(Cols), Matrix[Double](Array(7.0 / 3, 14.0 / 3), (1, 2))) + assertMatrixEquals(mat1.mean(0), mat1.mean(Rows)) + assertMatrixEquals(mat1.mean(1), mat1.mean(Cols)) + } + + test("mean along dimension, row-major") { + val mat1 = Matrix[Double](Array(1.0, 5.0, 4.0, 3.0, 2.0, 6.0), 3, 2, 2, 1, 0) + assert(mat1.isDenseRowMajor) + + assertMatrixEquals(mat1.mean(Rows), Matrix[Double](Array(3.0, 3.5, 4.0), (3, 1))) + assertMatrixEquals(mat1.mean(Cols), Matrix[Double](Array(7.0 / 3, 14.0 / 3), (1, 2))) + } + + test("mean along dimension, strided view") { + // [[1,2,3],[4,5,6]] sliced out of the 3x3 + val view = mat1to9(0 to 1, 0 to 2) + + assertMatrixEquals(view.mean(Rows), Matrix[Double](Array(2.0, 5.0), (2, 1))) + assertMatrixEquals(view.mean(Cols), Matrix[Double](Array(2.5, 3.5, 4.5), (1, 3))) + } + + test("mean along dimension agrees with overall mean") { + val mat1 = Matrix[Double](Array(1.0, 4.0, 2.0, 5.0, 3.0, 6.0), (3, 2)) + assertEqualsDouble(mat1.mean(Rows).mean, mat1.mean, 1e-12) + assertEqualsDouble(mat1.mean(Cols).mean, mat1.mean, 1e-12) + } + + test("mean along invalid dimension throws") { + intercept[InvalidDimensionException](mat1to9.mean(2)) + } + // reduceAlongDimension (which backs sum/min/max/product along a dimension) has no dedicated test at all for // Float or Int, row-major or column-major — this covers the same bug as the Double row-major tests above, for // both remaining types it was fixed in, using the same logical matrix ([[1,5],[4,3],[2,6]]) throughout. diff --git a/vecxt_re/src/LossMatrix.scala b/vecxt_re/src/LossMatrix.scala new file mode 100644 index 00000000..99dc886d --- /dev/null +++ b/vecxt_re/src/LossMatrix.scala @@ -0,0 +1,33 @@ +package vecxt_re + +import vecxt.all.Matrix +import vecxt.dimensionExtender.DimensionExtender.Dimension +import vecxt.DoubleMatrix.reduceAlongDimension +import vecxt.all.mean +import vecxt.all.sum +import vecxt.all.> +import vecxt.all.trues + +/** The assumption here. + * + * Potential future outcomes of a single transactions run down columns. + * + * Each row represents one potential future outcome, across a portfolio + */ +opaque type LossMatrix = Matrix[Double] + +object LossMatrix: + inline def apply(m: Matrix[Double]): LossMatrix = m +end LossMatrix + +extension (m: LossMatrix) + def transactionEls = m.mean(Dimension.Cols) + + def el = m.mean + + /** The probability of paying any loss + * + * @return + */ + def attachProb = (m.sum(Dimension.Rows) > 0).trues.toDouble / m.rows.toDouble +end extension diff --git a/vecxt_re/src/PositionCalculations.scala b/vecxt_re/src/PositionCalculations.scala new file mode 100644 index 00000000..0e52d568 --- /dev/null +++ b/vecxt_re/src/PositionCalculations.scala @@ -0,0 +1,106 @@ +package vecxt_re + +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import vecxt.all.- + +object PositionCalculations: + /** Forecasts the price of a bond which is being pulled to parity assuming a one year time horizon. + * + * This allows us to ignore seasonality, which is assumed to be annual. + * + * @param price + * the current price, expressed in `priceUnit` + * @param priceUnit + * the unit `price` is quoted in (e.g. `Pts` for a price of 102 meaning 102% of par) + * @param priceDate + * the date on which `price` was observed; the forecast is for one year after this date + * @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. + */ + def priceForecast1Year( + price: Double, + priceUnit: PriceUnit, + priceDate: LocalDate, + maturity: LocalDate + ): Double = + price + pull2Parity1Year(price, priceUnit, priceDate, maturity) + + /** The change in price over one year of a bond which is being pulled linearly (by calendar days) to parity at + * maturity. + * + * This allows us to ignore seasonality, which is assumed to be annual. + * + * Uses calendar days, not yield accretion. + * + * @param price + * the current price, expressed in `priceUnit` + * @param priceUnit + * the unit `price` is quoted in + * @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. + */ + def pull2Parity1Year( + price: Double, + priceUnit: PriceUnit, + priceDate: LocalDate, + maturity: LocalDate + ): Double = + 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 = PriceUnit.One.convert(1.0, priceUnit) + val projectionDate = priceDate.plusYears(1L) + + val days2Maturity = ChronoUnit.DAYS.between(priceDate, maturity) + val days2Project = ChronoUnit.DAYS.between(priceDate, projectionDate) + if maturity < projectionDate then one - price + else days2Project.toDouble / days2Maturity.toDouble * (one - price) + end if + end pull2Parity1Year + + /** Investment is purchased on the price date + * Investment is sold in one years time, it is assumed the market is at a steady state over this period with no material change in risk premia. + * Annual seasonality - i.e. seasonality can be ignored over a single annual time period. + * A deep liquid market which is not affected by purchase / sale + * Investments renew at expiry into an equivalent bond at par, at the same risk spread and expected loss. + * All capital is ultimately returned to investor - impaired bonds would not pull to parity + * The lossVector is _assumed_ to be in units of `One` + * + * @param price the current price, expressed in `priceUnit` + * @param priceUnit the unit `price` is quoted in + * @param priceDate the date on which `price` was observed (the purchase date) + * @param maturity the maturity date of the bond + * @param lossVector - By convention all positive numbers + * @param riskFreeRate the annual risk free rate, expressed in `riskFreeRateUnit` (e.g. 325 in `Bps` is 3.25%) + * @param riskFreeRateUnit the unit `riskFreeRate` is quoted in + * @return one element per entry of `lossVector`: the one year PnL in units of `One`, i.e. risk free interest + * plus pull to parity minus that loss + */ + def pnlForecast1Year( + price: Double, + priceUnit: PriceUnit, + priceDate: LocalDate, + maturity: LocalDate, + lossVector: Array[Double], + riskFreeRate: Double, + riskFreeRateUnit: PriceUnit, + spread: Double, + spreadUnit: PriceUnit + ): Array[Double] = + val riskFreeInterest = riskFreeRateUnit.convert(1.0, PriceUnit.One) * riskFreeRate + val spreadInOne = spreadUnit.convert(spread, PriceUnit.One) + val priceInOne = priceUnit.convert(price, PriceUnit.One) + (spreadInOne + riskFreeInterest + pull2Parity1Year(priceInOne, PriceUnit.One, priceDate, maturity)) - lossVector + end pnlForecast1Year +end PositionCalculations diff --git a/vecxt_re/src/PriceUnit.scala b/vecxt_re/src/PriceUnit.scala new file mode 100644 index 00000000..a939162d --- /dev/null +++ b/vecxt_re/src/PriceUnit.scala @@ -0,0 +1,14 @@ +package vecxt_re + +/** The unit a price or rate is quoted in. `unit` is the value that represents par (100%) in that unit. */ +enum PriceUnit(val unit: Int): + case Bps extends PriceUnit(10000) + case Pts extends PriceUnit(100) + case One extends PriceUnit(1) + + /** Converts `value`, expressed in this unit, into the unit `to`. e.g. `Pts.convert(102, One) == 1.02` */ + def convert[A: Fractional](value: A, to: PriceUnit): A = + import scala.math.Fractional.Implicits.* + value * Fractional[A].fromInt(to.unit) / Fractional[A].fromInt(unit) + end convert +end PriceUnit diff --git a/vecxt_re/test/src/priceForecast.test.scala b/vecxt_re/test/src/priceForecast.test.scala new file mode 100644 index 00000000..74c76de9 --- /dev/null +++ b/vecxt_re/test/src/priceForecast.test.scala @@ -0,0 +1,73 @@ +package vecxt_re + +import java.time.LocalDate +import vecxt.all.+ + +class PositionCalculationsSuite extends munit.FunSuite: + + test("one year maturity"): + val reportDate = LocalDate.of(2026, 1, 1) + val forecastPrice = PositionCalculations.priceForecast1Year(102, PriceUnit.Pts, reportDate, reportDate.plusYears(1)) + + assertEqualsDouble(forecastPrice, 100.0, 0.0000001) + + test("Maturity same date"): + val reportDate = LocalDate.of(2026, 1, 1) + val forecastPrice = PositionCalculations.priceForecast1Year(102, PriceUnit.Pts, reportDate, reportDate) + + assertEqualsDouble(forecastPrice, 100.0, 0.0000001) + + test("Early maturity"): + val reportDate = LocalDate.of(2026, 1, 1) + val forecastPrice = + PositionCalculations.priceForecast1Year(102, PriceUnit.Pts, reportDate, reportDate.plusMonths(6)) + val forecastPrice2 = PositionCalculations.priceForecast1Year(102, PriceUnit.Pts, reportDate, reportDate.plusDays(1)) + + assertEqualsDouble(forecastPrice, 100.0, 0.0000001) + assertEqualsDouble(forecastPrice2, 100.0, 0.0000001) + + test("refuses already matured"): + val reportDate = LocalDate.of(2026, 1, 1) + intercept[java.lang.AssertionError]( + PositionCalculations.priceForecast1Year(102, PriceUnit.Pts, reportDate, reportDate.plusYears(-1)) + ) + + test("Interpolation to a point"): + val reportDate = LocalDate.of(2026, 1, 1) + val forecastPrice = + PositionCalculations.priceForecast1Year(10200, PriceUnit.Bps, reportDate, reportDate.plusYears(2)) + + assertEqualsDouble(forecastPrice, 10100.0, 0.0000001) + + test("pnlForecast"): + + val reportDate = LocalDate.of(2026, 1, 1) + val losses = Array[Double](0.0, 0.1, 0.0, 0.2) + val forecastPrice = PositionCalculations.pnlForecast1Year( + 102, + PriceUnit.Pts, + reportDate, + reportDate.plusYears(2), + losses, + 325.0, + PriceUnit.Bps, + 0, + PriceUnit.Bps + ) + + val forecast = Array[Double](0.0225, -0.0775, 0.0225, -0.1775) + assertVecEquals(forecastPrice, forecast) + + val forecastPriceWSpread = PositionCalculations.pnlForecast1Year( + 102, + PriceUnit.Pts, + reportDate, + reportDate.plusYears(2), + losses, + 325.0, + PriceUnit.Bps, + 100.0, + PriceUnit.Bps + ) + assertVecEquals(forecastPriceWSpread, forecast + 0.01) +end PositionCalculationsSuite diff --git a/vecxt_re/test/src/priceUnit.test.scala b/vecxt_re/test/src/priceUnit.test.scala new file mode 100644 index 00000000..48de8842 --- /dev/null +++ b/vecxt_re/test/src/priceUnit.test.scala @@ -0,0 +1,9 @@ +package vecxt_re + +class PriceUnitSuite extends munit.FunSuite: + + test("conversions"): + assertEqualsDouble(PriceUnit.Bps.convert(10000, PriceUnit.One), 1.0, 0.00000001) + assertEqualsDouble(PriceUnit.Pts.convert(100, PriceUnit.One), 1.0, 0.00000001) + assertEqualsDouble(PriceUnit.One.convert(1, PriceUnit.Bps), 1e4, 0.00000001) +end PriceUnitSuite