Pub/Sub Topics
Purpose¶
Canonical naming scheme for Dapr pub/sub topics and state keys across all Virtufin services. Defines the routing taxonomy, CloudEvents envelope contract, state-key taxonomy, and scenario-id axis. Subsumes the prior cross-cutting "Per-service Pubsub Topics" requirement.
Related specs:
- Scenarios — scenario registry, triplet definition, LIVE bootstrap.
- Cross-cutting — API-mediated pub/sub (envelope contract travels through the API gRPC).
- Worker Management — worker + connection lifecycle events (workmanager.lifecycle, websocketmanager.lifecycle).
- Proto-to-Client Mapping — code-generation conventions for the gRPC types referenced here.
Architecture¶
Two orthogonal routing axes plus a scenario-agnostic infrastructure axis:
| Axis | Pattern | Scope | Example |
|---|---|---|---|
| Market data world | act.exchange.<venue>.<entity>.<event>[.<id>] or hyp.<name>.exchange.<venue>.<entity>.<event>[.<id>] |
data-universe-bound; shared across scenarios | act.exchange.binance.orderbook.update.BTCUSDT |
| Scenario world | sc.<scenarioId>.<domain>.<entity>.<event>[.<id>] |
triplet-bound (market + portfolio + strategy) | sc.LIVE.trading.order.filled.<order_id> |
| Tier 0 / infrastructure | <service>.<category> |
scenario-agnostic | websocketmanager.lifecycle, state.change |
Rationale: - Market data is world-scoped because it depends on the data universe (live vs hypothetical replay) and is shareable across scenarios. Two paper runs sharing the same Binance feed do not double-publish ticks. - Trading / position / risk / pnl are scenario-scoped because each depends on the full triplet. P&L in particular is meaningless without knowing all three legs. - Tier 0 is scenario-agnostic because infra lifecycle (workers up, connections reconnecting) is a deployment concern, not a trading concern.
act and hyp.<name> are both top-level world markers. act is the single actual reality (act.exchange.*); a hypothetical world carries a <name> segment identifying which hypothetical reality is in play (hyp.BACKTEST.exchange.*). Bare hyp (no name) is never a valid topic segment — it denotes the category of all hypothetical worlds, not a specific one (see Scenarios spec §Named Hypothetical Worlds). The data universe (ALL, EQ_EUROPE, EQ_2008_2009, …) is not encoded in the topic — it travels in the marketuniverse CloudEvent envelope extension (see below) and on the producing WebSocketManager connection's universe tag. The scenario triplet (market + portfolio + strategy legs) lives in the scenario registry.
Requirements¶
Requirement: Topic Patterns¶
Services SHALL publish to and subscribe from topics matching one of the following patterns, to keep the taxonomy consistent and routable across the platform. This is a naming convention, not an enforced contract: no service (publisher, subscriber, or virtufin-api itself) validates or rejects topics against these patterns before publish -- see cross-cutting spec for why no enforcement point owns this check.
Scenario: Tier 0 / infrastructure topic¶
- WHEN a service publishes a lifecycle / infra event
- THEN the topic SHALL match
<service>.<category>(e.g.websocketmanager.lifecycle,workmanager.lifecycle) - AND the
<category>SHALL be a domain noun (lifecycle,infra,audit) — not a state name
Scenario: Market data topic (real)¶
- WHEN a service publishes real (live) market data
- THEN the topic SHALL match
act.exchange.<venue>.<entity>.<event>[.<entity_id>] - AND the data universe SHALL be carried in the
marketuniverseenvelope extension (ALLfor unrestricted,EQ_EUROPEfor a live subset) — it is not a topic segment
Scenario: Market data topic (hypothetical)¶
- WHEN a service publishes hypothetical (replay / backtest) market data
- THEN the topic SHALL match
hyp.<name>.exchange.<venue>.<entity>.<event>[.<entity_id>] - AND
<name>SHALL identify the specific hypothetical world (see Scenarios spec §Named Hypothetical Worlds) — barehyp(no name) SHALL NOT appear in a topic - AND the data universe SHALL be carried in the
marketuniverseenvelope extension, independent of<name>— the same universe MAY appear under different hypothetical worlds and vice versa
Scenario: Trading topic¶
- WHEN a service publishes a trading-domain event (order lifecycle, trade lifecycle)
- THEN the topic SHALL match
sc.<scenarioId>.trading.<order|trade>.<event>(consolidated; per-order/per-trade routing viace-subject-- see the Cardinality Rule requirement for why the entity ID does not appear in the topic) - AND
<scenarioId>SHALL be either the reservedLIVEor a registered opaque scenario ID - AND
ce-subjectSHALL be set toorder/<order_id>ortrade/<trade_id>per the Common Envelope Extensions requirement
Scenario: Position topic¶
- WHEN a service publishes a position-domain event
- THEN the topic SHALL match
sc.<scenarioId>.position.<event>(consolidated; per-symbol and portfolio-aggregate routing viace-subject) — there is no longer a separateposition.portfolio.<event>form - AND
ce-subjectSHALL be set toposition/<symbol>for a per-symbol event orposition/portfoliofor the aggregate snapshot
Scenario: Risk topic¶
- WHEN a service publishes a risk-domain event
- THEN the topic SHALL match
sc.<scenarioId>.risk.<metric>.<event>(consolidated; per-symbol routing viasubject)
Scenario: P&L topic¶
- WHEN a service publishes a P&L-domain event
- THEN the topic SHALL match
sc.<scenarioId>.pnl.<metric>.<event>(consolidated; per-symbol routing viasubject)
Requirement: No Topic or Scenario-State Validation Anywhere¶
No service — publisher, subscriber, virtufin-api's Pubsub.PublishEvent handler, or any shared helper — SHALL validate a topic's shape against the patterns above, or a scenario ID's registration/lifecycle status, before publish. This was previously a per-service inline requirement (added by the 2026-07-09 pubsub-pattern-fixes change); reversed because it duplicated Virtufin domain vocabulary (universes, scenario IDs, lifecycle status) into every publishing service's own code, including services whose charter is explicitly "pure infrastructure, no domain logic" (WebSocketManager, WorkManager). virtufin-websocketmanager's TopicValidator was the one place this was actually implemented; it has been removed in full.
Scenario: Malformed or unregistered topic is published anyway¶
- WHEN a service publishes to a topic that matches no pattern in this spec, or to an
sc.<scenarioId>.*topic whosescenarioIdis not registered (or ispaused/archived/deleted) - THEN the publish SHALL proceed; Dapr/NATS is the only enforcement point, accepting whatever it considers a legal topic string
- AND detecting and correcting a bad topic or stale scenario reference is a monitoring/observability concern (dead-letter, consumer-side logging), not a publish-time rejection
Requirement: CloudEvents Envelope¶
Every event SHALL be a CloudEvents v1.0 envelope. The CE base attributes (id, source, type, time, subject when applicable, specversion, datacontenttype) SHALL be carried as ce- prefixed metadata; data SHALL be the JSON payload.
Scenario: ce-type format¶
- WHEN a service publishes an event
- THEN
ce-typeSHALL match one of: - Tier 0:
com.virtufin.<service>.<action>(e.g.com.virtufin.websocketmanager.connected) — no category segment: the Tier 0 topic is already<service>.<category>, so repeating it in the type duplicates routing the subscriber already has. An<action>is one token; multi-word actions hyphenate (instance-registered) rather than adding dotted sub-levels. The same<action>MAY appear under two topics of one service (e.g.com.virtufin.workmanager.onlineon bothworkmanager.lifecycleandworkmanager.infra.worker); the topic is what disambiguates them. - Market data:
com.virtufin.exchange.<venue>.<entity>.<event>(e.g.com.virtufin.exchange.binance.orderbook.updated) - Trading:
com.virtufin.trading.<order|trade>.<event>(e.g.com.virtufin.trading.order.filled) - Position:
com.virtufin.position.<event>(e.g.com.virtufin.position.flipped) - Risk:
com.virtufin.risk.<event>(e.g.com.virtufin.risk.exposure.updated) - P&L:
com.virtufin.pnl.<event>(e.g.com.virtufin.pnl.realized.updated)
Scenario: ce-source format¶
- WHEN a service publishes an event
- THEN
ce-sourceSHALL beurn:com.virtufin:<service>(e.g.urn:com.virtufin:websocketmanager,urn:com.virtufin:trading-engine) - AND a per-instance publisher MAY append the instance as a further segment (e.g.
urn:com.virtufin:websocketmanager:{websocketId}) - AND the namespace and service SHALL be colon-separated, not dot-separated:
urn:<NID>:<NSS>is the URN shape, and the earlierurn:com.virtufin.<service>had no NSS at all - AND the URN SHALL be stable per service (not per instance)
Scenario: subject attribute (per-entity events)¶
- WHEN a service publishes an event referring to a specific entity (order, trade, position, symbol)
- THEN
ce-subjectSHALL be set to<entity_type>/<entity_id>(e.g.order/<order_id>,position/BTCUSDT,currency_pair/BTCUSDT) - AND consolidated topics (risk, pnl, portfolio) SHALL also use
subjectto enable per-entity routing without topic proliferation
Requirement: Common Envelope Extensions¶
Every Tier 1 event SHALL carry the following CloudEvents extensions, scoped by the event's axis. Tier 0 events MAY carry correlationid only.
The scenario-bound extensions -- scenarioid, runid, portfolioworld, strategyworld -- apply to scenario-scoped Tier 1 events (sc.<scenarioId>.*) only. World-scoped market-data events (act|hyp.<name>.exchange.<venue>.*) SHALL NOT carry them: market data is shared across scenarios (see Architecture), so no single scenario, run, portfolio or strategy owns it, and any value would be wrong for every other consumer.
| Extension | Type | Required | Notes |
|---|---|---|---|
correlationid |
UUID | Tier 1 yes; Tier 0 no | Ties fills back to originating order across services |
scenarioid |
string | Scenario-scoped yes; market data no | Opaque registry key; LIVE for production |
runid |
UUID | Scenario-scoped yes when parallel backtests; market data no | Disambiguates concurrent runs sharing a scenario |
exchange |
string | Tier 1 yes (when applicable) | Exchange name (binance, coinbase, …); distinct from ce-source |
clocktype |
wall | historical |
Tier 1 yes | wall for prod + paper; historical for backtest replay |
eventtime |
RFC3339 | Tier 1 yes (= ce-time) |
Logical event time matching clocktype; for derived events, inherited from the triggering event (see below) |
walltime |
RFC3339 | Tier 1 yes | Always real clock at publish — for tracing / logging |
marketworld |
act | hyp.<name> |
Tier 1 yes | Triplet leg (world) for market |
marketuniverse |
string | Tier 1 yes | Triplet leg (universe) for market; present for both act and hyp.<name> (use "ALL" for unrestricted) |
portfolioworld |
act | hyp.<name> |
Scenario-scoped yes; market data no | Triplet leg for portfolio |
strategyworld |
act | hyp.<name> |
Scenario-scoped yes; market data no | Triplet leg for strategy |
CloudEvents extension attribute names are restricted to lowercase alphanumerics — no dots, no underscores. These four are deliberately concatenated (marketworld, not market.world or market_world) to satisfy that constraint, matching every other extension in this table.
Scenario: Envelope on a production fill event¶
- WHEN a service publishes
sc.LIVE.trading.order.filled(ce-subject = order/<order_id>) - THEN the CloudEvent extensions SHALL include
scenarioid=LIVE,runid=<uuid>,correlationid=<uuid>,exchange=binance,clocktype=wall,eventtime=<rfc3339>,walltime=<rfc3339>,marketworld=act,marketuniverse=ALL,portfolioworld=act,strategyworld=act
Scenario: Envelope on a backtest fill event¶
- WHEN a backtest engine publishes
sc.S7F3A9B.trading.order.filled(ce-subject = order/<order_id>) - THEN the envelope SHALL include
scenarioid=S7F3A9B,clocktype=historical,eventtime=<historical rfc3339>(the replayed tick's timestamp),walltime=<real rfc3339>(now),marketworld=hyp.BACKTEST,marketuniverse=EQ_2008_2009,portfolioworld=hyp.BACKTEST,strategyworld=hyp.BACKTEST - AND
runidSHALL differ for concurrent backtests against the same scenario
Scenario: clocktype rationale¶
- WHEN a strategy computes a time-series indicator (e.g. 20-period moving average) from a backtest stream
- THEN it SHALL use
eventtime(which ishistoricalfor backtests) to maintain correct indicator semantics - AND it SHALL use
walltimeonly for tracing, logging, and alerting
Scenario: Envelope on a derived event¶
- WHEN a worker publishes an event derived from a triggering event (an indicator value from a candle, an order from a market event, a trade event from an order)
- THEN its
eventtimeSHALL be the triggering event'seventtime, else the triggering event'sce-time, else the current time, regardless ofclocktype - AND its
ce-timeSHALL equal thateventtime, itswalltimeSHALL be the current time, and itsclocktypeSHALL be the triggering event's, defaulting towall - AND it SHALL carry the triggering event's other common envelope extensions (
correlationid,scenarioid,runid,exchange, the world and universe legs) unless the deriving worker sets them explicitly
Scenario: Envelope on a market data event¶
- WHEN a service publishes a world-scoped market-data event, e.g.
act.exchange.binance.orderbook.changed.BTCUSDT - THEN the CloudEvent extensions SHALL include
exchange=binance,clocktype=wall,eventtime=<rfc3339>(the exchange's own event time, =ce-time),walltime=<rfc3339>,marketworld=act,marketuniverse=ALL - AND they SHALL NOT include
scenarioid,runid,portfolioworldorstrategyworld
Requirement: Trade Lifecycle Events¶
The trading domain SHALL model the lifecycle: submitted → accepted → (partially_filled →)* filled → cleared → settled. For Binance CEX, cleared and settled collapse to near-instantaneous but the events remain distinct for future DeFi / TradFi integration. Every event below publishes to a consolidated per-scenario topic (no <order_id>/<trade_id> topic segment); ce-subject carries the entity reference per the Common Envelope Extensions and Cardinality Rule requirements.
Scenario: Order submitted¶
- WHEN a service submits an order to an exchange
- THEN it SHALL publish
sc.<scenarioId>.trading.order.submittedwithce-type = com.virtufin.trading.order.submittedandce-subject = order/<order_id> - AND the data payload SHALL include
order_id,symbol,side(buy|sell),type(market|limit),qty,price?,venue,submitted_at(symbolwas missing from this scenario prior to the 2026-08-16 topic-consolidation change -- an order-submitted event is unusable to a downstream execution engine without it; corrected here since the gap surfaced while wiringStrategyWorkerBaseagainst this exact scenario)
Scenario: Order accepted¶
- WHEN the venue acknowledges the order
- THEN the service SHALL publish
sc.<scenarioId>.trading.order.acceptedwithce-subject = order/<order_id>and datavenue_order_id,accepted_at
Scenario: Order rejected¶
- WHEN an order is rejected by pre-trade risk or the venue
- THEN the service SHALL publish
sc.<scenarioId>.trading.order.rejectedwithce-subject = order/<order_id>and datareject_reason,reject_source(risk|venue)
Scenario: Order amended (mutation, not transition)¶
- WHEN an order's price or quantity is changed in-place
- THEN the service SHALL publish
sc.<scenarioId>.trading.order.amendedwithce-subject = order/<order_id>and datanew_price?,new_qty?,amended_at - AND the order's status SHALL remain unchanged (
pendingorpartially_filled)
Scenario: Order cancelled¶
- WHEN an order is canceled by the user or system
- THEN the service SHALL publish
sc.<scenarioId>.trading.order.canceledwithce-subject = order/<order_id>and datacanceled_at,canceled_by(user|system|venue)
Scenario: Order expired¶
- WHEN an order's time-in-force is reached without a fill
- THEN the service SHALL publish
sc.<scenarioId>.trading.order.expiredwithce-subject = order/<order_id>and dataexpired_at
Scenario: Order filled (workhorse event)¶
- WHEN a partial or full fill is received from the venue (Binance WebSocket fill callback)
- THEN the service SHALL publish
sc.<scenarioId>.trading.order.filledwithce-subject = order/<order_id>and datafill_id,qty_filled,price,fee,liquidity(maker|taker),remaining_qty,filled_at,realized_pnl?(optional, included if computed at fill time) - AND for partial fills,
remaining_qtySHALL be the post-fill remaining quantity (decreasing across events) - AND when
remaining_qty == 0, the order is implicitly fully filled (no separateorder.completedevent)
Scenario: Trade cleared¶
- WHEN an order is fully filled and counterparty obligations are netted
- THEN the service SHALL publish
sc.<scenarioId>.trading.trade.clearedwithce-subject = trade/<trade_id>and datatrade_id,order_id,cleared_at - AND this event SHALL fire exactly once per trade (not per fill)
Scenario: Trade settled¶
- WHEN asset transfer for a trade is final
- THEN the service SHALL publish
sc.<scenarioId>.trading.trade.settledwithce-subject = trade/<trade_id>and datatrade_id,order_id,settled_at,tx_hash?(null for CEX, populated for on-chain settlement) - AND for Binance CEX,
clearedandsettledSHALL be emitted back-to-back by the publisher - AND for DeFi / TradFi integration, the gap between
clearedandsettledmay span minutes to days
Requirement: Position Lifecycle Events¶
Position state SHALL be derived from signed size plus a liquidation sub-state. position.flipped SHALL be a first-class atomic event because the risk engine needs direction-change awareness without a flat interval. Every event below publishes to a consolidated per-scenario topic (no <symbol> topic segment); ce-subject carries the entity reference.
Scenario: Position opened¶
- WHEN a new position is established (size transitions from
0to non-zero) - THEN the service SHALL publish
sc.<scenarioId>.position.openedwithce-subject = position/<symbol>and dataside(long|short),qty,avg_price,opened_at
Scenario: Position increased¶
- WHEN an existing position grows in size (same side)
- THEN the service SHALL publish
sc.<scenarioId>.position.increasedwithce-subject = position/<symbol>and datanew_qty,new_avg_price,delta_qty
Scenario: Position decreased¶
- WHEN an existing position shrinks in size (same side, partial close)
- THEN the service SHALL publish
sc.<scenarioId>.position.decreasedwithce-subject = position/<symbol>and datanew_qty,realized_pnl,delta_qty
Scenario: Position flipped (atomic direction change)¶
- WHEN a position changes from
longtoshortor vice versa in a single fill sequence - THEN the service SHALL publish
sc.<scenarioId>.position.flippedwithce-subject = position/<symbol>and datafrom_side,to_side,qty,price,order_id,realized_pnl - AND this event SHALL be atomic — no intermediate
position.closed+position.openedsequence
Scenario: Position closed¶
- WHEN a position returns to flat (size = 0) via user close
- THEN the service SHALL publish
sc.<scenarioId>.position.closedwithce-subject = position/<symbol>and datarealized_pnl,closed_at
Scenario: Position liquidated (forced)¶
- WHEN a position is force-closed by the venue
- THEN the service SHALL publish
sc.<scenarioId>.position.liquidatedwithce-subject = position/<symbol>and dataliquidation_price,realized_pnl,liquidated_at
Scenario: Liquidation warning (signal, not transition)¶
- WHEN margin ratio crosses a warning threshold
- THEN the service SHALL publish
sc.<scenarioId>.position.liquidation.warningwithce-subject = position/<symbol>and datamargin_ratio,threshold - AND the position's primary state SHALL NOT change; only the
liquidation_statussub-state becomeswarning
Scenario: Position adjustment (corporate action)¶
- WHEN a corporate action (split, dividend, symbol change) mutates a position's size or avg_price
- THEN the service SHALL publish
sc.<scenarioId>.position.adjustmentwithce-subject = position/<symbol>and dataadjustment_type(split|dividend|symbol_change),new_qty,new_avg_price,corporate_action_id
Scenario: Portfolio snapshot¶
- WHEN a full portfolio snapshot is published (consolidated)
- THEN the service SHALL publish
sc.<scenarioId>.position.snapshotwithce-subject = position/portfolioand datatotal_value,total_pnl,position_count
Requirement: Market Data Events¶
Market data events SHALL live under the world-scoped pattern (act.exchange.<venue>.* or hyp.<name>.exchange.<venue>.*) — hyp carries one more segment than act (the <name> slot); neither carries the data universe, which travels in the marketuniverse envelope extension. Per-currency-pair cardinality SHALL be used for orderbook.*, trade.tape, ticker.update, funding.rate, mark.price, ohlcv.candle.*. Consolidated (no per-pair segment) for instrument.info.
Scenario: Order book snapshot¶
- WHEN the venue publishes a full order book snapshot at depth N
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.orderbook.snapshot.<currency_pair>(act omits<name>) withbids[],asks[],last_update_id,snapshot_at
Scenario: Order book update (delta)¶
- WHEN the venue publishes an L2/L3 order book delta
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.orderbook.update.<currency_pair>(act omits<name>) withbids_delta[],asks_delta[],first_update_id,last_update_id
Scenario: Order book change notification¶
- WHEN a service applies a snapshot or update-delta to a maintained per-pair local order book (see the State Keys requirement's "Order book state key" scenario)
- THEN it SHALL publish
act\|hyp.<name>.exchange.<venue>.orderbook.changed.<currency_pair>(act omits<name>) with ce-typecom.virtufin.exchange.<venue>.orderbook.changedand payloadaction(changed),pair,last_update_id, andevent_time(RFC3339, the exchange's time of the last applied delta) when known - AND this topic is a push notification only -- the authoritative book itself lives in State (see the State Keys requirement), not in this event's payload
Scenario: Order book gap detected¶
- WHEN a service maintaining a local order book detects a sequence gap (an update-delta's first update id doesn't bracket the book's current
last_update_id) that it cannot resolve without a fresh snapshot - THEN it SHALL publish
act\|hyp.<name>.exchange.<venue>.orderbook.changed.<currency_pair>.error(act omits<name>) with ce-typecom.virtufin.exchange.<venue>.orderbook.gapand payloadaction(gap),pair,expected(the update id the book needed),got(the update id actually received), andevent_timewhen known - AND it SHALL NOT overwrite the order book state key's cached value until a fresh snapshot resolves the gap -- a consumer that needs to know whether the cached book is currently fresh or stale SHALL subscribe to this topic rather than assume the state key alone reflects freshness
Scenario: Public trade tape¶
- WHEN a public trade occurs on the venue
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.trade.tape.<currency_pair>(act omits<name>) withtrade_id,price,qty,side,trade_time
Scenario: Ticker update¶
- WHEN the venue publishes 24h rolling stats
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.ticker.update.<currency_pair>(act omits<name>) withlast_price,volume_24h,change_24h_pct
Scenario: Funding rate¶
- WHEN the venue publishes a perpetual funding rate tick
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.funding.rate.<currency_pair>(act omits<name>) withfunding_rate,next_funding_time
Scenario: Mark price¶
- WHEN the venue publishes a mark price update (derivatives)
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.mark.price.<currency_pair>(act omits<name>) withmark_price,index_price
Scenario: OHLCV candle¶
- WHEN a candle interval closes
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.ohlcv.candle.<interval>.<currency_pair>(act omits<name>) with ce-typecom.virtufin.exchange.<venue>.ohlcv.candleandce-subjectcurrency_pair/<currency_pair> - AND the payload SHALL be JSON
{ symbol, interval, open, high, low, close, volume, windowStart, windowEnd }: prices and volume as JSON numbers,windowStart/windowEndas RFC3339 (windowEndis the candle's close time) - AND
<interval>SHALL be the venue's own interval string (1s,1m,5m,1h,1d, ...), identical in the topic segment and theintervalfield - AND the envelope SHALL be the world-scoped market-data envelope (Common Envelope Extensions requirement), with
eventtime=ce-time=windowEnd - AND only closed candles SHALL be published: a still-open candle is not a fact yet
Scenario: Candle intervals are market data¶
- WHEN a candle is aggregated over a standard venue interval
- THEN it SHALL be published as market data under this requirement, whatever the interval (a
1dcandle and a1scandle are equally market data) - AND an aggregation over a non-standard, strategy-chosen window SHALL instead be an indicator (Indicator Events requirement)
Scenario: Instrument info¶
- WHEN instrument metadata changes (lot size, tick size, status)
- THEN the service SHALL publish
act\|hyp.<name>.exchange.<venue>.instrument.info(act omits<name>) withsymbol,status,base_asset,quote_asset,lot_size,tick_size
Requirement: Risk Events¶
Risk events SHALL be consolidated (one topic per metric). Per-symbol routing SHALL use ce-subject.
Scenario: Exposure update¶
- WHEN risk greeks (delta / gamma / vega / theta) are recomputed
- THEN the service SHALL publish
sc.<scenarioId>.risk.exposure.updatewithgreeks: { delta, gamma, vega, theta },underlyings[]
Scenario: Margin update¶
- WHEN margin used or available changes
- THEN the service SHALL publish
sc.<scenarioId>.risk.margin.updatewithmargin_used,margin_available,margin_ratio
Scenario: Drawdown alert¶
- WHEN drawdown crosses a configured threshold
- THEN the service SHALL publish
sc.<scenarioId>.risk.drawdown.alertwithdrawdown_pct,threshold_pct,peak_value,current_value
Scenario: Limit breach¶
- WHEN a configured risk limit is breached
- THEN the service SHALL publish
sc.<scenarioId>.risk.limit.breachwithlimit_name,limit_value,actual_value,severity
Scenario: Scenario P&L (stress test)¶
- WHEN a stress / scenario P&L computation completes
- THEN the service SHALL publish
sc.<scenarioId>.risk.scenario.pnlwithscenario_name,pnl,scenario_inputs
Requirement: P&L Events¶
P&L events SHALL be consolidated. The full triplet context is carried in the envelope (not the topic) because P&L is meaningless without it.
Scenario: Realized P&L update¶
- WHEN realized P&L is recomputed (e.g. on fill or position close)
- THEN the service SHALL publish
sc.<scenarioId>.pnl.realized.updatewithrealized_pnl,by_symbol: { <symbol>: <pnl> },period(daily|session|cumulative)
Scenario: Unrealized P&L update¶
- WHEN unrealized P&L is recomputed (mark-to-market)
- THEN the service SHALL publish
sc.<scenarioId>.pnl.unrealized.updatewithunrealized_pnl,by_symbol: { <symbol>: <pnl> },mark_prices: { <symbol>: <price> }
Requirement: Tier 0 / Infrastructure Events¶
Tier 0 events SHALL be scenario-agnostic and SHALL follow the <service>.<category> pattern. Access control SHALL move to NATS account / subject permissions (no public / internal segment in the topic).
Scenario: Worker lifecycle event¶
- WHEN a worker changes state (created, started, online, degraded, stopping, stopped, failed)
- THEN the WorkManager SHALL publish to
workmanager.lifecyclewithce-type = com.virtufin.workmanager.lifecycle.<state> - AND the event SHALL carry
worker_id,group?,topic, and state-specific fields (error_type?,error_message?)
Scenario: Connection lifecycle event¶
- WHEN a WebSocket connection changes state (registered, connecting, connected, degraded, reconnecting, closed, failed)
- THEN the WebSocketManager SHALL publish to
websocketmanager.lifecyclewithce-type = com.virtufin.websocketmanager.lifecycle.<state> - AND the event SHALL carry
connection_id,url,instance_id, and state-specific fields
Scenario: Infra events (connection / worker detail)¶
- WHEN finer-grained infra events are needed (heartbeats, grouplock changes)
- THEN the service SHALL publish to
<service>.infra.<sub_category>(e.g.websocketmanager.infra.connection.heartbeat,workmanager.infra.worker.heartbeat,workmanager.infra.grouplock)
Requirement: Cardinality Rule¶
Services SHALL prefer consolidated topics (one topic, per-entity routing via ce-subject) over per-entity topics (.<entity_id> segment). Per-entity topics SHALL be used only when a subscriber chooses one specific, known entity at subscribe time and the deployed pub/sub backend supports an exact-topic subscription for it. Per-entity topics SHALL NOT be used when a subscriber needs an unbounded or dynamically-appearing set of entities (e.g. every order in a scenario, every symbol in a portfolio) — that access pattern requires either a consolidated topic or genuine broker-level wildcard/pattern subscription support, and the currently-deployed Dapr pubsub component (pubsub.redis, Redis Streams) provides neither: its Subscribe/Publish implementation matches the topic string against a Redis Stream key exactly, with no pattern matching (confirmed by reading components-contrib/pubsub/redis/redis.go — XGROUP CREATE MKSTREAM <topic> and XAdd(ctx, req.Topic, ...) both treat <topic> as a literal key).
Scenario: Per-entity topic justified¶
- WHEN a subscriber knows the specific entity ahead of time and subscribes to exactly that entity's topic (e.g., order book updates for one specific, chosen currency pair)
- THEN the topic SHALL carry the entity ID segment (
exchange.binance.orderbook.update.<currency_pair>)
Scenario: Consolidated topic preferred¶
- WHEN a subscriber needs events for an unbounded or dynamically-appearing set of entities it cannot enumerate ahead of subscribing (e.g., all orders in a scenario, all symbols in a portfolio)
- THEN the topic SHALL be consolidated (
risk.exposure.update,sc.<scenarioId>.trading.order.filled,sc.<scenarioId>.position.opened) andce-subjectSHALL carry the entity reference (subject=position/BTCUSDT,subject=order/<order_id>) - AND this SHALL NOT be worked around with a broker-level wildcard subscription (e.g.
sc.<scenarioId>.>) unless the deployed pub/sub backend is confirmed to support wildcard/pattern subscriptions -- it does not today (see the requirement description above)
Scenario: act and hyp carry no universe segment¶
- WHEN a topic uses the
act.exchange.<venue>.*orhyp.<name>.exchange.<venue>.*pattern - THEN the
hypform SHALL have exactly one more segment than theactform (the<name>slot) - AND the data universe SHALL NOT appear in either topic — it travels in the
marketuniverseenvelope extension, not as a topic segment - AND brokers and wildcard subscriptions SHALL NOT assume segment-count parity between the two forms — subscribe to
act.exchange.*andhyp.*.exchange.*as two separate patterns rather than one shared positional pattern
Requirement: NATS Permissions (replacing pub/prv)¶
Access control SHALL be enforced via NATS account / subject permissions, not via topic naming. pub / prv segments are NOT part of any topic pattern.
Scenario: Market data permissions¶
- WHEN NATS permissions are configured
- THEN
act.exchange.*SHALL allow publishers = exchange connectors, subscribers = all trading / risk / backtest services - AND
hyp.*.exchange.*SHALL allow publishers = backtest engines, subscribers = strategy services (no production services) — note the extra*for the<name>segment versusact
Scenario: Scenario event permissions¶
- WHEN NATS permissions are configured
- THEN
sc.*.trading.*SHALL allow publishers = trading / strategy services, subscribers = risk / position / pnl / dashboards - AND
sc.*.position.*SHALL allow publishers = trading services, subscribers = risk / pnl - AND
sc.*.risk.*andsc.*.pnl.*SHALL allow publishers = risk / pnl services, subscribers = dashboards / alerting - AND
sc.LIVE.*writes SHALL require elevated permissions (production role)
Scenario: Tier 0 permissions¶
- WHEN NATS permissions are configured
- THEN
<service>.lifecycleand<service>.infra.*SHALL allow publishers = owning service, subscribers = all services
Requirement: State Keys¶
State keys SHALL share the topic taxonomy. After the <app-name>|| prefix:
| Namespace | Pattern | Example |
|---|---|---|
| Tier 0 / infra | <service>.<entity>[.<id>] |
workmanager.worker.<worker_id> |
| Market data cache | act\|hyp.<name>.exchange.<venue>.<entity>[.<id>] |
act.exchange.binance.orderbook.BTCUSDT (act omits <name>) |
| Scenario state | sc.<scenarioId>.<domain>.<entity>[.<id>] |
sc.LIVE.trading.order.<order_id> |
| Scenario registry | scenario.<scenarioId> |
scenario.LIVE |
Scenario: Order state key¶
- WHEN a service writes order state
- THEN the key SHALL be
sc.<scenarioId>.trading.order.<order_id> - AND the value SHALL be JSON
{ status, side, type, qty, remaining_qty, avg_price, venue, created_at, updated_at }
Scenario: Trade state key¶
- WHEN a service writes trade state
- THEN the key SHALL be
sc.<scenarioId>.trading.trade.<trade_id> - AND the value SHALL be JSON
{ status, order_id, cleared_at?, settled_at?, tx_hash? }
Scenario: Position state key¶
- WHEN a service writes position state
- THEN the key SHALL be
sc.<scenarioId>.position.<symbol> - AND the value SHALL be JSON
{ size, avg_price, realized_pnl, liquidation_status, updated_at }
Scenario: Portfolio state key¶
- WHEN a service writes portfolio state
- THEN the key SHALL be
sc.<scenarioId>.position.portfolio - AND the value SHALL be JSON
{ total_value, total_pnl, position_count, updated_at }
Scenario: Indicator state key¶
- WHEN a service persists a deployed indicator's current value (e.g.
IndicatorWorkerBaseinvirtufin-indicator-devkit) - THEN the key SHALL be
sc.<scenarioId>.indicator.<name>.<symbol>(e.g.sc.LIVE.indicator.sma10.BTCUSDT) - AND the value SHALL be JSON
{ value, updated_at }(the indicator's own encoding of its current value, plus a write timestamp) - AND this key is scenario-scoped rather than market-data-cache-scoped (contrast the "Order book state key" scenario below): an indicator fed by scenario-specific replayed/synthetic data under
hyp.*needs run disambiguation two concurrent backtests of the same symbol would otherwise collide on — an order book has no such need, since it's a genuinely singular venue-level fact regardless of observer
Scenario: Worker state key¶
- WHEN the WorkManager writes worker state
- THEN the key SHALL be
workmanager.worker.<worker_id> - AND the value SHALL be JSON
{ status, last_heartbeat, started_at, ... }
Scenario: Connection state key¶
- WHEN the WebSocketManager writes connection state
- THEN the key SHALL be
websocketmanager.connection.<connection_id> - AND the value SHALL be JSON
{ status, last_heartbeat, connected_at, ... }
Scenario: Order book state key¶
- WHEN a service maintains a per-pair local order book (e.g. a WorkManager worker consuming the venue's update-delta and snapshot feeds -- not necessarily the WebSocketManager itself, which is a generic, exchange-agnostic relay with no order-book logic of its own)
- THEN the key SHALL be
act\|hyp.<name>.exchange.<venue>.orderbook.<currency_pair>(act omits<name>) - AND the value SHALL be JSON
{ bids, asks, last_update_id, updated_at }(consolidated, single source of truth per venue + pair;updated_atis an RFC3339 timestamp of the last applied snapshot/update) - AND on a detected sequence gap the value SHALL NOT be overwritten until a fresh snapshot resolves it (see the Market Data Events requirement's "Order book gap detected" scenario) -- the key keeps serving the last known-good book rather than being cleared
Scenario: Scenario registry key¶
- WHEN a service writes scenario metadata
- THEN the key SHALL be
scenario.<scenarioId> - AND the value SHALL be the scenario triplet definition (see Scenarios spec)
Requirement: Source-of-Truth Rule¶
Each piece of derived or owned state SHALL have exactly one writer.
Scenario: Single publisher per status field¶
- WHEN two publishers attempt to write the same status field on the same entity (e.g. both writing
order.status) - THEN this SHALL be treated as a design bug — the entity has exactly one owner for status transitions
- AND consumers SHALL treat the status field as monotonically advancing through the state machine
Scenario: Single owner per derived metric¶
- WHEN multiple services could compute the same derived metric (e.g. portfolio total value)
- THEN exactly one aggregator SHALL own the write; other services SHALL subscribe to the topic for read access
Requirement: Delete-on-Deploy (No Deprecation)¶
Topic and state-key renames SHALL NOT carry a deprecation window. Old names SHALL be deleted on the same commit that introduces new names.
Scenario: Rename lifecycle¶
- WHEN a topic or state-key name is changed
- THEN the change SHALL be a single commit per affected repo
- AND old names SHALL be removed in the same commit (no parallel publishing under both names)
- AND the migration document SHALL list old → new mappings
Requirement: Multi-Publisher Tier 2¶
Subscribers of Tier 2 (scenario-scoped) topics SHALL NOT assume single-source. A scenario topic MAY have multiple publishers (e.g., aggregator + per-leg publisher).
Scenario: Multiple publishers on same topic¶
- WHEN two services publish to the same scenario topic (e.g., a strategy publishes
order.filledand a replayer also publishes the same event during replay) - THEN consumers SHALL handle events from either source
- AND
ce-sourceSHALL identify the publishing service for filtering / dedup
Requirement: Run-ID in Envelope, Not Topic¶
Backtest replay and parallel scenarios SHALL NOT introduce run-id segments into topics. Run isolation SHALL be achieved via the runid envelope extension.
Scenario: Parallel backtests share topic¶
- WHEN two backtests run concurrently against the same scenario (
sc.S7F3A9B) - THEN both SHALL publish to
sc.S7F3A9B.trading.* - AND each event SHALL carry a distinct
runidextension - AND consumers that want a single backtest's events SHALL filter by
runid
Scenario: Run-id dropped from topic¶
- WHEN a service migrates from
binance-depth.<run_id>to the new scheme - THEN the topic SHALL become
act.exchange.binance.orderbook.update.<currency_pair>(no run-id segment) - AND the run-id SHALL travel in the
runidenvelope extension (or be omitted for production traffic)
Requirement: Subscription Implementation Pattern¶
Dapr-based subscribe handlers in any Virtufin service SHALL open one Dapr subscription per topic per service process and fan out the resulting event stream in-process to every interested consumer within that process. Per-call Dapr subscriptions (one per gRPC call or one per caller) are PROHIBITED because they cause the same event to be silently load-balanced across callers instead of broadcast to each.
Scenario: Multiple callers on one topic¶
- WHEN two gRPC clients subscribe to the same topic in the same API process
- THEN each caller SHALL receive every message on that topic (true 1:many broadcast within the process)
- AND the Dapr sidecar SHALL see exactly one subscription per topic per process
Scenario: Every replica receives every message¶
- WHEN a topic is subscribed in several processes, e.g. two API replicas each serving a caller
- THEN each process SHALL receive every message on the topic: the pub/sub component SHALL give every sidecar its own consumer group (
consumerID: {podName}with Dapr's Redis pub/sub), not one group shared by an app's replicas - AND there SHALL be no competing-consumer layer: Dapr has no first-class broadcast (dapr/dapr#3332), so this per-sidecar group is how broadcast is achieved, and fan-out within a process stays the process's job
Scenario: New caller arrives¶
- WHEN a third gRPC client subscribes to a topic that already has two subscribers in the same process
- THEN the existing Dapr subscription SHALL be reused; the new caller's in-process handler SHALL be added to the fan-out list; no new Dapr subscription SHALL be opened
Scenario: Last caller leaves¶
- WHEN the last gRPC client unsubscribes from a topic
- THEN the in-process fan-out registration SHALL be removed; the Dapr subscription SHALL be disposed; subsequent callers SHALL open a new Dapr subscription on demand
Scenario: New subscription starts at the stream tail¶
- WHEN a service process opens a new Dapr subscription for a topic (no in-process subscription for that topic exists)
- THEN it SHALL receive only messages published after the subscription opened -- no replay of entries still in the stream, and no redelivery of entries left pending by an earlier subscription
- AND there is no catch-up: messages published while the process held no subscription for the topic SHALL NOT be delivered to it, including during the brief gap of a consumer reload; consumers that need continuity SHALL reconcile from state (e.g. an order book resyncs from its next snapshot)
- AND with Dapr's Redis pub/sub (one consumer group per sidecar,
consumerID: {podName}) the process SHALL move its own consumer group to the stream tail ($) and ack the group's pending entries before subscribing, because Dapr resumes an existing group at itslast-delivered-id, creates a missing one at the stream start ("0"), andstreamTTLtrims only on publish - AND if the group cannot be positioned the subscribe SHALL fail rather than fall back to replay
Scenario: In-process exception on a subscriber¶
- WHEN an in-process subscriber's handler throws an exception while processing a fanned-out message
- THEN the broker SHALL continue delivering to the remaining subscribers (per-subscriber fault isolation)
- AND the failed subscriber SHALL be logged but SHALL NOT cause the message to be re-delivered to the surviving subscribers
Requirement: Retry Policy on Dapr Subscribe Exceptions¶
Services that consume pub/sub messages SHALL guarantee at-least-once processing with a bounded retry count, followed by dead-letter publication on exhaustion — messages SHALL NOT be silently lost on an uncaught processing exception. Where this guarantee is enforced depends on the consumption model:
- A service holding its own direct Dapr subscription (the classic HTTP subscribe model) SHALL return
TopicResponseAction.Retryon uncaught exceptions so Dapr'smaxRedeliveryCountand dead-letter topic configuration take effect.DropSHALL be reserved for explicit business-logic skips (e.g., "no local worker registered for this topic"); it SHALL NOT be used to silently discard a message on an uncaught exception. - A service consuming pub/sub via the streaming
Pubsub.SubscribegRPC API (see the "Subscription Implementation Pattern" requirement above) is one of potentially several in-process fan-out consumers per Dapr subscription; the Gateway acks Dapr's broker before any consumer processes the message, so broker-level redelivery is not available to it — retrying at the broker would double-process the fan-out's other, already-succeeded consumers. Such a consumer SHALL implement its own bounded-retry-then-dead-letter mechanism at the application level: retry the failed operation up to a configured attempt count with backoff, and on exhaustion publish the original message to the configured dead-letter topic (default<topic>-dead) rather than discarding it.
Scenario: Worker throws on every message¶
- WHEN a service holding its own direct Dapr subscription throws on every message it receives
- THEN Dapr SHALL re-deliver the message up to
maxRedeliveryCount(default 3) times - AND after the redelivery cap is reached the message SHALL be published to the configured dead-letter topic (default
<topic>-dead) - AND the handler's process SHALL NOT terminate
Scenario: Explicit business-logic skip¶
- WHEN a handler determines the message is not relevant (e.g., no local worker on the topic) and explicitly chooses to skip it
- THEN the handler MAY return
TopicResponseAction.Dropand the message SHALL be acked with no redelivery
Scenario: Handler times out¶
- WHEN a handler does not return within
MessageHandlingPolicytimeout (default 30 s) - THEN Dapr SHALL re-deliver the message (
Retrypolicy) - AND the timed-out handler's process SHALL remain responsive
Scenario: Streaming-API consumer throws on every message¶
- WHEN a consumer of the streaming
Pubsub.SubscribeAPI throws processing every message on a topic - THEN the consumer SHALL retry the operation up to its configured attempt count with backoff
- AND after attempts are exhausted the consumer SHALL publish the original message to the configured dead-letter topic (default
<topic>-dead) - AND the consumer's process SHALL NOT terminate and SHALL continue processing subsequent messages
Requirement: State Change Events¶
virtufin-api's State RPCs publish a change notification when the caller opts in. Which topic that notification belongs on SHALL follow the tier the state belongs to, not the service that wrote it.
Infrastructure state — records whose owner is a platform service and whose meaning is deployment-scoped (worker registrations, connection records, leader leases) — SHALL use the Tier 0 topic state.change.
Domain state — records that are scenario- or universe-scoped (positions, portfolios, orders, risk and P&L artefacts) — SHALL NOT use state.change. Its change notifications SHALL be published as ordinary Tier 1 events on the market-data or scenario axes, carrying the full Tier 1 envelope. Which service performed the write is an implementation detail; subscribers route on the domain topic and MUST NOT be required to track it.
The gateway cannot synthesise a Tier 1 event from a state write: it knows the store, key and value, but not the scenario, the world markers, the clock type, or the writing service's identity. A caller publishing a domain state change SHALL therefore supply the complete CloudEvent and its target topic, and the gateway SHALL publish it only after the write has succeeded.
Scenario: Infra state change¶
- WHEN a service writes infrastructure state with change notification enabled
- THEN the event SHALL be published to
state.change - AND
ce-typeSHALL becom.virtufin.api.state-savedorcom.virtufin.api.state-deleted, discriminating the action so a subscriber can filter without parsing the payload - AND
ce-sourceSHALL beurn:com.virtufin:api, the publishing service
Scenario: Domain state change¶
- WHEN a service writes scenario- or universe-scoped domain state with change notification enabled
- THEN the caller SHALL supply the CloudEvent and its topic, matching the Tier 1 patterns in the Topic Patterns requirement (e.g.
sc.<scenarioId>.trading.position.updated) - AND the event SHALL carry the extensions required of every Tier 1 event by the Common Envelope Extensions requirement
- AND the gateway SHALL publish it only after the state write succeeds
- AND the gateway SHALL NOT additionally publish that change to
state.change
Scenario: Domain state change without a caller-supplied event¶
- WHEN a caller enables change notification for domain state but supplies no CloudEvent
- THEN the gateway SHALL publish to
state.changeas for infra state - AND the resulting event is Tier 0 and carries no scenario or world markers, so subscribers cannot route it by scenario — the caller has opted out of the domain taxonomy, not into a degraded form of it
Scenario: State-name topics are not a substitute¶
- WHEN a design would give each state its own topic under
state.change(state.change.<state_name>) - THEN it SHALL be rejected: the Tier 0 pattern requires
<category>to be a domain noun and not a state name, and per-key topics contradict the Cardinality Rule - AND domain state SHALL use the Tier 1 taxonomy instead, which already provides the routing such a design is reaching for
Scenario: Change payload¶
- WHEN the gateway publishes to
state.change - THEN the payload SHALL carry
service, thekey(orkeysfor a bulk delete), andaction(save/delete) - AND it SHALL carry
valueonly when the caller set the include-value flag
Scenario: Tier 0 events carry no version¶
- WHEN the gateway publishes a
state.changeevent - THEN it SHALL NOT claim a version for the written value: Dapr's state API returns no etag from a write, so the gateway cannot label the event with the version it just produced
- AND it SHALL NOT re-read the key to obtain one — a concurrent writer may have advanced the value in between, which would attribute a newer version to an older payload and corrupt exactly the ordering the version exists to establish
Scenario: Event-carried state transfer on the Tier 0 topic¶
- WHEN a subscriber uses a
state.changeevent'svalueinstead of reading the state back - THEN it SHALL treat the value as advisory: with no version there is no way to detect out-of-order delivery, so two rapid writes to one key may arrive reversed under redelivery and silently regress the subscriber
- AND it SHALL do so only where writes to that key are known to be serialized, and SHALL read state back where correctness depends on freshness
- AND a subscriber needing a stronger guarantee SHALL use the Tier 1 path below, where the caller controls the envelope
Scenario: Ordering on the Tier 1 path¶
- WHEN a caller supplies a CloudEvent for a domain state change and subscribers consume its payload without reading state back
- THEN the caller SHALL carry a monotonic ordering token for the entity in the event (a domain version, sequence number, or
eventtimewhere the clock is authoritative) - AND subscribers SHALL discard any event not newer than the last processed for that entity
- AND the token is the caller's to supply because only the caller knows the entity's versioning; the gateway has the key and the bytes and nothing that orders them
Scenario: Delivery guarantee¶
- WHEN the state write succeeds and the subsequent publish fails
- THEN the change event is lost: publication is at-most-once and is not atomic with the write
- AND subscribers SHALL be designed to reconcile from state rather than assuming every change produced an event
- AND the gateway SHALL NOT report the write itself as failed on account of a failed publish, since the write did land and reporting failure invites a caller to repeat it
Requirement: Indicator Events¶
An indicator deployed as a worker SHALL publish each new value as a scenario-scoped event, because an indicator is a tuned analytic owned by a scenario (behaviour spec, Glossary requirement), not a world-scoped market fact.
Scenario: Indicator changed¶
- WHEN a deployed indicator folds a sample and has a new value
- THEN it SHALL publish
sc.<scenarioId>.indicator.<name>.<currency_pair>.changedwith ce-typecom.virtufin.indicator.<name>.changedandce-subjectcurrency_pair/<currency_pair> - AND
<name>SHALL be the deployment's configured indicator name (e.g.sma20), so two parameterisations of one indicator (sma7,sma30) publish on distinct topics - AND the payload SHALL be the indicator's own JSON encoding of its value,
{ value }for a scalar - AND the envelope SHALL follow the derived-event rule (Common Envelope Extensions requirement):
eventtimeinherited from the triggering sample, andscenarioidset to<scenarioId>
Scenario: Indicator value persisted¶
- WHEN an indicator publishes a changed event
- THEN it SHALL have saved the same value under the indicator state key (State Keys requirement) first, so a consumer that receives the event can read the matching state
Requirement: Cross-Topic Delivery Ordering¶
Consumers SHALL NOT assume any ordering guarantee between messages published to different topics. Per the Subscription Implementation Pattern requirement, each topic is its own independent Dapr subscription with its own dispatch loop; two topics' streams interleave in whatever order messages happen to arrive from the broker, not by publish wall-clock order. Ordering guarantees (if any) apply only within a single topic and are broker-dependent.
Scenario: Strategy worker's market and position feeds¶
- WHEN a strategy worker subscribes to both a market-data topic and a position topic (see the multi-topic worker support in worker-management)
- THEN a market event and a related position update MAY arrive in either order
- AND a strategy that needs the two consistent with each other (e.g. "this candle's decision must see the fill it depends on") SHALL NOT assume the position update arrives first -- it must handle the out-of-order case itself (e.g. by re-evaluating on the next event) or accept the resulting staleness window
Scenario: No cross-topic sequencing infrastructure exists¶
- WHEN a service needs strict ordering between events on two different topics
- THEN it SHALL implement that ordering itself (e.g. via a sequence number in the envelope, or by consolidating both event kinds onto one topic) -- the platform provides no built-in mechanism for it