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 diff --git a/build.mill b/build.mill index 8ef5d489..4e993889 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: @@ -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 85% rename from vecxt_re/src/CurrencyValue.scala rename to vecxt_re/src/CurrencyAmount.scala index 7e47b652..d7596501 100644 --- a/vecxt_re/src/CurrencyValue.scala +++ b/vecxt_re/src/CurrencyAmount.scala @@ -1,16 +1,7 @@ package vecxt_re -enum Ccy derives CanEqual: - case USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD -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 @@ -72,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 d664b71f..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.* @@ -107,4 +109,33 @@ 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("")) + + 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