Multi-venue strategy¶
The multi-venue strategy quotes the same logical market on more than one venue from a single decision point. One strategy process runs one shared quoter actor bound to two thin actor slots; the actor evaluates the combined Kuru + Lighter snapshot on every callback and returns intents for the calling leg only.
Kuru and Lighter are the current legs. The strategy is shared, but execution and fill ingestion are venue-specific — that asymmetry is the thing to understand before changing anything here.
Source references below are repo-relative paths in dyson.
Quote path¶
flowchart TD
MD["NATS market data<br/>+ venue private state"]
RT["Dyson Runtime"]
SR["StrategyRunner"]
SK["ActorSlot — Kuru"]
SL["ActorSlot — Lighter"]
QA["Shared MultiVenueQuoterActor"]
EV["MultiVenueEngine::evaluate"]
TBK["TargetBook — Kuru leg"]
TBL["TargetBook — Lighter leg"]
IK["kuru::to_intent<br/>whole-book replace"]
IL["lighter::to_intent<br/>per-order convergence"]
OM["Order Manager"]
RV["RoutedExecutionVenue"]
AK["Kuru adapter"]
AL["Lighter adapter"]
BC["Blockchain"]
LX["Lighter API / WS"]
MD --> RT --> SR
SR --> SK
SR --> SL
SK --> QA
SL --> QA
QA --> EV
EV --> TBK
EV --> TBL
TBK --> IK
TBL --> IL
IK --> OM
IL --> OM
OM --> RV
RV --> AK
RV --> AL
AK --> BC
AL --> LX
classDef shared fill:#2e7d32,stroke:#1b5e20,color:#fff
classDef kuru stroke:#2e7d32,stroke-width:2px
classDef lighter stroke:#7cb342,stroke-width:2px
class QA,EV,OM,RV shared
class SK,TBK,IK,AK,BC kuru
class SL,TBL,IL,AL,LX lighter
Green nodes are shared across legs; the outer columns are each leg's venue-specific path. Fills return by two different routes — see Fill and position flow.
Startup and state initialization¶
The executable (strategies/multi-venue/src/bin/multi-venue.rs):
- Loads the deployment and multi-venue strategy configuration from MongoDB.
- Connects the Kuru MultiAssetVault and loads its initial balance snapshot.
- Connects Lighter, starts its private account stream, and rejects startup if unmanaged open orders exist.
- Loads configured starting positions for both instruments.
- Constructs one shared
MultiVenueQuoterActor. - Registers two thin algo-trading actor bindings — Kuru first, then Lighter.
- Constructs two execution routes keyed by
(actor_id, instrument). - Starts the Influx writer, NATS runtime, and health endpoints.
Starting positions come from strategy.starting_positions[instrument] and are
copied into the actor's per-leg position cache
(strategies/multi-venue/src/bootstrap.rs).
Positions are operator-supplied, not derived
Neither Kuru vault balances nor Lighter collateral establish the signed strategy position. They are readiness and account checks only. After a restart, an operator must supply authoritative starting positions.
Market data to quote decision¶
Market-data packets arrive on the configured NATS feed subjects and enter
KuruMarketDataProcessor. Once signals and fair value are updated, algo-trading
calls each actor binding — and both bindings point at the same shared actor
(strategies/multi-venue/src/actor.rs):
Kuru binding callback
-> update cached Kuru position from ActorContext
-> evaluate the complete Kuru + Lighter snapshot
-> return only Kuru intents
Lighter binding callback
-> update cached Lighter position from ActorContext
-> evaluate the complete Kuru + Lighter snapshot
-> return only Lighter intents
Every evaluation computes:
aggregate position = position offset + Kuru position + Lighter position
The engine then applies aggregate-position skew and venue-position skew,
enforces aggregate and venue limits, and produces one TargetBook per leg
(strategies/multi-venue/src/engine.rs).
Target books to exchange requests¶
The shared actor converts only the calling leg's target book into venue intents. The two conversions are structurally different:
| Kuru | Lighter | |
|---|---|---|
| Convergence model | Whole-book replace | Per-order convergence |
| Operation | cancel_all_replace, or cancel_all for an empty target |
Cancel stale orders, await CancelAccepted or another terminal event, then submit missing levels |
| Atomicity | One atomic vault operation | Multi-step, event-driven |
| Throttling | — | Market-data-triggered requotes are rate-limited; terminal cancel events bypass the throttle so cancel-before-submit cannot strand |
The algo-trading Order Manager converts intents into requests and owns client
IDs plus pending, live, and partially-filled lifecycle transitions.
RoutedExecutionVenue groups requests by (actor_id, instrument) and forwards
each group to the owning adapter in one send_batch
(crates/venue-adapters/src/routed.rs).
Fill and position flow¶
This is where the venues diverge most.
| Kuru | Lighter | |
|---|---|---|
| Fill source | Public Kuru trade packet from NATS | Private Lighter account stream |
| Attribution | Vault / proxy address | client_order_index |
| Position update | Directly through strategy state | Through normalized OrderEvent::Filled |
| Deduplication | Chain transaction / log identity | (client_order_index, fill_id) |
| Order attribution | Best effort | Required correlation |
| Fee | Kuru fee schedule | Currently emitted as 0.0 |
flowchart TD
subgraph K["Kuru — fills arrive as public market data"]
direction TB
KT["Kuru trade packet on NATS"] --> KA["KuruTradeApplier<br/>vault / proxy maker-or-taker check"]
KA --> KD["dedupe by chain tx / log identity"]
KD --> KF["strategy.apply_fill()"]
KF --> KP["instrument position<br/>+ aggregate position and PnL"]
KP --> KI["fill record to Influx"]
KI --> KE["process the packet, then force actor<br/>evaluation if no valuation update fired"]
end
subgraph L["Lighter — fills arrive as private execution events"]
direction TB
LS["Lighter private account stream"] --> LR["resolve client_order_index<br/>to the process-owned order"]
LR --> LD["dedupe by (client_order_index, fill_id)"]
LD --> LQ["update adapter-side filled quantity"]
LQ --> LE["emit OrderEvent::Filled"]
LE --> LM["RoutedExecutionVenue merges route events<br/>by timestamp; StrategyRunner applies them"]
LM --> LP["instrument position + aggregate PnL,<br/>FillRecord and response projection"]
LP --> LN["on_signal_update on every actor slot<br/>-> both legs requote"]
end
classDef kuru stroke:#2e7d32,stroke-width:2px
classDef lighter stroke:#7cb342,stroke-width:2px
class KT,KA,KD,KF,KP,KI,KE kuru
class LS,LR,LD,LQ,LE,LM,LP,LN lighter
Kuru fills are applied from market-data trades before strategy evaluation,
which is what keeps them from being double-counted through the order path.
Kuru position accounting deliberately does not depend on finding the local
order: the feed may arrive after that order has been replaced or dropped from
local state. Order attribution is enrichment; position movement is
authoritative. See crates/venue-adapters/src/kuru/trades.rs and
crates/venue-adapters/src/kuru/runtime.rs.
The Lighter path is the conventional one — resolve, dedupe, normalize, apply
(crates/venue-adapters/src/lighter/state.rs), with generic fill application in
external/algo-trading/crates/strategy/src/engine.rs.
Readiness and safety¶
Failures have two scopes:
| Failure | Scope | Effect |
|---|---|---|
| Account or reconciliation failure | Global — aggregate position is unreliable | Every venue receives an empty target |
| Market-data or execution failure | Route-local | Only that venue's target is cleared |
Each adapter is wrapped in HealthObservedExecutionVenue
(crates/venue-adapters/src/health.rs). A dispatch or health failure changes
that leg's execution readiness, while the routed venue keeps polling all
children — one unhealthy venue cannot block fills or acknowledgements from the
other.
Kuru balances refresh every 15 seconds with a two-second timeout. After 45 seconds without a successful snapshot, Kuru reconciliation becomes unavailable and the next evaluation clears both books.
Observability¶
InfluxDB post-trade data¶
The shared post-trade sink writes strategy_action, strategy_order_response,
strategy_fill, strategy_action_signal, and strategy_fill_signal
(crates/database/src/influxdb/adapter.rs). Fill rows carry instrument and
side, price, quantity and fee, remaining quantity, instrument position after
fill, aggregate strategy position after fill, and fair value plus BBO context.
Multi-venue decision data¶
Every actor evaluation writes one strategy_quote_decision (aggregate position
and the complete decision JSON) and one strategy_venue_decision per venue
(disposition, adjusted fair value, target level count, clear reason). All points
from one evaluation share a decision_id
(strategies/multi-venue/src/observability.rs).
Runtime telemetry over NATS¶
The runtime publishes lifecycle and status snapshots, command results, open-order snapshots, market-data update counts, and venue-event counts.
| Subject | Carries |
|---|---|
strategy.telemetry.<strategy>.<run> |
Telemetry |
strategy.events.<strategy>.<run> |
Durable operational events |
HTTP, Prometheus, and logs¶
The process exposes /health/live, /health/ready, and /metrics. Prometheus
includes runtime health and command counters plus Influx queue counters for
enqueued, evicted, retried, and failed batches. Structured tracing logs fills,
dropped and unattributed fills, route errors, reconciliation failures, and
lifecycle changes.
Current limitations¶
-
Position snapshots are not perfectly atomic. The actor updates one cached leg position per binding callback before evaluating, and bindings run Kuru first, Lighter second. A Lighter fill can therefore let the Kuru callback decide against the previous cached Lighter position; the following Lighter callback sees the new position but emits only Lighter intents. The next market-data cycle corrects Kuru.
-
strategy_quote_decisioncan disagree with what was sent. Quote-decision recording happens before the actor's laterStrategyModeforced-clear override, so in paused or stopped mode the recorded decision can describe a quote while the target actually sent to convergence is empty. Usestrategy_actionrecords for what was dispatched.