1use std::collections::{HashMap, HashSet};
2use std::sync::{Arc, RwLock};
3
4mod account;
5mod activities;
6mod executions;
7mod market_data;
8mod positions;
9
10use chrono::{SecondsFormat, Utc};
11use rust_decimal::Decimal;
12use serde::Serialize;
13use thiserror::Error;
14
15use alpaca_trade::activities::Activity;
16use alpaca_trade::orders::{
17 CancelAllOrderResult, OptionLegRequest, Order, OrderClass, OrderSide, OrderStatus, OrderType,
18 PositionIntent, SortDirection, StopLoss, TakeProfit, TimeInForce,
19};
20use alpaca_trade::positions::{
21 ClosePositionBody, ClosePositionResult, ExercisePositionBody, Position,
22};
23
24use activities::{
25 ActivityEvent, ActivityEventKind, is_public_activity, matches_activity_type, project_activity,
26};
27use executions::ExecutionFact;
28pub use market_data::{DEFAULT_STOCK_SYMBOL, InstrumentSnapshot, LiveMarketDataBridge};
29use positions::{OptionContractType, PositionBook, parse_option_symbol, project_position};
30
31#[derive(Debug, Clone)]
32pub struct MockServerState {
33 inner: Arc<SharedState>,
34}
35
36#[derive(Debug)]
37struct SharedState {
38 accounts: RwLock<HashMap<String, VirtualAccountState>>,
39 http_fault: RwLock<Option<InjectedHttpFault>>,
40 market_data_bridge: Option<LiveMarketDataBridge>,
41}
42
43#[derive(Debug, Clone)]
44pub(crate) struct VirtualAccountState {
45 account_profile: account::AccountProfile,
46 cash_ledger: account::CashLedger,
47 orders: HashMap<String, StoredOrder>,
48 client_order_ids: HashMap<String, String>,
49 executions: Vec<ExecutionFact>,
50 positions: PositionBook,
51 activities: Vec<ActivityEvent>,
52 sequence_clock: u64,
53}
54
55#[derive(Debug, Clone)]
56struct StoredOrder {
57 order: Order,
58 request_side: OrderSide,
59}
60
61#[derive(Debug, Clone, Default)]
62pub struct CreateOrderInput {
63 pub symbol: Option<String>,
64 pub qty: Option<Decimal>,
65 pub notional: Option<Decimal>,
66 pub side: Option<OrderSide>,
67 pub order_type: Option<OrderType>,
68 pub time_in_force: Option<TimeInForce>,
69 pub limit_price: Option<Decimal>,
70 pub stop_price: Option<Decimal>,
71 pub trail_price: Option<Decimal>,
72 pub trail_percent: Option<Decimal>,
73 pub extended_hours: Option<bool>,
74 pub client_order_id: Option<String>,
75 pub order_class: Option<OrderClass>,
76 pub position_intent: Option<PositionIntent>,
77 pub take_profit: Option<TakeProfit>,
78 pub stop_loss: Option<StopLoss>,
79 pub legs: Option<Vec<OptionLegRequest>>,
80}
81
82#[derive(Debug, Clone, Default)]
83pub struct ReplaceOrderInput {
84 pub qty: Option<Decimal>,
85 pub time_in_force: Option<TimeInForce>,
86 pub limit_price: Option<Decimal>,
87 pub stop_price: Option<Decimal>,
88 pub trail: Option<Decimal>,
89 pub client_order_id: Option<String>,
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct ClosePositionInput {
94 pub qty: Option<Decimal>,
95 pub percentage: Option<Decimal>,
96}
97
98#[derive(Debug, Clone, Default)]
99pub struct ListOrdersFilter {
100 pub status: Option<String>,
101 pub symbols: Option<Vec<String>>,
102 pub side: Option<OrderSide>,
103 pub asset_class: Option<String>,
104}
105
106#[derive(Debug, Clone, Default)]
107pub struct ListActivitiesFilter {
108 pub activity_types: Option<Vec<String>>,
109 pub date: Option<String>,
110 pub until: Option<String>,
111 pub after: Option<String>,
112 pub direction: Option<SortDirection>,
113 pub page_size: Option<u32>,
114 pub page_token: Option<String>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct InjectedHttpFault {
119 pub status: u16,
120 pub message: String,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct AdminStateResponse {
125 pub account_count: usize,
126 pub market_data_bridge_configured: bool,
127 pub http_fault: Option<InjectedHttpFault>,
128}
129
130#[derive(Debug, Error)]
131pub enum MarketDataBridgeError {
132 #[error(transparent)]
133 Data(#[from] alpaca_data::Error),
134 #[error("market data unavailable: {0}")]
135 Unavailable(String),
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Error)]
139pub enum MockStateError {
140 #[error("{0}")]
141 NotFound(String),
142 #[error("{0}")]
143 Conflict(String),
144 #[error("{0}")]
145 MarketDataUnavailable(String),
146}
147
148impl MockServerState {
149 #[must_use]
150 pub fn new() -> Self {
151 Self {
152 inner: Arc::new(SharedState {
153 accounts: RwLock::new(HashMap::new()),
154 http_fault: RwLock::new(None),
155 market_data_bridge: None,
156 }),
157 }
158 }
159
160 pub fn from_env() -> Result<Self, MarketDataBridgeError> {
161 Ok(match LiveMarketDataBridge::from_env_optional()? {
162 Some(bridge) => Self::new().with_market_data_bridge(bridge),
163 None => Self::new(),
164 })
165 }
166
167 #[must_use]
168 pub fn with_market_data_bridge(mut self, bridge: LiveMarketDataBridge) -> Self {
169 Arc::get_mut(&mut self.inner)
170 .expect("mock state should be uniquely owned during configuration")
171 .market_data_bridge = Some(bridge);
172 self
173 }
174
175 #[must_use]
176 pub fn market_data_bridge(&self) -> Option<&LiveMarketDataBridge> {
177 self.inner.market_data_bridge.as_ref()
178 }
179
180 pub fn ensure_account(&self, api_key: &str) {
181 let mut accounts = self
182 .inner
183 .accounts
184 .write()
185 .expect("accounts lock should not poison");
186 accounts
187 .entry(api_key.to_owned())
188 .or_insert_with(|| VirtualAccountState::new(api_key));
189 }
190
191 #[must_use]
192 pub fn project_account(&self, api_key: &str) -> alpaca_trade::account::Account {
193 self.ensure_account(api_key);
194 let accounts = self
195 .inner
196 .accounts
197 .read()
198 .expect("accounts lock should not poison");
199 let state = accounts
200 .get(api_key)
201 .expect("account should exist after ensure_account");
202 account::project_account(state)
203 }
204
205 pub async fn create_order(
206 &self,
207 api_key: &str,
208 input: CreateOrderInput,
209 ) -> Result<Order, MockStateError> {
210 let order_class = input.order_class.clone().unwrap_or(OrderClass::Simple);
211 let request_side = input.side.clone().unwrap_or(OrderSide::Buy);
212 if request_side == OrderSide::Unspecified {
213 return Err(MockStateError::Conflict(
214 "mock orders require an explicit buy or sell side".to_owned(),
215 ));
216 }
217
218 let order_type = input.order_type.clone().unwrap_or(OrderType::Market);
219 let time_in_force = input.time_in_force.clone().unwrap_or(TimeInForce::Day);
220 let requested_symbol = input
221 .symbol
222 .clone()
223 .unwrap_or_else(|| DEFAULT_STOCK_SYMBOL.to_owned());
224 let client_order_id = input
225 .client_order_id
226 .unwrap_or_else(|| format!("mock-client-order-{}", now_millis()));
227 let market_quotes = self
228 .resolve_market_quotes(
229 if order_class == OrderClass::Mleg {
230 None
231 } else {
232 Some(requested_symbol.as_str())
233 },
234 input.legs.as_deref(),
235 )
236 .await?;
237 let qty = if order_class == OrderClass::Mleg {
238 normalize_qty(input.qty, None, Decimal::ONE)?
239 } else {
240 let snapshot = market_quotes.get(&requested_symbol).ok_or_else(|| {
241 MockStateError::MarketDataUnavailable(format!(
242 "mock order creation for {requested_symbol} requires live market data"
243 ))
244 })?;
245 let reference_price = reference_price(&request_side, snapshot);
246 normalize_qty(input.qty, input.notional, reference_price)?
247 };
248
249 let mut accounts = self
250 .inner
251 .accounts
252 .write()
253 .expect("accounts lock should not poison");
254 let account = accounts
255 .entry(api_key.to_owned())
256 .or_insert_with(|| VirtualAccountState::new(api_key));
257 if account.client_order_ids.contains_key(&client_order_id) {
258 return Err(MockStateError::Conflict(format!(
259 "client_order_id {client_order_id} already exists"
260 )));
261 }
262
263 let order_id = account.next_order_id();
264 let now = now_string();
265 let order_time_in_force = time_in_force.clone();
266 let order_type_for_legs = order_type.clone();
267 let mut order = Order {
268 id: order_id.clone(),
269 client_order_id: client_order_id.clone(),
270 created_at: now.clone(),
271 updated_at: now.clone(),
272 submitted_at: now.clone(),
273 filled_at: None,
274 expired_at: None,
275 expires_at: expires_at_for(&time_in_force),
276 canceled_at: None,
277 failed_at: None,
278 replaced_at: None,
279 replaced_by: None,
280 replaces: None,
281 asset_id: if order_class == OrderClass::Mleg {
282 String::new()
283 } else {
284 mock_asset_id(&requested_symbol)
285 },
286 symbol: if order_class == OrderClass::Mleg {
287 String::new()
288 } else {
289 requested_symbol.clone()
290 },
291 asset_class: if order_class == OrderClass::Mleg {
292 String::new()
293 } else {
294 market_quotes
295 .get(&requested_symbol)
296 .expect("simple order market quote should exist")
297 .asset_class
298 .clone()
299 },
300 notional: input.notional,
301 qty: Some(qty),
302 filled_qty: Decimal::ZERO,
303 filled_avg_price: None,
304 order_class: order_class.clone(),
305 order_type: order_type.clone(),
306 r#type: order_type,
307 side: if order_class == OrderClass::Mleg {
308 OrderSide::Unspecified
309 } else {
310 request_side.clone()
311 },
312 position_intent: if order_class == OrderClass::Mleg {
313 None
314 } else {
315 input.position_intent.clone()
316 },
317 time_in_force: order_time_in_force,
318 limit_price: input.limit_price,
319 stop_price: input.stop_price,
320 status: OrderStatus::New,
321 extended_hours: input.extended_hours.unwrap_or(false),
322 legs: if order_class == OrderClass::Mleg {
323 Some(build_leg_orders_from_requests(
324 input.legs.as_deref().unwrap_or(&[]),
325 qty,
326 order_type_for_legs,
327 time_in_force.clone(),
328 &now,
329 None,
330 ))
331 } else {
332 None
333 },
334 trail_percent: input.trail_percent,
335 trail_price: input.trail_price,
336 hwm: None,
337 ratio_qty: None,
338 take_profit: input.take_profit,
339 stop_loss: input.stop_loss,
340 subtag: None,
341 source: None,
342 };
343 if order_class == OrderClass::Mleg {
344 apply_mleg_fill_rules(&mut order, &request_side, &market_quotes);
345 } else {
346 let snapshot = market_quotes
347 .get(&requested_symbol)
348 .expect("simple order market quote should exist");
349 let fill_price = marketable_fill_price(
350 &order.order_type,
351 &request_side,
352 order.limit_price,
353 snapshot,
354 );
355 order.filled_at = fill_price.map(|_| now.clone());
356 order.filled_qty = fill_price.map_or(Decimal::ZERO, |_| qty);
357 order.filled_avg_price = fill_price;
358 order.status = if fill_price.is_some() {
359 OrderStatus::Filled
360 } else {
361 OrderStatus::New
362 };
363 }
364
365 account
366 .client_order_ids
367 .insert(client_order_id, order_id.clone());
368 account.orders.insert(
369 order_id,
370 StoredOrder {
371 order: order.clone(),
372 request_side: request_side.clone(),
373 },
374 );
375 record_create_effects(account, &order, &request_side);
376
377 Ok(order)
378 }
379
380 #[must_use]
381 pub fn list_orders(&self, api_key: &str, filter: ListOrdersFilter) -> Vec<Order> {
382 let accounts = self
383 .inner
384 .accounts
385 .read()
386 .expect("accounts lock should not poison");
387 let Some(account) = accounts.get(api_key) else {
388 return Vec::new();
389 };
390
391 let symbol_filter = filter.symbols.map(|symbols| {
392 symbols
393 .into_iter()
394 .map(|symbol| symbol.trim().to_owned())
395 .filter(|symbol| !symbol.is_empty())
396 .collect::<HashSet<_>>()
397 });
398
399 let mut orders = account
400 .orders
401 .values()
402 .filter(|stored| {
403 let order = &stored.order;
404 matches_status_filter(order, filter.status.as_deref())
405 && symbol_filter
406 .as_ref()
407 .is_none_or(|symbols| symbols.contains(&order.symbol))
408 && filter.side.as_ref().is_none_or(|side| &order.side == side)
409 && filter
410 .asset_class
411 .as_deref()
412 .is_none_or(|asset_class| order.asset_class == asset_class)
413 })
414 .map(|stored| stored.order.clone())
415 .collect::<Vec<_>>();
416 orders.sort_by(|left, right| right.created_at.cmp(&left.created_at));
417 orders
418 }
419
420 #[must_use]
421 pub fn get_order(&self, api_key: &str, order_id: &str) -> Option<Order> {
422 self.inner
423 .accounts
424 .read()
425 .expect("accounts lock should not poison")
426 .get(api_key)
427 .and_then(|account| account.orders.get(order_id))
428 .map(|stored| stored.order.clone())
429 }
430
431 #[must_use]
432 pub fn get_by_client_order_id(&self, api_key: &str, client_order_id: &str) -> Option<Order> {
433 let accounts = self
434 .inner
435 .accounts
436 .read()
437 .expect("accounts lock should not poison");
438 let account = accounts.get(api_key)?;
439 let order_id = account.client_order_ids.get(client_order_id)?;
440 account
441 .orders
442 .get(order_id)
443 .map(|stored| stored.order.clone())
444 }
445
446 #[must_use]
447 pub fn list_activities(&self, api_key: &str, filter: ListActivitiesFilter) -> Vec<Activity> {
448 let accounts = self
449 .inner
450 .accounts
451 .read()
452 .expect("accounts lock should not poison");
453 let Some(account) = accounts.get(api_key) else {
454 return Vec::new();
455 };
456
457 let requested_types = filter.activity_types.map(|activity_types| {
458 activity_types
459 .into_iter()
460 .map(|activity_type| activity_type.trim().to_owned())
461 .filter(|activity_type| !activity_type.is_empty())
462 .collect::<Vec<_>>()
463 });
464 let direction = filter.direction.unwrap_or(SortDirection::Desc);
465
466 let mut events = account
467 .activities
468 .iter()
469 .filter(|event| is_public_activity(event))
470 .filter(|event| {
471 requested_types.as_ref().is_none_or(|activity_types| {
472 activity_types
473 .iter()
474 .any(|activity_type| matches_activity_type(event, activity_type))
475 })
476 })
477 .filter(|event| {
478 filter
479 .date
480 .as_deref()
481 .is_none_or(|date| event_matches_date(event, date))
482 })
483 .filter(|event| {
484 filter
485 .after
486 .as_deref()
487 .is_none_or(|after| event.occurred_at.as_str() >= after)
488 })
489 .filter(|event| {
490 filter
491 .until
492 .as_deref()
493 .is_none_or(|until| event.occurred_at.as_str() <= until)
494 })
495 .cloned()
496 .collect::<Vec<_>>();
497
498 events.sort_by(|left, right| left.sequence.cmp(&right.sequence));
499 if matches!(direction, SortDirection::Desc) {
500 events.reverse();
501 }
502
503 let mut activities = events
504 .into_iter()
505 .filter_map(|event| project_activity(&event))
506 .collect::<Vec<_>>();
507
508 if let Some(page_token) = filter.page_token {
509 if let Some(position) = activities
510 .iter()
511 .position(|activity| activity.id == page_token)
512 {
513 activities = activities.into_iter().skip(position + 1).collect();
514 }
515 }
516
517 if let Some(page_size) = filter.page_size {
518 activities.truncate(page_size as usize);
519 }
520
521 activities
522 }
523
524 pub async fn replace_order(
525 &self,
526 api_key: &str,
527 order_id: &str,
528 input: ReplaceOrderInput,
529 ) -> Result<Order, MockStateError> {
530 let current = {
531 let accounts = self
532 .inner
533 .accounts
534 .read()
535 .expect("accounts lock should not poison");
536 let account = accounts.get(api_key).ok_or_else(|| {
537 MockStateError::NotFound(format!("order {order_id} was not found"))
538 })?;
539 account.orders.get(order_id).cloned().ok_or_else(|| {
540 MockStateError::NotFound(format!("order {order_id} was not found"))
541 })?
542 };
543
544 if is_terminal_status(¤t.order.status) {
545 return Err(MockStateError::Conflict(format!(
546 "order {order_id} is no longer replaceable"
547 )));
548 }
549
550 let leg_requests = current
551 .order
552 .legs
553 .as_deref()
554 .map(option_leg_requests_from_orders)
555 .unwrap_or_default();
556 let market_quotes = self
557 .resolve_market_quotes(
558 if current.order.order_class == OrderClass::Mleg || current.order.symbol.is_empty()
559 {
560 None
561 } else {
562 Some(current.order.symbol.as_str())
563 },
564 if leg_requests.is_empty() {
565 None
566 } else {
567 Some(leg_requests.as_slice())
568 },
569 )
570 .await?;
571 let request_side = current.request_side.clone();
572 let replacement_limit_price = input.limit_price.or(current.order.limit_price);
573 let replacement_qty = input.qty.or(current.order.qty);
574 let replacement_client_order_id = input
575 .client_order_id
576 .clone()
577 .unwrap_or_else(|| current.order.client_order_id.clone());
578 let replacement_time_in_force = input
579 .time_in_force
580 .clone()
581 .unwrap_or_else(|| current.order.time_in_force.clone());
582 let qty = if current.order.order_class == OrderClass::Mleg {
583 normalize_qty(replacement_qty, None, Decimal::ONE)?
584 } else {
585 let snapshot = market_quotes.get(¤t.order.symbol).ok_or_else(|| {
586 MockStateError::MarketDataUnavailable(format!(
587 "mock order replacement for {} requires live market data",
588 current.order.symbol
589 ))
590 })?;
591 normalize_qty(
592 replacement_qty,
593 current.order.notional,
594 reference_price(&request_side, snapshot),
595 )?
596 };
597
598 let mut accounts = self
599 .inner
600 .accounts
601 .write()
602 .expect("accounts lock should not poison");
603 let account = accounts
604 .entry(api_key.to_owned())
605 .or_insert_with(|| VirtualAccountState::new(api_key));
606
607 if replacement_client_order_id != current.order.client_order_id
608 && account
609 .client_order_ids
610 .contains_key(&replacement_client_order_id)
611 {
612 return Err(MockStateError::Conflict(format!(
613 "client_order_id {replacement_client_order_id} already exists"
614 )));
615 }
616
617 let now = now_string();
618 let replacement_order_id = account.next_order_id();
619 let replacement_time_in_force_for_legs = replacement_time_in_force.clone();
620 let mut replacement = Order {
621 id: replacement_order_id.clone(),
622 client_order_id: replacement_client_order_id.clone(),
623 created_at: now.clone(),
624 updated_at: now.clone(),
625 submitted_at: now.clone(),
626 filled_at: None,
627 expired_at: None,
628 expires_at: expires_at_for(&replacement_time_in_force),
629 canceled_at: None,
630 failed_at: None,
631 replaced_at: None,
632 replaced_by: None,
633 replaces: Some(current.order.id.clone()),
634 asset_id: current.order.asset_id.clone(),
635 symbol: current.order.symbol.clone(),
636 asset_class: current.order.asset_class.clone(),
637 notional: current.order.notional,
638 qty: Some(qty),
639 filled_qty: Decimal::ZERO,
640 filled_avg_price: None,
641 order_class: current.order.order_class.clone(),
642 order_type: current.order.order_type.clone(),
643 r#type: current.order.r#type.clone(),
644 side: current.order.side.clone(),
645 position_intent: current.order.position_intent.clone(),
646 time_in_force: replacement_time_in_force,
647 limit_price: replacement_limit_price,
648 stop_price: input.stop_price.or(current.order.stop_price),
649 status: OrderStatus::New,
650 extended_hours: current.order.extended_hours,
651 legs: if current.order.order_class == OrderClass::Mleg {
652 Some(build_leg_orders_from_requests(
653 &leg_requests,
654 qty,
655 current.order.r#type.clone(),
656 replacement_time_in_force_for_legs,
657 &now,
658 current.order.legs.as_deref(),
659 ))
660 } else {
661 current.order.legs.clone()
662 },
663 trail_percent: current.order.trail_percent,
664 trail_price: input.trail.or(current.order.trail_price),
665 hwm: current.order.hwm,
666 ratio_qty: current.order.ratio_qty,
667 take_profit: current.order.take_profit.clone(),
668 stop_loss: current.order.stop_loss.clone(),
669 subtag: current.order.subtag.clone(),
670 source: current.order.source.clone(),
671 };
672 if current.order.order_class == OrderClass::Mleg {
673 apply_mleg_fill_rules(&mut replacement, &request_side, &market_quotes);
674 } else {
675 let snapshot = market_quotes
676 .get(¤t.order.symbol)
677 .expect("simple order market quote should exist");
678 let fill_price = marketable_fill_price(
679 ¤t.order.r#type,
680 &request_side,
681 replacement.limit_price,
682 snapshot,
683 );
684 replacement.filled_at = fill_price.map(|_| now.clone());
685 replacement.filled_qty = fill_price.map_or(Decimal::ZERO, |_| qty);
686 replacement.filled_avg_price = fill_price;
687 replacement.status = if fill_price.is_some() {
688 OrderStatus::Filled
689 } else {
690 OrderStatus::New
691 };
692 }
693
694 let (current_order_id, current_client_order_id, current_symbol, current_asset_class) = {
695 let current = account.orders.get_mut(order_id).ok_or_else(|| {
696 MockStateError::NotFound(format!("order {order_id} was not found"))
697 })?;
698 if is_terminal_status(¤t.order.status) {
699 return Err(MockStateError::Conflict(format!(
700 "order {order_id} is no longer replaceable"
701 )));
702 }
703 mark_order_replaced(&mut current.order, &replacement, &now);
704 (
705 current.order.id.clone(),
706 current.order.client_order_id.clone(),
707 current.order.symbol.clone(),
708 current.order.asset_class.clone(),
709 )
710 };
711
712 if replacement_client_order_id == current.order.client_order_id {
713 account
714 .client_order_ids
715 .insert(replacement_client_order_id.clone(), replacement.id.clone());
716 } else {
717 account
718 .client_order_ids
719 .insert(replacement_client_order_id.clone(), replacement.id.clone());
720 }
721 account.orders.insert(
722 replacement.id.clone(),
723 StoredOrder {
724 order: replacement.clone(),
725 request_side: request_side.clone(),
726 },
727 );
728
729 let replaced_event = ActivityEvent::new(
730 account.next_sequence(),
731 ActivityEventKind::Replaced,
732 current_order_id,
733 current_client_order_id,
734 Some(replacement.id.clone()),
735 Some(OrderStatus::Replaced),
736 current_symbol,
737 current_asset_class,
738 now.clone(),
739 Decimal::ZERO,
740 );
741 account.activities.push(replaced_event);
742 record_post_replace_effects(account, &replacement, &request_side);
743
744 Ok(replacement)
745 }
746
747 pub fn cancel_order(&self, api_key: &str, order_id: &str) -> Result<(), MockStateError> {
748 let mut accounts = self
749 .inner
750 .accounts
751 .write()
752 .expect("accounts lock should not poison");
753 let account = accounts
754 .entry(api_key.to_owned())
755 .or_insert_with(|| VirtualAccountState::new(api_key));
756 let now = now_string();
757 let (order_id, client_order_id, symbol, asset_class) = {
758 let stored = account.orders.get_mut(order_id).ok_or_else(|| {
759 MockStateError::NotFound(format!("order {order_id} was not found"))
760 })?;
761 if is_terminal_status(&stored.order.status) {
762 return Err(MockStateError::Conflict(format!(
763 "order {order_id} is no longer cancelable"
764 )));
765 }
766 mark_order_canceled(&mut stored.order, &now);
767 (
768 stored.order.id.clone(),
769 stored.order.client_order_id.clone(),
770 stored.order.symbol.clone(),
771 stored.order.asset_class.clone(),
772 )
773 };
774 let sequence = account.next_sequence();
775 account.activities.push(ActivityEvent::new(
776 sequence,
777 ActivityEventKind::Canceled,
778 order_id,
779 client_order_id,
780 None,
781 Some(OrderStatus::Canceled),
782 symbol,
783 asset_class,
784 now,
785 Decimal::ZERO,
786 ));
787 Ok(())
788 }
789
790 pub fn cancel_all_orders(&self, api_key: &str) -> Vec<CancelAllOrderResult> {
791 let mut accounts = self
792 .inner
793 .accounts
794 .write()
795 .expect("accounts lock should not poison");
796 let account = accounts
797 .entry(api_key.to_owned())
798 .or_insert_with(|| VirtualAccountState::new(api_key));
799 let cancelable_ids = account
800 .orders
801 .iter()
802 .filter_map(|(order_id, stored)| {
803 if is_terminal_status(&stored.order.status) {
804 None
805 } else {
806 Some(order_id.clone())
807 }
808 })
809 .collect::<Vec<_>>();
810
811 let mut results = Vec::with_capacity(cancelable_ids.len());
812 for order_id in cancelable_ids {
813 let now = now_string();
814 let body = {
815 let stored = account
816 .orders
817 .get_mut(&order_id)
818 .expect("cancelable order should remain present");
819 mark_order_canceled(&mut stored.order, &now);
820 stored.order.clone()
821 };
822 let sequence = account.next_sequence();
823 account.activities.push(ActivityEvent::new(
824 sequence,
825 ActivityEventKind::Canceled,
826 body.id.clone(),
827 body.client_order_id.clone(),
828 None,
829 Some(OrderStatus::Canceled),
830 body.symbol.clone(),
831 body.asset_class.clone(),
832 now,
833 Decimal::ZERO,
834 ));
835 results.push(CancelAllOrderResult {
836 id: body.id.clone(),
837 status: 200,
838 body: Some(body),
839 });
840 }
841
842 results
843 }
844
845 pub async fn list_positions(&self, api_key: &str) -> Result<Vec<Position>, MockStateError> {
846 let open_positions = {
847 let accounts = self
848 .inner
849 .accounts
850 .read()
851 .expect("accounts lock should not poison");
852 accounts
853 .get(api_key)
854 .map(|account| account.positions.list_open_positions())
855 .unwrap_or_default()
856 };
857
858 let mut projected = Vec::with_capacity(open_positions.len());
859 for position in open_positions {
860 let snapshot = self
861 .instrument_snapshot(&position.instrument_identity.symbol)
862 .await?;
863 projected.push(public_position_from_projection(project_position(
864 &position, &snapshot,
865 )));
866 }
867 projected.sort_by(|left, right| left.symbol.cmp(&right.symbol));
868
869 Ok(projected)
870 }
871
872 pub async fn get_position(
873 &self,
874 api_key: &str,
875 symbol_or_asset_id: &str,
876 ) -> Result<Position, MockStateError> {
877 let position = {
878 let accounts = self
879 .inner
880 .accounts
881 .read()
882 .expect("accounts lock should not poison");
883 let account = accounts.get(api_key).ok_or_else(|| {
884 MockStateError::NotFound(format!("position {symbol_or_asset_id} was not found"))
885 })?;
886 account
887 .positions
888 .find_open_position(symbol_or_asset_id)
889 .ok_or_else(|| {
890 MockStateError::NotFound(format!("position {symbol_or_asset_id} was not found"))
891 })?
892 };
893 let snapshot = self
894 .instrument_snapshot(&position.instrument_identity.symbol)
895 .await?;
896
897 Ok(public_position_from_projection(project_position(
898 &position, &snapshot,
899 )))
900 }
901
902 pub async fn close_position(
903 &self,
904 api_key: &str,
905 symbol_or_asset_id: &str,
906 input: ClosePositionInput,
907 ) -> Result<ClosePositionBody, MockStateError> {
908 let position = {
909 let accounts = self
910 .inner
911 .accounts
912 .read()
913 .expect("accounts lock should not poison");
914 let account = accounts.get(api_key).ok_or_else(|| {
915 MockStateError::NotFound(format!("position {symbol_or_asset_id} was not found"))
916 })?;
917 account
918 .positions
919 .find_open_position(symbol_or_asset_id)
920 .ok_or_else(|| {
921 MockStateError::NotFound(format!("position {symbol_or_asset_id} was not found"))
922 })?
923 };
924 let snapshot = self
925 .instrument_snapshot(&position.instrument_identity.symbol)
926 .await?;
927 let close_qty = resolve_close_qty(&position, &input)?;
928 let request_side = if position.net_qty > Decimal::ZERO {
929 OrderSide::Sell
930 } else {
931 OrderSide::Buy
932 };
933 let price = reference_price(&request_side, &snapshot);
934 let now = now_string();
935
936 let mut accounts = self
937 .inner
938 .accounts
939 .write()
940 .expect("accounts lock should not poison");
941 let account = accounts
942 .entry(api_key.to_owned())
943 .or_insert_with(|| VirtualAccountState::new(api_key));
944 let order = Order {
945 id: account.next_order_id(),
946 client_order_id: format!("mock-position-close-{}", now_millis()),
947 created_at: now.clone(),
948 updated_at: now.clone(),
949 submitted_at: now.clone(),
950 filled_at: Some(now.clone()),
951 expired_at: None,
952 expires_at: None,
953 canceled_at: None,
954 failed_at: None,
955 replaced_at: None,
956 replaced_by: None,
957 replaces: None,
958 asset_id: position.instrument_identity.asset_id.clone(),
959 symbol: position.instrument_identity.symbol.clone(),
960 asset_class: position.instrument_identity.asset_class.clone(),
961 notional: None,
962 qty: Some(close_qty),
963 filled_qty: close_qty,
964 filled_avg_price: Some(price),
965 order_class: OrderClass::Simple,
966 order_type: OrderType::Market,
967 r#type: OrderType::Market,
968 side: request_side.clone(),
969 position_intent: closing_position_intent(&position, &request_side),
970 time_in_force: TimeInForce::Day,
971 limit_price: None,
972 stop_price: None,
973 status: OrderStatus::Filled,
974 extended_hours: false,
975 legs: None,
976 trail_percent: None,
977 trail_price: None,
978 hwm: None,
979 ratio_qty: None,
980 take_profit: None,
981 stop_loss: None,
982 subtag: None,
983 source: None,
984 };
985 account
986 .client_order_ids
987 .insert(order.client_order_id.clone(), order.id.clone());
988 account.orders.insert(
989 order.id.clone(),
990 StoredOrder {
991 order: order.clone(),
992 request_side: request_side.clone(),
993 },
994 );
995 apply_fill_effects(account, &order, &request_side);
996
997 Ok(ClosePositionBody::from(order))
998 }
999
1000 pub async fn close_all_positions(
1001 &self,
1002 api_key: &str,
1003 cancel_orders: bool,
1004 ) -> Result<Vec<ClosePositionResult>, MockStateError> {
1005 if cancel_orders {
1006 let _ = self.cancel_all_orders(api_key);
1007 }
1008
1009 let positions = {
1010 let accounts = self
1011 .inner
1012 .accounts
1013 .read()
1014 .expect("accounts lock should not poison");
1015 accounts
1016 .get(api_key)
1017 .map(|account| account.positions.list_open_positions())
1018 .unwrap_or_default()
1019 };
1020
1021 let mut results = Vec::with_capacity(positions.len());
1022 for position in positions {
1023 let symbol = position.instrument_identity.symbol.clone();
1024 let body = self
1025 .close_position(api_key, &symbol, ClosePositionInput::default())
1026 .await?;
1027 results.push(ClosePositionResult {
1028 symbol,
1029 status: 200,
1030 body: Some(body),
1031 });
1032 }
1033
1034 Ok(results)
1035 }
1036
1037 pub fn do_not_exercise_position(
1038 &self,
1039 api_key: &str,
1040 symbol_or_contract_id: &str,
1041 ) -> Result<(), MockStateError> {
1042 let now = now_string();
1043 let mut accounts = self
1044 .inner
1045 .accounts
1046 .write()
1047 .expect("accounts lock should not poison");
1048 let account = accounts
1049 .entry(api_key.to_owned())
1050 .or_insert_with(|| VirtualAccountState::new(api_key));
1051 let position = account
1052 .positions
1053 .find_open_position(symbol_or_contract_id)
1054 .ok_or_else(|| {
1055 MockStateError::NotFound(format!("position {symbol_or_contract_id} was not found"))
1056 })?;
1057 ensure_exercisable_long_option_position(&position)?;
1058 account
1059 .positions
1060 .record_do_not_exercise(&position.instrument_identity.symbol, &now);
1061 let sequence = account.next_sequence();
1062 let action_id = format!("mock-dne-{}", now_millis());
1063 account.activities.push(ActivityEvent::new(
1064 sequence,
1065 ActivityEventKind::DoNotExercise,
1066 action_id.clone(),
1067 action_id,
1068 None,
1069 None,
1070 position.instrument_identity.symbol,
1071 position.instrument_identity.asset_class,
1072 now,
1073 Decimal::ZERO,
1074 ));
1075 Ok(())
1076 }
1077
1078 pub fn exercise_position(
1079 &self,
1080 api_key: &str,
1081 symbol_or_contract_id: &str,
1082 ) -> Result<ExercisePositionBody, MockStateError> {
1083 let now = now_string();
1084 let mut accounts = self
1085 .inner
1086 .accounts
1087 .write()
1088 .expect("accounts lock should not poison");
1089 let account = accounts
1090 .entry(api_key.to_owned())
1091 .or_insert_with(|| VirtualAccountState::new(api_key));
1092 let position = account
1093 .positions
1094 .find_open_position(symbol_or_contract_id)
1095 .ok_or_else(|| {
1096 MockStateError::NotFound(format!("position {symbol_or_contract_id} was not found"))
1097 })?;
1098 ensure_exercisable_long_option_position(&position)?;
1099 let parsed =
1100 parse_option_symbol(&position.instrument_identity.symbol).ok_or_else(|| {
1101 MockStateError::Conflict(format!(
1102 "option symbol {} is not a parseable OCC contract",
1103 position.instrument_identity.symbol
1104 ))
1105 })?;
1106 let option_qty = position.net_qty.abs();
1107 let share_qty = option_qty * Decimal::new(100, 0);
1108 let option_execution = ExecutionFact::new(
1109 account.next_sequence(),
1110 format!("mock-exercise-option-{}", now_millis()),
1111 position.instrument_identity.asset_id.clone(),
1112 position.instrument_identity.symbol.clone(),
1113 position.instrument_identity.asset_class.clone(),
1114 OrderSide::Sell,
1115 Some(PositionIntent::SellToClose),
1116 option_qty,
1117 Decimal::ZERO,
1118 now.clone(),
1119 );
1120 let (underlying_side, position_intent) = match parsed.contract_type {
1121 OptionContractType::Call => (OrderSide::Buy, Some(PositionIntent::BuyToOpen)),
1122 OptionContractType::Put => (OrderSide::Sell, Some(PositionIntent::SellToOpen)),
1123 };
1124 let underlying_execution = ExecutionFact::new(
1125 account.next_sequence(),
1126 format!("mock-exercise-underlying-{}", now_millis()),
1127 mock_asset_id(&parsed.underlying_symbol),
1128 parsed.underlying_symbol.clone(),
1129 "us_equity".to_owned(),
1130 underlying_side.clone(),
1131 position_intent,
1132 share_qty,
1133 parsed.strike_price,
1134 now.clone(),
1135 );
1136 account
1137 .positions
1138 .clear_do_not_exercise_override(&position.instrument_identity.symbol);
1139 account.positions.apply_execution(&option_execution);
1140 account.executions.push(option_execution);
1141 let underlying_cash_delta = signed_cash_delta(
1142 &underlying_side,
1143 underlying_execution.qty,
1144 underlying_execution.price,
1145 );
1146 account.cash_ledger.apply_delta(underlying_cash_delta);
1147 account.positions.apply_execution(&underlying_execution);
1148 account.executions.push(underlying_execution);
1149 let sequence = account.next_sequence();
1150 let action_id = format!("mock-exercise-{}", now_millis());
1151 account.activities.push(ActivityEvent::new(
1152 sequence,
1153 ActivityEventKind::Exercised,
1154 action_id.clone(),
1155 action_id,
1156 None,
1157 None,
1158 position.instrument_identity.symbol,
1159 position.instrument_identity.asset_class,
1160 now,
1161 underlying_cash_delta,
1162 ));
1163
1164 Ok(ExercisePositionBody {
1165 qty_exercised: option_qty,
1166 qty_remaining: Decimal::ZERO,
1167 })
1168 }
1169
1170 pub fn reset(&self) {
1171 self.inner
1172 .accounts
1173 .write()
1174 .expect("accounts lock should not poison")
1175 .clear();
1176 self.clear_http_fault();
1177 }
1178
1179 pub fn set_http_fault(&self, fault: InjectedHttpFault) {
1180 *self
1181 .inner
1182 .http_fault
1183 .write()
1184 .expect("fault lock should not poison") = Some(fault);
1185 }
1186
1187 pub fn clear_http_fault(&self) {
1188 *self
1189 .inner
1190 .http_fault
1191 .write()
1192 .expect("fault lock should not poison") = None;
1193 }
1194
1195 #[must_use]
1196 pub fn http_fault(&self) -> Option<InjectedHttpFault> {
1197 self.inner
1198 .http_fault
1199 .read()
1200 .expect("fault lock should not poison")
1201 .clone()
1202 }
1203
1204 #[must_use]
1205 pub fn account_count(&self) -> usize {
1206 self.inner
1207 .accounts
1208 .read()
1209 .expect("accounts lock should not poison")
1210 .len()
1211 }
1212
1213 #[must_use]
1214 pub fn admin_state(&self) -> AdminStateResponse {
1215 AdminStateResponse {
1216 account_count: self.account_count(),
1217 market_data_bridge_configured: self.market_data_bridge().is_some(),
1218 http_fault: self.http_fault(),
1219 }
1220 }
1221
1222 async fn instrument_snapshot(
1223 &self,
1224 symbol: &str,
1225 ) -> Result<InstrumentSnapshot, MockStateError> {
1226 let bridge = self.market_data_bridge().cloned().ok_or_else(|| {
1227 MockStateError::MarketDataUnavailable(
1228 "mock order simulation requires ALPACA_DATA_* credentials and a configured market data bridge".to_owned(),
1229 )
1230 })?;
1231 bridge
1232 .instrument_snapshot(symbol)
1233 .await
1234 .map_err(|error| MockStateError::MarketDataUnavailable(error.to_string()))
1235 }
1236
1237 async fn resolve_market_quotes(
1238 &self,
1239 symbol: Option<&str>,
1240 legs: Option<&[OptionLegRequest]>,
1241 ) -> Result<HashMap<String, InstrumentSnapshot>, MockStateError> {
1242 let mut quotes = HashMap::new();
1243 for requested_symbol in requested_symbols(symbol, legs) {
1244 let snapshot = self.instrument_snapshot(&requested_symbol).await?;
1245 quotes.insert(requested_symbol, snapshot);
1246 }
1247 Ok(quotes)
1248 }
1249}
1250
1251impl Default for MockServerState {
1252 fn default() -> Self {
1253 Self::new()
1254 }
1255}
1256
1257impl VirtualAccountState {
1258 fn new(api_key: &str) -> Self {
1259 Self {
1260 account_profile: account::AccountProfile::new(api_key),
1261 cash_ledger: account::CashLedger::seeded_default(),
1262 orders: HashMap::new(),
1263 client_order_ids: HashMap::new(),
1264 executions: Vec::new(),
1265 positions: PositionBook::default(),
1266 activities: Vec::new(),
1267 sequence_clock: 0,
1268 }
1269 }
1270
1271 fn next_sequence(&mut self) -> u64 {
1272 self.sequence_clock += 1;
1273 self.sequence_clock
1274 }
1275
1276 fn next_order_id(&mut self) -> String {
1277 format!("mock-order-{}-{}", now_millis(), self.next_sequence())
1278 }
1279}
1280
1281impl InjectedHttpFault {
1282 pub fn new(status: u16, message: impl Into<String>) -> Result<Self, String> {
1283 if !(100..=599).contains(&status) {
1284 return Err(format!(
1285 "status must be a valid HTTP status code, got {status}"
1286 ));
1287 }
1288
1289 let message = message.into();
1290 if message.trim().is_empty() {
1291 return Err("message must not be empty".to_owned());
1292 }
1293
1294 Ok(Self { status, message })
1295 }
1296
1297 pub fn status_code(&self) -> Result<axum::http::StatusCode, String> {
1298 axum::http::StatusCode::from_u16(self.status)
1299 .map_err(|error| format!("invalid fault status {}: {error}", self.status))
1300 }
1301}
1302
1303pub(crate) fn cash_balance(state: &VirtualAccountState) -> Decimal {
1304 state.cash_ledger.cash_balance()
1305}
1306
1307pub(crate) fn account_profile(state: &VirtualAccountState) -> &account::AccountProfile {
1308 &state.account_profile
1309}
1310
1311fn normalize_qty(
1312 qty: Option<Decimal>,
1313 notional: Option<Decimal>,
1314 price: Decimal,
1315) -> Result<Decimal, MockStateError> {
1316 let qty = match qty {
1317 Some(qty) => qty,
1318 None => match notional {
1319 Some(notional) => (notional / price).round_dp(8),
1320 None => Decimal::ONE,
1321 },
1322 };
1323
1324 if qty <= Decimal::ZERO {
1325 return Err(MockStateError::Conflict(
1326 "order quantity must be greater than 0".to_owned(),
1327 ));
1328 }
1329
1330 Ok(qty)
1331}
1332
1333fn resolve_close_qty(
1334 position: &positions::InstrumentPosition,
1335 input: &ClosePositionInput,
1336) -> Result<Decimal, MockStateError> {
1337 let available = position.net_qty.abs();
1338 let qty = if let Some(qty) = input.qty {
1339 qty
1340 } else if let Some(percentage) = input.percentage {
1341 if percentage <= Decimal::ZERO || percentage > Decimal::new(100, 0) {
1342 return Err(MockStateError::Conflict(
1343 "close percentage must be greater than 0 and at most 100".to_owned(),
1344 ));
1345 }
1346 (available * percentage / Decimal::new(100, 0)).round_dp(8)
1347 } else {
1348 available
1349 };
1350
1351 if qty <= Decimal::ZERO {
1352 return Err(MockStateError::Conflict(
1353 "close quantity must be greater than 0".to_owned(),
1354 ));
1355 }
1356 if qty > available {
1357 return Err(MockStateError::Conflict(format!(
1358 "close quantity {qty} exceeds available position quantity {available}"
1359 )));
1360 }
1361
1362 Ok(qty)
1363}
1364
1365fn reference_price(side: &OrderSide, snapshot: &InstrumentSnapshot) -> Decimal {
1366 match side {
1367 OrderSide::Buy | OrderSide::Sell => snapshot.mid_price(),
1368 OrderSide::Unspecified => snapshot.mid_price(),
1369 }
1370}
1371
1372fn marketable_fill_price(
1373 order_type: &OrderType,
1374 side: &OrderSide,
1375 limit_price: Option<Decimal>,
1376 snapshot: &InstrumentSnapshot,
1377) -> Option<Decimal> {
1378 let mid = snapshot.mid_price();
1379 match order_type {
1380 OrderType::Market => Some(reference_price(side, snapshot)),
1381 OrderType::Limit => match side {
1382 OrderSide::Buy => limit_price.filter(|limit| *limit >= mid).map(|_| mid),
1383 OrderSide::Sell => limit_price.filter(|limit| *limit <= mid).map(|_| mid),
1384 OrderSide::Unspecified => None,
1385 },
1386 OrderType::Stop
1387 | OrderType::StopLimit
1388 | OrderType::TrailingStop
1389 | OrderType::Unspecified => None,
1390 }
1391}
1392
1393fn apply_mleg_fill_rules(
1394 order: &mut Order,
1395 request_side: &OrderSide,
1396 market_quotes: &HashMap<String, InstrumentSnapshot>,
1397) {
1398 let fill_price =
1399 mleg_mid_price(order, request_side, market_quotes).and_then(|mid| match order.r#type {
1400 OrderType::Market => Some(mid),
1401 OrderType::Limit => match request_side {
1402 OrderSide::Buy | OrderSide::Unspecified => order
1403 .limit_price
1404 .filter(|limit_price| *limit_price >= mid)
1405 .map(|_| mid),
1406 OrderSide::Sell => order
1407 .limit_price
1408 .filter(|limit_price| *limit_price <= mid)
1409 .map(|_| mid),
1410 },
1411 OrderType::Stop
1412 | OrderType::StopLimit
1413 | OrderType::TrailingStop
1414 | OrderType::Unspecified => None,
1415 });
1416
1417 if let Some(fill_price) = fill_price {
1418 let now = now_string();
1419 order.status = OrderStatus::Filled;
1420 order.filled_qty = order.qty.unwrap_or(Decimal::ZERO);
1421 order.filled_avg_price = Some(fill_price);
1422 order.filled_at = Some(now.clone());
1423 order.updated_at = now;
1424 order.canceled_at = None;
1425 sync_nested_legs(order, market_quotes, Some(fill_price), OrderStatus::Filled);
1426 return;
1427 }
1428
1429 order.status = OrderStatus::New;
1430 order.filled_qty = Decimal::ZERO;
1431 order.filled_avg_price = None;
1432 order.filled_at = None;
1433 sync_nested_legs(order, market_quotes, None, OrderStatus::New);
1434}
1435
1436fn record_create_effects(
1437 account: &mut VirtualAccountState,
1438 order: &Order,
1439 request_side: &OrderSide,
1440) {
1441 if order.status == OrderStatus::Filled {
1442 apply_fill_effects(account, order, request_side);
1443 } else {
1444 let sequence = account.next_sequence();
1445 account.activities.push(ActivityEvent::new(
1446 sequence,
1447 ActivityEventKind::New,
1448 order.id.clone(),
1449 order.client_order_id.clone(),
1450 None,
1451 Some(order.status.clone()),
1452 order.symbol.clone(),
1453 order.asset_class.clone(),
1454 order.created_at.clone(),
1455 Decimal::ZERO,
1456 ));
1457 }
1458}
1459
1460fn record_post_replace_effects(
1461 account: &mut VirtualAccountState,
1462 order: &Order,
1463 request_side: &OrderSide,
1464) {
1465 if order.status == OrderStatus::Filled {
1466 apply_fill_effects(account, order, request_side);
1467 } else {
1468 let sequence = account.next_sequence();
1469 account.activities.push(ActivityEvent::new(
1470 sequence,
1471 ActivityEventKind::New,
1472 order.id.clone(),
1473 order.client_order_id.clone(),
1474 order.replaces.clone(),
1475 Some(order.status.clone()),
1476 order.symbol.clone(),
1477 order.asset_class.clone(),
1478 order.created_at.clone(),
1479 Decimal::ZERO,
1480 ));
1481 }
1482}
1483
1484fn apply_fill_effects(account: &mut VirtualAccountState, order: &Order, request_side: &OrderSide) {
1485 let price = order
1486 .filled_avg_price
1487 .expect("filled mock order should always have filled_avg_price");
1488 let qty = order.filled_qty;
1489 let cash_delta = signed_cash_delta(request_side, qty, price);
1490 account.cash_ledger.apply_delta(cash_delta);
1491 let occurred_at = order
1492 .filled_at
1493 .clone()
1494 .unwrap_or_else(|| order.updated_at.clone());
1495 let executions = execution_facts_from_order(account, order, request_side, &occurred_at);
1496 for execution in executions {
1497 account.positions.apply_execution(&execution);
1498 account.executions.push(execution);
1499 }
1500 let activity_sequence = account.next_sequence();
1501 account.activities.push(
1502 ActivityEvent::new(
1503 activity_sequence,
1504 ActivityEventKind::Filled,
1505 order.id.clone(),
1506 order.client_order_id.clone(),
1507 order.replaces.clone(),
1508 Some(OrderStatus::Filled),
1509 order.symbol.clone(),
1510 order.asset_class.clone(),
1511 order
1512 .filled_at
1513 .clone()
1514 .unwrap_or_else(|| order.updated_at.clone()),
1515 cash_delta,
1516 )
1517 .with_fill_order(order, request_side),
1518 );
1519}
1520
1521fn signed_cash_delta(side: &OrderSide, qty: Decimal, price: Decimal) -> Decimal {
1522 let gross = (price * qty).round_dp(8);
1523 match side {
1524 OrderSide::Buy => -gross,
1525 OrderSide::Sell => gross,
1526 OrderSide::Unspecified => Decimal::ZERO,
1527 }
1528}
1529
1530fn execution_facts_from_order(
1531 account: &mut VirtualAccountState,
1532 order: &Order,
1533 request_side: &OrderSide,
1534 occurred_at: &str,
1535) -> Vec<ExecutionFact> {
1536 if order.order_class == OrderClass::Mleg {
1537 return order
1538 .legs
1539 .as_ref()
1540 .map(|legs| {
1541 legs.iter()
1542 .map(|leg| {
1543 ExecutionFact::new(
1544 account.next_sequence(),
1545 leg.id.clone(),
1546 leg.asset_id.clone(),
1547 leg.symbol.clone(),
1548 leg.asset_class.clone(),
1549 leg.side.clone(),
1550 leg.position_intent.clone(),
1551 leg.filled_qty,
1552 leg.filled_avg_price.unwrap_or(Decimal::ZERO),
1553 leg.filled_at
1554 .clone()
1555 .unwrap_or_else(|| occurred_at.to_owned()),
1556 )
1557 })
1558 .collect()
1559 })
1560 .unwrap_or_default();
1561 }
1562
1563 vec![ExecutionFact::new(
1564 account.next_sequence(),
1565 order.id.clone(),
1566 order.asset_id.clone(),
1567 order.symbol.clone(),
1568 order.asset_class.clone(),
1569 request_side.clone(),
1570 order.position_intent.clone(),
1571 order.filled_qty,
1572 order.filled_avg_price.unwrap_or(Decimal::ZERO),
1573 occurred_at.to_owned(),
1574 )]
1575}
1576
1577fn is_terminal_status(status: &OrderStatus) -> bool {
1578 matches!(
1579 status,
1580 OrderStatus::Filled
1581 | OrderStatus::Canceled
1582 | OrderStatus::Expired
1583 | OrderStatus::Replaced
1584 | OrderStatus::Rejected
1585 | OrderStatus::Suspended
1586 | OrderStatus::DoneForDay
1587 | OrderStatus::Stopped
1588 | OrderStatus::Calculated
1589 )
1590}
1591
1592fn matches_status_filter(order: &Order, status: Option<&str>) -> bool {
1593 match status {
1594 None => true,
1595 Some(value) if value.eq_ignore_ascii_case("all") => true,
1596 Some(value) if value.eq_ignore_ascii_case("open") => !is_terminal_status(&order.status),
1597 Some(value) if value.eq_ignore_ascii_case("closed") => is_terminal_status(&order.status),
1598 Some(_) => true,
1599 }
1600}
1601
1602fn event_matches_date(event: &ActivityEvent, date: &str) -> bool {
1603 event
1604 .occurred_at
1605 .split_once('T')
1606 .map(|(event_date, _)| event_date == date)
1607 .unwrap_or_else(|| event.occurred_at.starts_with(date))
1608}
1609
1610fn mock_asset_id(symbol: &str) -> String {
1611 let mut sanitized = String::with_capacity(symbol.len());
1612 for ch in symbol.chars() {
1613 if ch.is_ascii_alphanumeric() {
1614 sanitized.push(ch.to_ascii_lowercase());
1615 } else {
1616 sanitized.push('-');
1617 }
1618 }
1619 format!("mock-asset-{}", sanitized.trim_matches('-'))
1620}
1621
1622fn mleg_mid_price(
1623 order: &Order,
1624 request_side: &OrderSide,
1625 market_quotes: &HashMap<String, InstrumentSnapshot>,
1626) -> Option<Decimal> {
1627 let raw_total = order
1628 .legs
1629 .as_ref()?
1630 .iter()
1631 .try_fold(Decimal::ZERO, |total, leg| {
1632 let instrument = market_quotes.get(&leg.symbol)?;
1633 let leg_mid = instrument.mid_price();
1634 let ratio_qty = Decimal::from(leg.ratio_qty.unwrap_or(1));
1635 let contribution = match leg.side {
1636 OrderSide::Buy => leg_mid * ratio_qty,
1637 OrderSide::Sell => -(leg_mid * ratio_qty),
1638 OrderSide::Unspecified => return None,
1639 };
1640 Some(total + contribution)
1641 })?;
1642
1643 let normalized_total = match request_side {
1644 OrderSide::Buy | OrderSide::Unspecified => raw_total,
1645 OrderSide::Sell => -raw_total,
1646 };
1647
1648 Some(normalized_total.round_dp(2))
1649}
1650
1651fn sync_nested_legs(
1652 order: &mut Order,
1653 market_quotes: &HashMap<String, InstrumentSnapshot>,
1654 fill_price: Option<Decimal>,
1655 status: OrderStatus,
1656) {
1657 let Some(legs) = order.legs.as_mut() else {
1658 return;
1659 };
1660
1661 let now = now_string();
1662 for leg in legs {
1663 leg.updated_at = now.clone();
1664 leg.status = status.clone();
1665 match fill_price {
1666 Some(_) => {
1667 leg.filled_qty = leg.qty.unwrap_or(Decimal::ZERO);
1668 leg.filled_avg_price = market_quotes
1669 .get(&leg.symbol)
1670 .map(InstrumentSnapshot::mid_price);
1671 leg.filled_at = Some(now.clone());
1672 leg.canceled_at = None;
1673 }
1674 None => {
1675 leg.filled_qty = Decimal::ZERO;
1676 leg.filled_avg_price = None;
1677 leg.filled_at = None;
1678 }
1679 }
1680 }
1681}
1682
1683fn mark_order_canceled(order: &mut Order, canceled_at: &str) {
1684 order.status = OrderStatus::Canceled;
1685 order.updated_at = canceled_at.to_owned();
1686 order.canceled_at = Some(canceled_at.to_owned());
1687 order.filled_at = None;
1688 order.filled_qty = Decimal::ZERO;
1689 order.filled_avg_price = None;
1690
1691 if let Some(legs) = order.legs.as_mut() {
1692 for leg in legs {
1693 leg.status = OrderStatus::Canceled;
1694 leg.updated_at = canceled_at.to_owned();
1695 leg.canceled_at = Some(canceled_at.to_owned());
1696 leg.filled_at = None;
1697 leg.filled_qty = Decimal::ZERO;
1698 leg.filled_avg_price = None;
1699 }
1700 }
1701}
1702
1703fn mark_order_replaced(order: &mut Order, replacement: &Order, replaced_at: &str) {
1704 order.status = OrderStatus::Replaced;
1705 order.updated_at = replaced_at.to_owned();
1706 order.replaced_at = Some(replaced_at.to_owned());
1707 order.replaced_by = Some(replacement.id.clone());
1708
1709 if let (Some(current_legs), Some(replacement_legs)) =
1710 (order.legs.as_mut(), replacement.legs.as_ref())
1711 {
1712 for (current_leg, replacement_leg) in current_legs.iter_mut().zip(replacement_legs.iter()) {
1713 current_leg.status = OrderStatus::Replaced;
1714 current_leg.updated_at = replaced_at.to_owned();
1715 current_leg.replaced_at = Some(replaced_at.to_owned());
1716 current_leg.replaced_by = Some(replacement_leg.id.clone());
1717 }
1718 }
1719}
1720
1721fn requested_symbols(symbol: Option<&str>, legs: Option<&[OptionLegRequest]>) -> Vec<String> {
1722 let mut symbols = Vec::new();
1723 if let Some(symbol) = symbol {
1724 symbols.push(symbol.to_owned());
1725 }
1726 if let Some(legs) = legs {
1727 symbols.extend(legs.iter().map(|leg| leg.symbol.clone()));
1728 }
1729 symbols.sort();
1730 symbols.dedup();
1731 symbols
1732}
1733
1734fn option_leg_requests_from_orders(legs: &[Order]) -> Vec<OptionLegRequest> {
1735 legs.iter()
1736 .map(|leg| OptionLegRequest {
1737 symbol: leg.symbol.clone(),
1738 ratio_qty: leg.ratio_qty.unwrap_or(1),
1739 side: Some(leg.side.clone()),
1740 position_intent: leg.position_intent.clone(),
1741 })
1742 .collect()
1743}
1744
1745fn build_leg_orders_from_requests(
1746 legs: &[OptionLegRequest],
1747 parent_qty: Decimal,
1748 order_type: OrderType,
1749 time_in_force: TimeInForce,
1750 now: &str,
1751 previous_legs: Option<&[Order]>,
1752) -> Vec<Order> {
1753 legs.iter()
1754 .enumerate()
1755 .map(|(index, leg)| {
1756 let previous_leg = previous_legs.and_then(|legs| legs.get(index));
1757 let leg_qty = parent_qty * Decimal::from(leg.ratio_qty);
1758 Order {
1759 id: format!("mock-leg-order-{}-{index}", now_millis()),
1760 client_order_id: format!("mock-leg-client-order-{}-{index}", now_millis()),
1761 created_at: now.to_owned(),
1762 updated_at: now.to_owned(),
1763 submitted_at: now.to_owned(),
1764 filled_at: None,
1765 expired_at: None,
1766 expires_at: expires_at_for(&time_in_force),
1767 canceled_at: None,
1768 failed_at: None,
1769 replaced_at: None,
1770 replaced_by: None,
1771 replaces: previous_leg.map(|leg| leg.id.clone()),
1772 asset_id: previous_leg
1773 .map(|leg| leg.asset_id.clone())
1774 .unwrap_or_else(|| mock_asset_id(&leg.symbol)),
1775 symbol: leg.symbol.clone(),
1776 asset_class: "us_option".to_owned(),
1777 notional: None,
1778 qty: Some(leg_qty),
1779 filled_qty: Decimal::ZERO,
1780 filled_avg_price: None,
1781 order_class: OrderClass::Mleg,
1782 order_type: order_type.clone(),
1783 r#type: order_type.clone(),
1784 side: leg.side.clone().unwrap_or(OrderSide::Buy),
1785 position_intent: leg.position_intent.clone(),
1786 time_in_force: time_in_force.clone(),
1787 limit_price: None,
1788 stop_price: None,
1789 status: OrderStatus::New,
1790 extended_hours: false,
1791 legs: None,
1792 trail_percent: None,
1793 trail_price: None,
1794 hwm: None,
1795 ratio_qty: Some(leg.ratio_qty),
1796 take_profit: None,
1797 stop_loss: None,
1798 subtag: None,
1799 source: None,
1800 }
1801 })
1802 .collect()
1803}
1804
1805fn public_position_from_projection(projected: positions::ProjectedPosition) -> Position {
1806 Position {
1807 asset_id: projected.asset_id,
1808 symbol: projected.symbol,
1809 exchange: projected.exchange,
1810 asset_class: projected.asset_class,
1811 asset_marginable: projected.asset_marginable,
1812 side: projected.side,
1813 qty: projected.qty,
1814 avg_entry_price: projected.avg_entry_price,
1815 market_value: projected.market_value,
1816 cost_basis: projected.cost_basis,
1817 unrealized_pl: projected.unrealized_pl,
1818 unrealized_plpc: projected.unrealized_plpc,
1819 current_price: projected.current_price,
1820 lastday_price: projected.lastday_price,
1821 change_today: projected.change_today,
1822 qty_available: projected.qty_available,
1823 }
1824}
1825
1826fn closing_position_intent(
1827 position: &positions::InstrumentPosition,
1828 request_side: &OrderSide,
1829) -> Option<PositionIntent> {
1830 if position.instrument_identity.asset_class != "us_option" {
1831 return None;
1832 }
1833
1834 match request_side {
1835 OrderSide::Buy => Some(PositionIntent::BuyToClose),
1836 OrderSide::Sell => Some(PositionIntent::SellToClose),
1837 OrderSide::Unspecified => None,
1838 }
1839}
1840
1841fn ensure_exercisable_long_option_position(
1842 position: &positions::InstrumentPosition,
1843) -> Result<(), MockStateError> {
1844 if position.instrument_identity.asset_class != "us_option" {
1845 return Err(MockStateError::Conflict(format!(
1846 "position {} is not an option contract",
1847 position.instrument_identity.symbol
1848 )));
1849 }
1850 if position.net_qty <= Decimal::ZERO {
1851 return Err(MockStateError::Conflict(format!(
1852 "position {} must be a long option position to use exercise controls",
1853 position.instrument_identity.symbol
1854 )));
1855 }
1856
1857 Ok(())
1858}
1859
1860fn now_string() -> String {
1861 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
1862}
1863
1864#[cfg(test)]
1865mod tests {
1866 use std::collections::HashMap;
1867
1868 use super::*;
1869
1870 fn equity_snapshot() -> InstrumentSnapshot {
1871 InstrumentSnapshot::equity(Decimal::new(100, 0), Decimal::new(101, 0))
1872 }
1873
1874 fn option_snapshot() -> InstrumentSnapshot {
1875 InstrumentSnapshot::option(Decimal::new(120, 2), Decimal::new(140, 2))
1876 }
1877
1878 fn build_test_mleg_order(order_type: OrderType, limit_price: Option<Decimal>) -> Order {
1879 let now = "2026-04-09T12:00:00Z";
1880 Order {
1881 id: "mock-parent-order".to_owned(),
1882 client_order_id: "mock-parent-client-order".to_owned(),
1883 created_at: now.to_owned(),
1884 updated_at: now.to_owned(),
1885 submitted_at: now.to_owned(),
1886 filled_at: None,
1887 expired_at: None,
1888 expires_at: expires_at_for(&TimeInForce::Day),
1889 canceled_at: None,
1890 failed_at: None,
1891 replaced_at: None,
1892 replaced_by: None,
1893 replaces: None,
1894 asset_id: String::new(),
1895 symbol: String::new(),
1896 asset_class: String::new(),
1897 notional: None,
1898 qty: Some(Decimal::ONE),
1899 filled_qty: Decimal::ZERO,
1900 filled_avg_price: None,
1901 order_class: OrderClass::Mleg,
1902 order_type: order_type.clone(),
1903 r#type: order_type.clone(),
1904 side: OrderSide::Unspecified,
1905 position_intent: None,
1906 time_in_force: TimeInForce::Day,
1907 limit_price,
1908 stop_price: None,
1909 status: OrderStatus::New,
1910 extended_hours: false,
1911 legs: Some(build_leg_orders_from_requests(
1912 &[
1913 OptionLegRequest {
1914 symbol: "OPT-BUY".to_owned(),
1915 ratio_qty: 1,
1916 side: Some(OrderSide::Buy),
1917 position_intent: None,
1918 },
1919 OptionLegRequest {
1920 symbol: "OPT-SELL".to_owned(),
1921 ratio_qty: 1,
1922 side: Some(OrderSide::Sell),
1923 position_intent: None,
1924 },
1925 ],
1926 Decimal::ONE,
1927 order_type,
1928 TimeInForce::Day,
1929 now,
1930 None,
1931 )),
1932 trail_percent: None,
1933 trail_price: None,
1934 hwm: None,
1935 ratio_qty: None,
1936 take_profit: None,
1937 stop_loss: None,
1938 subtag: None,
1939 source: None,
1940 }
1941 }
1942
1943 #[test]
1944 fn stock_single_leg_orders_use_mid_price_for_market_and_limit() {
1945 let snapshot = equity_snapshot();
1946 let mid = snapshot.mid_price();
1947
1948 assert_eq!(reference_price(&OrderSide::Buy, &snapshot), mid);
1949 assert_eq!(reference_price(&OrderSide::Sell, &snapshot), mid);
1950 assert_eq!(
1951 marketable_fill_price(&OrderType::Market, &OrderSide::Buy, None, &snapshot),
1952 Some(mid)
1953 );
1954 assert_eq!(
1955 marketable_fill_price(&OrderType::Limit, &OrderSide::Buy, Some(mid), &snapshot,),
1956 Some(mid)
1957 );
1958 assert_eq!(
1959 marketable_fill_price(
1960 &OrderType::Limit,
1961 &OrderSide::Buy,
1962 Some(mid - Decimal::new(1, 2)),
1963 &snapshot,
1964 ),
1965 None
1966 );
1967 assert_eq!(
1968 marketable_fill_price(&OrderType::Limit, &OrderSide::Sell, Some(mid), &snapshot,),
1969 Some(mid)
1970 );
1971 assert_eq!(
1972 marketable_fill_price(
1973 &OrderType::Limit,
1974 &OrderSide::Sell,
1975 Some(mid + Decimal::new(1, 2)),
1976 &snapshot,
1977 ),
1978 None
1979 );
1980 }
1981
1982 #[test]
1983 fn option_single_leg_orders_use_mid_price_for_market_and_limit() {
1984 let snapshot = option_snapshot();
1985 let mid = snapshot.mid_price();
1986
1987 assert_eq!(reference_price(&OrderSide::Buy, &snapshot), mid);
1988 assert_eq!(reference_price(&OrderSide::Sell, &snapshot), mid);
1989 assert_eq!(
1990 marketable_fill_price(&OrderType::Market, &OrderSide::Sell, None, &snapshot),
1991 Some(mid)
1992 );
1993 assert_eq!(
1994 marketable_fill_price(&OrderType::Limit, &OrderSide::Buy, Some(mid), &snapshot,),
1995 Some(mid)
1996 );
1997 assert_eq!(
1998 marketable_fill_price(&OrderType::Limit, &OrderSide::Sell, Some(mid), &snapshot,),
1999 Some(mid)
2000 );
2001 }
2002
2003 #[test]
2004 fn multi_leg_orders_use_composite_mid_price_for_market_and_limit() {
2005 let market_quotes = HashMap::from([
2006 (
2007 "OPT-BUY".to_owned(),
2008 InstrumentSnapshot::option(Decimal::new(300, 2), Decimal::new(340, 2)),
2009 ),
2010 (
2011 "OPT-SELL".to_owned(),
2012 InstrumentSnapshot::option(Decimal::new(100, 2), Decimal::new(140, 2)),
2013 ),
2014 ]);
2015 let expected_mid = Decimal::new(200, 2);
2016
2017 let mut market_order = build_test_mleg_order(OrderType::Market, None);
2018 apply_mleg_fill_rules(&mut market_order, &OrderSide::Buy, &market_quotes);
2019 assert_eq!(market_order.status, OrderStatus::Filled);
2020 assert_eq!(market_order.filled_avg_price, Some(expected_mid));
2021
2022 let filled_legs = market_order
2023 .legs
2024 .expect("filled mleg should keep nested legs");
2025 assert_eq!(filled_legs.len(), 2);
2026 assert_eq!(filled_legs[0].filled_avg_price, Some(Decimal::new(320, 2)));
2027 assert_eq!(filled_legs[1].filled_avg_price, Some(Decimal::new(120, 2)));
2028
2029 let mut limit_order = build_test_mleg_order(OrderType::Limit, Some(expected_mid));
2030 apply_mleg_fill_rules(&mut limit_order, &OrderSide::Buy, &market_quotes);
2031 assert_eq!(limit_order.status, OrderStatus::Filled);
2032 assert_eq!(limit_order.filled_avg_price, Some(expected_mid));
2033
2034 let mut resting_order =
2035 build_test_mleg_order(OrderType::Limit, Some(expected_mid - Decimal::new(1, 2)));
2036 apply_mleg_fill_rules(&mut resting_order, &OrderSide::Buy, &market_quotes);
2037 assert_eq!(resting_order.status, OrderStatus::New);
2038 assert_eq!(resting_order.filled_avg_price, None);
2039 }
2040}
2041
2042fn now_millis() -> u128 {
2043 std::time::SystemTime::now()
2044 .duration_since(std::time::UNIX_EPOCH)
2045 .expect("system clock should be after unix epoch")
2046 .as_millis()
2047}
2048
2049fn expires_at_for(time_in_force: &TimeInForce) -> Option<String> {
2050 match time_in_force {
2051 TimeInForce::Gtd => Some(now_string()),
2052 TimeInForce::Day
2053 | TimeInForce::Gtc
2054 | TimeInForce::Opg
2055 | TimeInForce::Cls
2056 | TimeInForce::Ioc
2057 | TimeInForce::Fok => None,
2058 }
2059}