Skip to content

Signals and actors

These are the two extension points you are most likely to touch. Signals answer "what is true about the market right now?" Actors answer "so what should we do?"

Writing a signal

A signal implements four things that matter and a lot of optional callbacks:

id()            -> its unique name
dependencies()  -> other signals it reads
subscriptions   -> which raw events it wants
callbacks       -> how it reacts

The one rule

Subscribe to a raw event only when your signal needs the raw event payload. Depend on other signals when your signal only needs their current output.

Getting this wrong is the most common mistake. A signal that subscribes to every book update "just in case" recomputes far more often than it needs to, and a signal that reimplements a dependency's math drifts away from it over time.

Choosing a callback

Your signal… Declare Implement
Reads the book when book data changes book_update_subscriptions() on_book_update
Reacts to trades trade_update_subscriptions() on_trade_update
Recomputes when a dependency changes dependencies() on_signal_update
Cares only when a dependency becomes usable dependencies() on_signal_valid_update
Needs the whole packet applied first after_packet_subscribed() on_after_packet
Emits or resets at packet end before_end_of_packet_subscribed() on_before_end_of_packet
Fires on a clock rather than on data time_clock_subscribed() the time-clock callback

Returning a result

A callback does not return the new value. It returns a SignalUpdate — two dirty flags:

SignalUpdate {
    value_dirty: bool,
    validity_changed: bool,
}

After the callback returns, the runtime reads your value() and is_valid() and refreshes the shared snapshot. Downstream signals are scheduled only if your flags say something changed, so honest flags are what keep propagation cheap. Marking everything dirty works, and quietly costs you on every packet.

flowchart LR
    EV["Raw event<br/>or dependency change"] --> CB["Your callback<br/>updates internal state"]
    CB --> UP["Returns SignalUpdate<br/>dirty flags only"]
    UP --> RD["Runtime reads<br/>value() and is_valid()"]
    RD --> SNAP["Snapshot refreshed"]
    SNAP --> DOWN["Dependents scheduled<br/>only if dirty"]

    classDef key fill:#2e7d32,stroke:#1b5e20,color:#fff
    class UP key

Writing an actor

An actor is smaller than a signal. Two required methods, two optional:

Method Called when Required
on_signal_update A subscribed signal's value changed Yes
on_signal_valid_update A signal's validity changed Yes
on_end_of_packet_signal_changed At packet end, if signals moved No
on_order_event One of its orders was accepted, rejected, filled, canceled No

Each returns Vec<OrderIntent> — possibly empty. Returning nothing is normal and means "no change from what I already have resting."

What an actor gets to see

ActorContext is the entire world an actor is allowed to reason about:

Field Use
fair_value The signal snapshot — check is_valid before using it
book Current book event
strategy_position This strategy's position
global_position Position across strategies, when configured
mode The StrategyMode safety dial
tick_size Venue price granularity
resting_orders What this actor already has live
can_cancel_pending_submit Whether an unacknowledged order can be pulled

There is a convenience method, fair_value_number(), that returns None when the fair value is invalid — prefer it over reading value directly, because it makes the validity check impossible to forget.

The shape of a quoting actor

if fair_value is not valid        -> cancel everything
if mode is Stopped or paused      -> cancel or hold, per policy
otherwise:
    compute desired prices from fair value, edge, and skew
    compare against resting_orders
    return intents only for what differs

The last line is the one that matters for venue load: actors are expected to be idempotent. Recomputing the same desired book should produce no intents at all, so quoting cost tracks how much the market moved, not how often the actor ran.

Where these run in Dyson

Dyson binds actors to venues, drives them with live NATS packets, and routes their intents to real adapters. The actor itself cannot tell the difference between that and a backtest — see Runtime and strategies and Multi-venue strategy for a strategy that binds one shared actor to two venues at once.