From 2220e35d10232c30e22dd10b0865d3aa4fff25d2 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 27 Oct 2026 16:49:36 +0100 Subject: [PATCH 1/3] extensible currencies --- build.mill | 2 +- vecxt_re/src/CurrencyValue.scala | 25 +++++++++++++++++++-- vecxt_re/test/src/currencyAmount.test.scala | 14 ++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/build.mill b/build.mill index 8ef5d489..12fa5435 100644 --- a/build.mill +++ b/build.mill @@ -1,4 +1,4 @@ -//| 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: diff --git a/vecxt_re/src/CurrencyValue.scala b/vecxt_re/src/CurrencyValue.scala index 7e47b652..c22518bb 100644 --- a/vecxt_re/src/CurrencyValue.scala +++ b/vecxt_re/src/CurrencyValue.scala @@ -1,7 +1,28 @@ package vecxt_re -enum Ccy derives CanEqual: - case USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD +/** An ISO 4217 currency code, e.g. `Ccy.USD` or `Ccy("SEK")`. Open, so callers may use currencies not listed here. */ +opaque type Ccy = String +object Ccy: + def apply(code: String): Ccy = + require(code.length == 3 && code.forall(c => c >= 'A' && c <= 'Z'), s"Not an ISO 4217 currency code: '$code'") + code + end apply + + val USD: Ccy = "USD" + val EUR: Ccy = "EUR" + val GBP: Ccy = "GBP" + val CHF: Ccy = "CHF" + val JPY: Ccy = "JPY" + val AUD: Ccy = "AUD" + val CAD: Ccy = "CAD" + val NZD: Ccy = "NZD" + + /** The currencies predefined above. */ + val majorCurrencies: IArray[Ccy] = Array(USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD) + + extension (c: Ccy) def code: String = c + + given CanEqual[Ccy, Ccy] = CanEqual.derived end Ccy /** The scale a monetary amount is quoted in. `multiplier` is the number of major currency units in one of this unit. */ diff --git a/vecxt_re/test/src/currencyAmount.test.scala b/vecxt_re/test/src/currencyAmount.test.scala index d664b71f..4112f208 100644 --- a/vecxt_re/test/src/currencyAmount.test.scala +++ b/vecxt_re/test/src/currencyAmount.test.scala @@ -107,4 +107,18 @@ class CurrencyAmountSuite extends munit.FunSuite: assertEquals(CurrencyAmount(2.5, Mn, USD).toString, "2500000.00 USD") assertEquals(CurrencyAmount(-1.234, K, EUR).toString, "-1234.00 EUR") + test("currencies not predefined can be created from their code"): + val sek = Ccy("SEK") + assertEquals(sek.code, "SEK") + assertEquals(sek, Ccy("SEK")) + assertEquals(Ccy("USD"), USD) + assertEquals(CurrencyAmount(1, K, sek).toString, "1000.00 SEK") + intercept[IllegalArgumentException](CurrencyAmount(1, K, sek) + CurrencyAmount(1, K, USD)) + + test("invalid currency codes are rejected"): + intercept[IllegalArgumentException](Ccy("usd")) + intercept[IllegalArgumentException](Ccy("US")) + intercept[IllegalArgumentException](Ccy("USDX")) + intercept[IllegalArgumentException](Ccy("")) + end CurrencyAmountSuite From a98246f8f8c8d41790339183d91459bd2fe1d629 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 27 Oct 2026 17:07:51 +0100 Subject: [PATCH 2/3] currency rate --- build.mill | 1 + vecxt_re/package.mill | 7 ++- vecxt_re/src/AmountUnit.scala | 9 ++++ vecxt_re/src/Ccy.scala | 43 +++++++++++++++++++ ...rrencyValue.scala => CurrencyAmount.scala} | 37 +++------------- vecxt_re/src/CurrencyRate.scala | 13 ++++++ vecxt_re/test/src/currencyAmount.test.scala | 17 ++++++++ vecxt_re/test/src/currencyRate.test.scala | 34 +++++++++++++++ 8 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 vecxt_re/src/AmountUnit.scala create mode 100644 vecxt_re/src/Ccy.scala rename vecxt_re/src/{CurrencyValue.scala => CurrencyAmount.scala} (69%) create mode 100644 vecxt_re/src/CurrencyRate.scala create mode 100644 vecxt_re/test/src/currencyRate.test.scala diff --git a/build.mill b/build.mill index 12fa5435..4e993889 100644 --- a/build.mill +++ b/build.mill @@ -50,6 +50,7 @@ object V: val nativeBSP = false val jvmBSP = true val oslib = mvn"com.lihaoyi::os-lib::0.11.8" + val jsoniterVersion = "2.41.5" end V trait VecxtPublishModule extends PublishModule, ScalaModule, ScalafixModule: diff --git a/vecxt_re/package.mill b/vecxt_re/package.mill index 2f2956b3..4158c881 100644 --- a/vecxt_re/package.mill +++ b/vecxt_re/package.mill @@ -6,7 +6,12 @@ import mill.api.Task.Simple object `package` extends Module: trait VexctReModule extends PlatformScalaModule with build.VecxtPublishModule with build.ShareCompileResources: - def mvnDeps = super.mvnDeps() ++ Seq() + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-core::${build.V.jsoniterVersion}" + ) + def compileMvnDeps = super.compileMvnDeps() ++ Seq( + mvn"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros::${build.V.jsoniterVersion}" + ) trait VexctReTest extends ScalaTests, TestModule.Munit: def mvnDeps = super.mvnDeps() ++ Seq( diff --git a/vecxt_re/src/AmountUnit.scala b/vecxt_re/src/AmountUnit.scala new file mode 100644 index 00000000..268f016d --- /dev/null +++ b/vecxt_re/src/AmountUnit.scala @@ -0,0 +1,9 @@ +package vecxt_re + +/** The scale a monetary amount is quoted in. `multiplier` is the number of major currency units in one of this unit. */ +enum AmountUnit(val multiplier: Long): + case One extends AmountUnit(1L) + case K extends AmountUnit(1_000L) + case Mn extends AmountUnit(1_000_000L) + case Bn extends AmountUnit(1_000_000_000L) +end AmountUnit diff --git a/vecxt_re/src/Ccy.scala b/vecxt_re/src/Ccy.scala new file mode 100644 index 00000000..0318a2ae --- /dev/null +++ b/vecxt_re/src/Ccy.scala @@ -0,0 +1,43 @@ +package vecxt_re + +import com.github.plokhotnyuk.jsoniter_scala.core.* + +/** An ISO 4217 currency code, e.g. `Ccy.USD` or `Ccy("SEK")`. Open, so callers may use currencies not listed here. */ +opaque type Ccy = String +object Ccy: + def apply(code: String): Ccy = + require(code.length == 3 && code.forall(c => c >= 'A' && c <= 'Z'), s"Not an ISO 4217 currency code: '$code'") + code + end apply + + val USD: Ccy = "USD" + val EUR: Ccy = "EUR" + val GBP: Ccy = "GBP" + val CHF: Ccy = "CHF" + val JPY: Ccy = "JPY" + val AUD: Ccy = "AUD" + val CAD: Ccy = "CAD" + val NZD: Ccy = "NZD" + + /** The currencies predefined above. */ + val majorCurrencies: IArray[Ccy] = IArray(USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD) + + extension (c: Ccy) def code: String = c + end extension + + given CanEqual[Ccy, Ccy] = CanEqual.derived + + /** Encodes as the bare code, e.g. `"USD"`. Decoding validates the code as [[apply]] does. */ + given JsonValueCodec[Ccy] with + def decodeValue(in: JsonReader, default: Ccy): Ccy = + val code = in.readString(null) + if code == null then in.decodeError("expected a currency code, got null") + else + try Ccy(code) + catch case e: IllegalArgumentException => in.decodeError(e.getMessage) + end if + end decodeValue + def encodeValue(x: Ccy, out: JsonWriter): Unit = out.writeVal(x) + def nullValue: Ccy = null.asInstanceOf[Ccy] + end given +end Ccy diff --git a/vecxt_re/src/CurrencyValue.scala b/vecxt_re/src/CurrencyAmount.scala similarity index 69% rename from vecxt_re/src/CurrencyValue.scala rename to vecxt_re/src/CurrencyAmount.scala index c22518bb..d7596501 100644 --- a/vecxt_re/src/CurrencyValue.scala +++ b/vecxt_re/src/CurrencyAmount.scala @@ -1,37 +1,7 @@ package vecxt_re -/** An ISO 4217 currency code, e.g. `Ccy.USD` or `Ccy("SEK")`. Open, so callers may use currencies not listed here. */ -opaque type Ccy = String -object Ccy: - def apply(code: String): Ccy = - require(code.length == 3 && code.forall(c => c >= 'A' && c <= 'Z'), s"Not an ISO 4217 currency code: '$code'") - code - end apply - - val USD: Ccy = "USD" - val EUR: Ccy = "EUR" - val GBP: Ccy = "GBP" - val CHF: Ccy = "CHF" - val JPY: Ccy = "JPY" - val AUD: Ccy = "AUD" - val CAD: Ccy = "CAD" - val NZD: Ccy = "NZD" - - /** The currencies predefined above. */ - val majorCurrencies: IArray[Ccy] = Array(USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD) - - extension (c: Ccy) def code: String = c - - given CanEqual[Ccy, Ccy] = CanEqual.derived -end Ccy - -/** The scale a monetary amount is quoted in. `multiplier` is the number of major currency units in one of this unit. */ -enum AmountUnit(val multiplier: Long): - case One extends AmountUnit(1L) - case K extends AmountUnit(1_000L) - case Mn extends AmountUnit(1_000_000L) - case Bn extends AmountUnit(1_000_000_000L) -end AmountUnit +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* /** This should _NOT_ be used for actual cash values. We use double here because this intended for financial simulation * where errors of <0.01 are meaningless. An absolute monetary quantity denominated in a single currency, e.g. a @@ -93,4 +63,7 @@ object CurrencyAmount: java.lang.Double.compare(a.amount, b.amount) end compare end given + + /** In major currency units, e.g. `{"amount":2500000.0,"ccy":"USD"}` */ + given JsonValueCodec[CurrencyAmount] = JsonCodecMaker.make end CurrencyAmount diff --git a/vecxt_re/src/CurrencyRate.scala b/vecxt_re/src/CurrencyRate.scala new file mode 100644 index 00000000..9c89f1de --- /dev/null +++ b/vecxt_re/src/CurrencyRate.scala @@ -0,0 +1,13 @@ +package vecxt_re + +import java.time.LocalDate + +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* + +case class CurrencyRate(date: LocalDate, base: Ccy, quote: Ccy, rate: Double) + +object CurrencyRate: + /** e.g. `{"date":"2026-10-27","base":"EUR","quote":"USD","rate":1.08}` */ + given JsonValueCodec[CurrencyRate] = JsonCodecMaker.make +end CurrencyRate diff --git a/vecxt_re/test/src/currencyAmount.test.scala b/vecxt_re/test/src/currencyAmount.test.scala index 4112f208..4cc709ac 100644 --- a/vecxt_re/test/src/currencyAmount.test.scala +++ b/vecxt_re/test/src/currencyAmount.test.scala @@ -1,5 +1,7 @@ package vecxt_re +import com.github.plokhotnyuk.jsoniter_scala.core.* + import AmountUnit.* import Ccy.* @@ -121,4 +123,19 @@ class CurrencyAmountSuite extends munit.FunSuite: intercept[IllegalArgumentException](Ccy("USDX")) intercept[IllegalArgumentException](Ccy("")) + test("json is in major units with the currency code"): + val x = CurrencyAmount(2.5, Mn, USD) + val json = """{"amount":2500000.0,"ccy":"USD"}""" + assertEquals(writeToString(x), json) + assertEquals(readFromString[CurrencyAmount](json), x) + + test("json round trips a currency not predefined"): + val x = CurrencyAmount(-1.234, K, Ccy("SEK")) + assertEquals(readFromString[CurrencyAmount](writeToString(x)), x) + + test("json with a missing field or invalid currency is rejected"): + intercept[JsonReaderException](readFromString[CurrencyAmount]("""{"amount":1.0}""")) + intercept[JsonReaderException](readFromString[CurrencyAmount]("""{"ccy":"USD"}""")) + intercept[JsonReaderException](readFromString[CurrencyAmount]("""{"amount":1.0,"ccy":"usd"}""")) + end CurrencyAmountSuite diff --git a/vecxt_re/test/src/currencyRate.test.scala b/vecxt_re/test/src/currencyRate.test.scala new file mode 100644 index 00000000..41c087b9 --- /dev/null +++ b/vecxt_re/test/src/currencyRate.test.scala @@ -0,0 +1,34 @@ +package vecxt_re + +import java.time.LocalDate + +import com.github.plokhotnyuk.jsoniter_scala.core.* + +class CurrencyRateSuite extends munit.FunSuite: + + private val eurUsd = CurrencyRate(LocalDate.of(2026, 10, 27), Ccy.EUR, Ccy.USD, 1.08) + private val eurUsdJson = """{"date":"2026-10-27","base":"EUR","quote":"USD","rate":1.08}""" + + test("rate encodes to json"): + assertEquals(writeToString(eurUsd), eurUsdJson) + + test("rate decodes from json"): + assertEquals(readFromString[CurrencyRate](eurUsdJson), eurUsd) + + test("currencies not predefined round trip"): + val r = CurrencyRate(LocalDate.of(2026, 1, 2), Ccy("SEK"), Ccy.CHF, 0.083) + assertEquals(readFromString[CurrencyRate](writeToString(r)), r) + + test("ccy encodes as its bare code"): + assertEquals(writeToString(Ccy.GBP), "\"GBP\"") + assertEquals(readFromString[Ccy]("\"NOK\""), Ccy("NOK")) + + test("invalid currency codes are rejected when decoding"): + intercept[JsonReaderException](readFromString[CurrencyRate](eurUsdJson.replace("\"EUR\"", "\"eur\""))) + intercept[JsonReaderException](readFromString[CurrencyRate](eurUsdJson.replace("\"USD\"", "\"USDX\""))) + intercept[JsonReaderException](readFromString[CurrencyRate](eurUsdJson.replace("\"USD\"", "null"))) + + test("missing fields are rejected"): + intercept[JsonReaderException](readFromString[CurrencyRate]("""{"date":"2026-10-27","base":"EUR","rate":1.08}""")) + +end CurrencyRateSuite From 202a5bdbb6643c1ddacd58e164dab4e3fcaccf99 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 27 Oct 2026 17:16:25 +0100 Subject: [PATCH 3/3] . --- .github/workflows/ci.yml | 6 +++++- .github/workflows/copilot-setup-steps.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79961cc5..d284bba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,8 +58,12 @@ jobs: - if: matrix.project == 'js' run: npm install + # sblas links -lcblas; OpenBLAS exports the cblas_* symbols but ships no libcblas.so, so alias it - if: matrix.project == 'native' - run: sudo apt-get install -y libatlas-base-dev + run: | + sudo apt-get update + sudo apt-get install -y libopenblas-dev + sudo ln -sf "$(realpath /usr/lib/x86_64-linux-gnu/libopenblas.so)" /usr/lib/x86_64-linux-gnu/libcblas.so - name: scalaJSLink if: matrix.project == 'js' diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index a99e2984..b473a208 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -36,8 +36,12 @@ jobs: node-version: 22 cache: npm + # sblas links -lcblas; OpenBLAS exports the cblas_* symbols but ships no libcblas.so, so alias it - name: prepare native - run: sudo apt-get install -y libatlas-base-dev + run: | + sudo apt-get update + sudo apt-get install -y libopenblas-dev + sudo ln -sf "$(realpath /usr/lib/x86_64-linux-gnu/libopenblas.so)" /usr/lib/x86_64-linux-gnu/libcblas.so - name: Setup Java 21 uses: actions/setup-java@v4