Skip to content

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 marketuniverse envelope extension (ALL for unrestricted, EQ_EUROPE for 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) — bare hyp (no name) SHALL NOT appear in a topic
  • AND the data universe SHALL be carried in the marketuniverse envelope 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 via ce-subject -- see the Cardinality Rule requirement for why the entity ID does not appear in the topic)
  • AND <scenarioId> SHALL be either the reserved LIVE or a registered opaque scenario ID
  • AND ce-subject SHALL be set to order/<order_id> or trade/<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 via ce-subject) — there is no longer a separate position.portfolio.<event> form
  • AND ce-subject SHALL be set to position/<symbol> for a per-symbol event or position/portfolio for 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 via subject)

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 via subject)

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 whose scenarioId is not registered (or is paused/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-type SHALL 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.online on both workmanager.lifecycle and workmanager.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-source SHALL be urn: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 earlier urn: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-subject SHALL 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 subject to 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 runid SHALL 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 is historical for backtests) to maintain correct indicator semantics
  • AND it SHALL use walltime only 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 eventtime SHALL be the triggering event's eventtime, else the triggering event's ce-time, else the current time, regardless of clocktype
  • AND its ce-time SHALL equal that eventtime, its walltime SHALL be the current time, and its clocktype SHALL be the triggering event's, defaulting to wall
  • 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, portfolioworld or strategyworld

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.submitted with ce-type = com.virtufin.trading.order.submitted and ce-subject = order/<order_id>
  • AND the data payload SHALL include order_id, symbol, side (buy|sell), type (market|limit), qty, price?, venue, submitted_at (symbol was 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 wiring StrategyWorkerBase against this exact scenario)

Scenario: Order accepted

  • WHEN the venue acknowledges the order
  • THEN the service SHALL publish sc.<scenarioId>.trading.order.accepted with ce-subject = order/<order_id> and data venue_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.rejected with ce-subject = order/<order_id> and data reject_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.amended with ce-subject = order/<order_id> and data new_price?, new_qty?, amended_at
  • AND the order's status SHALL remain unchanged (pending or partially_filled)

Scenario: Order cancelled

  • WHEN an order is canceled by the user or system
  • THEN the service SHALL publish sc.<scenarioId>.trading.order.canceled with ce-subject = order/<order_id> and data canceled_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.expired with ce-subject = order/<order_id> and data expired_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.filled with ce-subject = order/<order_id> and data fill_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_qty SHALL be the post-fill remaining quantity (decreasing across events)
  • AND when remaining_qty == 0, the order is implicitly fully filled (no separate order.completed event)

Scenario: Trade cleared

  • WHEN an order is fully filled and counterparty obligations are netted
  • THEN the service SHALL publish sc.<scenarioId>.trading.trade.cleared with ce-subject = trade/<trade_id> and data trade_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.settled with ce-subject = trade/<trade_id> and data trade_id, order_id, settled_at, tx_hash? (null for CEX, populated for on-chain settlement)
  • AND for Binance CEX, cleared and settled SHALL be emitted back-to-back by the publisher
  • AND for DeFi / TradFi integration, the gap between cleared and settled may 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 0 to non-zero)
  • THEN the service SHALL publish sc.<scenarioId>.position.opened with ce-subject = position/<symbol> and data side (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.increased with ce-subject = position/<symbol> and data new_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.decreased with ce-subject = position/<symbol> and data new_qty, realized_pnl, delta_qty

Scenario: Position flipped (atomic direction change)

  • WHEN a position changes from long to short or vice versa in a single fill sequence
  • THEN the service SHALL publish sc.<scenarioId>.position.flipped with ce-subject = position/<symbol> and data from_side, to_side, qty, price, order_id, realized_pnl
  • AND this event SHALL be atomic — no intermediate position.closed + position.opened sequence

Scenario: Position closed

  • WHEN a position returns to flat (size = 0) via user close
  • THEN the service SHALL publish sc.<scenarioId>.position.closed with ce-subject = position/<symbol> and data realized_pnl, closed_at

Scenario: Position liquidated (forced)

  • WHEN a position is force-closed by the venue
  • THEN the service SHALL publish sc.<scenarioId>.position.liquidated with ce-subject = position/<symbol> and data liquidation_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.warning with ce-subject = position/<symbol> and data margin_ratio, threshold
  • AND the position's primary state SHALL NOT change; only the liquidation_status sub-state becomes warning

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.adjustment with ce-subject = position/<symbol> and data adjustment_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.snapshot with ce-subject = position/portfolio and data total_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>) with bids[], 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>) with bids_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-type com.virtufin.exchange.<venue>.orderbook.changed and payload action (changed), pair, last_update_id, and event_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-type com.virtufin.exchange.<venue>.orderbook.gap and payload action (gap), pair, expected (the update id the book needed), got (the update id actually received), and event_time when 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>) with trade_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>) with last_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>) with funding_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>) with mark_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-type com.virtufin.exchange.<venue>.ohlcv.candle and ce-subject currency_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/windowEnd as RFC3339 (windowEnd is 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 the interval field
  • 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 1d candle and a 1s candle 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>) with symbol, 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.update with greeks: { delta, gamma, vega, theta }, underlyings[]

Scenario: Margin update

  • WHEN margin used or available changes
  • THEN the service SHALL publish sc.<scenarioId>.risk.margin.update with margin_used, margin_available, margin_ratio

Scenario: Drawdown alert

  • WHEN drawdown crosses a configured threshold
  • THEN the service SHALL publish sc.<scenarioId>.risk.drawdown.alert with drawdown_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.breach with limit_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.pnl with scenario_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.update with realized_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.update with unrealized_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.lifecycle with ce-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.lifecycle with ce-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) and ce-subject SHALL 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>.* or hyp.<name>.exchange.<venue>.* pattern
  • THEN the hyp form SHALL have exactly one more segment than the act form (the <name> slot)
  • AND the data universe SHALL NOT appear in either topic — it travels in the marketuniverse envelope 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.* and hyp.*.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 versus act

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.* and sc.*.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>.lifecycle and <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. IndicatorWorkerBase in virtufin-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_at is 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.filled and a replayer also publishes the same event during replay)
  • THEN consumers SHALL handle events from either source
  • AND ce-source SHALL 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 runid extension
  • 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 runid envelope 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 its last-delivered-id, creates a missing one at the stream start ("0"), and streamTTL trims 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.Retry on uncaught exceptions so Dapr's maxRedeliveryCount and dead-letter topic configuration take effect. Drop SHALL 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.Subscribe gRPC 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.Drop and the message SHALL be acked with no redelivery

Scenario: Handler times out

  • WHEN a handler does not return within MessageHandlingPolicy timeout (default 30 s)
  • THEN Dapr SHALL re-deliver the message (Retry policy)
  • 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.Subscribe API 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-type SHALL be com.virtufin.api.state-saved or com.virtufin.api.state-deleted, discriminating the action so a subscriber can filter without parsing the payload
  • AND ce-source SHALL be urn: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.change as 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, the key (or keys for a bulk delete), and action (save / delete)
  • AND it SHALL carry value only when the caller set the include-value flag

Scenario: Tier 0 events carry no version

  • WHEN the gateway publishes a state.change event
  • 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.change event's value instead 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 eventtime where 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>.changed with ce-type com.virtufin.indicator.<name>.changed and ce-subject currency_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): eventtime inherited from the triggering sample, and scenarioid set 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