Skip to main content

alpaca_mock/state/
mod.rs

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(&current.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(&current.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(&current.order.symbol)
677                .expect("simple order market quote should exist");
678            let fill_price = marketable_fill_price(
679                &current.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(&current.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}