Strategy
Implement the Strategy trait, wire up subscriptions in init, and place orders through the Ctx you're handed. You never import an engine — the server runs one behind this contract.
use signalai_quant::prelude::*;
struct MyStrategy { sym: SymbolId }
impl Strategy for MyStrategy {
fn init(&mut self, ctx: &mut Ctx) {
self.sym = ctx.universe()[0];
ctx.subscribe_bars(self.sym, Resolution::Day1);
}
fn on_bar(&mut self, ctx: &mut Ctx, bar: &Bar) {
if ctx.position(self.sym).is_zero() {
ctx.order(self.sym, Side::Buy, Qty::shares(10.0));
}
}
}
pub fn build_strategy() -> Box<dyn Strategy> {
Box::new(MyStrategy { sym: SymbolId(0) })
}
Two things are required: a type that impl Strategy, and a pub fn build_strategy() -> Box<dyn Strategy> the runtime calls to construct it.
Handlers
fn init(&mut self, ctx: &mut Ctx) — required
Called once at start (and once per session in day mode). Read the universe, subscribe to data, read params, and initialize state here.
fn trading_type(&self) -> TradingType
Declares the strategy's trading type. Defaults to TradingType::Swing; return TradingType::Day for intraday (flat at each session close).
fn on_bar(&mut self, ctx: &mut Ctx, bar: &Bar)
Called for each subscribed bar. Read prices with bar.close.as_f64() (see Events & types).
Other handlers
on_trade(&Trade), on_quote(&Quote), on_signal(&SignalValue), on_order(&OrderEvent) (fill notifications), on_timer(&Timer) — each (&mut self, ctx: &mut Ctx, event). Subscribe to the matching source in init to receive them.
Driving the run — the Ctx
Every handler receives &mut Ctx. It's how you read state and place orders.
Subscriptions (call in init)
ctx.subscribe_bars(sym, Resolution::Day1 | Resolution::Min1)— route bars toon_bar.ctx.subscribe_trades(sym)/ctx.subscribe_quotes(sym)— ticks →on_trade/on_quote.ctx.subscribe_signal("name")— a signal feed →on_signal.ctx.every("7d")— a periodic timer →on_timer.
Orders
ctx.order(sym, Side::Buy | Side::Sell, Qty::shares(n)) -> OrderId— a market order.Qty::sharestakesf64(Qty::shares(10.0), not an integer type).ctx.limit_order(sym, side, Qty::shares(n), Price::from_f64(p)) -> OrderId— a limit order.ctx.set_target(sym, Qty::shares(n)) -> Option<OrderId>— order the delta to reach a target position.ctx.liquidate(sym)— flatten the position insym.ctx.cancel(order_id)— cancel a resting order.
Orders never fill synchronously — they enqueue and execute as a later event. See Fills.
Reads
ctx.universe() -> Vec<SymbolId>— the run's symbols (iterate these; never hard-code tickers).ctx.position(sym) -> Qty— current position (.is_zero(),.as_f64()).ctx.cash() -> f64,ctx.equity() -> f64— account cash and marked-to-market equity.ctx.last_price(sym) -> Price— the last seen price forsym.
Params
ctx.param_f64("name"),ctx.param_i64("name"),ctx.param_bool("name")— the run's tunable values (see SDK overview → Parameters).
Signals (your SignalAI signals as inputs)
Feed a run your signals by naming them at submit (signals: ["my-signal"] in the backtest request). Their historic values interleave with bars in timestamp order — no lookahead: at simulation time T the strategy has seen only points with ts ≤ T.
ctx.subscribe_signal("my-signal")— declare the dependency ininit.fn on_signal(&mut self, ctx: &mut Ctx, sig: &SignalValue)— fires at each point;sig.name,sig.key,sig.ts,sig.value. Orders placed here fill at the next bar's open, like all orders.- Keyed signals: one signal can hold several keyed sub-series (team → win %, contract → odds). Each keyed row arrives as its own
on_signalevent withsig.key: Option<String>set; plain scalar signals havesig.key = None. Match on it:if sig.key.as_deref() == Some("FRA") { … }. - Delivery is push-only. If you need the latest value in
on_bar, store it yourself: alast_value: Option<f64>field (or aBTreeMap<String, f64>per key), set inon_signal.
The submit fails with a clear error if a named signal has no datapoints in the range; the backtest's run configuration shows exactly how many points each signal contributed.
Corporate actions (dividends & splits)
Every run automatically receives its symbols' corporate actions, delivered at the ex-date, before that day's bar (strictly point-in-time — you never see one early):
ctx.subscribe_corporate_actions(sym)— declare interest ininit(events are delivered run-wide either way).fn on_corporate_action(&mut self, ctx: &mut Ctx, ca: &CorpAction)—ca.symbol,ca.ts, andca.kind:CorpActionKind::Split { from, to }orCorpActionKind::Dividend { amount }.- Dividends are credited automatically: the engine adds
position × amountto cash at delivery — hold through an ex-date and your equity reflects it (total-return honesty). In adjusted daily mode, amounts are split-adjusted to match adjusted prices. Splits are informational — adjusted prices already encode them; your share count is never modified.
Execution costs (fees + slippage)
Runs accept an execution config ({"preset": "retail"} on submit; a selector on the web run form). Presets: retail ($0-commission broker + US regulatory fees + ~3 bps impact — the honest default), ibkr-pro (per-share commission schedule), conservative (10 bps impact), zero (frictionless), custom (explicit fields incl. dividend withholding). Costs adjust fill prices (buys pay up, sells receive less; forced liquidations pay too) and deduct fees from cash; results report total_fees and total_slippage_cost.
Fundamentals (filings, point-in-time)
Every run also receives its symbols' fundamentals filings, delivered at the filing date — a March quarter filed in May is visible in May, never earlier (no lookahead by construction):
fn on_fundamentals(&mut self, ctx: &mut Ctx, f: &Fundamentals)—f.symbol,f.ts,f.period(e.g."Q1 2026"), andf.fields: BTreeMap<String, f64>with the reported metrics:revenues,net_income,eps_basic,eps_diluted,shares_basic,assets,liabilities,equity,operating_cash_flow(absent when not reported — gate withf.fields.get("eps_basic")).- Store what you need in your struct (push-only, like signals) — e.g. keep the latest
eps_dilutedand compute a trailing P/E againstbar.closeinon_bar.
Indicators
The indicators module has incremental, lookahead-free helpers. Note the return types differ:
indicators::Sma::new(n)—.update(x) -> Option<f64>(Noneuntilnsamples).indicators::Rsi::new(n)—.update(x) -> Option<f64>(Noneuntil warm).indicators::Ema::new(n)—.update(x) -> f64(a value from the first bar — notOption; warm up with your own bar counter).indicators::Macd::new(fast, slow, signal)—.update(x) -> Option<MacdValue>(Noneuntilslowsamples; fields.macd,.signal,.histogram).indicators::Bollinger::new(n, k)—.update(x) -> Option<BollingerValue>(Noneuntiln; fields.upper,.middle,.lower= SMA ± k·σ).indicators::Atr::new(n)—.update(high, low, close) -> Option<f64>(Wilder smoothing;Noneuntilnbars).indicators::Vwap::new()—.update(price, volume) -> Option<f64>(cumulative since construction; call.reset()at each session open in DAY strategies).indicators::Stochastic::new(k_period, d_period)—.update(high, low, close) -> Option<StochasticValue>(Noneuntil warm; fields.k,.d).indicators::RollingWindow::new(n)—.update(x)returns(); then read.mean(),.std(),.min(),.max(),.last(),.len().
Gate the Option-returning ones: let Some(v) = sma.update(x) else { return };. Bind EMA directly: let v = ema.update(x);.