Skip to content

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):

  1. Loads the deployment and multi-venue strategy configuration from MongoDB.
  2. Connects the Kuru MultiAssetVault and loads its initial balance snapshot.
  3. Connects Lighter, starts its private account stream, and rejects startup if unmanaged open orders exist.
  4. Loads configured starting positions for both instruments.
  5. Constructs one shared MultiVenueQuoterActor.
  6. Registers two thin algo-trading actor bindings — Kuru first, then Lighter.
  7. Constructs two execution routes keyed by (actor_id, instrument).
  8. 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

  1. 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.

  2. strategy_quote_decision can disagree with what was sent. Quote-decision recording happens before the actor's later StrategyMode forced-clear override, so in paused or stopped mode the recorded decision can describe a quote while the target actually sent to convergence is empty. Use strategy_action records for what was dispatched.