SignalAI

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 to on_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::shares takes f64 (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 in sym.
  • 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 for sym.

Params

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 in init.
  • 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_signal event with sig.key: Option<String> set; plain scalar signals have sig.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: a last_value: Option<f64> field (or a BTreeMap<String, f64> per key), set in on_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 in init (events are delivered run-wide either way).
  • fn on_corporate_action(&mut self, ctx: &mut Ctx, ca: &CorpAction)ca.symbol, ca.ts, and ca.kind: CorpActionKind::Split { from, to } or CorpActionKind::Dividend { amount }.
  • Dividends are credited automatically: the engine adds position × amount to 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"), and f.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 with f.fields.get("eps_basic")).
  • Store what you need in your struct (push-only, like signals) — e.g. keep the latest eps_diluted and compute a trailing P/E against bar.close in on_bar.

Indicators

The indicators module has incremental, lookahead-free helpers. Note the return types differ:

  • indicators::Sma::new(n).update(x) -> Option<f64> (None until n samples).
  • indicators::Rsi::new(n).update(x) -> Option<f64> (None until warm).
  • indicators::Ema::new(n).update(x) -> f64 (a value from the first bar — not Option; warm up with your own bar counter).
  • indicators::Macd::new(fast, slow, signal).update(x) -> Option<MacdValue> (None until slow samples; fields .macd, .signal, .histogram).
  • indicators::Bollinger::new(n, k).update(x) -> Option<BollingerValue> (None until n; fields .upper, .middle, .lower = SMA ± k·σ).
  • indicators::Atr::new(n).update(high, low, close) -> Option<f64> (Wilder smoothing; None until n bars).
  • 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> (None until 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);.