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