Skip to content

Strategy Trait

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

The current ABI version is STRATEGY_ABI_VERSION = 5. Strategies built against an older SDK will be rejected at load time — rebuild against the current tradectl-sdk to upgrade.

Definition

rust
pub trait Strategy: Send {
    fn name(&self) -> &str;                                              // required

    // All other methods are optional.
    fn on_ticker(&mut self, ticker: &TickerEvent, ctx: &StrategyContext) -> Action {
        Action::Hold
    }
    fn on_trade(&mut self, trade: &TradeEvent, ctx: &StrategyContext) -> Action {
        Action::Hold
    }
    fn on_fill(&mut self, fill: &FillEvent, ctx: &StrategyContext) -> FillResponse {
        FillResponse { actions: vec![], notify: true }
    }
    fn describe(&self) -> &str { "" }
    fn params_schema(&self) -> Vec<ParamDef> { vec![] }
    fn monitor_snapshot(&self, ctx: &StrategyContext, ticker: &TickerEvent) -> MonitorSnapshot {
        MonitorSnapshot::default()
    }
    fn session_state(&mut self) -> Option<serde_json::Value> { None }
    fn session_reset(&mut self, _symbol: &str) {}
}

on_ticker and on_trade return an Action. on_fill returns a FillResponse (a list of follow-up actions plus a notification flag). All other methods are pure observers.

Events

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,
}

#[repr(C)]
pub struct TradeEvent {
    pub price: f64,
    pub quantity: f64,
    pub timestamp_ms: u64,
    pub is_buyer_maker: bool,
}

#[repr(C)] 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,
    pub direction: Side,           // Long-only, Short-only, or per-strategy direction
    pub max_orders_reached: bool,
    pub depth: Option<&'a OrderBookDepth>,
    pub volume: Option<&'a VolumeProfile>,
    pub can_enter: bool,           // false when gated by trigger/session/penalty/etc.
}

can_enter is computed by the runner before the callback. Always honor it — return Action::Hold if you intend to open an entry but can_enter is false.

PositionInfo

Positions are accumulated, not per-entry — multiple entries on the same side merge into one PositionInfo.

rust
pub struct PositionInfo {
    pub side: Side,
    pub avg_entry: f64,        // weighted average across all fills
    pub quantity: f64,         // remaining after exits
    pub total_entered: f64,    // cumulative entered (does not decrease on exits)
    pub entry_count: usize,
    pub last_entry_price: f64,
}

Action enum

The Action enum drives the runner. Strategies own TP/SL placement explicitly via the exits list on each entry, and update them later via the *Exit variants.

rust
pub enum Action {
    Hold,

    PlaceEntry {
        side: Side,
        price: Option<f64>,         // None → market; Some(p) → limit
        size: f64,
        kind: OrderKind,            // Market | Limit
        exits: Vec<ExitOrder>,      // attached to the resulting position
        entry_id: Option<String>,   // for named entry slots (bidirectional / multi-entry)
    },
    EditEntry {
        order_id: String,
        price: Option<f64>,
        size: Option<f64>,
    },
    CancelEntry {
        order_ids: Vec<String>,     // cancel by exchange order ID
        entry_ids: Vec<String>,     // or by named entry slot ID
    },

    SetExits  { exits: Vec<ExitOrder> },   // diffs current exits by ExitOrder.id
    AddExit   { exit: ExitOrder },
    UpdateExit { exit: ExitOrder },
    RemoveExit { id: String },

    CloseAll,
}
VariantPurpose
HoldDo nothing
PlaceEntryPlace a market or limit entry, with TP/SL attached as exits
EditEntryModify a pending limit entry's price/size
CancelEntryCancel pending entries (by exchange ID or named entry slot)
SetExitsReplace all exits, diffed by ExitOrder.id
AddExit / UpdateExit / RemoveExitGranular exit management
CloseAllForce-close all positions on this symbol

ExitOrder

rust
pub struct ExitOrder {
    pub id: String,         // strategy-assigned, used for diffing
    pub price: f64,         // limit price (TP) or trigger price (SL)
    pub size: f64,
    pub kind: ExitType,     // Limit | Stop
    pub delay_ms: u64,      // typically 3000 for SL, 0 for TP
}

pub enum ExitType { Limit, Stop }
pub enum OrderKind { Market, Limit }
pub enum Side { Long, Short }
pub enum MarketType { Spot, Linear, Inverse }

delay_ms defers placing the exit until N milliseconds after the entry fill timestamp. The runner tracks the exit but does not push it to the exchange during the window — this prevents a stop trigger from firing on the same volatile candle as the entry. A 3000 ms delay is typical for stop-loss orders.

Fill callbacks

When the exchange reports a fill, the runner calls on_fill:

rust
pub struct FillEvent {
    pub order_id: String,
    pub symbol: String,
    pub price: f64,
    pub quantity: f64,
    pub is_entry: bool,
    pub is_partial: bool,
    pub exit_id: Option<String>,    // which ExitOrder.id triggered (exits only)
    pub position_closed: bool,
}

pub struct FillResponse {
    pub actions: Vec<Action>,        // follow-ups (e.g., resize exits)
    pub notify: bool,                // emit a Telegram fill notification
}

Common pattern — partial entry fill resizes exits to match:

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.quantity);
        return FillResponse { actions: vec![Action::SetExits { exits }], notify: false };
    }
    FillResponse { actions: vec![], notify: true }
}

Runner-level fill behavior

The runner already handles common edge cases — strategies don't need to:

  • Partial entry fill → runner auto-cancels the remainder, sizes exits to the actual filled qty. is_partial=true.
  • Race fills (multiple fills before status Filled) → first calls on_fill; subsequent ones only resize exits via edit_order.
  • Race fill with no exits yeton_fill is re-triggered so the strategy gets a chance to attach exits.
  • Exit fill for an unknown positionposition_closed=false, runner continues silently.

OrderBookDepth & VolumeProfile

Optional context fields — populated only when the strategy subscribes to L2 depth or the runner builds a volume profile.

rust
#[repr(C, align(8))]
pub struct DepthLevel { pub price: f64, pub quantity: f64 }

pub struct OrderBookDepth {
    pub bids: Vec<DepthLevel>,    // descending by price
    pub asks: Vec<DepthLevel>,    // ascending by price
    pub timestamp_ms: u64,
}

pub struct VolumeProfile {
    pub ratio: f64,               // current / baseline, capped at 1000.0
    pub baseline_per_min: f64,
    pub current_per_min: f64,
    pub buy_ratio: f64,           // 0.0 (all sells) to 1.0 (all buys)
    pub baseline_ready: bool,
}

declare_strategy! macro

Every strategy must export a single C-ABI symbol via the macro:

rust
tradectl_sdk::declare_strategy!("my_strategy", MyStrategy::new);

The macro emits extern "C" fn tradectl_strategy() -> StrategyPlugin, which the CLI and lab use to load the dynamic library. The macro also stamps STRATEGY_ABI_VERSION into the plugin so version mismatches are rejected.

For batch-mode (sweep/shadow optimization), use declare_batch_strategy! instead — it additionally exports a BatchFactory:

rust
tradectl_sdk::declare_batch_strategy!("my_strategy", MyStrategy::new, MyStrategyBatch::new);

See Optimization for the BatchStrategy trait.

Example: EMA Crossover

rust
use tradectl_sdk::{
    Strategy, Action, OrderKind, ExitOrder, ExitType,
    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,
    tp_pct: f64,
    sl_pct: 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.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 EmaCross {
    fn name(&self) -> &str { "ema_cross" }

    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 f = self.fast.value();
        let s = self.slow.value();

        let action = if !ctx.positions.is_empty() && self.prev_fast >= self.prev_slow && f < s {
            Action::CloseAll
        } else if ctx.positions.is_empty() && ctx.can_enter && self.prev_fast <= self.prev_slow && f > s {
            Action::PlaceEntry {
                side: Side::Long,
                price: None,
                size: self.order_size,
                kind: OrderKind::Market,
                exits: self.build_exits(Side::Long, mid),
                entry_id: None,
            }
        } else {
            Action::Hold
        };

        self.prev_fast = f;
        self.prev_slow = s;
        action
    }

    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.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) },
        ]
    }
}

tradectl — Automate Crypto Trading