Skip to main content

sandbox_quant/execution/
service.rs

1use crate::domain::identifiers::BatchId;
2use crate::domain::exposure::Exposure;
3use crate::domain::instrument::Instrument;
4use crate::domain::market::Market;
5use crate::error::exchange_error::ExchangeError;
6use crate::error::execution_error::ExecutionError;
7use crate::exchange::facade::ExchangeFacade;
8use crate::exchange::symbol_rules::SymbolRules;
9use crate::exchange::types::CloseOrderRequest;
10use crate::execution::close_all::CloseAllBatchResult;
11use crate::execution::close_symbol::{CloseSubmitResult, CloseSymbolResult};
12use crate::execution::command::{CommandSource, ExecutionCommand};
13use crate::execution::futures::planner::FuturesExecutionPlanner;
14use crate::execution::planner::ExecutionPlan;
15use crate::execution::price_source::PriceSource;
16use crate::execution::spot::planner::SpotExecutionPlanner;
17use crate::execution::target_translation::exposure_to_notional;
18use crate::portfolio::store::PortfolioStateStore;
19use crate::domain::position::PositionSnapshot;
20
21#[derive(Debug, Default)]
22pub struct ExecutionService {
23    pub last_command: Option<ExecutionCommand>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum ExecutionOutcome {
28    TargetExposureSubmitted {
29        instrument: Instrument,
30    },
31    CloseSymbol(CloseSymbolResult),
32    CloseAll(CloseAllBatchResult),
33}
34
35impl ExecutionService {
36    fn record(&mut self, command: ExecutionCommand) {
37        self.last_command = Some(command);
38    }
39
40    pub fn execute<E: ExchangeFacade<Error = ExchangeError>>(
41        &mut self,
42        exchange: &E,
43        store: &PortfolioStateStore,
44        price_source: &impl PriceSource,
45        command: ExecutionCommand,
46    ) -> Result<ExecutionOutcome, ExecutionError> {
47        self.record(command.clone());
48        match command {
49            ExecutionCommand::SetTargetExposure {
50                instrument,
51                target,
52                source: _source,
53            } => {
54                self.submit_target_exposure(exchange, store, price_source, &instrument, target)?;
55                Ok(ExecutionOutcome::TargetExposureSubmitted { instrument })
56            }
57            ExecutionCommand::CloseSymbol {
58                instrument,
59                source: _source,
60            } => Ok(ExecutionOutcome::CloseSymbol(
61                self.close_symbol(exchange, store, &instrument)?,
62            )),
63            ExecutionCommand::CloseAll { source } => {
64                let batch_id = match source {
65                    CommandSource::User => BatchId(1),
66                    CommandSource::System => BatchId(2),
67                };
68                Ok(ExecutionOutcome::CloseAll(
69                    self.close_all(exchange, store, batch_id),
70                ))
71            }
72        }
73    }
74
75    fn plan_close(
76        &self,
77        store: &PortfolioStateStore,
78        instrument: &Instrument,
79    ) -> Result<ExecutionPlan, ExecutionError> {
80        let Some(position) = store.snapshot.positions.get(instrument) else {
81            return Err(ExecutionError::NoOpenPosition);
82        };
83
84        match position.market {
85            Market::Spot => SpotExecutionPlanner.plan_close(position),
86            Market::Futures => FuturesExecutionPlanner.plan_close(position),
87        }
88    }
89
90    pub fn plan_target_exposure<E: ExchangeFacade<Error = ExchangeError>>(
91        &self,
92        exchange: &E,
93        store: &PortfolioStateStore,
94        price_source: &impl PriceSource,
95        instrument: &Instrument,
96        target: Exposure,
97    ) -> Result<ExecutionPlan, ExecutionError> {
98        let (resolved_instrument, market, current_qty) =
99            self.resolve_target_context(exchange, store, instrument)?;
100        let current_price = price_source
101            .current_price(&resolved_instrument)
102            .or_else(|| exchange.load_last_price(&resolved_instrument, market).ok())
103            .ok_or(ExecutionError::MissingPriceContext)?;
104        let equity_usdt: f64 = store.snapshot.balances.iter().map(|b| b.total()).sum();
105        let target_notional = exposure_to_notional(target, equity_usdt);
106        let synthetic_position = PositionSnapshot {
107            instrument: resolved_instrument.clone(),
108            market,
109            signed_qty: current_qty,
110            entry_price: None,
111        };
112
113        match market {
114            Market::Spot => SpotExecutionPlanner
115                .plan_target_exposure(&synthetic_position, current_price, target_notional.target_usdt),
116            Market::Futures => FuturesExecutionPlanner
117                .plan_target_exposure(&synthetic_position, current_price, target_notional.target_usdt),
118        }
119    }
120
121    pub fn submit_target_exposure<E: ExchangeFacade<Error = ExchangeError>>(
122        &mut self,
123        exchange: &E,
124        store: &PortfolioStateStore,
125        price_source: &impl PriceSource,
126        instrument: &Instrument,
127        target: Exposure,
128    ) -> Result<(), ExecutionError> {
129        let (resolved_instrument, market, current_qty) =
130            self.resolve_target_context(exchange, store, instrument)?;
131        let current_price = price_source
132            .current_price(&resolved_instrument)
133            .or_else(|| exchange.load_last_price(&resolved_instrument, market).ok())
134            .ok_or(ExecutionError::MissingPriceContext)?;
135        let equity_usdt: f64 = store.snapshot.balances.iter().map(|b| b.total()).sum();
136        let target_notional = exposure_to_notional(target, equity_usdt);
137        let synthetic_position = PositionSnapshot {
138            instrument: resolved_instrument.clone(),
139            market,
140            signed_qty: current_qty,
141            entry_price: None,
142        };
143        let plan = match market {
144            Market::Spot => SpotExecutionPlanner
145                .plan_target_exposure(&synthetic_position, current_price, target_notional.target_usdt),
146            Market::Futures => FuturesExecutionPlanner
147                .plan_target_exposure(&synthetic_position, current_price, target_notional.target_usdt),
148        }?;
149        let qty = self.normalize_order_qty(
150            exchange,
151            &plan.instrument,
152            market,
153            plan.qty,
154            target.value(),
155            equity_usdt,
156            current_price,
157            target_notional.target_usdt,
158        )?;
159
160        exchange.submit_order(CloseOrderRequest {
161            instrument: plan.instrument,
162            market,
163            side: plan.side,
164            qty,
165            reduce_only: plan.reduce_only,
166        })?;
167        Ok(())
168    }
169
170    fn resolve_target_context<E: ExchangeFacade<Error = ExchangeError>>(
171        &self,
172        exchange: &E,
173        store: &PortfolioStateStore,
174        instrument: &Instrument,
175    ) -> Result<(Instrument, Market, f64), ExecutionError> {
176        if let Some(position) = store.snapshot.positions.get(instrument) {
177            return Ok((instrument.clone(), position.market, position.signed_qty));
178        }
179
180        if exchange.load_symbol_rules(instrument, Market::Futures).is_ok() {
181            return Ok((instrument.clone(), Market::Futures, 0.0));
182        }
183
184        if exchange.load_symbol_rules(instrument, Market::Spot).is_ok() {
185            return Ok((instrument.clone(), Market::Spot, 0.0));
186        }
187
188        Err(ExecutionError::UnknownInstrument(instrument.0.clone()))
189    }
190
191    /// Submits a close order for the current authoritative position snapshot.
192    ///
193    /// Example:
194    /// - current signed qty = `-0.3`
195    /// - generated close side = `Buy`
196    /// - generated close qty = `0.3`
197    pub fn close_symbol<E: ExchangeFacade<Error = ExchangeError>>(
198        &mut self,
199        exchange: &E,
200        store: &PortfolioStateStore,
201        instrument: &Instrument,
202    ) -> Result<CloseSymbolResult, ExecutionError> {
203        let plan = match self.plan_close(store, instrument) {
204            Ok(plan) => plan,
205            Err(ExecutionError::NoOpenPosition) => {
206                return Ok(CloseSymbolResult {
207                    instrument: instrument.clone(),
208                    result: CloseSubmitResult::SkippedNoPosition,
209                });
210            }
211            Err(error) => return Err(error),
212        };
213
214        if plan.qty <= f64::EPSILON {
215            return Ok(CloseSymbolResult {
216                instrument: instrument.clone(),
217                result: CloseSubmitResult::SkippedNoPosition,
218            });
219        }
220        let market = store
221            .snapshot
222            .positions
223            .get(instrument)
224            .map(|position| position.market)
225            .ok_or(ExecutionError::NoOpenPosition)?;
226        let qty = self.normalize_order_qty(
227            exchange,
228            &plan.instrument,
229            market,
230            plan.qty,
231            0.0,
232            0.0,
233            0.0,
234            0.0,
235        )?;
236        exchange.submit_close_order(CloseOrderRequest {
237            instrument: plan.instrument.clone(),
238            market,
239            side: plan.side,
240            qty,
241            reduce_only: plan.reduce_only,
242        })?;
243
244        Ok(CloseSymbolResult {
245            instrument: instrument.clone(),
246            result: CloseSubmitResult::Submitted,
247        })
248    }
249
250    pub fn close_all<E: ExchangeFacade<Error = ExchangeError>>(
251        &mut self,
252        exchange: &E,
253        store: &PortfolioStateStore,
254        batch_id: BatchId,
255    ) -> CloseAllBatchResult {
256        let mut results = Vec::new();
257        for instrument in store.snapshot.positions.keys() {
258            let result = match self.close_symbol(exchange, store, instrument) {
259                Ok(result) => result,
260                Err(_) => CloseSymbolResult {
261                    instrument: instrument.clone(),
262                    result: CloseSubmitResult::Rejected,
263                },
264            };
265            results.push(result);
266        }
267        CloseAllBatchResult { batch_id, results }
268    }
269
270    fn normalize_order_qty<E: ExchangeFacade<Error = ExchangeError>>(
271        &self,
272        exchange: &E,
273        instrument: &Instrument,
274        market: Market,
275        raw_qty: f64,
276        target_exposure: f64,
277        equity_usdt: f64,
278        current_price: f64,
279        target_notional_usdt: f64,
280    ) -> Result<f64, ExecutionError> {
281        let rules = exchange.load_symbol_rules(instrument, market)?;
282        let normalized_qty = floor_to_step(raw_qty, rules.step_size);
283        validate_normalized_qty(
284            instrument,
285            market,
286            raw_qty,
287            normalized_qty,
288            rules,
289            target_exposure,
290            equity_usdt,
291            current_price,
292            target_notional_usdt,
293        )
294    }
295}
296
297fn floor_to_step(raw_qty: f64, step_size: f64) -> f64 {
298    if raw_qty <= f64::EPSILON || step_size <= f64::EPSILON {
299        return 0.0;
300    }
301    (raw_qty / step_size).floor() * step_size
302}
303
304fn validate_normalized_qty(
305    instrument: &Instrument,
306    market: Market,
307    raw_qty: f64,
308    normalized_qty: f64,
309    rules: SymbolRules,
310    target_exposure: f64,
311    equity_usdt: f64,
312    current_price: f64,
313    target_notional_usdt: f64,
314) -> Result<f64, ExecutionError> {
315    if normalized_qty <= f64::EPSILON || normalized_qty < rules.min_qty {
316        return Err(ExecutionError::OrderQtyTooSmall {
317            instrument: instrument.0.clone(),
318            market: format!("{market:?}"),
319            target_exposure,
320            equity_usdt,
321            current_price,
322            target_notional_usdt,
323            raw_qty,
324            normalized_qty,
325            min_qty: rules.min_qty,
326            step_size: rules.step_size,
327        });
328    }
329
330    Ok(normalized_qty)
331}