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,
}| Action | Description |
|---|---|
Hold | Do nothing |
MarketOpen | Open a position at market price. size: None uses configured default |
LimitOpen | Place a limit order at a specific price |
ClosePosition | Close a specific position by ID |
CloseAll | Close all open positions |
CancelPending | Cancel 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" }
}