Strategy API

#[strategy], the Strategy trait, SignalOutput shape, SL/TP emission modes, #[param], #[tag] declarations.

#[strategy]

#[strategy](name, description?, data_mode, timeframe?, emit_sltp?, execution?, uses_vp?, columns?)

Metadata attribute. Marks a struct as a Rust strategy (TBBO ticks or OHLC bars).

Stripped before compilation; the parser at electron-side reads these kwargs to register the strategy with the UI and pick the right runner (tick vs bar). The struct must #[derive(Default)] and implement either the Strategy trait (tick) or the OhlcStrategy trait (bar).

data_mode is REQUIRED. There is no default. "tick" feeds raw TBBO and uses the tick runner; "ohlc" feeds an OHLC bar buffer (native parquet OR TBBO aggregated to timeframe) and uses the bar runner. The choice is enforced at metadata parse time: omitting it throws a clear error.

timeframe is required when data_mode = "ohlc" (e.g. "1m", "5m"). Ignored for data_mode = "tick".

uses_vp = true enables the per-bar Volume Profile snapshot pre-compute and requires data_mode = "tick" with execution = "bar". Use it together with the VpStrategy trait for VP-zone style strategies.

columns = [...] is a UI hint about extra TBBO columns the strategy reads via data.col("name"). Undeclared columns still work; declaring them just surfaces the list in the inspector.

Parameters

  • name (str, default "Unnamed Strategy"): Display name in the strategy panel
  • description (str, default ""): Tooltip description
  • data_mode (str, default required): "tick" (raw TBBO) or "ohlc" (bar input)
  • timeframe (str, default none): Bar timeframe ("1m", "5m", ...). Required when data_mode = "ohlc".
  • emit_sltp (str, default "per_tick"): "per_tick" (default; SL/TP re-evaluated each tick) or "entry_only" (locked at entry)
  • execution (str, default auto): "tick" or "bar". Auto-set to "bar" when data_mode = "ohlc". For data_mode = "tick" you can opt into bar pacing.
  • uses_vp (bool, default false): Enables per-bar VP snapshot pre-compute. Requires data_mode = "tick" + execution = "bar".
  • columns ([&str], default []): Extra TBBO columns this strategy reads (UI hint)

Example

// Tick (TBBO) strategy
#[strategy(
    name = "Microprice Reversion",
    description = "Mean-revert against microprice deviation",
    data_mode = "tick",
    emit_sltp = "per_tick",
    columns = ["volume", "delta"],
)]
#[derive(Default)]
pub struct MicropriceReversion { /* fields */ }

// OHLC bar strategy
#[strategy(
    name = "EMA Cross",
    description = "Fast/slow EMA crossover at bar close",
    data_mode = "ohlc",
    timeframe = "1m",
    emit_sltp = "entry_only",
)]
#[derive(Default)]
pub struct EmaCross { /* fields */ }

Notes

  • data_mode = "ohlc" implies execution = "bar". You cannot have OHLC input with tick pacing.
  • uses_vp = true cannot be combined with data_mode = "ohlc"; VP snapshots are built from raw ticks.
  • OHLC strategies implement OhlcStrategy. Tick strategies implement Strategy. The same struct cannot do both.

#[param]

#[param](default, min?, max?, step?, label?, tooltip?)

Field attribute. Marks a struct field as a swept parameter.

Only fields annotated with #[param] are filled from the UI. Other fields use Default::default() and act as per-combo scratch memory.

Parameters

  • default (literal, default required): Default value (int, float, or string)
  • min (literal, default auto): Minimum allowed value
  • max (literal, default auto): Maximum allowed value
  • step (literal, default 1 or 0.1): Slider step
  • label (str, default field name): Display label in the params panel
  • tooltip (str, default ""): Hover tooltip

Example

#[param(default = 14, min = 2, max = 200, label = "Period")]
pub period: usize,

#[param(default = 2.0, min = 0.5, max = 5.0, step = 0.25, label = "ATR Mult",
        tooltip = "Stop distance as multiple of ATR")]
pub atr_mult: f64,

#[tag]

#[tag](name, label?, color?, description?)

Struct attribute. Declares UI metadata for a tag the strategy emits.

Tags are free-form. You can attach any tag name in calculate() via .with_tag(name, values) and it will work. #[tag] declarations exist purely so the UI shows nice labels and colors instead of auto-generated ones.

Parameters

  • name (str, default required): Tag identifier (matches with_tag() key)
  • label (str, default name): Display label
  • color (str, default auto): Hex color for the tag badge
  • description (str, default ""): Tooltip description

Example

#[tag(name = "morning", label = "Morning Session", color = "#7aa2f7", description = "9:30am-11am ET")]
#[tag(name = "trend", label = "Trend Filter", color = "#26A69A")]

Strategy trait (tick path)

Strategy trait (tick path)impl Strategy for MyStrategy

Two methods: prepare() (per-day hoisted work) and calculate() (per-combo tick signal generation). Implemented when data_mode = "tick".

Implement Strategy on your struct when data_mode = "tick". prepare() is optional and defaults to DayPrep::empty(). calculate() is required and returns a SignalOutput whose arrays have length equal to the tick count.

For data_mode = "ohlc" use the OhlcStrategy trait instead. The metadata parser uses your data_mode kwarg to pick the right runner; you only implement the matching trait.

Example

impl Strategy for MyStrategy {
    fn prepare(data: &TickData) -> DayPrep {
        let mut prep = DayPrep::empty();
        prep.insert_f64("atr", ta::atr_bid_ask(&data.bid, &data.ask, 30));
        prep
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
        let n = data.len();
        let atr = prep.f64("atr").unwrap_or(&[]);
        let mut entry_long = vec![false; n];
        let mut sl_long = vec![f64::NAN; n];

        for i in self.period..n {
            if data.mid[i] < data.bid[i] {
                entry_long[i] = true;
                if let Some(a) = atr.get(i) {
                    sl_long[i] = data.mid[i] - a * 2.0;
                }
            }
        }

        SignalOutput::new(entry_long, vec![false; n], vec![false; n], vec![false; n])
            .with_sl_long(sl_long)
            .with_entry_only_sltp()
    }
}

OhlcStrategy trait (bar path)

OhlcStrategy trait (bar path)impl OhlcStrategy for MyStrategy

Bar-input sibling of Strategy. Use when data_mode = "ohlc". Receives BarData; returns BarSignalOutput.

Implement OhlcStrategy when data_mode = "ohlc". The runner projects bars-as-ticks at write time, so each bar becomes one row in the bundle. prepare() is optional (default empty); calculate() is required.

Key shape differences vs Strategy:

  • Receives &BarData (open / high / low / close / volume series) instead of &TickData.
  • Returns BarSignalOutput (constructed via BarSignalOutput::for_bars(n)) instead of SignalOutput::new(...).
  • All output arrays have length data.len() (bar count).

Example

use qc_strategy_api::prelude::*;

#[derive(Default)]
pub struct EmaCross { pub fast: usize, pub slow: usize }

impl OhlcStrategy for EmaCross {
    fn calculate(&self, data: &BarData, _prep: &DayPrep) -> BarSignalOutput {
        let fast = ta::ema(&data.close, self.fast);
        let slow = ta::ema(&data.close, self.slow);
        let mut out = BarSignalOutput::for_bars(data.len());
        for i in 1..data.len() {
            if fast[i-1] <= slow[i-1] && fast[i] > slow[i] {
                out.entry_long[i] = true;
            }
            if fast[i-1] >= slow[i-1] && fast[i] < slow[i] {
                out.exit_long[i] = true;
            }
        }
        out
    }
}

SignalOutput

SignalOutputSignalOutput::new(entry_long, exit_long, entry_short, exit_short)

Return type from calculate(). Builder pattern for SL/TP, tags, and emission mode.

Each of the four bool Vecs has length equal to the tick count. Optional SL/TP arrays are added via .with_sl_long(), .with_tp_long(), .with_sl_short(), .with_tp_short(). Emission mode defaults to per-tick; call .with_entry_only_sltp() to lock SL/TP at entry instead.

Free-form tags via .with_tag(name, values). UI metadata via .with_tag_config(name, label, color) (or via the #[tag] macro).

Parameters

  • entry_long (Vec<bool>, default required): True on bars where a long entry fires
  • exit_long (Vec<bool>, default required): True on bars where a long position should close
  • entry_short (Vec<bool>, default required): True on bars where a short entry fires
  • exit_short (Vec<bool>, default required): True on bars where a short position should close

Returns

SignalOutput (use builder methods to attach SL/TP, tags, emission mode)

Example

SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
    .with_sl_long(sl_long)
    .with_tp_long(tp_long)
    .with_sl_short(sl_short)
    .with_tp_short(tp_short)
    .with_entry_only_sltp()
    .with_tag("morning", morning_mask)
    .with_tag("trend",   trend_mask)

with_size_long / with_size_short

with_size_long / with_size_short.with_size_long(Vec<f64>) / .with_size_short(Vec<f64>)

Per-trade dynamic position sizing.

Size at each entry tick where entry_long[i] == true (or entry_short[i] == true). Length must equal the tick count. When unset, the engine uses the UI-configured default size (current behavior).

Use cases: volatility-scaled sizing (size = target_dollar_risk / atr[i]), Kelly-criterion sizing, equity-fraction sizing.

Parameters

  • sizes (Vec<f64>, default required): Length-N vector. Read at the entry tick; ignored on non-entry ticks. NaN/Inf -> use default.

Example

let target_risk = 100.0; // dollars
let sizes: Vec<f64> = atr.iter().map(|a| target_risk / a.max(0.5)).collect();

SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
    .with_size_long(sizes.clone())
    .with_size_short(sizes)

Notes

  • Composes with fill_model(): a VolumeWeightedFillModel can clip the requested size based on available bid/ask depth.
  • Defaults to the UI size when None or when entry slot has NaN/Inf.

with_slippage_per_tick

with_slippage_per_tick.with_slippage_per_tick(Vec<f64>)

Variable slippage per tick (overrides the UI scalar).

When set, the engine reads slippage_per_tick[i] at fill time instead of the UI scalar. Use this to model wider slippage at market open / news events / wide-spread regimes.

Length must equal the tick count. Composes with fill_model() - the FillModel receives the per-tick slippage in its FillContext.

Parameters

  • slips (Vec<f64>, default required): Per-tick slippage (price units). NaN -> use scalar.

Example

// 4x slippage in the 30-second window around 8:30 ET news drops
let mut slips = vec![scalar; n];
let news_start = ts_for("08:29:30");
let news_end   = ts_for("08:30:30");
for i in 0..n {
    if data.timestamp[i] >= news_start && data.timestamp[i] <= news_end {
        slips[i] = scalar * 4.0;
    }
}

SignalOutput::new(...).with_slippage_per_tick(slips)

without_sl_ratchet

without_sl_ratchet.without_sl_ratchet()

Disable the auto-ratchet on SL (longs only rise, shorts only fall).

Default behavior: once a long SL has been raised by the strategy, the engine clamps subsequent values so SL never drops below the running max. This protects against accidental SL widening.

Call .without_sl_ratchet() for chandelier-style trailing exits where the SL CAN retreat (e.g. ATR widening pulls SL further from price during a volatility spike). The engine then takes whatever the strategy emits at each tick.

Example

// chandelier exit: SL widens with ATR
let chandelier_sl: Vec<f64> = (0..n).map(|i| close[i] - atr[i] * 3.0).collect();
SignalOutput::new(...)
    .with_sl_long(chandelier_sl)
    .without_sl_ratchet()

fill_model

fill_modelfn fill_model(&self) -> Option<Box<dyn FillModel>>

Optional Strategy method. Override the default ask/bid fill behavior.

By default the engine fills longs at ask + slippage and shorts at bid - slippage (AskBidFillModel). Override fill_model() on your Strategy impl to plug in a different model.

The FillModel trait sees a FillContext with bid/ask/mid/sizes/side/action and returns a FillResult with price, filled_size (can be partial), and effective slippage.

Built-in implementations:

  • AskBidFillModel { slippage: f64 } (default).
  • MidpointFillModel { slippage: f64 } - fills at (bid + ask) / 2. Useful for limit-order modeling.
  • VolumeWeightedFillModel { max_consume_pct: f64 } - clips fill size based on available bid/ask size; partial fills are returned.

Custom impls: implement FillModel for MyModel with fn fill(&self, ctx: &FillContext) -> FillResult. Anything goes (limit-with-offset, volume-aware, regime-aware, etc.).

Example

use qc_strategy_api::prelude::*;

impl Strategy for MyStrategy {
    fn fill_model(&self) -> Option<Box<dyn FillModel>> {
        Some(Box::new(VolumeWeightedFillModel { max_consume_pct: 0.10 }))
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
        // ... your logic ...
    }
}

Notes

  • Engine pre-baseline tests assert byte-identical results when fill_model() returns None.
  • MidpointFillModel is the simplest non-default; great for limit-order backtests.
  • VolumeWeightedFillModel returns partial filled_size when available size is below requested - integrate with with_size_long/short for proper sizing semantics.

SL/TP emission modes

PerTick (default) re-evaluates SL/TP each tick. EntryOnly locks them at entry.

SltpEmission::PerTick (default): the bracket engine reads sl_long[i] / tp_long[i] on every tick of an open position. Use this for trailing stops or any pattern where the bracket should adapt mid-trade.

SltpEmission::EntryOnly via .with_entry_only_sltp(): the engine reads SL/TP only on the entry tick and ignores all subsequent values. Use this for fixed brackets (the common case).

The difference matters for performance and semantics: per-tick is more flexible but the SL/TP arrays must be filled meaningfully on every in-position tick, not just at entry.

Example

// Trailing stop: per-tick re-evaluation
SignalOutput::new(...)
    .with_sl_long(trailing_sl)  // sl_long[i] updates each tick
    // emit_sltp defaults to PerTick

// Fixed bracket: locks at entry
SignalOutput::new(...)
    .with_sl_long(entry_sl)
    .with_tp_long(entry_tp)
    .with_entry_only_sltp()