DpExchange.Core.Types.Validate (DpExchangeCore v0.3.76)

Copy Markdown View Source

The validating constructor every Types.* struct builds new/1 on.

@enforce_keys guards presence, not nil

@enforce_keys [:open, :high, :low, :close, ...] makes struct!(Candle, attrs) raise when a key is absent from attrs. It does nothing when the key is present and nil: struct!(Candle, open: nil, high: ..., ...) builds without complaint, even though Candle's own typespec declares open: Decimal.t() — never Decimal.t() | nil.

That gap is not academic. A nil in a field the typespec calls non-nilable is exactly what a JSON decode bug produces — Map.get(json, "open") on a key the venue renamed returns nil, silently, and every value downstream stays plausible until the struct reaches Decimal.compare/2 or similar, several calls away from where the bad data actually entered. The error message at that point names a Decimal function, not the venue response that caused it.

What new!/3 adds

struct!/2, plus a check that every field named in required_non_nil is present AND non-nil, raising ArgumentError naming the offending field when it is not. A caller gets a message pointing at the exact field and the venue's own module, at the moment the struct was about to be built — not a FunctionClauseError inside Decimal three frames later with no indication which field was the problem.

What this deliberately does not do

No coercion, no defaulting, no guessing. A nil where the type forbids one is an error, full stop — the same rule this whole family applies everywhere else. new!/3 fails closed; it does not repair the input.

Two further values are refused in any field, however deeply nested in a list, tuple or plain map: a Decimal that is NaN or infinite, which is not a number any field of these types can carry, and a NaiveDateTime, because every time in this family is a DateTime and a time with no zone cannot be placed.

Closed vocabularies are checked per type, not here. new!/3 knows nothing about which field is a :buy | :sell. A type whose typespec states one pipes its result through one_of!/3 (and positive_integer!/2 for a count), so Fill, Trade, Position and OrderLeg refuse a side outside what they state. The Decimal fields are not type-checked for being a Decimal (an integer is accepted, and a venue builds these with integers in its own tests), but a float is refused at the top level of any field and inside a list or tuple, because no field of any type is a float and a float has already lost digits. Free-form maps (details, line_items, metrics) may still hold one: they are the venue's own.

Struct literals still work

Nothing here removes defstruct or @enforce_keys from any Types.* module, and %Candle{...} remains a valid, unchecked way to build one — internal code and tests that already construct known-good values by hand are unaffected. new/1 (built on this module, one per type) is the path a venue package's decoder should prefer, because it is the one that turns a decode bug into an error at the boundary instead of a crash three calls downstream.

Summary

Functions

Builds module's struct from attrs, raising ArgumentError naming the field if any of required_non_nil is absent or explicitly nil.

Returns built if its field holds one of allowed, and raises ArgumentError naming the field and the value otherwise.

Returns built if its field holds a positive integer, and raises ArgumentError naming the field and the value otherwise.

Functions

new!(module, required_non_nil, attrs)

@spec new!(module(), [atom()], keyword() | map()) :: struct()

Builds module's struct from attrs, raising ArgumentError naming the field if any of required_non_nil is absent or explicitly nil.

required_non_nil is ordinarily the type's own @enforce_keys — the fields whose typespec admits no nil. A type that deliberately allows nil on some enforced keys (see DpExchange.Core.Types.Order, which enforces presence of seven keys but documents that six of them may honestly be nil) passes a narrower list naming only the fields that must never be nil.

one_of!(built, field, allowed)

@spec one_of!(struct(), atom(), [term()]) :: struct()

Returns built if its field holds one of allowed, and raises ArgumentError naming the field and the value otherwise.

For a closed vocabulary a type's own typespec states — side: :buy | :sell, liquidity: :maker | :taker | nil. new!/3 refuses a nil and a NaN; it cannot refuse side: :long on a Fill, side: "buy" on a Trade, or :buy on a Position, and each of those is a plausible value with the wrong meaning: a position built :buy is read by a consumer matching :long | :short as no side at all, or as the wrong one. Include nil in allowed only where the type's typespec does.

positive_integer!(built, field)

@spec positive_integer!(
  struct(),
  atom()
) :: struct()

Returns built if its field holds a positive integer, and raises ArgumentError naming the field and the value otherwise.

For a count or a ratio whose typespec is pos_integer(). A Decimal 1, a float 1.0 and a 0 all read as "one leg" or "no legs" to whoever gets there first.