Breakout worker workflow
The C# notebook
dotnet/breakout-worker-workflow.verso
takes one worker through its whole life: develop, unit test, run locally, deploy, replay on the
cluster, compare with the local run, clean up. The strategy is a long-only Donchian breakout on 1h
BTC_USDT candles. It buys when the close breaks above the highest high of the last 20 candles and
sells the position when the close breaks below the lowest low of the last 10, with limit orders at the
close.
The workflow and the tools it uses are described in the worker devkit docs: the worker workflow and running workers locally.
Steps
| Step | What happens | Tool | Needs |
|---|---|---|---|
| 1. Develop | the breakout as an IDecide, its StrategyWorkerBase worker, and FillPositionsWorker, which turns fills into position events |
Strategy.DevKit | nothing |
| 2. Unit test | the decide function on synthetic candles; both workers through their CloudEvent entry points, with a real fill from SimulatedExecutor |
BehaviorTestHost |
nothing |
| 3. Data | 1h klines from Binance, or a seeded random walk offline | internet (optional) | |
| 4. Run locally | executor, positions and strategy wired by topics; the same strategy without the loop | LocalWorkManager, WorkerObservables.Run |
nothing |
| 5. Deploy | the worker cells as C# text (text/x-csharp), the executor as its package at the version the local run used |
platform.DeploySourceAsync, platform.DeployAsync |
a gateway (demo) |
| 6. Replay and compare | the same candles through the cluster, paced on the local order candles; orders, fills and positions compared by content | SubscribeAsync, PublishAsync |
a gateway |
| 7. Cleanup | the three workers | platform.CleanupAsync |
a gateway |
All of the notebook's code lives in its cells. The worker cells start with a marker
(// Worker source: breakout or // Worker source: positions), and step 5 builds one C# text per
marker from the saved notebook. Without a gateway, steps 5 to 7 report [skipped]; without
internet access, step 3 uses the random walk and everything before step 5 still runs.
The feedback loop
The strategy learns that it is long only from position events. SimulatedExecutor turns each order
into a fill, and FillPositionsWorker joins the fill with its order and publishes the position event
to the replay topic, the topic the candles arrive on. A fill names its order only in its subject, so a
fill that arrives before its order waits for it. Without the loop, under WorkerObservables.Run, the
strategy never learns that it bought: it buys on every breakout and never sells.
Every event in one candle's cascade carries that candle's close time as eventtime. The order
inherits it from the candle, the fill from the order and the position from the fill.
Local run and cluster run
Both runs deploy the same three workers with the same configs, and both feed the same comparison.
flowchart LR
C["Candle events<br/>scenarioid, clocktype=historical,<br/>eventtime = window end"]
subgraph LOCAL["Local run: one process"]
LP["PublishAsync(replay topic,<br/>candles.ToObservable())<br/>each cascade drained<br/>before the next candle"]
subgraph LWM["LocalWorkManager bus"]
LB["Breakout"]
LE["SimulatedExecutor"]
LF["FillPositions"]
LB -->|"order.submitted"| LE
LB -->|"order.submitted"| LF
LE -->|"order.filled"| LF
LF -->|"position, replay topic"| LB
end
LP --> LB
LO["Observe: orders, fills, positions,<br/>dead letters"]
LWM --> LO
end
subgraph CLUSTER["Cluster run: demo"]
GW["API gateway<br/>Publish, Subscribe, CreateWorker"]
WM["WorkManager"]
subgraph RT["Runtime actors"]
CB["Breakout<br/>C# text"]
CE["SimulatedExecutor<br/>package"]
CF["FillPositions<br/>C# text"]
end
PS[("Pubsub topics<br/>replay, order.submitted,<br/>order.filled, dlq.*")]
GW -->|"deploy"| WM --> RT
GW -->|"publish candles, paced"| PS
PS <--> RT
PS -->|"subscribe"| CO["Orders, fills, positions,<br/>dead letters"]
end
C --> LP
C --> GW
LO --> CMP{"Compare by content<br/>orders: eventtime, side, qty, limit<br/>fills: eventtime, qty, price<br/>positions: type, quantity"}
CO --> CMP
CMP --> R["identical, or the first difference"]
Locally, the bus drains each candle's whole cascade before the next candle, so the strategy always
knows its position. On the cluster, workers run concurrently and pubsub does not wait. The replay
therefore paces itself: after each candle on which the local run ordered, it waits up to 30 s for
that candle's position event (recognized by its eventtime) before it publishes the next candle.
The comparison ignores ids, sources and wall-clock times, which differ by design. It also ignores
the error types of dead letters: on the cluster, a dead letter's dlqerrortype is the transport's
exception type, not the worker's.
Running it
cd dotnet
verso run breakout-worker-workflow.verso --timeout 1500 --verbose
ContextName near the top selects the context (demo by default; see
Getting Started). Run it under caffeinate -i on
a Mac, so that the replay is not paused by sleep. Orphans from a failed cleanup carry the tag
source=breakout-worker-workflow.