Skip to main content

sandbox_quant/execution/
service.rs

1use crate::domain::exposure::Exposure;
2use crate::domain::identifiers::BatchId;
3use crate::domain::instrument::Instrument;
4use crate::domain::market::Market;
5use crate::domain::order_type::OrderType;
6use crate::domain::position::PositionSnapshot;
7use crate::error::exchange_error::ExchangeError;
8use crate::error::execution_error::ExecutionError;
9use crate::exchange::facade::ExchangeFacade;
10use crate::exchange::symbol_rules::SymbolRules;
11use crate::exchange::types::CloseOrderRequest;
12use crate::execution::close_all::CloseAllBatchResult;
13use crate::execution::close_symbol::{CloseSubmitResult, CloseSymbolResult};
14use crate::execution::command::{CommandSource, ExecutionCommand};
15use crate::execution::futures::planner::FuturesExecutionPlanner;
16use crate::execution::planner::ExecutionPlan;
17use crate::execution::price_source::PriceSource;
18use crate::execution::spot::planner::SpotExecutionPlanner;
19use crate::execution::target_translation::exposure_to_notional;
20use crate::portfolio::store::PortfolioStateStore;
21
22#[derive(Debug, Clone, PartialEq)]
23struct NormalizedOrderQty {
24    qty: f64,
25    qty_text: String,
26}
27
28#[derive(Debug, Default)]
29pub struct ExecutionService {
30    pub last_command: Option<ExecutionCommand>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
34pub enum ExecutionOutcome {
35    TargetExposureSubmitted { instrument: Instrument },
36    TargetExposureAlreadyAtTarget { instrument: Instrument },
37    OptionOrderSubmitted { instrument: Instrument },
38    CloseSymbol(CloseSymbolResult),
39    CloseAll(CloseAllBatchResult),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum TargetExposureSubmitResult {
44    Submitted,
45    AlreadyAtTarget,
46}
47
48impl ExecutionService {
49    fn record(&mut self, command: ExecutionCommand) {
50        self.last_command = Some(command);
51    }
52
53    pub fn execute<E: ExchangeFacade<Error = ExchangeError>>(
54        &mut self,
55        exchange: &E,
56        store: &PortfolioStateStore,
57        price_source: &impl PriceSource,
58        command: ExecutionCommand,
59    ) -> Result<ExecutionOutcome, ExecutionError> {
60        self.record(command.clone());
61        match command {
62            ExecutionCommand::SetTargetExposure {
63                instrument,
64                target,
65                order_type,
66                source: _source,
67            } => match self.submit_target_exposure(
68                exchange,
69                store,
70                price_source,
71                &instrument,
72                target,
73                order_type,
74            )? {
75                TargetExposureSubmitResult::Submitted => {
76                    Ok(ExecutionOutcome::TargetExposureSubmitted { instrument })
77                }
78                TargetExposureSubmitResult::AlreadyAtTarget => {
79                    Ok(ExecutionOutcome::TargetExposureAlreadyAtTarget { instrument })
80                }
81            },
82            ExecutionCommand::SubmitOptionOrder {
83                instrument,
84                side,
85                qty,
86                order_type,
87                source: _source,
88            } => {
89                self.submit_option_order(exchange, &instrument, side, qty, order_type)?;
90                Ok(ExecutionOutcome::OptionOrderSubmitted { instrument })
91            }
92            ExecutionCommand::CloseSymbol {
93                instrument,
94                source: _source,
95            } => Ok(ExecutionOutcome::CloseSymbol(self.close_symbol(
96                exchange,
97                store,
98                &instrument,
99            )?)),
100            ExecutionCommand::CloseAll { source } => {
101                let batch_id = match source {
102                    CommandSource::User => BatchId(1),
103                    CommandSource::System => BatchId(2),
104                };
105                Ok(ExecutionOutcome::CloseAll(
106                    self.close_all(exchange, store, batch_id),
107                ))
108            }
109        }
110    }
111
112    fn plan_close(
113        &self,
114        store: &PortfolioStateStore,
115        instrument: &Instrument,
116    ) -> Result<ExecutionPlan, ExecutionError> {
117        let Some(position) = store.snapshot.positions.get(instrument) else {
118            return Err(ExecutionError::NoOpenPosition);
119        };
120
121        match position.market {
122            Market::Spot => SpotExecutionPlanner.plan_close(position),
123            Market::Futures => FuturesExecutionPlanner.plan_close(position),
124            Market::Options => Err(ExecutionError::SubmitFailed(
125                ExchangeError::UnsupportedMarketOperation,
126            )),
127        }
128    }
129
130    pub fn plan_target_exposure<E: ExchangeFacade<Error = ExchangeError>>(
131        &self,
132        exchange: &E,
133        store: &PortfolioStateStore,
134        price_source: &impl PriceSource,
135        instrument: &Instrument,
136        target: Exposure,
137        _order_type: OrderType,
138    ) -> Result<ExecutionPlan, ExecutionError> {
139        let (resolved_instrument, market, current_qty) =
140            self.resolve_target_context(exchange, store, instrument)?;
141        let current_price = price_source
142            .current_price(&resolved_instrument)
143            .or_else(|| exchange.load_last_price(&resolved_instrument, market).ok())
144            .ok_or(ExecutionError::MissingPriceContext)?;
145        let equity_usdt: f64 = store.snapshot.balances.iter().map(|b| b.total()).sum();
146        let target_notional = exposure_to_notional(target, equity_usdt);
147        let synthetic_position = PositionSnapshot {
148            instrument: resolved_instrument.clone(),
149            market,
150            signed_qty: current_qty,
151            entry_price: None,
152        };
153
154        match market {
155            Market::Spot => SpotExecutionPlanner.plan_target_exposure(
156                &synthetic_position,
157                current_price,
158                target_notional.target_usdt,
159            ),
160            Market::Futures => FuturesExecutionPlanner.plan_target_exposure(
161                &synthetic_position,
162                current_price,
163                target_notional.target_usdt,
164            ),
165            Market::Options => Err(ExecutionError::SubmitFailed(
166                ExchangeError::UnsupportedMarketOperation,
167            )),
168        }
169    }
170
171    pub fn submit_target_exposure<E: ExchangeFacade<Error = ExchangeError>>(
172        &mut self,
173        exchange: &E,
174        store: &PortfolioStateStore,
175        price_source: &impl PriceSource,
176        instrument: &Instrument,
177        target: Exposure,
178        order_type: OrderType,
179    ) -> Result<TargetExposureSubmitResult, ExecutionError> {
180        let (resolved_instrument, market, current_qty) =
181            self.resolve_target_context(exchange, store, instrument)?;
182        let current_price = price_source
183            .current_price(&resolved_instrument)
184            .or_else(|| exchange.load_last_price(&resolved_instrument, market).ok())
185            .ok_or(ExecutionError::MissingPriceContext)?;
186        let equity_usdt: f64 = store.snapshot.balances.iter().map(|b| b.total()).sum();
187        let target_notional = exposure_to_notional(target, equity_usdt);
188        let synthetic_position = PositionSnapshot {
189            instrument: resolved_instrument.clone(),
190            market,
191            signed_qty: current_qty,
192            entry_price: None,
193        };
194        let plan = match market {
195            Market::Spot => SpotExecutionPlanner.plan_target_exposure(
196                &synthetic_position,
197                current_price,
198                target_notional.target_usdt,
199            ),
200            Market::Futures => FuturesExecutionPlanner.plan_target_exposure(
201                &synthetic_position,
202                current_price,
203                target_notional.target_usdt,
204            ),
205            Market::Options => {
206                return Err(ExecutionError::SubmitFailed(
207                    ExchangeError::UnsupportedMarketOperation,
208                ))
209            }
210        }?;
211        let qty = match self.normalize_order_qty(
212            exchange,
213            &plan.instrument,
214            market,
215            plan.qty,
216            target.value(),
217            equity_usdt,
218            current_price,
219            target_notional.target_usdt,
220        ) {
221            Ok(qty) => qty,
222            Err(ExecutionError::OrderQtyTooSmall {
223                raw_qty,
224                normalized_qty,
225                ..
226            }) if current_qty.abs() > f64::EPSILON
227                && raw_qty > f64::EPSILON
228                && normalized_qty <= f64::EPSILON =>
229            {
230                return Ok(TargetExposureSubmitResult::AlreadyAtTarget);
231            }
232            Err(error) => return Err(error),
233        };
234
235        exchange.submit_order(CloseOrderRequest {
236            instrument: plan.instrument,
237            market,
238            side: plan.side,
239            qty: qty.qty,
240            qty_text: qty.qty_text,
241            order_type,
242            reduce_only: plan.reduce_only,
243        })?;
244        Ok(TargetExposureSubmitResult::Submitted)
245    }
246
247    fn resolve_target_context<E: ExchangeFacade<Error = ExchangeError>>(
248        &self,
249        exchange: &E,
250        store: &PortfolioStateStore,
251        instrument: &Instrument,
252    ) -> Result<(Instrument, Market, f64), ExecutionError> {
253        if let Some(position) = store.snapshot.positions.get(instrument) {
254            return Ok((instrument.clone(), position.market, position.signed_qty));
255        }
256
257        if exchange
258            .load_symbol_rules(instrument, Market::Futures)
259            .is_ok()
260        {
261            return Ok((instrument.clone(), Market::Futures, 0.0));
262        }
263
264        if exchange.load_symbol_rules(instrument, Market::Spot).is_ok() {
265            return Ok((instrument.clone(), Market::Spot, 0.0));
266        }
267
268        Err(ExecutionError::UnknownInstrument(instrument.0.clone()))
269    }
270
271    pub fn submit_option_order<E: ExchangeFacade<Error = ExchangeError>>(
272        &mut self,
273        exchange: &E,
274        instrument: &Instrument,
275        side: crate::domain::position::Side,
276        qty: f64,
277        order_type: OrderType,
278    ) -> Result<(), ExecutionError> {
279        let normalized_qty =
280            self.normalize_direct_order_qty(exchange, instrument, Market::Options, qty)?;
281        exchange.submit_order(CloseOrderRequest {
282            instrument: instrument.clone(),
283            market: Market::Options,
284            side,
285            qty: normalized_qty.qty,
286            qty_text: normalized_qty.qty_text,
287            order_type,
288            reduce_only: false,
289        })?;
290        Ok(())
291    }
292
293    /// Submits a close order for the current authoritative position snapshot.
294    ///
295    /// Example:
296    /// - current signed qty = `-0.3`
297    /// - generated close side = `Buy`
298    /// - generated close qty = `0.3`
299    pub fn close_symbol<E: ExchangeFacade<Error = ExchangeError>>(
300        &mut self,
301        exchange: &E,
302        store: &PortfolioStateStore,
303        instrument: &Instrument,
304    ) -> Result<CloseSymbolResult, ExecutionError> {
305        let plan = match self.plan_close(store, instrument) {
306            Ok(plan) => plan,
307            Err(ExecutionError::NoOpenPosition) => {
308                return Ok(CloseSymbolResult {
309                    instrument: instrument.clone(),
310                    result: CloseSubmitResult::SkippedNoPosition,
311                });
312            }
313            Err(error) => return Err(error),
314        };
315
316        if plan.qty <= f64::EPSILON {
317            return Ok(CloseSymbolResult {
318                instrument: instrument.clone(),
319                result: CloseSubmitResult::SkippedNoPosition,
320            });
321        }
322        let market = store
323            .snapshot
324            .positions
325            .get(instrument)
326            .map(|position| position.market)
327            .ok_or(ExecutionError::NoOpenPosition)?;
328        let qty = self.normalize_order_qty(
329            exchange,
330            &plan.instrument,
331            market,
332            plan.qty,
333            0.0,
334            0.0,
335            0.0,
336            0.0,
337        )?;
338        exchange.submit_close_order(CloseOrderRequest {
339            instrument: plan.instrument.clone(),
340            market,
341            side: plan.side,
342            qty: qty.qty,
343            qty_text: qty.qty_text,
344            order_type: OrderType::Market,
345            reduce_only: plan.reduce_only,
346        })?;
347
348        Ok(CloseSymbolResult {
349            instrument: instrument.clone(),
350            result: CloseSubmitResult::Submitted,
351        })
352    }
353
354    pub fn close_all<E: ExchangeFacade<Error = ExchangeError>>(
355        &mut self,
356        exchange: &E,
357        store: &PortfolioStateStore,
358        batch_id: BatchId,
359    ) -> CloseAllBatchResult {
360        let mut results = Vec::new();
361        for instrument in store.snapshot.positions.keys() {
362            let result = match self.close_symbol(exchange, store, instrument) {
363                Ok(result) => result,
364                Err(_) => CloseSymbolResult {
365                    instrument: instrument.clone(),
366                    result: CloseSubmitResult::Rejected,
367                },
368            };
369            results.push(result);
370        }
371        CloseAllBatchResult { batch_id, results }
372    }
373
374    fn normalize_order_qty<E: ExchangeFacade<Error = ExchangeError>>(
375        &self,
376        exchange: &E,
377        instrument: &Instrument,
378        market: Market,
379        raw_qty: f64,
380        target_exposure: f64,
381        equity_usdt: f64,
382        current_price: f64,
383        target_notional_usdt: f64,
384    ) -> Result<NormalizedOrderQty, ExecutionError> {
385        let rules = exchange.load_symbol_rules(instrument, market)?;
386        let normalized_qty = floor_to_step(raw_qty, rules.step_size);
387        let validated_qty = validate_normalized_qty(
388            instrument,
389            market,
390            raw_qty,
391            normalized_qty,
392            rules,
393            target_exposure,
394            equity_usdt,
395            current_price,
396            target_notional_usdt,
397        )?;
398
399        Ok(NormalizedOrderQty {
400            qty: validated_qty,
401            qty_text: format_qty_to_step(validated_qty, rules.step_size),
402        })
403    }
404
405    fn normalize_direct_order_qty<E: ExchangeFacade<Error = ExchangeError>>(
406        &self,
407        exchange: &E,
408        instrument: &Instrument,
409        market: Market,
410        raw_qty: f64,
411    ) -> Result<NormalizedOrderQty, ExecutionError> {
412        let rules = exchange.load_symbol_rules(instrument, market)?;
413        let normalized_qty = floor_to_step(raw_qty, rules.step_size);
414        let validated_qty = validate_normalized_qty(
415            instrument,
416            market,
417            raw_qty,
418            normalized_qty,
419            rules,
420            0.0,
421            0.0,
422            0.0,
423            0.0,
424        )?;
425
426        Ok(NormalizedOrderQty {
427            qty: validated_qty,
428            qty_text: format_qty_to_step(validated_qty, rules.step_size),
429        })
430    }
431}
432
433fn floor_to_step(raw_qty: f64, step_size: f64) -> f64 {
434    if raw_qty <= f64::EPSILON || step_size <= f64::EPSILON {
435        return 0.0;
436    }
437    (raw_qty / step_size).floor() * step_size
438}
439
440fn format_qty_to_step(qty: f64, step_size: f64) -> String {
441    let precision = step_precision(step_size);
442    format!("{qty:.precision$}")
443}
444
445fn step_precision(step_size: f64) -> usize {
446    if step_size <= f64::EPSILON {
447        return 0;
448    }
449
450    let mut normalized = step_size.abs();
451    let mut precision = 0usize;
452    while precision < 12 && (normalized.round() - normalized).abs() > 1e-9 {
453        normalized *= 10.0;
454        precision += 1;
455    }
456    precision
457}
458
459fn validate_normalized_qty(
460    instrument: &Instrument,
461    market: Market,
462    raw_qty: f64,
463    normalized_qty: f64,
464    rules: SymbolRules,
465    target_exposure: f64,
466    equity_usdt: f64,
467    current_price: f64,
468    target_notional_usdt: f64,
469) -> Result<f64, ExecutionError> {
470    if normalized_qty <= f64::EPSILON || normalized_qty < rules.min_qty {
471        return Err(ExecutionError::OrderQtyTooSmall {
472            instrument: instrument.0.clone(),
473            market: format!("{market:?}"),
474            target_exposure,
475            equity_usdt,
476            current_price,
477            target_notional_usdt,
478            raw_qty,
479            normalized_qty,
480            min_qty: rules.min_qty,
481            step_size: rules.step_size,
482        });
483    }
484
485    Ok(normalized_qty)
486}