pub mod algorithm;
pub mod orchestrator;
pub mod repository;
pub use algorithm::{AlgoStatus, ChildOrderRequest, ExecutionAlgorithm};
pub use orchestrator::OrderOrchestrator;
pub use repository::{AlgoStateRepository, SqliteAlgoStateRepository};
use anyhow::{bail, Context};
use rust_decimal::Decimal;
use std::sync::Arc;
use tesser_broker::{BrokerError, BrokerResult, ExecutionClient};
use tesser_bybit::{BybitClient, BybitCredentials};
use tesser_core::{
Order, OrderRequest, OrderType, Price, Quantity, Side, Signal, SignalKind, Symbol,
};
use thiserror::Error;
use tracing::{info, warn};
pub trait OrderSizer: Send + Sync {
fn size(
&self,
signal: &Signal,
portfolio_equity: Price,
last_price: Price,
) -> anyhow::Result<Quantity>;
}
pub struct FixedOrderSizer {
pub quantity: Quantity,
}
impl OrderSizer for FixedOrderSizer {
fn size(
&self,
_signal: &Signal,
_portfolio_equity: Price,
_last_price: Price,
) -> anyhow::Result<Quantity> {
Ok(self.quantity)
}
}
pub struct PortfolioPercentSizer {
pub percent: Decimal,
}
impl OrderSizer for PortfolioPercentSizer {
fn size(
&self,
_signal: &Signal,
portfolio_equity: Price,
last_price: Price,
) -> anyhow::Result<Quantity> {
if last_price <= Decimal::ZERO {
bail!("cannot size order with zero or negative price");
}
if self.percent <= Decimal::ZERO {
return Ok(Decimal::ZERO);
}
let notional = portfolio_equity * self.percent;
Ok(notional / last_price)
}
}
#[derive(Default)]
pub struct RiskAdjustedSizer {
pub risk_fraction: Decimal,
}
impl OrderSizer for RiskAdjustedSizer {
fn size(
&self,
_signal: &Signal,
portfolio_equity: Price,
last_price: Price,
) -> anyhow::Result<Quantity> {
if last_price <= Decimal::ZERO {
bail!("cannot size order with zero or negative price");
}
if self.risk_fraction <= Decimal::ZERO {
return Ok(Decimal::ZERO);
}
let volatility = Decimal::new(2, 2); let denom = last_price * volatility;
if denom <= Decimal::ZERO {
bail!("volatility multiplier produced an invalid denominator");
}
let dollars_at_risk = portfolio_equity * self.risk_fraction;
Ok(dollars_at_risk / denom)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct RiskContext {
pub signed_position_qty: Quantity,
pub portfolio_equity: Price,
pub last_price: Price,
pub liquidate_only: bool,
}
pub trait PreTradeRiskChecker: Send + Sync {
fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError>;
}
pub struct NoopRiskChecker;
impl PreTradeRiskChecker for NoopRiskChecker {
fn check(&self, _request: &OrderRequest, _ctx: &RiskContext) -> Result<(), RiskError> {
Ok(())
}
}
#[derive(Clone, Copy, Debug)]
pub struct RiskLimits {
pub max_order_quantity: Quantity,
pub max_position_quantity: Quantity,
}
impl RiskLimits {
pub fn sanitized(self) -> Self {
Self {
max_order_quantity: self.max_order_quantity.max(Decimal::ZERO),
max_position_quantity: self.max_position_quantity.max(Decimal::ZERO),
}
}
}
pub struct BasicRiskChecker {
limits: RiskLimits,
}
impl BasicRiskChecker {
pub fn new(limits: RiskLimits) -> Self {
Self {
limits: limits.sanitized(),
}
}
}
impl PreTradeRiskChecker for BasicRiskChecker {
fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError> {
let qty = request.quantity.abs();
let max_order = self.limits.max_order_quantity;
if max_order > Decimal::ZERO && qty > max_order {
return Err(RiskError::MaxOrderSize {
quantity: qty,
limit: max_order,
});
}
let projected_position = match request.side {
Side::Buy => ctx.signed_position_qty + qty,
Side::Sell => ctx.signed_position_qty - qty,
};
let max_position = self.limits.max_position_quantity;
if max_position > Decimal::ZERO && projected_position.abs() > max_position {
return Err(RiskError::MaxPositionExposure {
projected: projected_position,
limit: max_position,
});
}
if ctx.liquidate_only {
let position = ctx.signed_position_qty;
if position.is_zero() {
return Err(RiskError::LiquidateOnly);
}
let reduces = (position > Decimal::ZERO && request.side == Side::Sell)
|| (position < Decimal::ZERO && request.side == Side::Buy);
if !reduces {
return Err(RiskError::LiquidateOnly);
}
if qty > position.abs() {
return Err(RiskError::LiquidateOnly);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tesser_core::SignalKind;
fn dummy_signal() -> Signal {
Signal::new("BTCUSDT", SignalKind::EnterLong, 1.0)
}
#[test]
fn portfolio_percent_sizer_matches_decimal_math() {
let signal = dummy_signal();
let sizer = PortfolioPercentSizer {
percent: Decimal::new(5, 2),
};
let qty = sizer
.size(&signal, Decimal::from(25_000), Decimal::from(50_000))
.unwrap();
assert_eq!(qty, Decimal::new(25, 3)); }
#[test]
fn risk_adjusted_sizer_respects_zero_price_guard() {
let signal = dummy_signal();
let sizer = RiskAdjustedSizer {
risk_fraction: Decimal::new(1, 2),
};
let err = sizer
.size(&signal, Decimal::from(10_000), Decimal::ZERO)
.unwrap_err();
assert!(
err.to_string().contains("zero or negative price"),
"unexpected error: {err}"
);
}
#[test]
fn liquidate_only_blocks_new_exposure() {
let checker = BasicRiskChecker::new(RiskLimits {
max_order_quantity: Decimal::ZERO,
max_position_quantity: Decimal::ZERO,
});
let ctx = RiskContext {
signed_position_qty: Decimal::from(2),
portfolio_equity: Decimal::from(10_000),
last_price: Decimal::from(25_000),
liquidate_only: true,
};
let order = OrderRequest {
symbol: "BTCUSDT".into(),
side: Side::Buy,
order_type: OrderType::Market,
quantity: Decimal::ONE,
price: None,
trigger_price: None,
time_in_force: None,
client_order_id: None,
take_profit: None,
stop_loss: None,
display_quantity: None,
};
let result = checker.check(&order, &ctx);
assert!(matches!(result, Err(RiskError::LiquidateOnly)));
}
#[test]
fn liquidate_only_allows_position_reduction() {
let checker = BasicRiskChecker::new(RiskLimits {
max_order_quantity: Decimal::ZERO,
max_position_quantity: Decimal::ZERO,
});
let ctx = RiskContext {
signed_position_qty: Decimal::from(2),
portfolio_equity: Decimal::from(10_000),
last_price: Decimal::from(25_000),
liquidate_only: true,
};
let reduce = OrderRequest {
symbol: "BTCUSDT".into(),
side: Side::Sell,
order_type: OrderType::Market,
quantity: Decimal::ONE,
price: None,
trigger_price: None,
time_in_force: None,
client_order_id: None,
take_profit: None,
stop_loss: None,
display_quantity: None,
};
assert!(checker.check(&reduce, &ctx).is_ok());
}
}
#[derive(Debug, Error)]
pub enum RiskError {
#[error("order quantity {quantity} exceeds limit {limit}")]
MaxOrderSize { quantity: Quantity, limit: Quantity },
#[error("projected position {projected} exceeds limit {limit}")]
MaxPositionExposure {
projected: Quantity,
limit: Quantity,
},
#[error("liquidate-only mode active")]
LiquidateOnly,
}
pub struct ExecutionEngine {
client: Arc<dyn ExecutionClient>,
sizer: Box<dyn OrderSizer>,
risk: Arc<dyn PreTradeRiskChecker>,
}
impl ExecutionEngine {
pub fn new(
client: Arc<dyn ExecutionClient>,
sizer: Box<dyn OrderSizer>,
risk: Arc<dyn PreTradeRiskChecker>,
) -> Self {
Self {
client,
sizer,
risk,
}
}
pub async fn handle_signal(
&self,
signal: Signal,
ctx: RiskContext,
) -> BrokerResult<Option<Order>> {
let qty = self
.sizer
.size(&signal, ctx.portfolio_equity, ctx.last_price)
.context("failed to determine order size")
.map_err(|err| BrokerError::Other(err.to_string()))?;
if qty <= Decimal::ZERO {
warn!(signal = ?signal.id, "order size is zero, skipping");
return Ok(None);
}
let client_order_id = signal.id.to_string();
let request = match signal.kind {
SignalKind::EnterLong => self.build_request(
signal.symbol.clone(),
Side::Buy,
qty,
Some(client_order_id.clone()),
),
SignalKind::ExitLong | SignalKind::Flatten => self.build_request(
signal.symbol.clone(),
Side::Sell,
qty,
Some(client_order_id.clone()),
),
SignalKind::EnterShort => self.build_request(
signal.symbol.clone(),
Side::Sell,
qty,
Some(client_order_id.clone()),
),
SignalKind::ExitShort => self.build_request(
signal.symbol.clone(),
Side::Buy,
qty,
Some(client_order_id.clone()),
),
};
let order = self.send_order(request, &ctx).await?;
let stop_side = match signal.kind {
SignalKind::EnterLong | SignalKind::ExitShort => Side::Sell,
SignalKind::EnterShort | SignalKind::ExitLong => Side::Buy,
SignalKind::Flatten => return Ok(Some(order)),
};
if let Some(sl_price) = signal.stop_loss {
let sl_request = OrderRequest {
symbol: signal.symbol.clone(),
side: stop_side,
order_type: OrderType::StopMarket,
quantity: qty,
price: None,
trigger_price: Some(sl_price),
time_in_force: None,
client_order_id: Some(format!("{}-sl", signal.id)),
take_profit: None,
stop_loss: None,
display_quantity: None,
};
if let Err(e) = self.send_order(sl_request, &ctx).await {
warn!(error = %e, "failed to place stop-loss order");
}
}
if let Some(tp_price) = signal.take_profit {
let tp_request = OrderRequest {
symbol: signal.symbol.clone(),
side: stop_side,
order_type: OrderType::StopMarket,
quantity: qty,
price: None,
trigger_price: Some(tp_price),
time_in_force: None,
client_order_id: Some(format!("{}-tp", signal.id)),
take_profit: None,
stop_loss: None,
display_quantity: None,
};
if let Err(e) = self.send_order(tp_request, &ctx).await {
warn!(error = %e, "failed to place take-profit order");
}
}
Ok(Some(order))
}
fn build_request(
&self,
symbol: Symbol,
side: Side,
qty: Quantity,
client_order_id: Option<String>,
) -> OrderRequest {
OrderRequest {
symbol,
side,
order_type: OrderType::Market,
quantity: qty,
price: None,
trigger_price: None,
time_in_force: None,
client_order_id,
take_profit: None,
stop_loss: None,
display_quantity: None,
}
}
async fn send_order(&self, request: OrderRequest, ctx: &RiskContext) -> BrokerResult<Order> {
self.risk
.check(&request, ctx)
.map_err(|err| BrokerError::InvalidRequest(err.to_string()))?;
let order = self.client.place_order(request).await?;
info!(
order_id = %order.id,
qty = %order.request.quantity,
"order sent to broker"
);
Ok(order)
}
pub fn client(&self) -> Arc<dyn ExecutionClient> {
Arc::clone(&self.client)
}
pub fn sizer(&self) -> &dyn OrderSizer {
self.sizer.as_ref()
}
pub fn credentials(&self) -> Option<BybitCredentials> {
self.client
.as_any()
.downcast_ref::<BybitClient>()
.and_then(|client| client.get_credentials())
}
pub fn ws_url(&self) -> String {
self.client
.as_any()
.downcast_ref::<BybitClient>()
.map(|client| client.get_ws_url())
.unwrap_or_default()
}
}