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