Skip to content

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.