Normalised trade data for a symbol: what it last traded at, how much has traded, and when the venue says so.
There is no bid and no ask here, deliberately
This struct used to carry :bid and :ask, and every venue package in the family filled
them in. That was wrong. A bid and an ask are the order book — resting orders, what
someone is willing to trade at. A price is an execution — what someone did trade at.
Putting both in one struct invites a caller to reach for whichever is populated, and
invites a package to fill price from ask when the venue sends no price, which is
exactly what one of them did.
Book data lives in Core.Types.TopOfBook (best bid/ask) and Core.Types.OrderBook
(depth). A caller that wants both makes two calls and gets two values that say what they
are. Nothing in this struct can stand in for a book, and nothing in a book can stand in
for a price.
:venue_time is the venue's own; :observed_at is when we read it — BREAKING in 0.2.0
These replace a single :timestamp, and the reason is the whole point of the split.
:timestamp was documented as "the venue's own, never invented". Two venues could not
keep that promise, because the frames they decode carry no venue time at all:
dp_exchange_schwab's LEVELONE_* quotes, and dp_exchange_gemini's partial-depth
books (the vendor's own AsyncAPI requires only [lastUpdateId, bids, asks] there, where
BookTicker requires E). With one field their only options were to lie or to drop real
data, and they lied — a read time in a field a consumer was told was the venue's.
So:
:venue_time— whatever the venue gave us, used as-is. Not normalised, not substituted, andnilwhere the venue published none. Anilhere is information, not an omission: it says the venue did not date this.:observed_at— when this package read it. Always present, nevernil.
:venue_time is the venue's stated time of the last trade :price came from — not of
a quote, and never an HTTP Date header, a message's send or assembly time, a socket
envelope's timestamp, or another event's time. Where the venue states no trade time it is
nil.
TopOfBook has had exactly this shape from the start, and its own doc already explained
why. This is that decision finishing its journey to the other two payload types.
Why :observed_at is mandatory and :venue_time is not
Requested by the consumer who decided this design (dp-exchange-core issue #31), and it is
the property that makes strict honesty affordable: a consumer always has a usable time,
so :venue_time can be left nil without anyone being forced to invent one. Drop the
guarantee and every caller needs a fallback, which is the substitution this whole change
exists to remove, relocated into consumer code.
What that buys, in their words: they store :venue_time as the point time where the venue
dated the frame, and where it did not they store :observed_at and record that they
did — so a mis-bucketed candle is attributable rather than invisible. With one field
they could not make that decision at all, because they could not see which kind of time
they had.
What this is not
It is not a licence to fill :venue_time from a local clock when the venue is quiet.
The rule that field carries is unchanged and absolute: whatever the venue gave us, or
nil. dp_exchange_schwab's Streamer book is the model — it reads the venue's
snapshot_time and fails closed when it is absent rather than substituting.
:volume is nil when the venue publishes none
Never 0. A venue that reports no volume and a venue reporting a genuinely flat period
are different facts, and 0 claims the second.
:volume_window says what quantity :volume is
:volume used to say only that it was a volume, and the family filled it with three
different quantities: Coinbase's stream a rolling 24-hour total, Schwab's stream one
print's size, Schwab's REST quote the day's running total. dp_crypto_management summed
the field into candles as if every value were one print. On 2026-10-02 that put a
Coinbase pair's 15-minute average volume about 1000× above what the venue trades in a
whole day (dp-exchange-core issue #42). The contract invited that mistake, so the window
is now stated on every quote that carries a volume:
:print— the size of the one trade:pricecame from. Sum these for an interval's volume. Only valid when every print is delivered. A conflated stream (several trades merged into one update, as Schwab'sLEVELONEis) skips prints, so summing its last sizes undercounts silently; such a stream reports the venue's cumulative volume as:running_total, the same as its REST quote.:running_total— a cumulative total that the venue resets at a boundary of its own (a trading day, a session). Difference consecutive values, never sum them, and treat a decrease as a reset.:rolling_24h— the trailing 24 hours, recomputed on every update. It cannot be turned into an interval's volume: differencing it gives what traded minus what rolled off a day ago. For per-interval volume, use the venue's trade stream (:trades) instead.
nil when :volume is nil. A quote carrying a volume with a nil window comes from a
package built before this field existed, and its volume must not be aggregated, because
nothing says which of the three it is. new/1 refuses an unknown window, and refuses a
window on a quote with no volume.
Summary
Types
@type t() :: %DpExchange.Core.Types.Quote{ observed_at: DateTime.t(), price: Decimal.t(), provider: atom(), symbol: String.t(), venue_time: DateTime.t() | nil, volume: Decimal.t() | nil, volume_window: volume_window() | nil }
@type volume_window() :: :print | :running_total | :rolling_24h
What quantity :volume is. See the moduledoc's :volume_window section.
Functions
Builds a t/0, failing closed if a required field is absent or nil.
@enforce_keys guards presence, not nil — see DpExchange.Core.Types.Validate.
Raises
On a :price of zero or less (a traded price is above zero), on a :volume_window outside [:print, :running_total, :rolling_24h], and on a :volume_window
given for a quote with no :volume. Both are a caller's mistake, and both would publish a
claim about a quantity that is not there.
@spec volume_windows() :: [volume_window()]
Every value :volume_window may take when :volume is present.