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 JsonObject EncodeAction(CloudEvent input, TradeAction action)
=> TradeActionJson.Encode(TradeActionEnricher.Enrich(
action,
GetStrategyId(input),
new Venue(ConfigResolution.ResolveString(input, "venue", "binance-spot")),
TimeSpan.FromSeconds(1)));
}
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, - encodes emitted actions into
{"command":"trade","success":true, "actions":[...],"count":n}.
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.
See Bridge for the routing architecture and Helpers for the reusable pieces.