Skip to content

Strategy Author Guide

Build a strategy worker in three steps: implement IDecide, subclass StrategyWorkerBase, and wire per-instance config.

1. Implement IDecide

Your strategy is a pure coalgebra — no CloudEvents, no Rx, no I/O. The hidden state is an explicit record; Step is a pure function.

using Virtufin.Core.Events.Trade;
using Virtufin.Strategy.DevKit;
using Virtufin.Strategy.DevKit.Events;

public sealed record RsiState(decimal AvgGain, decimal AvgLoss) : IStrategyState;

public sealed class RsiStrategy : IDecide<RsiState, RichMarketEvent, PortfolioState, TradeAction>
{
    public RsiState Initial => new(0m, 0m);

    public (RsiState, TradeAction[]) Step(RsiState state, (RichMarketEvent, PortfolioState) input)
    {
        var (market, portfolio) = input;
        if (market is not RichMarketEvent.CandleClosed candle)
        {
            return (state, []); // only candle closes move this strategy
        }
        // ... compute RSI, consult portfolio.Quantity(candle.Symbol) ...
        var next = state;
        var actions = new TradeAction[] { /* BuyOrder / SellOrder */ };
        return (next, actions);
    }
}

Constraints enforce the domain: TMarket : IMarketEvent, TPortfolio : IPortfolioState, TAction : ITradeAction. Emitting zero actions is normal (a no-op step).

2. Subclass StrategyWorkerBase

public sealed class RsiWorker : StrategyWorkerBase<RsiState, RichMarketEvent, PortfolioState, TradeAction>
{
    public RsiWorker() : base(
        new Uri("urn:virtufin:worker:rsi"),
        "rsi.response",
        new RsiStrategy(),
        new PortfolioAlgebra())
    {
    }

    protected override OrderSubmission ToOrderSubmission(CloudEvent input, TradeAction action)
        => action switch
        {
            TradeAction.BuyOrder buy => new OrderSubmission(
                buy.OrderId ?? Guid.NewGuid(), buy.Symbol.Value, "buy",
                buy.LimitPrice is null ? "market" : "limit", buy.Quantity, buy.LimitPrice,
                ConfigResolution.ResolveString(input, "venue", "binance-spot")),
            TradeAction.SellOrder sell => new OrderSubmission(
                sell.OrderId ?? Guid.NewGuid(), sell.Symbol.Value, "sell",
                sell.LimitPrice is null ? "market" : "limit", sell.Quantity, sell.LimitPrice,
                ConfigResolution.ResolveString(input, "venue", "binance-spot")),
            _ => throw new ArgumentOutOfRangeException(nameof(action), action.GetType(),
                "RsiStrategy only emits Buy/Sell orders."),
        };
}

The base class:

  • folds virtufin.position.* events into the portfolio (no response),
  • steps virtufin.market.* events through Step,
  • skips market events whose mapped type does not fit TMarket,
  • publishes one emitted action as the pubsub-topics spec's "Order submitted" event: topic sc.<scenarioid>.trading.order.submitted, ce-type = com.virtufin.trading.order.submitted, ce-subject = order/<order_id>, payload {"order_id":...,"symbol":...,"side":..., "type":...,"qty":...,"price":...,"venue":...,"submitted_at":...}. scenarioid config is required; runid/clocktype/the world triplet default sensibly for a live worker (see StrategyWorkerBase.ScenarioIdKey and its siblings). A strategy that emits more than one action per step isn't supported yet -- see BuildOrderSubmittedPayload's remarks.

Override OnMarketEvent to react to config changes (e.g. re-window an indicator) before each step.

3. Per-instance config

CreateWorkerRequest.config entries are stamped onto every triggering CloudEvent as extension attributes. Read them with ConfigResolution.* (extension-only — same-named payload fields are ignored, because strategy payloads are foreign domain schemas).

protected override RsiState OnMarketEvent(CloudEvent input, RsiState state)
{
    var period = ConfigResolution.ResolvePositiveInt(input, "rsiperiod", 14);
    return state.Period == period ? state : state with { Period = period };
}

Note: CloudEvents extension attribute names cannot contain underscores (the CloudNative SDK and the WorkManager's config stamping both validate). Use a no-underscore spelling for your config keys.

4. Test before you trade

Step is a pure function (state in, state + actions out) — unit test it directly against IDecide<TState, TMarket, TPortfolio, TAction>, no CloudEvents or WorkManager involved (see Virtufin.Strategy.DevKit.Tests/MovingAverageStrategyTests for the pattern: warm up the indicator, feed known candles, assert the emitted actions). Backtest by replaying a hyp.BACKTEST world's historical candle/position events through the deployed worker itself (the same StrategyWorkerBase code path production uses, not a separate simulator) before ever wiring a strategy to act.

Before trusting a strategy with act (real capital), route it through the scenarios spec's shadow mode: an ObserveOnlyExecutor (see virtufin-dotnet's Virtufin.Base.Execution.Executors) executes nothing — it observes the same order-submitted events a live executor would receive and records what would have happened, with zero market or capital risk. A scenario's active/paused/archived lifecycle (also in the scenarios spec) governs when a worker is actually live versus parked.

See Bridge for the routing architecture and Helpers for the reusable pieces.