Venue feeds: presets and the resolver
A WebSocketFeed
worker knows no venue and no consumer. It serves topics: it is configured
with the topics it fills (topics, changed at runtime by its serve command)
and maps each one to a venue item with its topicitems rules. This package
keeps two things for it:
- the presets: per venue, its connection protocol as plain feed config and
which of its market families it serves on which channel
(
feeds/<preset>.json, embedded inVirtufin.MarketData.DevKitand packed underfeeds/, shipped invirtufin-marketdata-devkitas package data and embedded in the npm package@virtufin/marketdata-devkit); - the resolver and orchestrator: the orchestrator-side library that turns
the topics a set of workers listens to into the feeds that serve them, and
opens, updates and deletes those feeds (
FeedResolver,FeedOrchestrator; Pythonfeeds.resolve,feeds.apply,feeds.delete; TypeScript the same names infeedsof@virtufin/marketdata-devkit).
Nothing in the platform matches topics to feeds: the WorkManager runs workers, the feed serves what it is told, the worker subscribes to its topics. Who opens which feed is decided by the orchestrator (a notebook, a script, the TUI), with this library.
Presets
| Preset | Venue | URL | Login | Serves (family: channel) | Limits | Routing key |
|---|---|---|---|---|---|---|
binance |
binance |
wss://stream.binance.com:9443/stream |
none | orderbook.update: depth@100ms, trade.update: trade, candle.update: kline_<interval> |
1024 streams | stream = {symbol:lower}@{channel} |
alpaca |
alpaca |
wss://stream.data.alpaca.markets/v1beta3/crypto/us |
auth with {secret:alpacakey}/{secret:alpacasecret}, expect "msg":"authenticated", fail "T":"error" |
orderbook.update: orderbooks, trade.update: trades |
10 order books, 1 connection | T,S = o\|BTC/USD, t\|BTC/USD, arrays split |
fakevenue-binance |
binance |
wss://fake-venue.virtufin.com/ws |
none | orderbook.update: depth@100ms |
none | as binance |
fakevenue-alpaca |
alpaca |
wss://fake-venue.virtufin.com/alpaca |
auth with a fixed key (the fake venue takes any) |
orderbook.update: orderbooks |
none | as alpaca |
A preset file:
{
"preset": "alpaca",
"venue": "alpaca",
"maxconnections": 1,
"secrets": {"alpacakey": "the Alpaca API key id", "alpacasecret": "the Alpaca API secret"},
"config": {"url": "wss://...", "handshake": [...], "subscribeframe": "...", "frameper": "channel",
"maxitemsperchannel": {"orderbooks": 10}, "routing": {"key_path": "T,S", "split_arrays": true}},
"topics": {
"orderbook.update": {"channel": "orderbooks", "routingkey": "o|{symbol}"},
"trade.update": {"channel": "trades", "routingkey": "t|{symbol}"}
}
}
config is the feed's config, decoded (see the
config schema).
topics maps a family of the venue's naming fragment (<entity>.<event>) to
its channel template and, where one connection carries several kinds of one
instrument, its own routing key. maxconnections is how many connections the
venue allows; the resolver never plans more.
The rules come from the naming fragments
VenueFeeds.Config(preset, overrides, world) (C#),
feeds.config(preset, overrides, world=...) (Python) and
feeds.config(preset, overrides, world) (TypeScript) render the feed config:
the preset's config, symbolformat/symbolcase from the venue
(virtufin.venue.<venue>), and topicitems from the venue's bound market
entities. For binance in world act:
[
{"topic": "act.exchange.binance.orderbook.update.{pair}", "channel": "depth@100ms"},
{"topic": "act.exchange.binance.trade.update.{pair}", "channel": "trade"},
{"topic": "act.exchange.binance.candle.update.{pair}.{interval}", "channel": "kline_{interval}", "values": {"interval": {"1mo": "1M"}}}
]
Each topic template is the entity's topic pattern with its identity
parameters as placeholders; the venue's interval names (1M for the
canonical 1mo) are the rule's values. No topic is written by hand, and a
new venue is a new fragment binding plus a preset file here, never code in
the feed.
Since 0.19.0 every family a feed serves is per instrument:
trade.update is act.exchange.<venue>.trade.update.<PAIR> and
candle.update is act.exchange.<venue>.candle.update.<PAIR>.<interval>
(they were one topic for every pair), like orderbook.update. The topic is
the whole dependency: a worker that reads BTC_USDT's trades subscribes to that
topic, and the resolver knows which item to subscribe at the venue.
The resolver
var plan = FeedResolver.Resolve(topics, new FeedResolverOptions
{
Owner = runGroup, // name prefix and owner tag
Presets = new Dictionary<string, string> { ["binance"] = "fakevenue-binance" }, // optional, per venue
Current = await orchestrator.CurrentAsync(runGroup), // optional: keep topics where they are
});
plan = feeds.resolve(topics, owner=run_group, presets={"binance": "fakevenue-binance"},
current=await feeds.current(run_group, api.gateway.workmanager))
import { feeds } from "@virtufin/marketdata-devkit";
const workManager = feeds.gatewayWorkManager(apiClient); // @virtufin/api ApiClient: invokeJson("workmanager", ...)
const plan = feeds.resolve(topics, { owner: runGroup, presets: { binance: "fakevenue-binance" },
current: await feeds.current(runGroup, workManager) });
| Step | What happens |
|---|---|
| input | topics (or the workers' catalog topics: FeedResolver.TopicsOf(ListWorkers), feeds.topics_of, TypeScript feeds.topicsOf); templated ones are skipped |
| match | each topic is parsed by the naming library: a venue-bound market entity's event. Its venue's preset (default: the preset named like the venue) must serve its family; anything else is listed in Unserved with the reason (orderbook.snapshot is an HttpGet worker's, not a feed's) |
| group | per (preset, world): a feed's rules are rendered for one world |
| split | the group's topics, sorted, fill as few feeds as maxitems and maxitemsperchannel allow; a topic stays on the feed that serves it now (Current) while that feed has room |
| limits | at most maxconnections feeds per preset, over all its worlds (one at Alpaca: the account's limit); topics that do not fit are listed in OverLimit with the limit, and the plan is incomplete: ThrowIfIncomplete() / raise_if_incomplete() / throwIfIncomplete(), never a silent drop |
| names | <owner>-<preset>-<world>-<n> (run-7-binance-act-1, run-7-binance-hyp-replay-1): the same topics give the same plan in any order |
The orchestrator
var feeds = new FeedOrchestrator(workManager, feedPackageUrl, SourceCredential.Virtufin, new FeedOrchestratorOptions
{
Secrets = new Dictionary<string, IReadOnlyDictionary<string, string>>
{
["alpaca"] = new Dictionary<string, string> { ["alpacakey"] = "alpaca-paper-key", ["alpacasecret"] = "alpaca-paper-secret" },
},
Config = new Dictionary<string, string> { ["publishtopic"] = unrouted, ["releasewhenidleminutes"] = "10" },
Tags = new Dictionary<string, string> { ["source"] = "my-notebook" },
});
await feeds.ApplyAsync(plan); // create or update by name; prune the owner's feeds the plan no longer names
await feeds.DeleteAsync(runGroup); // delete every feed the owner opened
await feeds.apply(plan, api.gateway.workmanager, feeds.package_source(url),
secrets={"alpaca": {"alpacakey": "alpaca-paper-key", "alpacasecret": "alpaca-paper-secret"}},
config={"publishtopic": unrouted}, tags={"source": "my-script"})
await feeds.delete(run_group, api.gateway.workmanager)
await feeds.apply(plan, workManager, feeds.packageSource(url), {
secrets: { alpaca: { alpacakey: "alpaca-paper-key", alpacasecret: "alpaca-paper-secret" } },
config: { publishtopic: unrouted }, tags: { source: "my-script" },
});
await feeds.delete(runGroup, workManager);
- Create or update by name. For each feed of the plan, the running feed
with that
nametag (and the owner'sfeedownertag) is sentserve {"topics": [...]}; if none runs, one is created (configautoreload=false, the preset for the plan's world, its topics), started and awaited. Running it again changes nothing; running it with other topics sends the feeds only the difference to the venue. - Order-independent. Workers subscribe to their topics whenever they start; a feed serves its topics whenever it runs. Apply before or after the workers, and again whenever their topics change.
- Self-release. With
releasewhenidleminutesa feed that served nothing for that long deletes itself. Its release and aserveare decided under one lock in the feed: a serve that lands first cancels the release, one that lands after it is refused (the feed reportsreleasing), and the apply then creates the feed again under its name. A release racing an apply always ends with exactly one feed serving the topics, never two and never none (tested on the devkit'sLocalWorkManagerwith the real feed). - Prune and delete. The owner's feeds the plan no longer names are
deleted;
DeleteAsync(owner)/delete(owner, ...)/feeds.delete(owner, ...)removes every feed the owner opened, and only those. - Secrets are references into the worker Secret, never values; a preset whose login needs secrets that are not given is refused before anything is created.
examples/dotnet/platform.cs wraps both for notebooks (ServeTopicsAsync),
examples/python/websocket_feed.py for the Python scripts (serve_topics), and
examples/typescript/examples/websocketfeed.ts uses them directly.
TypeScript
@virtufin/marketdata-devkit (npm, the @virtufin scope on the Gitea registry,
version = this repo's LIBRARY_VERSION) is the TypeScript side:
| Export | What |
|---|---|
NAMING_FRAGMENTS, fragments(), marketRegistry() |
the virtufin.market and venue fragments, embedded from naming/ at build; fragments() adds virtufin.worlds from @virtufin/worker-devkit (TypeScript has no fragment discovery: the caller composes, NameRegistry.compose) |
marketEntity(entity, venue) |
MarketNames.At: the market entity bound to a venue (marketEntity("orderbook", "binance").topic("update", ACT, CurrencyPair.parse("BTC_USDT"))) |
feeds.presets(), feeds.config(), feeds.topicItems(), feeds.channel(), ... |
VenueFeeds: the presets, embedded from feeds/ at build |
feeds.resolve(), FeedPlan, FeedSpec |
FeedResolver |
feeds.apply(), feeds.current(), feeds.delete() |
FeedOrchestrator, through a GatewayWorkManager (feeds.gatewayWorkManager(apiClient), or any object with the five WorkManager methods in the gateway's JSON mapping) |
The package embeds the repo's files when it is built (scripts/embed.mjs), so
it carries no copy that can drift; its tests check the embedded files against
the repo's.
Conformance
conformance/feed-resolver.v1.json holds the vectors every language runs: the
topicitems each preset renders per world, the channels, the feed names and
the plans resolve makes (groups, sorting, unserved reasons, maxitems and
maxitemsperchannel splits, Alpaca's one connection over all worlds, sticky
current placement), each also resolved in reverse input order. C#
FeedConformanceTests, Python test_feed_conformance.py and TypeScript
test/conformance.test.ts read it; a behaviour change is a vector change first.
The schema
Every preset's config and every rendered config is the decoded form of the
feed's
config schema.
feeds/schema/ holds a copy; the tests check it is identical to the one packed
in the Virtufin.Worker.WebSocketFeed package they run the presets on, and
validate every preset and its rendered config against it (C# and Python).