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, StoredAlgoState};
11
12use anyhow::{bail, Context};
13use rust_decimal::Decimal;
14use std::sync::Arc;
15use tesser_broker::{BrokerError, BrokerResult, ExecutionClient};
16use tesser_core::{
17    AssetId, ExchangeId, InstrumentKind, Order, OrderRequest, OrderType, OrderUpdateRequest, Price,
18    Quantity, Side, Signal, SignalKind, Symbol,
19};
20use thiserror::Error;
21use tracing::{info, warn};
22use uuid::Uuid;
23
24/// Determines how the orchestrator unwinds partially filled execution groups.
25#[derive(Clone, Copy, Debug, Default)]
26pub enum PanicCloseMode {
27    #[default]
28    Market,
29    AggressiveLimit,
30}
31
32/// Configuration describing how panic-close orders should be sent.
33#[derive(Clone, Copy, Debug)]
34pub struct PanicCloseConfig {
35    pub mode: PanicCloseMode,
36    /// Offset applied to the observed mid price when using [`PanicCloseMode::AggressiveLimit`] (basis points).
37    pub limit_offset_bps: Decimal,
38}
39
40impl Default for PanicCloseConfig {
41    fn default() -> Self {
42        Self {
43            mode: PanicCloseMode::Market,
44            limit_offset_bps: Decimal::from(50u32),
45        }
46    }
47}
48
49/// Observes panic-close events so callers can emit alerts or metrics.
50pub trait PanicObserver: Send + Sync {
51    fn on_group_event(&self, group_id: Uuid, symbol: Symbol, quantity: Quantity, reason: &str);
52}
53
54/// Determine how large an order should be for a given signal.
55pub trait OrderSizer: Send + Sync {
56    /// Calculate the desired base asset quantity.
57    fn size(
58        &self,
59        signal: &Signal,
60        portfolio_equity: Price,
61        last_price: Price,
62    ) -> anyhow::Result<Quantity>;
63}
64
65/// Simplest possible sizer that always returns a fixed size.
66pub struct FixedOrderSizer {
67    pub quantity: Quantity,
68}
69
70impl OrderSizer for FixedOrderSizer {
71    fn size(
72        &self,
73        _signal: &Signal,
74        _portfolio_equity: Price,
75        _last_price: Price,
76    ) -> anyhow::Result<Quantity> {
77        Ok(self.quantity)
78    }
79}
80
81/// Sizes orders based on a fixed percentage of portfolio equity.
82pub struct PortfolioPercentSizer {
83    /// The fraction of equity to allocate per trade (e.g., 0.02 for 2%).
84    pub percent: Decimal,
85}
86
87impl OrderSizer for PortfolioPercentSizer {
88    fn size(
89        &self,
90        _signal: &Signal,
91        portfolio_equity: Price,
92        last_price: Price,
93    ) -> anyhow::Result<Quantity> {
94        if last_price <= Decimal::ZERO {
95            bail!("cannot size order with zero or negative price");
96        }
97        if self.percent <= Decimal::ZERO {
98            return Ok(Decimal::ZERO);
99        }
100        let notional = portfolio_equity * self.percent;
101        Ok(notional / last_price)
102    }
103}
104
105/// Sizes orders based on position volatility. (Placeholder)
106#[derive(Default)]
107pub struct RiskAdjustedSizer {
108    /// Target risk contribution per trade, as a fraction of equity (e.g., 0.002 for 0.2%).
109    pub risk_fraction: Decimal,
110}
111
112impl OrderSizer for RiskAdjustedSizer {
113    fn size(
114        &self,
115        _signal: &Signal,
116        portfolio_equity: Price,
117        last_price: Price,
118    ) -> anyhow::Result<Quantity> {
119        if last_price <= Decimal::ZERO {
120            bail!("cannot size order with zero or negative price");
121        }
122        if self.risk_fraction <= Decimal::ZERO {
123            return Ok(Decimal::ZERO);
124        }
125        // Placeholder volatility; replace with instrument-specific estimator.
126        let volatility = Decimal::new(2, 2); // 0.02
127        let denom = last_price * volatility;
128        if denom <= Decimal::ZERO {
129            bail!("volatility multiplier produced an invalid denominator");
130        }
131        let dollars_at_risk = portfolio_equity * self.risk_fraction;
132        Ok(dollars_at_risk / denom)
133    }
134}
135
136/// Context passed to risk checks describing current exposure state.
137#[derive(Clone, Copy, Debug, Default)]
138pub struct RiskContext {
139    /// Symbol used to construct risk metadata.
140    pub symbol: Symbol,
141    /// Venue that will carry the exposure.
142    pub exchange: ExchangeId,
143    /// Signed quantity of the current open position (long positive, short negative).
144    pub signed_position_qty: Quantity,
145    /// Total current portfolio equity.
146    pub portfolio_equity: Price,
147    /// Equity scoped to the symbol's exchange.
148    pub exchange_equity: Price,
149    /// Last known price for the signal's symbol.
150    pub last_price: Price,
151    /// When true, only exposure-reducing orders are allowed.
152    pub liquidate_only: bool,
153    /// Instrument kind, if metadata is available.
154    pub instrument_kind: Option<InstrumentKind>,
155    /// Base asset tracked for solvency checks.
156    pub base_asset: AssetId,
157    /// Quote asset tracked for solvency checks.
158    pub quote_asset: AssetId,
159    /// Settlement asset for derivatives.
160    pub settlement_asset: AssetId,
161    /// Available base asset quantity on the venue.
162    pub base_available: Quantity,
163    /// Available quote asset quantity on the venue.
164    pub quote_available: Quantity,
165    /// Available settlement asset quantity on the venue.
166    pub settlement_available: Quantity,
167}
168
169/// Validates an order before it reaches the broker.
170pub trait PreTradeRiskChecker: Send + Sync {
171    /// Return `Ok(())` if the order passes risk checks.
172    fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError>;
173}
174
175/// No-op risk checker used by tests/backtests.
176pub struct NoopRiskChecker;
177
178impl PreTradeRiskChecker for NoopRiskChecker {
179    fn check(&self, _request: &OrderRequest, _ctx: &RiskContext) -> Result<(), RiskError> {
180        Ok(())
181    }
182}
183
184/// Upper bounds enforced by the [`BasicRiskChecker`].
185#[derive(Clone, Copy, Debug)]
186pub struct RiskLimits {
187    pub max_order_quantity: Quantity,
188    pub max_position_quantity: Quantity,
189    pub max_order_notional: Option<Price>,
190}
191
192impl RiskLimits {
193    /// Ensure limits are non-negative and default to zero (disabled) when NaN.
194    pub fn sanitized(self) -> Self {
195        Self {
196            max_order_quantity: self.max_order_quantity.max(Decimal::ZERO),
197            max_position_quantity: self.max_position_quantity.max(Decimal::ZERO),
198            max_order_notional: self
199                .max_order_notional
200                .and_then(|limit| (limit > Decimal::ZERO).then_some(limit)),
201        }
202    }
203}
204
205/// Simple risk checker enforcing fat-finger order size limits plus position caps.
206pub struct BasicRiskChecker {
207    limits: RiskLimits,
208}
209
210impl BasicRiskChecker {
211    /// Build a new checker with the provided limits.
212    pub fn new(limits: RiskLimits) -> Self {
213        Self {
214            limits: limits.sanitized(),
215        }
216    }
217}
218
219impl PreTradeRiskChecker for BasicRiskChecker {
220    fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError> {
221        let qty = request.quantity.abs();
222        let max_order = self.limits.max_order_quantity;
223        if max_order > Decimal::ZERO && qty > max_order {
224            return Err(RiskError::MaxOrderSize {
225                quantity: qty,
226                limit: max_order,
227            });
228        }
229
230        let positive_last_price = || {
231            if ctx.last_price > Decimal::ZERO {
232                Some(ctx.last_price)
233            } else {
234                None
235            }
236        };
237
238        let reference_price = match request.order_type {
239            OrderType::Limit => request
240                .price
241                .filter(|price| *price > Decimal::ZERO)
242                .or_else(positive_last_price),
243            _ => positive_last_price(),
244        };
245
246        if let Some(limit) = self.limits.max_order_notional {
247            if let Some(price) = reference_price {
248                let notional = qty * price;
249                if notional > limit {
250                    return Err(RiskError::MaxOrderNotional { notional, limit });
251                }
252            }
253        }
254
255        let projected_position = match request.side {
256            Side::Buy => ctx.signed_position_qty + qty,
257            Side::Sell => ctx.signed_position_qty - qty,
258        };
259
260        let max_position = self.limits.max_position_quantity;
261        if max_position > Decimal::ZERO && projected_position.abs() > max_position {
262            return Err(RiskError::MaxPositionExposure {
263                projected: projected_position,
264                limit: max_position,
265            });
266        }
267
268        if ctx.liquidate_only {
269            let position = ctx.signed_position_qty;
270            if position.is_zero() {
271                return Err(RiskError::LiquidateOnly);
272            }
273            let reduces = (position > Decimal::ZERO && request.side == Side::Sell)
274                || (position < Decimal::ZERO && request.side == Side::Buy);
275            if !reduces {
276                return Err(RiskError::LiquidateOnly);
277            }
278            if qty > position.abs() {
279                return Err(RiskError::LiquidateOnly);
280            }
281        }
282
283        match ctx.instrument_kind {
284            Some(InstrumentKind::Spot) => match request.side {
285                Side::Buy => {
286                    if let Some(price) = reference_price {
287                        let notional = qty * price;
288                        if ctx.quote_available < notional {
289                            return Err(RiskError::InsufficientBalance {
290                                asset: ctx.quote_asset,
291                                needed: notional,
292                                available: ctx.quote_available,
293                            });
294                        }
295                    }
296                }
297                Side::Sell => {
298                    if ctx.base_available < qty {
299                        return Err(RiskError::InsufficientBalance {
300                            asset: ctx.base_asset,
301                            needed: qty,
302                            available: ctx.base_available,
303                        });
304                    }
305                }
306            },
307            Some(InstrumentKind::LinearPerpetual) => {
308                if let Some(price) = reference_price {
309                    let margin = qty * price;
310                    if ctx.settlement_available < margin {
311                        return Err(RiskError::InsufficientBalance {
312                            asset: ctx.settlement_asset,
313                            needed: margin,
314                            available: ctx.settlement_available,
315                        });
316                    }
317                }
318            }
319            Some(InstrumentKind::InversePerpetual) => {
320                if let Some(price) = reference_price {
321                    if price > Decimal::ZERO {
322                        let margin = qty / price;
323                        if ctx.settlement_available < margin {
324                            return Err(RiskError::InsufficientBalance {
325                                asset: ctx.settlement_asset,
326                                needed: margin,
327                                available: ctx.settlement_available,
328                            });
329                        }
330                    }
331                }
332            }
333            None => {}
334        }
335
336        Ok(())
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use tesser_core::SignalKind;
344
345    fn dummy_signal() -> Signal {
346        Signal::new("BTCUSDT", SignalKind::EnterLong, 1.0)
347    }
348
349    #[test]
350    fn portfolio_percent_sizer_matches_decimal_math() {
351        let signal = dummy_signal();
352        let sizer = PortfolioPercentSizer {
353            percent: Decimal::new(5, 2),
354        };
355        let qty = sizer
356            .size(&signal, Decimal::from(25_000), Decimal::from(50_000))
357            .unwrap();
358        assert_eq!(qty, Decimal::new(25, 3)); // 0.025
359    }
360
361    #[test]
362    fn risk_adjusted_sizer_respects_zero_price_guard() {
363        let signal = dummy_signal();
364        let sizer = RiskAdjustedSizer {
365            risk_fraction: Decimal::new(1, 2),
366        };
367        let err = sizer
368            .size(&signal, Decimal::from(10_000), Decimal::ZERO)
369            .unwrap_err();
370        assert!(
371            err.to_string().contains("zero or negative price"),
372            "unexpected error: {err}"
373        );
374    }
375
376    #[test]
377    fn liquidate_only_blocks_new_exposure() {
378        let checker = BasicRiskChecker::new(RiskLimits {
379            max_order_quantity: Decimal::ZERO,
380            max_position_quantity: Decimal::ZERO,
381            max_order_notional: None,
382        });
383        let ctx = RiskContext {
384            signed_position_qty: Decimal::from(2),
385            portfolio_equity: Decimal::from(10_000),
386            last_price: Decimal::from(25_000),
387            liquidate_only: true,
388            ..RiskContext::default()
389        };
390        let order = OrderRequest {
391            symbol: "BTCUSDT".into(),
392            side: Side::Buy,
393            order_type: OrderType::Market,
394            quantity: Decimal::ONE,
395            price: None,
396            trigger_price: None,
397            time_in_force: None,
398            client_order_id: None,
399            take_profit: None,
400            stop_loss: None,
401            display_quantity: None,
402        };
403        let result = checker.check(&order, &ctx);
404        assert!(matches!(result, Err(RiskError::LiquidateOnly)));
405    }
406
407    #[test]
408    fn liquidate_only_allows_position_reduction() {
409        let checker = BasicRiskChecker::new(RiskLimits {
410            max_order_quantity: Decimal::ZERO,
411            max_position_quantity: Decimal::ZERO,
412            max_order_notional: None,
413        });
414        let ctx = RiskContext {
415            signed_position_qty: Decimal::from(2),
416            portfolio_equity: Decimal::from(10_000),
417            last_price: Decimal::from(25_000),
418            liquidate_only: true,
419            ..RiskContext::default()
420        };
421        let reduce = OrderRequest {
422            symbol: "BTCUSDT".into(),
423            side: Side::Sell,
424            order_type: OrderType::Market,
425            quantity: Decimal::ONE,
426            price: None,
427            trigger_price: None,
428            time_in_force: None,
429            client_order_id: None,
430            take_profit: None,
431            stop_loss: None,
432            display_quantity: None,
433        };
434        assert!(checker.check(&reduce, &ctx).is_ok());
435    }
436
437    #[test]
438    fn limit_order_notional_limit_triggers_rejection() {
439        let checker = BasicRiskChecker::new(RiskLimits {
440            max_order_quantity: Decimal::ZERO,
441            max_position_quantity: Decimal::ZERO,
442            max_order_notional: Some(Decimal::from(10_000u32)),
443        });
444        let ctx = RiskContext {
445            signed_position_qty: Decimal::ZERO,
446            portfolio_equity: Decimal::from(25_000u32),
447            last_price: Decimal::from(20_000u32),
448            liquidate_only: false,
449            ..RiskContext::default()
450        };
451        let order = OrderRequest {
452            symbol: "BTCUSDT".into(),
453            side: Side::Buy,
454            order_type: OrderType::Limit,
455            quantity: Decimal::ONE,
456            price: Some(Decimal::from(20_000u32)),
457            trigger_price: None,
458            time_in_force: None,
459            client_order_id: None,
460            take_profit: None,
461            stop_loss: None,
462            display_quantity: None,
463        };
464        match checker.check(&order, &ctx) {
465            Err(RiskError::MaxOrderNotional { notional, limit }) => {
466                assert_eq!(limit, Decimal::from(10_000u32));
467                assert!(notional > limit, "expected {notional} > {limit}");
468            }
469            other => panic!("unexpected result: {other:?}"),
470        }
471    }
472
473    #[test]
474    fn market_order_notional_limit_uses_last_price() {
475        let checker = BasicRiskChecker::new(RiskLimits {
476            max_order_quantity: Decimal::ZERO,
477            max_position_quantity: Decimal::ZERO,
478            max_order_notional: Some(Decimal::from(5_000u32)),
479        });
480        let ctx = RiskContext {
481            signed_position_qty: Decimal::ZERO,
482            portfolio_equity: Decimal::from(50_000u32),
483            last_price: Decimal::from(25_000u32),
484            liquidate_only: false,
485            ..RiskContext::default()
486        };
487        let order = OrderRequest {
488            symbol: "BTCUSDT".into(),
489            side: Side::Buy,
490            order_type: OrderType::Market,
491            quantity: Decimal::ONE,
492            price: None,
493            trigger_price: None,
494            time_in_force: None,
495            client_order_id: None,
496            take_profit: None,
497            stop_loss: None,
498            display_quantity: None,
499        };
500        assert!(matches!(
501            checker.check(&order, &ctx),
502            Err(RiskError::MaxOrderNotional { .. })
503        ));
504    }
505}
506
507/// Errors surfaced by pre-trade risk checks.
508#[derive(Debug, Error)]
509pub enum RiskError {
510    #[error("order quantity {quantity} exceeds limit {limit}")]
511    MaxOrderSize { quantity: Quantity, limit: Quantity },
512    #[error("order notional {notional} exceeds limit {limit}")]
513    MaxOrderNotional { notional: Price, limit: Price },
514    #[error("projected position {projected} exceeds limit {limit}")]
515    MaxPositionExposure {
516        projected: Quantity,
517        limit: Quantity,
518    },
519    #[error("liquidate-only mode active")]
520    LiquidateOnly,
521    #[error("insufficient {asset} balance: need {needed}, have {available}")]
522    InsufficientBalance {
523        asset: AssetId,
524        needed: Quantity,
525        available: Quantity,
526    },
527}
528
529/// Translates signals into orders using a provided [`ExecutionClient`].
530pub struct ExecutionEngine {
531    client: Arc<dyn ExecutionClient>,
532    sizer: Box<dyn OrderSizer>,
533    risk: Arc<dyn PreTradeRiskChecker>,
534}
535
536impl ExecutionEngine {
537    /// Instantiate the engine with its dependencies.
538    pub fn new(
539        client: Arc<dyn ExecutionClient>,
540        sizer: Box<dyn OrderSizer>,
541        risk: Arc<dyn PreTradeRiskChecker>,
542    ) -> Self {
543        Self {
544            client,
545            sizer,
546            risk,
547        }
548    }
549
550    /// Determine the quantity that should be used for a signal, honoring overrides when present.
551    pub fn determine_quantity(
552        &self,
553        signal: &Signal,
554        ctx: &RiskContext,
555    ) -> anyhow::Result<Quantity> {
556        if let Some(qty) = signal.quantity {
557            return Ok(qty.max(Decimal::ZERO));
558        }
559        self.sizer.size(signal, ctx.exchange_equity, ctx.last_price)
560    }
561
562    /// Consume a signal and forward it to the broker.
563    pub async fn handle_signal(
564        &self,
565        signal: Signal,
566        ctx: RiskContext,
567    ) -> BrokerResult<Option<Order>> {
568        let qty = self
569            .determine_quantity(&signal, &ctx)
570            .context("failed to determine order size")
571            .map_err(|err| BrokerError::Other(err.to_string()))?;
572
573        if qty <= Decimal::ZERO {
574            warn!(signal = ?signal.id, "order size is zero, skipping");
575            return Ok(None);
576        }
577
578        let client_order_id = if let Some(group) = signal.group_id {
579            format!("{}|grp:{}", signal.id, group)
580        } else {
581            signal.id.to_string()
582        };
583        let request = match signal.kind {
584            SignalKind::EnterLong => {
585                self.build_request(signal.symbol, Side::Buy, qty, Some(client_order_id.clone()))
586            }
587            SignalKind::ExitLong | SignalKind::Flatten => self.build_request(
588                signal.symbol,
589                Side::Sell,
590                qty,
591                Some(client_order_id.clone()),
592            ),
593            SignalKind::EnterShort => self.build_request(
594                signal.symbol,
595                Side::Sell,
596                qty,
597                Some(client_order_id.clone()),
598            ),
599            SignalKind::ExitShort => {
600                self.build_request(signal.symbol, Side::Buy, qty, Some(client_order_id.clone()))
601            }
602        };
603
604        let order = self.send_order(request, &ctx).await?;
605
606        let stop_side = match signal.kind {
607            SignalKind::EnterLong | SignalKind::ExitShort => Side::Sell,
608            SignalKind::EnterShort | SignalKind::ExitLong => Side::Buy,
609            SignalKind::Flatten => return Ok(Some(order)),
610        };
611
612        if let Some(sl_price) = signal.stop_loss {
613            let sl_request = OrderRequest {
614                symbol: signal.symbol,
615                side: stop_side,
616                order_type: OrderType::StopMarket,
617                quantity: qty,
618                price: None,
619                trigger_price: Some(sl_price),
620                time_in_force: None,
621                client_order_id: Some(format!("{}-sl", signal.id)),
622                take_profit: None,
623                stop_loss: None,
624                display_quantity: None,
625            };
626            if let Err(e) = self.send_order(sl_request, &ctx).await {
627                warn!(error = %e, "failed to place stop-loss order");
628            }
629        }
630
631        if let Some(tp_price) = signal.take_profit {
632            let tp_request = OrderRequest {
633                symbol: signal.symbol,
634                side: stop_side,
635                order_type: OrderType::StopMarket,
636                quantity: qty,
637                price: None,
638                trigger_price: Some(tp_price),
639                time_in_force: None,
640                client_order_id: Some(format!("{}-tp", signal.id)),
641                take_profit: None,
642                stop_loss: None,
643                display_quantity: None,
644            };
645            if let Err(e) = self.send_order(tp_request, &ctx).await {
646                warn!(error = %e, "failed to place take-profit order");
647            }
648        }
649
650        Ok(Some(order))
651    }
652
653    fn build_request(
654        &self,
655        symbol: Symbol,
656        side: Side,
657        qty: Quantity,
658        client_order_id: Option<String>,
659    ) -> OrderRequest {
660        OrderRequest {
661            symbol,
662            side,
663            order_type: OrderType::Market,
664            quantity: qty,
665            price: None,
666            trigger_price: None,
667            time_in_force: None,
668            client_order_id,
669            take_profit: None,
670            stop_loss: None,
671            display_quantity: None,
672        }
673    }
674
675    async fn send_order(&self, request: OrderRequest, ctx: &RiskContext) -> BrokerResult<Order> {
676        self.risk
677            .check(&request, ctx)
678            .map_err(|err| BrokerError::InvalidRequest(err.to_string()))?;
679        let order = self.client.place_order(request).await?;
680        info!(
681            order_id = %order.id,
682            qty = %order.request.quantity,
683            "order sent to broker"
684        );
685        Ok(order)
686    }
687
688    pub async fn amend_order(&self, request: OrderUpdateRequest) -> BrokerResult<Order> {
689        let order = self.client.amend_order(request).await?;
690        info!(
691            order_id = %order.id,
692            qty = %order.request.quantity,
693            "order amended via broker"
694        );
695        Ok(order)
696    }
697
698    pub fn client(&self) -> Arc<dyn ExecutionClient> {
699        Arc::clone(&self.client)
700    }
701
702    pub fn sizer(&self) -> &dyn OrderSizer {
703        self.sizer.as_ref()
704    }
705}