Examples
End-to-end strategies illustrating the new exit-management model. All examples target the current tradectl-sdk (ABI v5).
Bollinger Band Bounce
Buy at the lower band, short at the upper band, with TP and SL attached to each entry.
rust
use tradectl_sdk::{
Strategy, Action, OrderKind, ExitOrder, ExitType,
Side, TickerEvent, StrategyContext, Params, ParamDef,
};
use tradectl_sdk::indicators::BollingerBands;
tradectl_sdk::declare_strategy!("bb_bounce", BollingerBounce::new);
pub struct BollingerBounce {
bb: BollingerBands,
order_size: f64,
tp_pct: f64,
sl_pct: f64,
}
impl BollingerBounce {
pub fn new(params: &Params) -> Self {
Self {
bb: BollingerBands::new(
params.get("period", 20.0) as usize,
params.get("std_dev", 2.0),
),
order_size: params.get("order_size", 0.001),
tp_pct: params.get("tp_pct", 0.5),
sl_pct: params.get("sl_pct", 1.0),
}
}
fn build_exits(&self, side: Side, entry: f64) -> Vec<ExitOrder> {
let (tp, sl) = match side {
Side::Long => (entry * (1.0 + self.tp_pct / 100.0), entry * (1.0 - self.sl_pct / 100.0)),
Side::Short => (entry * (1.0 - self.tp_pct / 100.0), entry * (1.0 + self.sl_pct / 100.0)),
};
vec![
ExitOrder { id: "tp".into(), price: tp, size: self.order_size, kind: ExitType::Limit, delay_ms: 0 },
ExitOrder { id: "sl".into(), price: sl, size: self.order_size, kind: ExitType::Stop, delay_ms: 3000 },
]
}
}
impl Strategy for BollingerBounce {
fn name(&self) -> &str { "bb_bounce" }
fn on_ticker(&mut self, ticker: &TickerEvent, ctx: &StrategyContext) -> Action {
let mid = (ticker.bid_price + ticker.ask_price) * 0.5;
self.bb.update(mid);
if !self.bb.ready() || !ctx.positions.is_empty() || !ctx.can_enter {
return Action::Hold;
}
if mid <= self.bb.lower() {
return Action::PlaceEntry {
side: Side::Long,
price: None,
size: self.order_size,
kind: OrderKind::Market,
exits: self.build_exits(Side::Long, mid),
entry_id: None,
};
}
if mid >= self.bb.upper() {
return Action::PlaceEntry {
side: Side::Short,
price: None,
size: self.order_size,
kind: OrderKind::Market,
exits: self.build_exits(Side::Short, mid),
entry_id: None,
};
}
Action::Hold
}
fn params_schema(&self) -> Vec<ParamDef> {
vec![
ParamDef { key: "period", description: "BB period", default: 20.0, min: Some(5.0), max: Some(50.0), step: Some(1.0) },
ParamDef { key: "std_dev", description: "Std dev mult", default: 2.0, min: Some(1.0), max: Some(4.0), step: Some(0.1) },
ParamDef { key: "order_size", description: "Order size", default: 0.001, min: None, max: None, step: None },
ParamDef { key: "tp_pct", description: "Take profit %", default: 0.5, min: Some(0.05), max: Some(5.0), step: Some(0.05) },
ParamDef { key: "sl_pct", description: "Stop loss %", default: 1.0, min: Some(0.1), max: Some(5.0), step: Some(0.05) },
]
}
}RSI Mean Reversion (limit entry + edit)
Park a limit order at the lower band, slide it as the level updates, and close on RSI returning to neutral.
rust
use tradectl_sdk::{
Strategy, Action, OrderKind, ExitOrder, ExitType,
Side, TickerEvent, StrategyContext, Params, ParamDef, FillEvent, FillResponse,
};
use tradectl_sdk::indicators::Rsi;
tradectl_sdk::declare_strategy!("rsi_reversion", RsiReversion::new);
pub struct RsiReversion {
rsi: Rsi,
oversold: f64,
overbought: f64,
order_size: f64,
pending_id: Option<String>,
}
impl RsiReversion {
pub fn new(params: &Params) -> Self {
Self {
rsi: Rsi::new(params.get("period", 14.0) as usize),
oversold: params.get("oversold", 30.0),
overbought: params.get("overbought", 70.0),
order_size: params.get("order_size", 0.001),
pending_id: None,
}
}
}
impl Strategy for RsiReversion {
fn name(&self) -> &str { "rsi_reversion" }
fn on_ticker(&mut self, ticker: &TickerEvent, ctx: &StrategyContext) -> Action {
let mid = (ticker.bid_price + ticker.ask_price) * 0.5;
self.rsi.update(mid);
if !self.rsi.ready() { return Action::Hold; }
let rsi = self.rsi.value();
// Position open: close on RSI returning to neutral.
if let Some(pos) = ctx.positions.first() {
let neutral_long = pos.side == Side::Long && rsi > 50.0;
let neutral_short = pos.side == Side::Short && rsi < 50.0;
if neutral_long || neutral_short {
return Action::CloseAll;
}
return Action::Hold;
}
// No position: place or slide a passive limit entry.
if !ctx.can_enter { return Action::Hold; }
if rsi < self.oversold {
return Action::PlaceEntry {
side: Side::Long,
price: Some(ticker.bid_price),
size: self.order_size,
kind: OrderKind::Limit,
exits: vec![], // attached on fill via on_fill
entry_id: Some("rsi_long".into()),
};
}
Action::Hold
}
fn on_fill(&mut self, fill: &FillEvent, _ctx: &StrategyContext) -> FillResponse {
if !fill.is_entry { return FillResponse { actions: vec![], notify: true }; }
let exits = vec![
ExitOrder { id: "tp".into(), price: fill.price * 1.005, size: fill.quantity, kind: ExitType::Limit, delay_ms: 0 },
ExitOrder { id: "sl".into(), price: fill.price * 0.99, size: fill.quantity, kind: ExitType::Stop, delay_ms: 3000 },
];
FillResponse { actions: vec![Action::SetExits { exits }], notify: false }
}
fn params_schema(&self) -> Vec<ParamDef> {
vec![
ParamDef { key: "period", description: "RSI period", default: 14.0, min: Some(5.0), max: Some(50.0), step: Some(1.0) },
ParamDef { key: "oversold", description: "Oversold limit", default: 30.0, min: Some(10.0), max: Some(40.0), step: Some(5.0) },
ParamDef { key: "overbought", description: "Overbought lim", default: 70.0, min: Some(60.0), max: Some(90.0), step: Some(5.0) },
ParamDef { key: "order_size", description: "Order size", default: 0.001, min: None, max: None, step: None },
]
}
}Resizing exits on partial fill
When the runner reports a partial fill, the unfilled remainder is auto-cancelled and exits should be resized to the fill quantity:
rust
fn on_fill(&mut self, fill: &FillEvent, _ctx: &StrategyContext) -> FillResponse {
if fill.is_entry && fill.is_partial {
let exits = self.build_exits_for_size(fill.price, fill.quantity);
return FillResponse {
actions: vec![Action::SetExits { exits }],
notify: false,
};
}
FillResponse { actions: vec![], notify: true }
}SetExits diffs by ExitOrder.id, so the runner only edits the orders whose size or price changed.
Testing a strategy
rust
#[cfg(test)]
mod tests {
use tradectl_sdk::{Action, Params, Side, StrategyContext, TickerEvent};
use super::BollingerBounce;
fn ctx<'a>(book: &'a TickerEvent) -> StrategyContext<'a> {
StrategyContext {
timestamp_ms: 1_000,
book: Some(book),
positions: &[],
balance: 10_000.0,
unrealized_pnl: 0.0,
realized_pnl: 0.0,
trade_count: 0,
direction: Side::Long,
max_orders_reached: false,
depth: None,
volume: None,
can_enter: true,
}
}
#[test]
fn holds_until_indicator_ready() {
let mut s = BollingerBounce::new(&Params::new());
let book = TickerEvent { bid_price: 50_000.0, bid_qty: 1.0, ask_price: 50_001.0, ask_qty: 1.0, timestamp_ms: 1_000 };
let action = s.on_ticker(&book, &ctx(&book));
assert!(matches!(action, Action::Hold));
}
}For end-to-end behavior tests, run the strategy through the backtest engine via tradectl backtest -d data/prepared.bin -p key=value. See Backtesting.
