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 paneldescription(str, default""): Tooltip descriptiondata_mode(str, defaultrequired): "tick" (raw TBBO) or "ohlc" (bar input)timeframe(str, defaultnone): 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, defaultauto): "tick" or "bar". Auto-set to "bar" when data_mode = "ohlc". For data_mode = "tick" you can opt into bar pacing.uses_vp(bool, defaultfalse): 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, defaultrequired): Default value (int, float, or string)min(literal, defaultauto): Minimum allowed valuemax(literal, defaultauto): Maximum allowed valuestep(literal, default1 or 0.1): Slider steplabel(str, defaultfield name): Display label in the params paneltooltip(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, defaultrequired): Tag identifier (matcheswith_tag()key)label(str, defaultname): Display labelcolor(str, defaultauto): Hex color for the tag badgedescription(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 viaBarSignalOutput::for_bars(n)) instead ofSignalOutput::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>, defaultrequired): True on bars where a long entry firesexit_long(Vec<bool>, defaultrequired): True on bars where a long position should closeentry_short(Vec<bool>, defaultrequired): True on bars where a short entry firesexit_short(Vec<bool>, defaultrequired): 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>, defaultrequired): 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(): aVolumeWeightedFillModelcan 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>, defaultrequired): 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. MidpointFillModelis the simplest non-default; great for limit-order backtests.VolumeWeightedFillModelreturns partialfilled_sizewhen available size is below requested - integrate withwith_size_long/shortfor 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()