tesser_execution/
lib.rs

1//! Order management and signal execution helpers.
2
3pub mod algorithm;
4pub mod orchestrator;
5pub mod repository;
6
7// Re-export key types for convenience
8pub use algorithm::{AlgoStatus, ChildOrderRequest, ExecutionAlgorithm};
9pub use orchestrator::OrderOrchestrator;
10pub use repository::{AlgoStateRepository, SqliteAlgoStateRepository};
11
12use anyhow::{bail, Context};
13use rust_decimal::Decimal;
14use std::sync::Arc;
15use tesser_broker::{BrokerError, BrokerResult, ExecutionClient};
16use tesser_core::{
17    Order, OrderRequest, OrderType, Price, Quantity, Side, Signal, SignalKind, Symbol,
18};
19use thiserror::Error;
20use tracing::{info, warn};
21
22/// Determine how large an order should be for a given signal.
23pub trait OrderSizer: Send + Sync {
24    /// Calculate the desired base asset quantity.
25    fn size(
26        &self,
27        signal: &Signal,
28        portfolio_equity: Price,
29        last_price: Price,
30    ) -> anyhow::Result<Quantity>;
31}
32
33/// Simplest possible sizer that always returns a fixed size.
34pub struct FixedOrderSizer {
35    pub quantity: Quantity,
36}
37
38impl OrderSizer for FixedOrderSizer {
39    fn size(
40        &self,
41        _signal: &Signal,
42        _portfolio_equity: Price,
43        _last_price: Price,
44    ) -> anyhow::Result<Quantity> {
45        Ok(self.quantity)
46    }
47}
48
49/// Sizes orders based on a fixed percentage of portfolio equity.
50pub struct PortfolioPercentSizer {
51    /// The fraction of equity to allocate per trade (e.g., 0.02 for 2%).
52    pub percent: Decimal,
53}
54
55impl OrderSizer for PortfolioPercentSizer {
56    fn size(
57        &self,
58        _signal: &Signal,
59        portfolio_equity: Price,
60        last_price: Price,
61    ) -> anyhow::Result<Quantity> {
62        if last_price <= Decimal::ZERO {
63            bail!("cannot size order with zero or negative price");
64        }
65        if self.percent <= Decimal::ZERO {
66            return Ok(Decimal::ZERO);
67        }
68        let notional = portfolio_equity * self.percent;
69        Ok(notional / last_price)
70    }
71}
72
73/// Sizes orders based on position volatility. (Placeholder)
74#[derive(Default)]
75pub struct RiskAdjustedSizer {
76    /// Target risk contribution per trade, as a fraction of equity (e.g., 0.002 for 0.2%).
77    pub risk_fraction: Decimal,
78}
79
80impl OrderSizer for RiskAdjustedSizer {
81    fn size(
82        &self,
83        _signal: &Signal,
84        portfolio_equity: Price,
85        last_price: Price,
86    ) -> anyhow::Result<Quantity> {
87        if last_price <= Decimal::ZERO {
88            bail!("cannot size order with zero or negative price");
89        }
90        if self.risk_fraction <= Decimal::ZERO {
91            return Ok(Decimal::ZERO);
92        }
93        // Placeholder volatility; replace with instrument-specific estimator.
94        let volatility = Decimal::new(2, 2); // 0.02
95        let denom = last_price * volatility;
96        if denom <= Decimal::ZERO {
97            bail!("volatility multiplier produced an invalid denominator");
98        }
99        let dollars_at_risk = portfolio_equity * self.risk_fraction;
100        Ok(dollars_at_risk / denom)
101    }
102}
103
104/// Context passed to risk checks describing current exposure state.
105#[derive(Clone, Copy, Debug, Default)]
106pub struct RiskContext {
107    /// Signed quantity of the current open position (long positive, short negative).
108    pub signed_position_qty: Quantity,
109    /// Total current portfolio equity.
110    pub portfolio_equity: Price,
111    /// Last known price for the signal's symbol.
112    pub last_price: Price,
113    /// When true, only exposure-reducing orders are allowed.
114    pub liquidate_only: bool,
115}
116
117/// Validates an order before it reaches the broker.
118pub trait PreTradeRiskChecker: Send + Sync {
119    /// Return `Ok(())` if the order passes risk checks.
120    fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError>;
121}
122
123/// No-op risk checker used by tests/backtests.
124pub struct NoopRiskChecker;
125
126impl PreTradeRiskChecker for NoopRiskChecker {
127    fn check(&self, _request: &OrderRequest, _ctx: &RiskContext) -> Result<(), RiskError> {
128        Ok(())
129    }
130}
131
132/// Upper bounds enforced by the [`BasicRiskChecker`].
133#[derive(Clone, Copy, Debug)]
134pub struct RiskLimits {
135    pub max_order_quantity: Quantity,
136    pub max_position_quantity: Quantity,
137}
138
139impl RiskLimits {
140    /// Ensure limits are non-negative and default to zero (disabled) when NaN.
141    pub fn sanitized(self) -> Self {
142        Self {
143            max_order_quantity: self.max_order_quantity.max(Decimal::ZERO),
144            max_position_quantity: self.max_position_quantity.max(Decimal::ZERO),
145        }
146    }
147}
148
149/// Simple risk checker enforcing fat-finger order size limits plus position caps.
150pub struct BasicRiskChecker {
151    limits: RiskLimits,
152}
153
154impl BasicRiskChecker {
155    /// Build a new checker with the provided limits.
156    pub fn new(limits: RiskLimits) -> Self {
157        Self {
158            limits: limits.sanitized(),
159        }
160    }
161}
162
163impl PreTradeRiskChecker for BasicRiskChecker {
164    fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError> {
165        let qty = request.quantity.abs();
166        let max_order = self.limits.max_order_quantity;
167        if max_order > Decimal::ZERO && qty > max_order {
168            return Err(RiskError::MaxOrderSize {
169                quantity: qty,
170                limit: max_order,
171            });
172        }
173
174        let projected_position = match request.side {
175            Side::Buy => ctx.signed_position_qty + qty,
176            Side::Sell => ctx.signed_position_qty - qty,
177        };
178
179        let max_position = self.limits.max_position_quantity;
180        if max_position > Decimal::ZERO && projected_position.abs() > max_position {
181            return Err(RiskError::MaxPositionExposure {
182                projected: projected_position,
183                limit: max_position,
184            });
185        }
186
187        if ctx.liquidate_only {
188            let position = ctx.signed_position_qty;
189            if position.is_zero() {
190                return Err(RiskError::LiquidateOnly);
191            }
192            let reduces = (position > Decimal::ZERO && request.side == Side::Sell)
193                || (position < Decimal::ZERO && request.side == Side::Buy);
194            if !reduces {
195                return Err(RiskError::LiquidateOnly);
196            }
197            if qty > position.abs() {
198                return Err(RiskError::LiquidateOnly);
199            }
200        }
201
202        Ok(())
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use tesser_core::SignalKind;
210
211    fn dummy_signal() -> Signal {
212        Signal::new("BTCUSDT", SignalKind::EnterLong, 1.0)
213    }
214
215    #[test]
216    fn portfolio_percent_sizer_matches_decimal_math() {
217        let signal = dummy_signal();
218        let sizer = PortfolioPercentSizer {
219            percent: Decimal::new(5, 2),
220        };
221        let qty = sizer
222            .size(&signal, Decimal::from(25_000), Decimal::from(50_000))
223            .unwrap();
224        assert_eq!(qty, Decimal::new(25, 3)); // 0.025
225    }
226
227    #[test]
228    fn risk_adjusted_sizer_respects_zero_price_guard() {
229        let signal = dummy_signal();
230        let sizer = RiskAdjustedSizer {
231            risk_fraction: Decimal::new(1, 2),
232        };
233        let err = sizer
234            .size(&signal, Decimal::from(10_000), Decimal::ZERO)
235            .unwrap_err();
236        assert!(
237            err.to_string().contains("zero or negative price"),
238            "unexpected error: {err}"
239        );
240    }
241
242    #[test]
243    fn liquidate_only_blocks_new_exposure() {
244        let checker = BasicRiskChecker::new(RiskLimits {
245            max_order_quantity: Decimal::ZERO,
246            max_position_quantity: Decimal::ZERO,
247        });
248        let ctx = RiskContext {
249            signed_position_qty: Decimal::from(2),
250            portfolio_equity: Decimal::from(10_000),
251            last_price: Decimal::from(25_000),
252            liquidate_only: true,
253        };
254        let order = OrderRequest {
255            symbol: "BTCUSDT".into(),
256            side: Side::Buy,
257            order_type: OrderType::Market,
258            quantity: Decimal::ONE,
259            price: None,
260            trigger_price: None,
261            time_in_force: None,
262            client_order_id: None,
263            take_profit: None,
264            stop_loss: None,
265            display_quantity: None,
266        };
267        let result = checker.check(&order, &ctx);
268        assert!(matches!(result, Err(RiskError::LiquidateOnly)));
269    }
270
271    #[test]
272    fn liquidate_only_allows_position_reduction() {
273        let checker = BasicRiskChecker::new(RiskLimits {
274            max_order_quantity: Decimal::ZERO,
275            max_position_quantity: Decimal::ZERO,
276        });
277        let ctx = RiskContext {
278            signed_position_qty: Decimal::from(2),
279            portfolio_equity: Decimal::from(10_000),
280            last_price: Decimal::from(25_000),
281            liquidate_only: true,
282        };
283        let reduce = OrderRequest {
284            symbol: "BTCUSDT".into(),
285            side: Side::Sell,
286            order_type: OrderType::Market,
287            quantity: Decimal::ONE,
288            price: None,
289            trigger_price: None,
290            time_in_force: None,
291            client_order_id: None,
292            take_profit: None,
293            stop_loss: None,
294            display_quantity: None,
295        };
296        assert!(checker.check(&reduce, &ctx).is_ok());
297    }
298}
299
300/// Errors surfaced by pre-trade risk checks.
301#[derive(Debug, Error)]
302pub enum RiskError {
303    #[error("order quantity {quantity} exceeds limit {limit}")]
304    MaxOrderSize { quantity: Quantity, limit: Quantity },
305    #[error("projected position {projected} exceeds limit {limit}")]
306    MaxPositionExposure {
307        projected: Quantity,
308        limit: Quantity,
309    },
310    #[error("liquidate-only mode active")]
311    LiquidateOnly,
312}
313
314/// Translates signals into orders using a provided [`ExecutionClient`].
315pub struct ExecutionEngine {
316    client: Arc<dyn ExecutionClient>,
317    sizer: Box<dyn OrderSizer>,
318    risk: Arc<dyn PreTradeRiskChecker>,
319}
320
321impl ExecutionEngine {
322    /// Instantiate the engine with its dependencies.
323    pub fn new(
324        client: Arc<dyn ExecutionClient>,
325        sizer: Box<dyn OrderSizer>,
326        risk: Arc<dyn PreTradeRiskChecker>,
327    ) -> Self {
328        Self {
329            client,
330            sizer,
331            risk,
332        }
333    }
334
335    /// Consume a signal and forward it to the broker.
336    pub async fn handle_signal(
337        &self,
338        signal: Signal,
339        ctx: RiskContext,
340    ) -> BrokerResult<Option<Order>> {
341        let qty = self
342            .sizer
343            .size(&signal, ctx.portfolio_equity, ctx.last_price)
344            .context("failed to determine order size")
345            .map_err(|err| BrokerError::Other(err.to_string()))?;
346
347        if qty <= Decimal::ZERO {
348            warn!(signal = ?signal.id, "order size is zero, skipping");
349            return Ok(None);
350        }
351
352        let client_order_id = signal.id.to_string();
353        let request = match signal.kind {
354            SignalKind::EnterLong => self.build_request(
355                signal.symbol.clone(),
356                Side::Buy,
357                qty,
358                Some(client_order_id.clone()),
359            ),
360            SignalKind::ExitLong | SignalKind::Flatten => self.build_request(
361                signal.symbol.clone(),
362                Side::Sell,
363                qty,
364                Some(client_order_id.clone()),
365            ),
366            SignalKind::EnterShort => self.build_request(
367                signal.symbol.clone(),
368                Side::Sell,
369                qty,
370                Some(client_order_id.clone()),
371            ),
372            SignalKind::ExitShort => self.build_request(
373                signal.symbol.clone(),
374                Side::Buy,
375                qty,
376                Some(client_order_id.clone()),
377            ),
378        };
379
380        let order = self.send_order(request, &ctx).await?;
381
382        let stop_side = match signal.kind {
383            SignalKind::EnterLong | SignalKind::ExitShort => Side::Sell,
384            SignalKind::EnterShort | SignalKind::ExitLong => Side::Buy,
385            SignalKind::Flatten => return Ok(Some(order)),
386        };
387
388        if let Some(sl_price) = signal.stop_loss {
389            let sl_request = OrderRequest {
390                symbol: signal.symbol.clone(),
391                side: stop_side,
392                order_type: OrderType::StopMarket,
393                quantity: qty,
394                price: None,
395                trigger_price: Some(sl_price),
396                time_in_force: None,
397                client_order_id: Some(format!("{}-sl", signal.id)),
398                take_profit: None,
399                stop_loss: None,
400                display_quantity: None,
401            };
402            if let Err(e) = self.send_order(sl_request, &ctx).await {
403                warn!(error = %e, "failed to place stop-loss order");
404            }
405        }
406
407        if let Some(tp_price) = signal.take_profit {
408            let tp_request = OrderRequest {
409                symbol: signal.symbol.clone(),
410                side: stop_side,
411                order_type: OrderType::StopMarket,
412                quantity: qty,
413                price: None,
414                trigger_price: Some(tp_price),
415                time_in_force: None,
416                client_order_id: Some(format!("{}-tp", signal.id)),
417                take_profit: None,
418                stop_loss: None,
419                display_quantity: None,
420            };
421            if let Err(e) = self.send_order(tp_request, &ctx).await {
422                warn!(error = %e, "failed to place take-profit order");
423            }
424        }
425
426        Ok(Some(order))
427    }
428
429    fn build_request(
430        &self,
431        symbol: Symbol,
432        side: Side,
433        qty: Quantity,
434        client_order_id: Option<String>,
435    ) -> OrderRequest {
436        OrderRequest {
437            symbol,
438            side,
439            order_type: OrderType::Market,
440            quantity: qty,
441            price: None,
442            trigger_price: None,
443            time_in_force: None,
444            client_order_id,
445            take_profit: None,
446            stop_loss: None,
447            display_quantity: None,
448        }
449    }
450
451    async fn send_order(&self, request: OrderRequest, ctx: &RiskContext) -> BrokerResult<Order> {
452        self.risk
453            .check(&request, ctx)
454            .map_err(|err| BrokerError::InvalidRequest(err.to_string()))?;
455        let order = self.client.place_order(request).await?;
456        info!(
457            order_id = %order.id,
458            qty = %order.request.quantity,
459            "order sent to broker"
460        );
461        Ok(order)
462    }
463
464    pub fn client(&self) -> Arc<dyn ExecutionClient> {
465        Arc::clone(&self.client)
466    }
467
468    pub fn sizer(&self) -> &dyn OrderSizer {
469        self.sizer.as_ref()
470    }
471}