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};
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
23pub trait OrderSizer: Send + Sync {
25 fn size(
27 &self,
28 signal: &Signal,
29 portfolio_equity: Price,
30 last_price: Price,
31 ) -> anyhow::Result<Quantity>;
32}
33
34pub 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
50pub struct PortfolioPercentSizer {
52 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#[derive(Default)]
76pub struct RiskAdjustedSizer {
77 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 let volatility = Decimal::new(2, 2); 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#[derive(Clone, Copy, Debug, Default)]
107pub struct RiskContext {
108 pub signed_position_qty: Quantity,
110 pub portfolio_equity: Price,
112 pub last_price: Price,
114 pub liquidate_only: bool,
116}
117
118pub trait PreTradeRiskChecker: Send + Sync {
120 fn check(&self, request: &OrderRequest, ctx: &RiskContext) -> Result<(), RiskError>;
122}
123
124pub struct NoopRiskChecker;
126
127impl PreTradeRiskChecker for NoopRiskChecker {
128 fn check(&self, _request: &OrderRequest, _ctx: &RiskContext) -> Result<(), RiskError> {
129 Ok(())
130 }
131}
132
133#[derive(Clone, Copy, Debug)]
135pub struct RiskLimits {
136 pub max_order_quantity: Quantity,
137 pub max_position_quantity: Quantity,
138}
139
140impl RiskLimits {
141 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
150pub struct BasicRiskChecker {
152 limits: RiskLimits,
153}
154
155impl BasicRiskChecker {
156 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)); }
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#[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
315pub struct ExecutionEngine {
317 client: Arc<dyn ExecutionClient>,
318 sizer: Box<dyn OrderSizer>,
319 risk: Arc<dyn PreTradeRiskChecker>,
320}
321
322impl ExecutionEngine {
323 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 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}