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