Skip to content

Strategy Trait

The Strategy trait is the core abstraction. Every trading strategy implements it.

Definition

rust
pub trait Strategy: Send {
    fn on_ticker(&mut self, ticker: &TickerEvent, ctx: &StrategyContext) -> Action;
    fn name(&self) -> &str;

    // Optional
    fn on_trade(&mut self, trade: &TradeEvent, ctx: &StrategyContext) -> Action { Action::Hold }
    fn describe(&self) -> &str { "" }
    fn params_schema(&self) -> Vec<ParamDef> { vec![] }
    fn on_position_close(&mut self, close: &CloseInfo, ctx: &StrategyContext) { }
    fn monitor_snapshot(&self, ctx: &StrategyContext, ticker: &TickerEvent) -> MonitorSnapshot { .. }
}

TickerEvent

rust
#[repr(C)]
pub struct TickerEvent {
    pub bid_price: f64,
    pub bid_qty: f64,
    pub ask_price: f64,
    pub ask_qty: f64,
    pub timestamp_ms: u64,
}

The #[repr(C)] attribute enables zero-copy binary serialization for fast data loading.

StrategyContext

rust
pub struct StrategyContext<'a> {
    pub timestamp_ms: u64,
    pub book: Option<&'a TickerEvent>,
    pub positions: &'a [PositionInfo],
    pub balance: f64,
    pub unrealized_pnl: f64,
    pub realized_pnl: f64,
    pub trade_count: usize,
}

PositionInfo

rust
pub struct PositionInfo {
    pub id: u64,
    pub side: Side,
    pub entry_price: f64,
    pub quantity: f64,
    pub unrealized_pnl: f64,
    pub entry_time: u64,
}

Actions

rust
pub enum Action {
    Hold,
    MarketOpen { side: Side, size: Option<f64> },
    LimitOpen { side: Side, price: f64, size: Option<f64> },
    ClosePosition { position_id: u64, reason: CloseReason },
    CloseAll,
    CancelPending,
}
ActionDescription
HoldDo nothing
MarketOpenOpen a position at market price. size: None uses configured default
LimitOpenPlace a limit order at a specific price
ClosePositionClose a specific position by ID
CloseAllClose all open positions
CancelPendingCancel all pending limit orders

Example: EMA Crossover

rust
use tradectl_sdk::{Strategy, Action, Side, TickerEvent, StrategyContext, Params, ParamDef};
use tradectl_sdk::indicators::Ema;

tradectl_sdk::declare_strategy!("ema_cross", EmaCross::new);

pub struct EmaCross {
    fast: Ema,
    slow: Ema,
    prev_fast: f64,
    prev_slow: f64,
    order_size: f64,
}

impl EmaCross {
    pub fn new(params: &Params) -> Self {
        Self {
            fast: Ema::new(params.get("fast_period", 12.0) as usize),
            slow: Ema::new(params.get("slow_period", 26.0) as usize),
            prev_fast: 0.0,
            prev_slow: 0.0,
            order_size: params.get("order_size", 0.1),
        }
    }
}

impl Strategy for EmaCross {
    fn on_ticker(&mut self, ticker: &TickerEvent, ctx: &StrategyContext) -> Action {
        let mid = (ticker.bid_price + ticker.ask_price) * 0.5;
        self.fast.update(mid);
        self.slow.update(mid);

        if !self.fast.ready() || !self.slow.ready() {
            return Action::Hold;
        }

        let fast_val = self.fast.value();
        let slow_val = self.slow.value();

        let action = if self.prev_fast <= self.prev_slow && fast_val > slow_val {
            // Bullish crossover
            if !ctx.positions.is_empty() { Action::CloseAll } else {
                Action::MarketOpen { side: Side::Long, size: Some(self.order_size) }
            }
        } else if self.prev_fast >= self.prev_slow && fast_val < slow_val {
            // Bearish crossover
            if !ctx.positions.is_empty() { Action::CloseAll } else {
                Action::MarketOpen { side: Side::Short, size: Some(self.order_size) }
            }
        } else {
            Action::Hold
        };

        self.prev_fast = fast_val;
        self.prev_slow = slow_val;
        action
    }

    fn name(&self) -> &str { "ema_cross" }
    fn describe(&self) -> &str { "EMA crossover strategy" }

    fn params_schema(&self) -> Vec<ParamDef> {
        vec![
            ParamDef { key: "fast_period", description: "Fast EMA period", default: 12.0, min: Some(2.0), max: Some(50.0), step: Some(1.0) },
            ParamDef { key: "slow_period", description: "Slow EMA period", default: 26.0, min: Some(10.0), max: Some(200.0), step: Some(1.0) },
            ParamDef { key: "order_size", description: "Order size", default: 0.1, min: None, max: None, step: None },
        ]
    }
}

Composition with Managers

Strategies use composition — inject managers as struct fields:

rust
use tradectl_sdk::*;
use tradectl_sdk::indicators::Rsi;

tradectl_sdk::declare_strategy!("rsi_kline", RsiKline::new);

pub struct RsiKline {
    klines: KlineManager,
    rsi: Rsi,
    order_size: f64,
}

impl RsiKline {
    pub fn new(params: &Params) -> Self {
        Self {
            klines: KlineManager::new(Duration::from_secs(60)),
            rsi: Rsi::new(params.get("rsi_period", 14.0) as usize),
            order_size: params.get("order_size", 0.1),
        }
    }
}

impl Strategy for RsiKline {
    fn on_ticker(&mut self, ticker: &TickerEvent, ctx: &StrategyContext) -> Action {
        self.klines.update(ticker);

        if let Some(candle) = self.klines.last_closed() {
            self.rsi.update(candle.close);

            if self.rsi.ready() {
                let rsi = self.rsi.value();
                if rsi < 30.0 && ctx.positions.is_empty() {
                    return Action::MarketOpen {
                        side: Side::Long,
                        size: Some(self.order_size),
                    };
                }
                if rsi > 70.0 && !ctx.positions.is_empty() {
                    return Action::CloseAll;
                }
            }
        }

        Action::Hold
    }

    fn name(&self) -> &str { "rsi_kline" }
    fn describe(&self) -> &str { "RSI with 1m candles" }
}

tradectl — Automate Crypto Trading