Bridge
IndicatorWorkerBase<TIndicator, TSample, TValue> is the bridge between
an IIndicator<TSelf, TSample, TValue> and the WorkManager's worker
contract (IWorker.ProcessAsync(CloudEvent input, IWorkerContext context)).
Per-symbol dispatch
A worker instance tracks one indicator instance per symbol seen, not
one shared instance across everything it observes — mirrors
BinanceOrderBookWorker's multi-pair-per-instance precedent
(market-data-workers). One-worker-per-symbol was judged
operationally heavy; nothing about an indicator's own state needs
isolating any other way. The constructor takes a factory (not a single
instance) so a fresh, empty indicator can be created the first time a new
symbol is seen:
Func<TIndicator>: every symbol gets the same indicator.Func<CloudEvent, TIndicator>: the factory gets the event that first shows the symbol, so parameters can come from the worker's config, e.g.ConfigResolution.ResolvePositiveInt(input, "windowcandles", 20). One package then servessma-7andsma-30deployments.
Sample source and encoder
A worker is constructed from its worker type, an ISampleSource<TSample>, an
IValueEncoder<TValue> and the indicator factory. The source decides which
events are samples (Accepts; an event it rejects is skipped: no state change,
no output, no error), which symbol a sample belongs to (ResolveSymbol) and how
it decodes (Decode); the encoder turns the value into the event's data and the
saved state. No canonical wire shape is imposed: an SMA-over-candles worker's
sample looks nothing like an RSI-over-ticks worker's.
flowchart TD
E[CloudEvent] --> ACC{"source.Accepts(input)"}
ACC -->|no| SKIP[skip: no output]
ACC -->|yes| SYM["source.ResolveSymbol(input)"]
SYM --> GET["IndicatorFor(symbol, input)"]
GET --> DEC["source.Decode(input)"]
DEC --> ADD["indicator.Add(sample)"]
ADD --> ENC["encoder.Encode(indicator.Value, input)"]
ENC --> SAVE["SaveState (per-symbol key)"]
SAVE --> ENV["BuildEnvelope"]
ENV --> RESP[response / .changed CloudEvent]
Topic and subject
A changed event is published on sc.<scenarioId>.indicator.<name>.changed.<PAIR>
with ce-type com.virtufin.indicator.<name>.changed and ce-subject
(all built by the naming library from this package's virtufin.indicator
fragment; see Naming: <name> is lowercase kebab, <PAIR> canonical)
currency_pair/<PAIR> (openspec pubsub-topics, "Indicator Events"; the
subject was the bare symbol before 0.4.0).
Envelope
Changed events carry the pubsub-topics spec's Common Envelope Extensions.
eventtime (= ce-time) comes from the input's eventtime, else its
ce-time, else now, so indicators run on event time. walltime is the
publish time. clocktype (default wall), exchange, marketworld,
marketuniverse, portfolioworld, strategyworld and runid are carried
forward from the input event or config when present.
State persistence
Each update is persisted via the State API of the worker's context
(IWorkerContext.Api): the cluster's gateway under the Runtime, the in-memory
LocalVirtufinApi under a LocalWorkManager or a BehaviorTestHost, so a
worker saves state locally with no gateway configured. CreateStateStore(input,
context) wraps it as an ApiClientIndicatorStateStore; a test injects a fake
IIndicatorStateStore through the StateStore property. An apihost config
value (host[:port], https://host[:port] for TLS) names an explicit other
gateway instead, dialled through Worker.DevKit's WorkerApiEndpoint (the
host's key is sent only to its own configured gateway). The state key is:
sc.<scenarioId>.indicator.<name>.<PAIR>
Scenario-scoped, not market-data-cache-scoped
(act|hyp.<name>.exchange.<venue>.*, the pattern BinanceOrderBookWorker
uses for order books) — a real correctness requirement: an indicator fed
by scenario-specific replayed/synthetic data under hyp.* needs
disambiguation two concurrent backtests of the same symbol would
otherwise collide on. An order book has no such need (it's a genuinely
singular venue-level fact regardless of observer), which is why that
pattern differs.
Response routing
Like the trading envelope (StrategyWorkerBase/ExecutorWorkerBase),
ce-type stays a stable identifier
(com.virtufin.indicator.<name>.changed) independent of the
per-scenario topic. Routing goes through
Virtufin.Worker.Host.WorkerBase.PublishTopicAttribute
(publishtopic), set by BuildEnvelope to
sc.<scenarioId>.indicator.<name>.changed.<PAIR>; WorkManager reads
that extension to pick the publish topic and strips it before publish,
so subscribers never see it.
State
Per-symbol indicator instances live in the worker instance's own dictionary — the engine keeps one worker object alive for the deployment's lifetime, so state threads across deliveries, as in the strategy and execution bridges.
Redelivery
Inputs arrive at least once since WorkManager 0.38.0: after a hard crash the
samples of the last cursor window (≤250 ms) come again
(Inputs across a move). IndicatorWorkerBase folds each sample once
with no code of its own, by input-id deduplication in BehaviorWorkerBase
(Inputs can arrive twice): a sample whose source and id the
worker already took is dropped before it is decoded, so no window counts it
twice and no .changed event repeats. A crashed producer (BinanceCandles,
BinanceTrades) republishes what it published under the same ids, so the
running indicator drops those too. A moved indicator starts with empty
windows and folds the replayed samples once. replay stays on (default).
Limitations
One sample in, one .changed event out. IndicatorWorkerBase is built on
BehaviorWorkerBase (Virtufin.Behavior.DevKit): the indicator runs as
an IAsyncProcess per symbol (indicator.AsProcess() from
IndicatorBehaviorExtensions lifts any indicator to a process), scenarioid and indicatorname are
checked before the sample is folded in, and the value is saved before the
in-memory state advances -- a failed save is an error and the sample does
not count.
A lighter alternative
IndicatorWorkerBase is the full bridge: per-symbol dispatch, State
persistence, response routing. For cases that don't need any of that (inline in
a strategy, in a test, a one-off script), lib's IndicatorExtensions.Adapt
(Virtufin.Core.Behavior) builds an indicator that takes a raw input, here a
CloudEvent, decoding it into a sample before folding:
var sma = SimpleMovingAverage<DateTimeOffset, DecimalAmount>.Empty(fromCount, isExpired)
.Adapt((CloudEvent ce) => DecodeCandle(ce))
.Add(candleEvent);
Pacing: publish and save intervals
An indicator that folds a high-rate input (trades, quotes) cannot announce or
save per sample. Two optional keys, both defaulting to 0 = every sample:
| Key | Effect |
|---|---|
publishintervalms |
at most one changed event per symbol per interval, latest wins. The fold runs on every sample; only the announcement is thinned, and a symbol with no due sample simply emits nothing (there is no timer) |
saveintervalms |
at most one state write per symbol per interval, in the background, always the newest value. 0 keeps the inline save, where the in-memory state only advances after the write succeeds |
Invalid values fail the sample with an error response naming the key. The gate
and writer live in IndicatorPacing/PublishGate/LatestValueWriter.
Sample sources and encoders
An indicator's input family and its wire encoding are separate from the fold:
ISampleSource<TSample>decodes one family (candles,trades,quotes,book) into samples and owns theAccepts/ResolveSymboldecisions.Decodetakes the wholeCloudEvent, so JSON and binary payloads are both possible.IValueEncoder<TValue>producesEncodedPayload: the JSON state that is saved under the pair's key, and optionally bytes for the event (with content type and dataschema). The state stays JSON even when the published value is binary.
Every IndicatorWorkerBase is constructed with a source, an encoder and an
indicator factory. Samples arrive ordered per key, but sample time is the
venue's and is not guaranteed monotonic (a late trade, a re-sent full book or
a replay can go backwards), so a windowed fold must not assume it is.
Binary input works through Behavior.DevKit's LenientJsonCodec: a
source-driven worker turns a non-JSON payload into an empty object and the source
reads the bytes from the event. Binary output is EncodedPayload.WithBinary.