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
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.
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.
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.
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.
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.
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.
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.
@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.
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.
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.
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.
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.
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.
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.
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.
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).