Skip to content

Strategies

A strategy is a Rust struct that implements the Strategy trait, compiled as a cdylib and loaded by the runner via libloading. It receives market data and fill events and returns Actions that the runner translates into exchange orders.

The current ABI is STRATEGY_ABI_VERSION = 5. Build against the latest tradectl-sdk — older binaries are rejected at load.

The Strategy Trait

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

    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) {}
}

Only name is required. The runner calls on_ticker on every bid/ask update, on_trade on every aggregate trade, and on_fill whenever the exchange reports a fill against an order this strategy placed.

Events the strategy sees

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)] keeps the binary layout stable so prepared data files (.bin) can be mmap'd and read with zero copies.

StrategyContext

The runner hands the strategy a fresh context on every callback:

rust
pub struct StrategyContext<'a> {
    pub timestamp_ms: u64,
    pub book: Option<&'a TickerEvent>,
    pub positions: &'a [PositionInfo],   // accumulated, one per side
    pub balance: f64,
    pub unrealized_pnl: f64,
    pub realized_pnl: f64,
    pub trade_count: usize,
    pub direction: Side,                 // strategy direction (Long or Short)
    pub max_orders_reached: bool,
    pub depth: Option<&'a OrderBookDepth>,
    pub volume: Option<&'a VolumeProfile>,
    pub can_enter: bool,                 // false when gated
}

can_enter is the unified entry gate. The runner sets it to false when any of these fire:

  • Trigger system: triggerByKeys not satisfied, or blacklistKeys active
  • Session profit/loss limit hit, or session_strat_max reached
  • Penalty cooldown active (penaltyTime, strategyPenalty, minOrderDelay)
  • shadow_only: true and no shadow variant has been promoted yet
  • API rate limit pause (preemptive at ~90% weight)
  • WebSocket disconnected or paused_by_edge_decay set

Always honor ctx.can_enter — a strategy that ignores it will be cancelled by the runner immediately and may be penalized.

Actions

Strategies own TP/SL placement. Each entry attaches its own exits list. Subsequent updates use the granular exit variants.

rust
pub enum Action {
    Hold,

    PlaceEntry {
        side: Side,
        price: Option<f64>,         // None = market, Some(p) = limit
        size: f64,
        kind: OrderKind,            // Market or Limit
        exits: Vec<ExitOrder>,
        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>, entry_ids: Vec<String> },

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

    CloseAll,
}

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

SetExits is the workhorse for managing stop-loss + take-profit simultaneously. It compares ids — orders not in the new list are cancelled, orders with changed price/size are edited in place, new IDs are placed.

delay_ms on stop-loss

Setting delay_ms = 3000 on an ExitType::Stop defers placement for 3 seconds after the entry fill timestamp. The runner tracks the exit but does not push it to the exchange during the window. Without this, an SL set close to the entry can trigger on the same volatile candle as the fill and immediately bleed.

Fill callback

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>,
    pub notify: bool,               // emit a Telegram fill notification
}

Common follow-ups:

  • Entry fully filled → no follow-up needed; exits attached at PlaceEntry time are already live.
  • Entry partially filled → runner auto-cancels the remainder. Resize exits via SetExits to match fill.quantity.
  • Exit filled (position_closed=true) → strategy-side accounting; runner already cleared the position.

The runner already handles a few edge cases internally:

  • Race fills (multiple fills before Filled): first call into on_fill; subsequent ones only resize exits.
  • Race fill with no exits attached yet: on_fill is re-triggered so the strategy gets a chance to place exits.
  • Exit fill for an unknown position: runner logs position_closed=false and continues silently.

declare_strategy! macro

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

The macro emits the C-ABI export the CLI loads. For batch-mode (sweep + shadow optimization) plugins, use:

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

The batch variant additionally registers a BatchFactory. Without it, sweeps and shadow fall back to "generic mode" — about 1000× slower because each variant runs as its own Strategy instance.

Params-Based Constructor

Strategies receive parameters through a Params map (JSON-typed, so it accepts numbers, strings, arrays, and nested objects):

rust
pub struct MyStrategy {
    order_size: f64,
}

impl MyStrategy {
    pub fn new(params: &Params) -> Self {
        Self {
            order_size: params.get("order_size", 0.001),
        }
    }
}

Define the parameter schema for the dashboard, lab, and CLI:

rust
fn params_schema(&self) -> Vec<ParamDef> {
    vec![
        ParamDef {
            key: "order_size",
            description: "Order size in base currency",
            default: 0.001,
            min: None,
            max: None,
            step: None,
        },
    ]
}

Bidirectional / multi-entry

Use entry_id on PlaceEntry to manage multiple independent entry slots on the same symbol. This is how a single instance handles both Long and Short books simultaneously without doubling margin:

rust
Action::PlaceEntry { side: Side::Long,  entry_id: Some("long".into()),  /* ... */ };
Action::PlaceEntry { side: Side::Short, entry_id: Some("short".into()), /* ... */ };

Cancel a single slot:

rust
Action::CancelEntry { order_ids: vec![], entry_ids: vec!["long".into()] };

Managers

Strategies use composition over inheritance. Helpers live alongside the SDK:

KlineManager

Builds candles from tick data:

rust
use tradectl_sdk::KlineManager;
use std::time::Duration;

pub struct MyStrategy {
    klines: KlineManager,
}

impl MyStrategy {
    pub fn new(params: &Params) -> Self {
        Self { klines: KlineManager::new(Duration::from_secs(60)) }
    }
}

RangeBarManager

Builds range bars (fixed price movement per bar):

rust
use tradectl_sdk::RangeBarManager;

pub struct MyStrategy {
    bars: RangeBarManager,
}

impl MyStrategy {
    pub fn new(params: &Params) -> Self {
        Self { bars: RangeBarManager::new(10.0) } // $10 range per bar
    }
}

Indicators

tradectl_sdk::indicators::{Sma, Ema, Rsi, Macd, BollingerBands, Atr, StdDev, Vwap}. See Indicators.

Position Close Callback

React to position closes (logging, state updates):

rust
fn on_position_close(&mut self, close: &CloseInfo, ctx: &StrategyContext) {
    // close.side, close.entry_price, close.close_price
    // close.profit_pct, close.profit_usd, close.reason
}

Stop-loss mode

By default, SL is placed as an exchange-side STOP_MARKET reduce-only order. For accounts running multiple strategies on the same symbol, set virtual_sl: 1 in the strat config to move SL evaluation to the bot. See Virtual stop-loss for the full design.

tradectl — Automate Crypto Trading