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
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
#[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
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.
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.
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,
}| Variant | Purpose |
|---|---|
Hold | Do nothing |
PlaceEntry | Place a market or limit entry, with TP/SL attached as exits |
EditEntry | Modify a pending limit entry's price/size |
CancelEntry | Cancel pending entries (by exchange ID or named entry slot) |
SetExits | Replace all exits, diffed by ExitOrder.id |
AddExit / UpdateExit / RemoveExit | Granular exit management |
CloseAll | Force-close all positions on this symbol |
ExitOrder
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:
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:
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 callson_fill; subsequent ones only resize exits viaedit_order. - Race fill with no exits yet →
on_fillis re-triggered so the strategy gets a chance to attach exits. - Exit fill for an unknown position →
position_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.
#[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:
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:
tradectl_sdk::declare_batch_strategy!("my_strategy", MyStrategy::new, MyStrategyBatch::new);See Optimization for the BatchStrategy trait.
Example: EMA Crossover
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) },
]
}
}