Models and streams market data with yggdryl's graph layer in Rust, Python and Node.js - orders, quotes, executions and trades as dated events (OrderEvent, QuoteEvent, ExecutionEvent, TradeEvent), identities and side-keyed chains (curruuid, crossuuid, with_previous / withPrevious, EventIterator), order books (BookIterator, BookEvent, limits, best_price / bestPrice, spread, depth, SnapshotEvent), the Side and MarketDataKind enums and the lifted marketdata Arrow row (MarketData.arrow_reader / arrowReader, from_arrow_reader / fromArrowReader, apply_view / applyView). Use when building market events, folding them into books, or persisting or querying marketdata batches.
Resources
1Install
npx skillscat add platob/yggdryl/yggdryl-market-data Install via the SkillsCat registry.
yggdryl market data
The graph layer is market data as elements that name each other by
identity, never by reference. Four Rust traits say what an element answers -Element (identity, cross code, digest, sources), Event (instant, state,
place at its instant, clocks), Market (twenty-seven facts: price, quantity, currency,
unit, side, security ids and the isincode they hold, classification, market,
last-trade, progress, FX parts, the stated bid and ask - bidpx, bidqty,bidccy, askpx, askqty, askccy - the fxrates, ticker, metadata) andOperation (three more: time in force, whether it trades, the altids) - and
typed leaves answer them: Order/OrderEvent, Quote/QuoteEvent,Execution/ExecutionEvent, the composite TradeEvent, and the book typesBookEvent and SnapshotEvent. MarketData is the one value over every
leaf, and the lifted marketdata Arrow row (53 columns, led by themarketdatakind its leaf stands under) is how any of them crosses a boundary.
Hold five facts:
- Instants are
i64nanoseconds since the Unix epoch, UTC -currunix,creaunix,execunix,recdunix,exprunix,prevunix,snapunix.
Python ints, JavaScriptbigints, Arrowdatetime64(ns, UTC). - Identity is derived, not assigned. A leaf is finalized on construction:
currhashcodeis the XXH3-64 of its content,curruuida UUIDv7 of
millisecond + place for a dated leaf (UUIDv8 of content otherwise),crossuuidthe chain every incarnation shares, from the cross code. - A side is never null, and it keys the chain.
SideandMarketDataKindareint32enums; an element stating no side holdsUNKNOWN(code 0). A sided element stores its cross code under its side -BUY:O-1001- so the two sides of one identifier are two chains;UNKNOWN
and books keep the bare code. - Chains are walked, not rebuilt. An event names only its predecessor
(prevuuid);seqnumis its place among the events of its instant, which
orders the identities of one millisecond, and a step keeps its own unless
its predecessor shares or passes its instant.EventIteratorjoins a stream by cross identity and
by thealtidsa live element went by, folds twins, emits expiries, and
leaves every element statingcreaunix. A grid view is the live element
as of its tick: dated at it (currunix=snapunix), so itscurruuid
is the tick's own while content,seqnum,prevuuidandcrossuuidare
the live element's; it advances nothing. - Books are folded, not replayed by hand.
BookIteratorfolds a sorted
stream into oneBookEventper book and instant; read books back frommarketdatarows, never by re-applying their deltas.
Choose the door
| Task | Rust | Python | JavaScript |
|---|---|---|---|
| a dated order / quote / execution | OrderEvent::at(unix), set_*, finalize() |
graph.OrderEvent(unix, **facts) |
new graph.OrderEvent(unix, facts) |
| an undated leaf, dated later | Order::new(), order.at(unix), event.into_element() |
graph.Order(**facts), .at(unix), .into_element() |
new graph.Order(facts), .at(unix), .intoElement() |
| a two-sided quote | set_bidpx, set_askpx, set_bidqty ... |
graph.QuoteEvent(unix, bidpx=..., askpx=...) |
new graph.QuoteEvent(unix, { bidpx, askpx }) |
| a market-data entry's book control | event.with_book(BookRef { .. }) |
event.with_book(graph.BookRef(action="new", position=1)) |
event.withBook(new graph.BookRef({ action: 'new', position: 1 })) |
| security identifiers | insert_securityid(SecurityId::new(..)?)? (Rust-only verbs) |
securityids={"ISIN": ...} at build |
securityids: { ISIN: ... } at build |
| FX rates (nothing fills them) | insert_fxrate(ccy, rate), set_fxrates(map) |
fxrates={"EUR": Decimal("1.1")} at build |
fxrates: { EUR: '1.1' } at build |
| a composite trade | TradeEvent::from_parts(&root, executions)? |
graph.TradeEvent.from_parts(root, executions) |
graph.TradeEvent.fromParts(root, executions) |
| follow a predecessor | event.with_previous(&prev) |
event.with_previous(prev) |
event.withPrevious(prev) |
| merge two statements of one event | event.merge_with(&other) |
event.merge_with(other) |
event.mergeWith(other) |
| walk a stream into chains | EventIterator::new(items, sorted), .with_snapshot_ns(ns) |
graph.EventIterator(items, sorted=True, snapshot_ns=None) |
new graph.EventIterator(items, sorted, snapshotNs) (sorted defaults to true) |
| any leaf as one value | MarketData::from(leaf), kind(), marketdatakind(), as_order_event(), TryFrom |
graph.MarketData(leaf), .kind, .marketdatakind, .as_order_event(), .into_leaf() |
new graph.MarketData(leaf), .kind, .marketdatakind, .asOrderEvent(), .intoLeaf() |
the marketdata row schema |
MarketData::field()? |
graph.MarketData.field() |
graph.MarketData.field() |
| leaves to Arrow batches | MarketData::arrow_reader(values, None, None)? |
graph.MarketData.arrow_reader(values) |
graph.MarketData.arrowReader(values) |
| Arrow batches to leaves | MarketData::from_arrow_reader(reader)? |
graph.MarketData.from_arrow_reader(source) |
graph.MarketData.fromArrowReader(reader) |
| fold a sorted stream into books | BookIterator::new(items, snapshot_millis)? |
graph.BookIterator(items, snapshot_millis=0) |
new graph.BookIterator(items, snapshotMillis = 0) |
| one book by hand | BookEvent::new(unix, symbol), add_operations(..)? |
graph.BookEvent(unix, symbol).with_operations([...]) |
new graph.BookEvent(unix, symbol).withOperations([...]) |
| a book's entries | alive(), deltas(), executions() |
book.alive, book.deltas, book.executions |
book.alive(), book.deltas(), book.executions() |
| read a side | limits(Side::Buy), best_price(Side::Buy), best_quantity(..), depth(Side::Buy, n) |
book.limits(Side.BUY), book.best_price(Side.BUY), book.depth(Side.BUY, n) |
book.limits('BUY'), book.bestPrice('BUY'), book.depth('BUY', n) |
| read both sides | get_bidpx(), get_askpx(), spread(), is_crossed(), imbalance(n) |
book.bidpx, book.askpx, book.spread, book.is_crossed, book.imbalance(n) |
book.bidpx, book.askpx, book.spread, book.isCrossed, book.imbalance(n) |
| clear a scope with a snapshot | SnapshotEvent::snapshot(&event, scope) |
graph.SnapshotEvent.snapshot(event, scope=None) |
graph.SnapshotEvent.snapshot(event, scope) |
| a named view of a stream | MarketData::apply_view(&MarketView::Orders, &lifts, reader)? |
graph.MarketData.apply_view("orders", source, lifts) |
graph.MarketData.applyView('orders', reader, lifts) |
| a view as a plan | MarketData::plan(&view, &lifts)? |
graph.MarketData.plan("trades") |
graph.MarketData.plan('trades') |
| FIX to sorted market data / books | codec.market_data(codec.lifecycle(msgs)), codec.book_arrow_reader(msgs, 0)? |
codec.market_data(...), codec.book_arrow_reader(msgs, snapshot_millis=0) |
codec.marketData(..), codec.bookArrowReader(msgs, 0) |
Rules for fast, correct use
- Stay in the
marketdatarow for bulk.MarketData.arrow_readerwrites
column by column with no per-rowScalar, closing batches on a row and a
byte bound;from_arrow_readerlands each batch once and resolves columns
by name once per stream. Persist and exchange that row (Parquet, IPC)
rather than bespoke per-leaf tables. from_arrow_readeris tolerant of shape: any subset of columns, any order,
any case, extra columns ignored, castable columns cast by one plan compiled
before the first batch. Amarketdatakindcolumn and, for a dated leaf,currunixare the minimum: an undatedORDR,QUOTorEXECrow is an
order, a quote or an execution, a dated one the event;TRADandBOOK
must be dated, and aBOOKrow is a book where itsalivecell is not
null, a snapshot control where it is.- Views are
Plans run by the expression engine:apply_viewbinds once
against the reader's schema and streams;plan()shows the text. Add a
nested fact as a column with a lift (securityids['ISIN'] as isin) instead
of post-processing rows. Thelifecycleview collects (it orders). - Feed
BookIteratora sorted stream (bysnapunix, elsecurrunix); it
refuses a timestamp regression.FixCodec.market_datais the sorted door
for a FIX capture;EventIterator(sorted=false)sorts a finite stream
itself. - One book per touched instant and book key - the input's ticker, else its
category{miccode}:{cficode}(XXXX,XXXXXXfor what it does not
state). Depth persists,deltasandexecutionscarry only that instant's
changes. A positivesnapshot_millisadds the complete live book at every
crossed epoch-aligned tick. - A book row nests
alive,deltas,executions(operation rows) andbidlimits,asklimits(oneLimitper price level, best first:price,quantity,uuids,tradable). A level trades unless every entry at it
statestradable = false;best_price, the book'sbidpx/askpx, the
spread and the crossed and locked readings read the first level that trades. - Join and chain by identity:
crossuuidis one chain whatever identifier an
event used; a later event joins a live one of its side through analtids
pair (ORDERID,CLORDID,MDENTRYID...), and one stating no side joins
the single side alive under its code. Name identifiers there, upper-cased,
rather than inventing a column. - Leaves are immutable in the bindings:
with_previous,merge_with,restating,with_book,with_operationsanswer a new value; onlywith_previousandmerge_withanswerNone/nullwhen nothing moved.
Build with named facts: a fact given as.../undefinedis skipped,None/nullclears it; a derived identity (curruuid,crossuuid,currhashcode,crosshashcode) is refused. - Sources are provenance:
srcuuidsnever changes identity, never travels
along a chain, and merges as a sorted union - use it to point back at the
lines an event was read from. - Numbers are exact decimals: pass
Decimal('189.5')in Python and the text'189.5'in JavaScript - a float price (189.5) is refused at$.price
(got f64). Python answersScalar(.as_py()->Decimal), JavaScript
exact text ('189.5'), RustDecimal.
Pitfalls
- A millisecond or second timestamp where nanoseconds are expected lands in
1970:graph.OrderEvent(1_700_000_000_000, ...)is 28 minutes after the
epoch. Multiply to nanoseconds first. crosscodeanswers the stored, side-prefixed code: a buy set toO-1
readsBUY:O-1, and the lifecycle view and any lookup name it that way.- Python enum facts are
IntEnummembers (order.side is Side.BUY); JavaScript
getters answer the name ('BUY') while an Arrow column stores the code
(Side.BUY === 1). Compare against the one you hold. BookIteratorover an unsorted list refuses the first regression at$.operations("expected a sorted operation timestamp at or after ...");
sort first, or take the operations fromFixCodec.market_data.- A book folds only dated operations, trades and snapshot controls: an
undatedOrderor aBookEventis refused - byBookIteratorat$.operation.kind, bywith_operations/add_operationsat$.operations[i].kind. A live entry or an execution whose side isUNKNOWNcannot be placed on a side. EventIteratordefaults tosorted=True/trueand trusts the order: an
unsorted stream is not refused, it silently yields broken chains (an
element before the live one is yielded as it came and joins nothing). Passsorted=False/falsefor a stream you have not
sorted (it collects to sort); onlyBookIteratorrefuses a regression.- A trade is built only through
TradeEvent.from_parts: at least one
execution, each bid- or ask-sided, at the root's instant, one ticker,
distinct cross codes. - A lift key is matched exactly and stored upper case:
securityids['ISIN'], neversecurityids['isin'](that reads null). MarketData.kindisorder_eventfor a dated order; the leaf's ownkind
isorder; both stand undermarketdatakindORDR.- An order's
priceis what it states, never its last execution and never a
bid: readlastpx/lastqtyfor fills,bidpx/askpxfor the bid and ask
a quote or a book states. fxratesmaps a target currency to the rate an amount in the element'scurrencyis divided by; nothing fills it, and a merge unions the targets.- The lifecycle view needs its chain:
apply_view("lifecycle", source, crosscode="BUY:O-1"); every other view refuses acrosscode. - Rust's
Limitvalue type, the column enums' verbs (EventColumn::fact,record) and theinsert_/remove_/derive_identifier verbs are
Rust-only; the bindings answer a limit as a structScalar(Python) or a
plain object (JavaScript) and state identifiers when they build a leaf.
Language references
- Rust: references/rust.md -
yggdryl::graph::*leaves and traits,Decimal,Side,MarketDataKind,Ccy. - Python: references/python.md -
from yggdryl import graph,Side,MarketDataKind, pyarrow readers. - JavaScript: references/javascript.md -
graph,Side,MarketDataKind.
Read the one for the language you write; recipes appear in the same order in each.
Deeper
- Graph overview and traits: https://platob.github.io/yggdryl/graph/
- Element (identity, cross code, sources): https://platob.github.io/yggdryl/graph/element/
- Event (instants, following, merging, the walk): https://platob.github.io/yggdryl/graph/event/
- Market facts and security identifiers: https://platob.github.io/yggdryl/graph/market/
- Operation facts and identifier maps: https://platob.github.io/yggdryl/graph/operation/
- Order, quote, execution leaves and book control: https://platob.github.io/yggdryl/graph/order/
- Trade: https://platob.github.io/yggdryl/graph/trade/
- Book, limits, snapshots, the fold: https://platob.github.io/yggdryl/graph/book/
MarketData, columns, Arrow row, views: https://platob.github.io/yggdryl/graph/market-data/SideandMarketDataKind: https://platob.github.io/yggdryl/types/enum/- Sibling skills:
yggdryl-fix(FIX captures into market data and books),yggdryl-expressions(thePlana view is),yggdryl-records(persistingmarketdatabatches),yggdryl-arrow(BatchReader, casts),yggdryl-hashing(the digests and UUIDs identities are built from),yggdryl-types(Decimal, the codesccyandforex, the enumsside,marketdatakind,state).