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 throughStep, - 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":...}.scenarioidconfig is required;runid/clocktype/the world triplet default sensibly for a live worker (seeStrategyWorkerBase.ScenarioIdKeyand its siblings). A strategy that emits more than one action per step isn't supported yet -- seeBuildOrderSubmittedPayload'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.