Skip to content

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.

tradectl — Automate Crypto Trading