Table of Contents

Timestamps and Prices

Read this before you compute anything from a record. DBN's timestamps are ulong nanoseconds, its prices are long at a fixed 1e-9 scale, and both use MaxValue as an "undefined" sentinel. Every one of those three facts has an obvious-looking conversion that is silently wrong.


The rule in one line

ulong nanoseconds on the wire and in the codec; NodaTime at every boundary above it. DbnTime is the one crossing between them.

Why record fields stay ulong

Records are reinterpreted in place over the read buffer, so a field's type is its wire layout. The wire has an 8-byte u64. Instant is 16 bytes and LocalDate is 4. Putting a NodaTime type in a record struct would not be a compile error — it would be silent data corruption, with every field after it read from the wrong offset.

Why not DateTime

A DateTime tick is 100 nanoseconds. A DBN timestamp is nanoseconds. The BCL literally cannot represent one:

1609160400000000001 ns  →  Instant   →  1609160400000000001   ✓ exact
1609160400000000001 ns  →  DateTime  →  1609160400000000000   ✗ last digit gone

That is not a rounding nicety. Two events one nanosecond apart in an MBO stream — a cancel and the replacement order — become simultaneous, and their order becomes whatever your sort happens to do with a tie.

NodaTime reaches you transitively as a public dependency of DatabentoDotNet.Dbn, because Instant and LocalDate appear in the public API. That was a deliberate cost, not an accident.

The repository enforces this on itself with BannedApiAnalyzers: DateTime, DateTimeOffset, DateOnly, TimeOnly, and TimeSpan are build errors in every project, tests included. You are not bound by that in your own code — convert an Instant to whatever your application uses. Just be aware of what the conversion costs.

Converting timestamps

using DatabentoDotNet.Dbn;
using NodaTime;

ulong ns = trade.IndexTs;

DbnTime.IsUndefined(ns)                        // is this the sentinel?
DbnTime.TryToInstant(ns, out Instant instant)  // false when undefined
DbnTime.ToInstant(ns)                          // throws when undefined
DbnTime.TryToUtcDate(ns, out LocalDate date)   // false when undefined
DbnTime.ToUtcDate(ns)                          // throws when undefined
DbnTime.ToUnixNanoseconds(instant)             // back to the wire
DbnTime.ToUnixNanosecondsAtMidnightUtc(date)

Use the Try* pair when "no timestamp" is an ordinary outcome, and the throwing pair when a missing timestamp means the data is wrong.

The sentinel that survives a naive conversion

DBN's undefined-timestamp sentinel is ulong.MaxValue, and the obvious cast wraps silently:

Duration.FromNanoseconds((long)DbnConstants.UndefTimestamp)   // -1 ns. No exception.

Duration.FromNanoseconds takes a long. (long)ulong.MaxValue is -1. That resolves to an Instant one nanosecond before the Unix epoch — 1969-12-31T23:59:59.999999999Z — which looks like a real timestamp, sorts like a real timestamp, and is not one.

The sentinel is no safer as a date. It floor-divides to an entirely ordinary-looking day in 2554.

Every DbnTime conversion checks the sentinel first. Do not add a second conversion path that skips the check, and do not hand-roll one:

// Wrong. Silently produces 1969-12-31T23:59:59.999999999Z for an absent timestamp.
var t = Instant.FromUnixTimeTicks(0) + Duration.FromNanoseconds((long)ns);

// Right.
if (!DbnTime.TryToInstant(ns, out var t)) { /* no timestamp */ }

The 2262 ceiling, and why DbnTime does not go through a single long

long.MaxValue nanoseconds since the epoch is the year 2262. Any ulong above that overflows a naive conversion, and there is a whole range of them below the sentinel.

DbnTime therefore splits the value into whole days plus a nanosecond-of-day remainder rather than counting in a single long. Every ulong below the sentinel converts exactly:

ulong.MaxValue - 1  →  2554-07-21T23:34:33.709551614Z     ✓ not an overflow
ulong.MaxValue      →  undefined                          ✓ reported, not converted

Which timestamp to use

record.IndexTs, not record.Header.TsEvent.

Fourteen of the twenty-one record structs carry a ts_recv and index on it; the rest have no ts_recv at all and fall back to ts_event. IndexTs picks the right field per record type, and RecordRef.IndexTs does it without knowing the concrete struct.

Field What it is
Header.TsEvent When the venue says the event happened
TsRecv When Databento's capture received it
IndexTs Whichever of the two this record type is indexed by
TsOut When the live gateway sent it. Present only if the session negotiated ts_out

The distinction is not cosmetic. ts_event and ts_recv can fall on opposite sides of UTC midnight. Resolve a symbol by the wrong one and you silently get the previous day's symbol, or nothing at all, with no error anywhere. That is the exact failure IndexTs exists to prevent, and it is why Symbol Resolution keys on IndexTs throughout.

For the date rather than the instant:

using DatabentoDotNet.Dbn;

if (record.TryIndexDate(out LocalDate date)) { /* … */ }
LocalDate d = record.IndexDate();                   // throws when undefined

Prices

Prices are long at a fixed 1e-9 scale. A price of 100_000_000_000 is 100.0.

const long Scale = DbnConstants.FixedPriceScale;    // 1_000_000_000

double display = trade.Price / (double)Scale;       // fine for printing
decimal exact   = trade.Price / (decimal)Scale;     // fine for arithmetic you will keep

decimal is not used on the wire or in the structs deliberately: it would cost throughput on the hot path, and a record field's type is its wire layout. Convert at the boundary, as with timestamps.

Prefer integer arithmetic where you can. Spreads, mid-points, and notional values are all exact in the fixed-point representation and only stop being exact once you divide:

long spread = ask - bid;                            // exact
long mid    = (ask + bid) / 2;                      // exact to 1e-9, which is the wire's own resolution

The price sentinel

DbnConstants.UndefPrice is long.MaxValue, and it means "no price" — an unquoted side, a book level that does not exist, a statistic that does not apply. Divided by 1e9 it becomes about 9.22e9, which is a number, not an error:

if (level.BidPx == DbnConstants.UndefPrice) { /* no bid */ }

Check it before you compute a spread. An unquoted side otherwise produces a spread of roughly nine billion dollars, and nothing anywhere reports a problem.

Size sentinels

DbnConstants.UndefOrderSize is uint.MaxValue, with the same reasoning. StatMsg has its own: UndefStatQuantity is long.MaxValue in v2+ and int.MaxValue in v1.

The three sentinels, together

Constant Value Naive conversion gives
UndefTimestamp ulong.MaxValue 1969-12-31T23:59:59.999999999Z, or a day in 2554
UndefPrice long.MaxValue 9223372036.854775807
UndefOrderSize uint.MaxValue 4294967295

None of them throws. All three look like data. Check them.

See also