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

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 type-checked through decimals!/2 (and decimal_map_values!/2 for maps of Decimals): a type pipes its result through them for every field its typespec states as Decimal.t(), and a string, a map or any other non-number is an ArgumentError naming module, field and value (an integer is accepted, and a venue builds these with integers in its own tests). 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.

Value rules a type's own doc states are held per type too, by positive!/2 (a traded price, a resting bid or ask, a position or withdrawal size, a strike), non_negative!/2, text!/2 (an address, network, asset or id is a non-blank string), maps!/2, dates!/2, datetimes!/2 (a time field new!/3 cannot recognise by name), symbol_lists!/2 (a container of the right shape or nil), integers!/2 (a sequence number) and timeframe!/2 (a candle width in Timeframe.nameable/0). Each names module, field and value, accepts nil (presence is new!/3's job; timeframe!/2 is the one exception, and its field is enforced non-nil) and never repairs the input.

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

Returns built if each of fields is nil or a Date, and raises ArgumentError otherwise. A DateTime or a string there sorts and compares wrongly in the consumer.

Returns built if each of fields is nil or a DateTime, and raises ArgumentError naming module, field and value otherwise.

Raises ArgumentError naming module, field and value unless value is a Decimal or an integer; returns :ok otherwise.

Returns built if each of fields is nil or a map whose values are all Decimals or integers, and raises ArgumentError naming module, field and value otherwise.

Returns built if every one of fields holds a Decimal, an integer or nil, and raises ArgumentError naming module, field and value otherwise.

Returns built if each of fields is nil or an integer, and raises ArgumentError naming module, field and value otherwise.

Returns built if each of fields is nil or a plain map (not a struct), and raises ArgumentError otherwise. For the free-form maps (details, line_items, metrics) whose contents are the venue's own but whose container is not.

The widest exponent a Decimal may carry in any field (+/-1000). Exposed so a venue decoder's own decimal/1 can apply the same bound before building a value.

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 each of fields is nil or a number that is not negative, and raises ArgumentError naming module, field and value otherwise.

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

Returns built if each of fields is nil or a number above zero, and raises ArgumentError naming module, field and value otherwise.

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

Returns built if each of fields is nil or a proper list of non-blank strings, and raises ArgumentError otherwise. nil and [] stay different answers ("not fetched" and "fetched, empty"); anything else, a bare string included, is neither.

Returns built if each of fields is nil or a non-blank string, and raises ArgumentError naming module, field and value otherwise.

Returns built if its field holds a label DpExchange.Core.Timeframe.nameable?/1 recognises, and raises ArgumentError naming module, field and value otherwise.

Functions

dates!(built, fields)

@spec dates!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a Date, and raises ArgumentError otherwise. A DateTime or a string there sorts and compares wrongly in the consumer.

datetimes!(built, fields)

@spec datetimes!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a DateTime, and raises ArgumentError naming module, field and value otherwise.

For a time whose field name new!/3 cannot recognise as one. new!/3 holds :venue_time, :timestamp, :as_of, :active_from and every *_at to this rule by name; a field typed DateTime.t() outside that vocabulary (StakingReward's :period_start and :period_end, which cannot join the global rule because a filing's same-named fields are Dates) is named here by its type instead. A Date, a string or an epoch integer there built, and then crashed DateTime.compare/2 in the consumer. A NaiveDateTime is refused by new!/3 first.

decimal!(module, field, value)

@spec decimal!(module(), atom(), term()) :: :ok

Raises ArgumentError naming module, field and value unless value is a Decimal or an integer; returns :ok otherwise.

The element-level check for a type that walks a nested structure itself (order book levels, option strikes). nil is NOT accepted here.

decimal_map_values!(built, fields)

@spec decimal_map_values!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a map whose values are all Decimals or integers, and raises ArgumentError naming module, field and value otherwise.

For %{String.t() => Decimal.t()} fields (VolumeProfile.buy_at_price, StakingBalance.by_provider). A field holding something other than a map is refused.

decimals!(built, fields)

@spec decimals!(struct(), [atom()]) :: struct()

Returns built if every one of fields holds a Decimal, an integer or nil, and raises ArgumentError naming module, field and value otherwise.

For the fields a type's typespec states as Decimal.t() (or Decimal.t() | nil). A "1.5", "abc" or a map in one builds fine under new!/3 and raises inside Decimal.compare/2 in the CONSUMER's process, so a decoder that forgot to parse a number is refused here, at the boundary. An integer is accepted (venues build with integers in their own tests). nil passes this check because presence is new!/3's job: a field whose typespec forbids nil is already in required_non_nil, and a field whose typespec allows it must not be refused here. Floats, NaN, infinities and absurd exponents are refused by new!/3 itself.

integers!(built, fields)

@spec integers!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or an integer, and raises ArgumentError naming module, field and value otherwise.

For a venue's sequence number (OrderBook.sequence, OrderBookDelta.sequence). A "7" there built, and a consumer comparing it with > against the integer it held last got Erlang term ordering (a binary sorts above every integer), so a stale frame read as the newest one and no error said why. A float is refused by new!/3 already; a Decimal is refused here.

maps!(built, fields)

@spec maps!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a plain map (not a struct), and raises ArgumentError otherwise. For the free-form maps (details, line_items, metrics) whose contents are the venue's own but whose container is not.

max_decimal_exponent()

@spec max_decimal_exponent() :: pos_integer()

The widest exponent a Decimal may carry in any field (+/-1000). Exposed so a venue decoder's own decimal/1 can apply the same bound before building a value.

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.

non_negative!(built, fields)

@spec non_negative!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a number that is not negative, and raises ArgumentError naming module, field and value otherwise.

For a size, quantity or volume: a negative one is never what a venue traded or quoted, it is a decode bug (a sign flipped, a delta read as a level), and every value around it stays plausible. Zero is allowed here, because a zero size is a real answer (an empty side, a flat interval); a type that cannot carry zero says so itself. Run it after decimals!/2, which guarantees the fields hold a Decimal, an integer or 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!(built, fields)

@spec positive!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a number above zero, and raises ArgumentError naming module, field and value otherwise.

For a traded price, a resting bid or ask, and a position's size: zero is not a price (a venue's 0 means "no resting order", which its decoder turns into nil) and not a size that is held. Run it after decimals!/2, which guarantees a Decimal, an integer or nil.

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.

symbol_lists!(built, fields)

@spec symbol_lists!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a proper list of non-blank strings, and raises ArgumentError otherwise. nil and [] stay different answers ("not fetched" and "fetched, empty"); anything else, a bare string included, is neither.

text!(built, fields)

@spec text!(struct(), [atom()]) :: struct()

Returns built if each of fields is nil or a non-blank string, and raises ArgumentError naming module, field and value otherwise.

For the identifying text of a funds-moving or addressing field (an address, a network, an asset, an id). A blank string is no name at all and an atom or number is not the venue's text; both used to build, and an address with an empty network is the one the deposit is lost to.

timeframe!(built, field)

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

Returns built if its field holds a label DpExchange.Core.Timeframe.nameable?/1 recognises, and raises ArgumentError naming module, field and value otherwise.

Timeframe.aligned?/2 answers true for a width it has no rule for, so a bar labelled "1H" or "60m" passed the authenticity check unexamined, and was stored under a label nothing else reads. The label is the contract's vocabulary or it is refused. A value that is not text at all is refused the same way (nameable?/1 answers false for it).