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 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}