1pub mod algorithm;
4pub mod orchestrator;
5pub mod repository;
6
7pub 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#[derive(Clone, Copy, Debug, Default)]
26pub enum PanicCloseMode {
27 #[default]
28 Market,
29 AggressiveLimit,
30}
31
32#[derive(Clone, Copy, Debug)]
34pub struct PanicCloseConfig {
35 pub mode: PanicCloseMode,
36 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
49pub trait PanicObserver: Send + Sync {
51 fn on_group_event(&self, group_id: Uuid, symbol: Symbol, quantity: Quantity, reason: &str);
52}
53
54pub trait OrderSizer: Send + Sync {
56 fn size(
58 &self,
59 signal: &Signal,
60 portfolio_equity: Price,
61 last_price: Price,
62 ) -> anyhow::Result<Quantity>;
63}
64
65pub 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
81pub struct PortfolioPercentSizer {
83 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#[derive(Default)]
107pub struct RiskAdjustedSizer {
108 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 let volatility = Decimal::new(2, 2); 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#[derive(Clone, Copy, Debug, Default)]
138pub struct RiskContext {
139 pub symbol: Symbol,
141 pub exchange: ExchangeId,
143 pub signed_position_qty: Quantity,
145 pub portfolio_equity: Price,
147 pub exchange_equity: Price,
149 pub last_price: Price,
151 pub liquidate_only: bool,
153 pub instrument_kind: Option<InstrumentKind>,
155 pub base_asset: AssetId,
157 pub quote_asset: AssetId,
159 pub settlement_asset: AssetId,
161 pub base_available: Quantity,
163 pub quote_available: Quantity,
165 pub settlement_available: Quantity,
167}
168
169pub trait PreTradeRiskChecker: Send + Sync {
171 fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError>;
173}
174
175pub struct NoopRiskChecker;
177
178impl PreTradeRiskChecker for NoopRiskChecker {
179 fn check(&self, _request: &OrderRequest, _ctx: &RiskContext) -> Result<(), RiskError> {
180 Ok(())
181 }
182}
183
184#[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 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
205pub struct BasicRiskChecker {
207 limits: RiskLimits,
208}
209
210impl BasicRiskChecker {
211 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)); }
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#[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
529pub struct ExecutionEngine {
531 client: Arc<dyn ExecutionClient>,
532 sizer: Box<dyn OrderSizer>,
533 risk: Arc<dyn PreTradeRiskChecker>,
534}
535
536impl ExecutionEngine {
537 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 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 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}