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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion build.mill
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion vecxt_re/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions vecxt_re/src/AmountUnit.scala
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions vecxt_re/src/Ccy.scala
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
13 changes: 13 additions & 0 deletions vecxt_re/src/CurrencyRate.scala
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions vecxt_re/test/src/currencyAmount.test.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package vecxt_re

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

import AmountUnit.*
import Ccy.*

Expand Down Expand Up @@ -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
34 changes: 34 additions & 0 deletions vecxt_re/test/src/currencyRate.test.scala
Original file line number Diff line number Diff line change
@@ -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
Loading