Decision models

Strategies

Decision models

Call a connected decision model from a strategy or an indicator. The model returns a choice, a score, or a probability. Your code places the order.

Connect a model

You connect a model in the Connections dialog, under Decisions. The built-in connection is System One. It speaks POST /v1/systemone, and its presets fill in the endpoint and model for you:

Preset Endpoint Model Key
Jev https://api.typesafe.ai jev-latest Your TypeSafe key.
OpenRouter https://openrouter.ai/api jev-latest Your OpenRouter key and credit.
Laya http://127.0.0.1:8080 laya None. Runs on this PC and takes up to 20 choice options.
Kev http://127.0.0.1:8008 kev-latest None. Runs on this PC.
Custom You enter it. You enter it. Optional.

Leave the endpoint and model empty to use the preset's. The client adds /v1/systemone. An API key is only sent over https, or to a server on this PC.

Connecting sends one yes/no question. If the server does not answer, the row stays failed. When it answers, the connection is pinned to the model version the server reports, so an alias such as jev-latest cannot switch models in the middle of a run. An answer from another version arrives as Faulted; reconnect to use the new version.

When more than one decision connection is up, the one that connected first answers, and the next one takes over when it disconnects or its key is rejected. A running strategy follows along without a restart. Turn off Use for decisions to keep a connection up without it answering. Parallel requests (1 to 8) and Call timeout (5 to 60 seconds) set how many asks may be in flight and how long one may take, counted from the moment you ask.

The state leaves this PC. For Jev the state and the questions go to TypeSafe, for OpenRouter to OpenRouter, and for Custom to the server you entered. For a Laya or Kev preset they go to the server on this machine. The SabrTrader cloud never sees them. It only grants the feature.

Build a request

A DecisionRequest is a name plus 1 to 16 questions (Name, Questions). It is immutable: build it once, keep it in a field, and pass the same instance on every bar. The engine sizes its buffers per request instance, and one strategy or indicator may ask with at most 32 different instances.

Every question has a Name (the key you read the answer back with) and Instructions (the text the model reads). There are three kinds:

Question Constructor The model returns
ChoiceQuestion (string name, string instructions, IReadOnlyList<DecisionOption> options), 2 to 255 options with unique keys. One option, a confidence, and optionally a probability per option.
ScoreQuestion (string name, string instructions, IReadOnlyList<string> levels), 2 to 10 levels, lowest first. A position on the scale in level units (0 is the first level), a confidence, and optionally a probability per level.
YesNoQuestion (string name, string instructions). The instructions are a statement. The probability, 0 to 1, that the statement is true.

A DecisionOption is (string key, string criterion): Key is what you compare against, Criterion tells the model when to pick it. Request names, question names, option keys and state field names are 1 to 64 characters of [A-Za-z0-9_]. Instructions, criteria and level texts are 1 to 1024 UTF-8 bytes. The constructors throw ArgumentException on a broken rule, so a bad request fails when you build it, never on a live bar.

C#private static readonly DecisionRequest EntryRequest = new("entry", new DecisionQuestion[]
{
    new ChoiceQuestion("side", "Which way does this closed bar's order flow point?", new[]
    {
        new DecisionOption("long", "Buyers lifted the offer and delta closed near its high"),
        new DecisionOption("short", "Sellers hit the bid and delta closed near its low"),
        new DecisionOption("flat", "The prints disagree, or the bar is noise"),
    }),
    new ScoreQuestion("conviction", "How clean is the imbalance on this bar?",
        new[] { "none", "weak", "clear", "strong" }),
    new YesNoQuestion("pocAtExtreme", "The point of control sits in the top or bottom third of the bar"),
});

Build the state

The state is what the model looks at: a list of named fields that DecisionStateBuilder writes into a compact byte form. Keep one builder per strategy, call Reset() before each bar's fields, and pass it to Ask. Ask copies the bytes, so you can reset and refill the builder right after.

Member What it does
Add(string name, string value)
Add(string name, bool value)
Add(string name, long value)
Add(string name, double value)
Writes one field. A string may be up to 1024 UTF-8 bytes. A double must be finite (NaN and infinity fault the builder), and -0 is written as 0.
BeginState(string name) / EndState() Open and close a named group of fields. Groups nest up to 4 deep. An EndState with no open group faults the builder.
HasFault True once any write broke a rule. The builder does not throw; it stops writing, and Ask returns InvalidState.
Reset() Clears the fields and the fault.
Length, Written The byte count and the bytes written so far.

Field names are unique within their group. Two sibling groups may use the same names, so a "prev" and a "curr" group can both hold "close". A whole state holds at most 256 entries (each BeginState and EndState counts as one) and 16 KB. Order matters: the same fields in the same order produce the same bytes.

Ask and decide

Decisions is an IDecisionClient with one method, DecisionAskStatus Ask(DecisionRequest request, DecisionStateBuilder state). It returns immediately and does not wait on the network. The answer arrives later in OnDecision(DecisionAnswer answer), on the same thread as OnBar. Place the order there, and only when answer.BarCount is still BarCount and the strategy is still flat. A late answer is for a bar that has already closed.

Ask returns Meaning
Accepted OnDecision runs once for this ask, unless the strategy stops first (or the indicator recalculates).
NotEntitled This seat cannot call a decision model. Nothing was sent.
NoConnection No decision connection is serving: none is connected, Use for decisions is off, or the server rejected the key.
InvalidState The builder has HasFault set, a choice question has more options than the connected model accepts (Laya takes 20), or this strategy already asked with 32 different request instances.
NotAvailableHere This host does not deliver decisions. Chart indicators also get this while history is loading.

A host that does not deliver decisions hands you DecisionClient.None, whose Ask always returns NotAvailableHere. It is also a handy stand-in in unit tests.

Each request instance holds at most one waiting ask. If you ask again with the same request before the waiting one has gone out, the waiting one completes as Superseded and the new state takes its place. An ask that is already with the model runs to the end. So a slow model never builds up a backlog: it answers the latest state you gave it.

answer.Status Meaning
Answered The model answered and the answer passed every check. The TryGet* lookups work.
Superseded You asked again with the same request before this ask was sent. The newer ask gets its own answer.
Unavailable No answer: a timeout, a busy or unreachable server, a disconnect, a rejected key, or a backtest past its call cap.
Faulted The model answered with something that did not pass the checks, or answered from another model version than the connection is pinned to.

The activity log says which case it was.

Read the answer

A DecisionAnswer carries RequestName, BarCount (your BarCount when you asked), ProviderId and ModelVersion (who answered), and Status. Read each question by name with TryGetChoice, TryGetScore or TryGetYesNo. They return false unless Status is Answered. The answer is never pooled, so you can keep it.

Type Members
ChoiceAnswer OptionKey, OptionIndex (its position in the options you passed), Probability (the chosen option's own probability when the model sent a distribution, otherwise the confidence), Confidence, and TryGetProbability(optionKey, out probability) for any other option the model priced.
ScoreAnswer Score in level units (0 is the first level), LevelIndex (the level the score falls in, Score rounded down), Confidence, and TryGetLevelProbability(levelIndex, out probability), which returns false when the model sent no per-level distribution.
YesNoAnswer Probability that the statement is true.
OptionProbability readonly struct (string OptionKey, double Probability): one entry of a choice distribution.

You choose the cutoff: the platform acts only on what your OnDecision does with the numbers.

Every answer is checked against your request before OnDecision sees it. It must answer every question exactly once with the right kind. A choice must be one of your option keys and may price only your options. Every probability and confidence must be 0 to 1. A score must lie between 0 and the last level, and a level distribution, when sent, must have one entry per level. Anything else produces Faulted, never Answered.

Example strategy

This strategy describes each closed bar's order flow to the model and asks which side it favours. It enters only from OnDecision. It implements IOrderFlowAwareStrategy so the host loads per-bar order flow for the run.

OrderFlowDecisionStrategy.csusing System.Collections.Generic;
using SabrTrader.Pipeline.Bars;
using SabrTrader.Pipeline.Decisions;
using SabrTrader.Pipeline.OrderFlow;
using SabrTrader.Pipeline.Strategies;

namespace MyCompany.Strategies;

public sealed class OrderFlowDecisionStrategy : Strategy, IOrderFlowAwareStrategy
{
    private static readonly DecisionRequest EntryRequest = new("entry", new DecisionQuestion[]
    {
        new ChoiceQuestion("side", "Which way does this closed bar's order flow point?", new[]
        {
            new DecisionOption("long", "Buyers lifted the offer and delta closed near its high"),
            new DecisionOption("short", "Sellers hit the bid and delta closed near its low"),
            new DecisionOption("flat", "The prints disagree, or the bar is noise"),
        }),
        new ScoreQuestion("conviction", "How clean is the imbalance on this bar?",
            new[] { "none", "weak", "clear", "strong" }),
        new YesNoQuestion("pocAtExtreme", "The point of control sits in the top or bottom third of the bar"),
    });

    private readonly DecisionStateBuilder _state = new();
    private DecisionAskStatus _lastStatus = DecisionAskStatus.Accepted;

    public bool RequiresOrderFlow(IReadOnlyDictionary<string, object>? parameterValues) => true;

    protected override void OnBar()
    {
        if (!IsFlat || !TryGetOrderFlowBar(0, out OrderFlowBar flow)) return;
        if (flow.TotalAskVolume + flow.TotalBidVolume == 0) return;
        Bar bar = flow.Bar;

        _state.Reset();
        _state.Add("instrument", Instrument);
        _state.BeginState("bar");
        _state.Add("open", bar.Open);
        _state.Add("high", bar.High);
        _state.Add("low", bar.Low);
        _state.Add("close", bar.Close);
        _state.Add("closedUp", bar.Close > bar.Open);
        _state.EndState();
        _state.BeginState("flow");
        _state.Add("delta", flow.Delta);
        _state.Add("deltaHigh", flow.LocalDeltaHigh);
        _state.Add("deltaLow", flow.LocalDeltaLow);
        _state.Add("askVolume", flow.TotalAskVolume);
        _state.Add("bidVolume", flow.TotalBidVolume);
        if (flow.TryGetPoc(out double poc, out long pocVolume))
        {
            _state.Add("poc", poc);
            _state.Add("pocVolume", pocVolume);
        }
        _state.EndState();

        DecisionAskStatus status = Decisions.Ask(EntryRequest, _state);
        if (status != _lastStatus && status != DecisionAskStatus.Accepted)
            Log("Decision not asked: " + status);
        _lastStatus = status;
    }

    protected override void OnDecision(DecisionAnswer answer)
    {
        if (answer.Status != DecisionStatus.Answered || answer.BarCount != BarCount || !IsFlat)
            return;
        if (!answer.TryGetChoice("side", out ChoiceAnswer side) || side.OptionKey == "flat")
            return;
        if (side.Probability < 0.6)
            return;
        if (!answer.TryGetScore("conviction", out ScoreAnswer conviction) || conviction.LevelIndex < 2)
            return;
        if (answer.TryGetYesNo("pocAtExtreme", out YesNoAnswer pocAtExtreme) && pocAtExtreme.Probability < 0.3)
            return;
        if (side.OptionKey == "long") EnterLong(1);
        else EnterShort(1);
    }
}

TryGetOrderFlowBar returns false when the run has no order flow for a bar, so a bar with nothing to describe never calls the model. The request is a static field, so every bar asks with the same instance.

Indicators

An indicator has the same Decisions and OnDecision. While the chart is loading history, Ask returns NotAvailableHere and does not call the model. On a live bar the answer is delivered on the chart thread. When the indicator recalculates, answers to asks made before that are dropped. An indicator does not submit an order. Write the result into a plot, or let a strategy read that plot.

Backtest

A backtest uses the same Ask and OnDecision, with the same checks and the same entitlement. After each bar closes it waits for the answers to that bar's asks and runs OnDecision for them in the order you asked, before the next bar, so the result does not depend on network timing. Cancelling the backtest stops the wait. The connected model is called for every ask on every run.

A backtest spends your model credit, so a run sends at most 500 asks. Past that, Ask still returns Accepted and the answer arrives as Unavailable without a call. The backtest result reports the count of those asks, so a capped run is never mistaken for a complete one. Zero means every ask was sent.

Write a decision provider

A decision model plugs in as a venue plugin. Its descriptor declares VenueCategory.Decisions in the manifest, which lists it under Decisions in the Connections dialog. Its session serves no data and routes no orders (Data and Trading are null). While it is connected, GetPort<IDecisionProvider>() returns the model. The host probes that port right after ConnectAsync completes, hands it to the decision engine, and takes it back before the session disconnects. The port is the only signal: a session that returns no IDecisionProvider (for example with its own "use for decisions" switch off) stays out of the answering order.

Type Role
IDecisionProvider DecisionProviderProfile Profile { get; } and ValueTask<DecisionProviderResult> AskAsync(DecisionProviderRequest request, CancellationToken cancellationToken). The engine calls AskAsync on a worker thread, up to Profile.ConcurrentCalls at once.
DecisionProviderProfile (string providerId, string modelId, string modelVersion, int maxChoiceOptions, int concurrentCalls, TimeSpan callTimeout). ProviderId is reported on every answer. ModelId is sent with every request and may be an alias. ModelVersion is the concrete version you learned when connecting: an answer from any other version is Faulted. MaxChoiceOptions is 2 to 255, ConcurrentCalls is MinConcurrentCalls (1) to MaxConcurrentCalls (8), CallTimeout is MinCallTimeout (1 s) to MaxCallTimeout (120 s). The constructor throws on anything outside those ranges. The profile is fixed for the life of the provider; a changed model or setting is a new connection.
DecisionProviderRequest ModelId, Request (the strategy's DecisionRequest, with its questions, options and levels) and CanonicalState (the state bytes).
DecisionStateReader new DecisionStateReader(canonicalState), then TryRead(out DecisionStateEntry entry) until it returns false. Walks the fields in the order the strategy wrote them.
DecisionStateEntry readonly struct: Kind, Name, and the value in Text, Bool, Int64 or Double depending on the kind.
DecisionValueKind enum: String, Bool, Int64, Double, BeginState (opens a group, Name is the group), EndState (closes the innermost group).
ProviderAnswer (string modelVersion, IReadOnlyList<ProviderQuestionAnswer> questions): the model's raw answer, one entry per question.
ProviderQuestionAnswer Abstract base with QuestionName. The three answers derive from it:
ProviderChoiceAnswer(string questionName, string optionKey, double confidence, IReadOnlyList<OptionProbability> probabilities)
ProviderScoreAnswer(string questionName, double score, double confidence, IReadOnlyList<double> levelProbabilities), with an empty list when the model has no per-level distribution
ProviderYesNoAnswer(string questionName, double probability)
DecisionProviderResult Built with a factory: FromAnswer(ProviderAnswer), Transient(reason, retryAfter?), Unauthorized(reason), Unreachable(reason), Invalid(reason). Exposes Kind, Answer (answered only), Reason (null only when answered) and RetryAfter (transient only). A reason is required, and a negative retry delay throws.

Report every outcome the wire can produce as a result, and leave timeouts, retries and answer checks to the engine. AskAsync must throw only OperationCanceledException, when the token fires. The token fires on the call timeout, when the strategy stops, and when the connection closes.

DecisionProviderKind Use it for What the engine does
Answered The model answered. Checks the answer against the request and the pinned version: Answered or Faulted.
Transient Busy or rate limited (HTTP 408, 429, 5xx). Without a delay, retries once if the call timeout leaves room and no newer ask is waiting. With RetryAfter, pauses the connection for that long (at most 5 minutes). Otherwise Unavailable.
Unauthorized The server refused the key or its account (401, 402, 403). The connection stops answering and the next decision connection takes over. Unavailable.
Unreachable Refused, DNS, TLS: a retry right away would fail the same way. Unavailable, no retry.
Invalid The server replied with something that is not a valid answer. Faulted.

A timeout ends as Unavailable. Any exception other than cancellation ends as Faulted and is logged, and the connection keeps serving.

This provider is a small rule model inside the plugin. It reads delta from the flow group of the example strategy's state and leans each answer toward the side that delta favours. A real provider would post the request to a server instead.

DeltaRuleProvider.csusing System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using SabrTrader.Pipeline.Decisions;

namespace MyCompany.Decisions;

internal sealed class DeltaRuleProvider : IDecisionProvider
{
    public DeltaRuleProvider(DecisionProviderProfile profile) => Profile = profile;

    public DecisionProviderProfile Profile { get; }

    public ValueTask<DecisionProviderResult> AskAsync(DecisionProviderRequest request, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        double lean = 1.0 / (1.0 + Math.Exp(-ReadDelta(request.CanonicalState) / 500.0));   // 0 to 1

        var answers = new List<ProviderQuestionAnswer>(request.Request.Questions.Count);
        foreach (DecisionQuestion question in request.Request.Questions)
        {
            switch (question)
            {
                case ChoiceQuestion choice:
                    answers.Add(Choose(choice, lean));
                    break;
                case ScoreQuestion score:
                    double top = score.Levels.Count - 1;
                    answers.Add(new ProviderScoreAnswer(score.Name, Math.Abs(lean - 0.5) * 2 * top, 0.5, Array.Empty<double>()));
                    break;
                case YesNoQuestion yesNo:
                    answers.Add(new ProviderYesNoAnswer(yesNo.Name, lean));
                    break;
                default:
                    return ValueTask.FromResult(DecisionProviderResult.Invalid("unknown question kind " + question.GetType().Name));
            }
        }
        return ValueTask.FromResult(DecisionProviderResult.FromAnswer(new ProviderAnswer(Profile.ModelVersion, answers)));
    }

    // Prices the first option at the lean and the second at the rest; any further option gets 0.
    private static ProviderChoiceAnswer Choose(ChoiceQuestion choice, double lean)
    {
        IReadOnlyList<DecisionOption> options = choice.Options;
        var probabilities = new OptionProbability[options.Count];
        for (int i = 0; i < options.Count; i++)
            probabilities[i] = new OptionProbability(options[i].Key, i == 0 ? lean : i == 1 ? 1 - lean : 0);
        string picked = lean >= 0.5 ? options[0].Key : options[1].Key;
        return new ProviderChoiceAnswer(choice.Name, picked, Math.Abs(lean - 0.5) * 2, probabilities);
    }

    private static double ReadDelta(ReadOnlyMemory<byte> state)
    {
        var reader = new DecisionStateReader(state);
        int depth = 0;
        bool inFlow = false;
        while (reader.TryRead(out DecisionStateEntry entry))
        {
            switch (entry.Kind)
            {
                case DecisionValueKind.BeginState:
                    depth++;
                    if (depth == 1) inFlow = entry.Name == "flow";
                    break;
                case DecisionValueKind.EndState:
                    depth--;
                    if (depth == 0) inFlow = false;
                    break;
                case DecisionValueKind.Int64 when inFlow && depth == 1 && entry.Name == "delta":
                    return entry.Int64;
            }
        }
        return 0;
    }
}

The venue side is a plugin, a descriptor and a session. The session builds the profile when it connects, because that is when a real provider learns the model version from its server.

DeltaRulePlugin.csusing System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using SabrTrader.Pipeline.Decisions;
using SabrTrader.Pipeline.Providers;
using SabrTrader.Pipeline.Venues;
using SabrTrader.Pipeline.Venues.Trading;

namespace MyCompany.Decisions;

public sealed class DeltaRulePlugin : IVenuePlugin
{
    public IReadOnlyList<IVenueDescriptor> Venues { get; } = new IVenueDescriptor[] { new DeltaRuleDescriptor() };
}

internal sealed class DeltaRuleDescriptor : IVenueDescriptor
{
    public VenueManifest Manifest { get; } = new(
        TypeId: "DeltaRule",
        DisplayName: "Delta rule model",
        Category: VenueCategory.Decisions,
        AssetClasses: Array.Empty<VenueAssetClass>(),
        Capabilities: VenueCapabilities.None,
        SettingsSchema: Array.Empty<ProviderCredentialField>());

    public IVenueSession CreateSession(VenueSessionContext context) => new DeltaRuleSession();
}

internal sealed class DeltaRuleSession : IVenueSession
{
    private DeltaRuleProvider? _provider;   // set only while connected
    private ProviderConnectionStatus _status = ProviderConnectionStatus.Disconnected;

    public ProviderConnectionStatus Status => _status;
    public event Action<ProviderConnectionStatus>? StatusChanged;
    public VenueFailure? LastFailure => null;
    public IDataProvider? Data => null;
    public ITradingProvider? Trading => null;
    public event Action<VenueStreams>? StreamsRestored { add { } remove { } }

    public T? GetPort<T>() where T : class => (this as T) ?? (_provider as T);

    public Task ConnectAsync(CancellationToken cancellationToken)
    {
        SetStatus(ProviderConnectionStatus.Connecting);
        _provider = new DeltaRuleProvider(new DecisionProviderProfile(
            providerId: "deltarule",
            modelId: "delta",
            modelVersion: "delta-1.0",
            maxChoiceOptions: DecisionLimits.MaxChoiceOptions,
            concurrentCalls: 2,
            callTimeout: TimeSpan.FromSeconds(5)));
        SetStatus(ProviderConnectionStatus.Connected);
        return Task.CompletedTask;
    }

    public Task DisconnectAsync()
    {
        _provider = null;
        SetStatus(ProviderConnectionStatus.Disconnected);
        return Task.CompletedTask;
    }

    public ValueTask DisposeAsync() => new(DisconnectAsync());

    private void SetStatus(ProviderConnectionStatus next)
    {
        if (_status == next) return;
        _status = next;
        StatusChanged?.Invoke(next);
    }
}

For settings, logging and failure reporting (VenueSessionContext, LastFailure, a permanent VenueFailure for a rejected key), follow A complete venue plugin. A provider that talks to a server should connect with one cheap question, as System One does, so a wrong endpoint or key fails in the Connections dialog instead of on the first live bar.

Limits

DecisionLimits holds every hard limit as a constant, so you can check against them in code.

Constant Value Applies to
MaxQuestions 16 Questions in one request.
MinChoiceOptions / MaxChoiceOptions 2 / 255 Options in one choice question. The connected model may accept fewer (DecisionProviderProfile.MaxChoiceOptions).
MinScoreLevels / MaxScoreLevels 2 / 10 Levels in one score question.
MinNameLength / MaxNameLength 1 / 64 Request, question, option and field names, [A-Za-z0-9_].
MaxStringUtf8Bytes 1024 Instructions, criteria, level texts, and string field values.
MaxStateBytes 16384 One state.
MaxEntries 256 Fields in one state, counting each BeginState and EndState.
MaxDepth 4 Nesting of BeginState groups.
MaxRequestsPerClient 32 Different request instances one strategy or indicator may ask with.

Who can call it

Pro, Ultimate, Lifetime, and an account trial can call it. A Free seat gets NotEntitled. The model is not called.