Skip to main content

hypercall/rsm/
margin_manager.rs

1//! Margin checking logic for order admission.
2//!
3//! `MarginManager` owns the `SpanMarginService` and `StandardMarginService`,
4//! and implements both Portfolio (SPAN) and Standard (Deribit-style) margin
5//! checks. The engine delegates all margin decisions here.
6
7use crate::portfolio::PortfolioBalance;
8use crate::rsm::engine_deps::{engine_positions_to_portfolio_balance, EngineDeps, EnginePosition};
9use crate::rsm::portfolio_margin::account_builder::{build_account_from_balance, expiry_to_years};
10use hypercall_engine::contract_key;
11use hypercall_engine::order_index::EngineOrderIndex;
12use hypercall_types::api_models::Order as ApiOrder;
13
14/// Position map type alias to keep method signatures readable.
15pub type EnginePositionMap = HashMap<(WalletAddress, String), EnginePosition>;
16use crate::rsm::margin_mode::MarginMode;
17use crate::rsm::margin_service::SpanMarginService;
18use crate::shared::order_types::{get_timestamp_millis, ParsedSymbol};
19use crate::standard_margin::StandardMarginService;
20use crate::types::{Account, MarginDetails, OptionContract, Position};
21use crate::vol_oracle::SharedVolOracle;
22use hypercall_engine::margin_admission::{
23    decide_portfolio_margin, decide_standard_margin, MarginAdmissionDecision,
24    PortfolioMarginAdmissionInput, StandardMarginAdmissionInput,
25};
26use hypercall_types::OrderInfo;
27use hypercall_types::{to_contract_units_decimal, to_human_readable_decimal, Side, WalletAddress};
28use rust_decimal::prelude::ToPrimitive;
29use rust_decimal::Decimal;
30use rust_decimal_macros::dec;
31use std::collections::HashMap;
32use tracing::{debug, info, warn};
33
34/// Margin manager encapsulating SPAN and Standard margin services.
35///
36/// Methods borrow `&EngineDeps` for access to caches, portfolio service, etc.
37pub struct MarginManager {
38    /// SPAN scenario-based margin service (Portfolio mode).
39    pub span_margin_service: SpanMarginService,
40    /// Deribit-style linear margin service (Standard mode).
41    pub standard_margin_service: StandardMarginService,
42}
43
44impl MarginManager {
45    /// Create a new `MarginManager` from config.
46    pub fn new(config: &crate::types::Config) -> Self {
47        Self::new_with_vol_oracle(config, None)
48    }
49
50    pub fn new_with_vol_oracle(
51        config: &crate::types::Config,
52        vol_oracle: Option<SharedVolOracle>,
53    ) -> Self {
54        Self {
55            span_margin_service: match vol_oracle {
56                Some(oracle) => SpanMarginService::new_with_vol_oracle(config.clone(), oracle),
57                None => SpanMarginService::new_fail_closed(config.clone()),
58            },
59            standard_margin_service: StandardMarginService::new(),
60        }
61    }
62
63    // ===== Public entry point =====
64
65    /// Unified margin check for both option and perp orders.
66    ///
67    /// Branches on margin mode:
68    /// - Portfolio mode: SPAN-based scenario margin
69    /// - Standard mode: Deribit-style linear margin
70    pub fn check_margin_for_order(
71        &self,
72        deps: &EngineDeps,
73        engine_positions: &EnginePositionMap,
74        balance_ledger: &HashMap<WalletAddress, Decimal>,
75        wallet: &WalletAddress,
76        order_info: &OrderInfo,
77        order_index: &EngineOrderIndex,
78    ) -> Result<(), String> {
79        let margin_mode = self.get_margin_mode(deps, wallet)?;
80
81        debug!(
82            "Unified margin check: Starting for wallet {} on symbol {} (mode: {:?})",
83            wallet, order_info.symbol, margin_mode
84        );
85
86        match margin_mode {
87            MarginMode::Portfolio => self.check_margin_portfolio(
88                deps,
89                engine_positions,
90                balance_ledger,
91                wallet,
92                order_info,
93                order_index,
94            ),
95            MarginMode::Standard => self.check_margin_standard(
96                deps,
97                engine_positions,
98                balance_ledger,
99                wallet,
100                order_info,
101                order_index,
102            ),
103        }
104    }
105
106    /// Margin check for a batch of orders that must be accepted or rejected
107    /// together, e.g. a multi-leg RFQ where leg N-1 hedges leg N and the
108    /// combination is healthy even though individual legs would not pass
109    /// margin in isolation.
110    ///
111    /// Branches on the wallet's configured margin mode, matching the
112    /// single-order admission path. RFQ is an immediately executed product,
113    /// so the Standard path checks the post-execution account state rather
114    /// than treating the proposed legs as resting open orders.
115    pub fn check_margin_for_orders(
116        &self,
117        deps: &EngineDeps,
118        engine_positions: &EnginePositionMap,
119        balance_ledger: &HashMap<WalletAddress, Decimal>,
120        wallet: &WalletAddress,
121        proposed_orders: &[OrderInfo],
122        order_index: &EngineOrderIndex,
123    ) -> Result<(), String> {
124        self.check_margin_for_orders_with_standard_short_bypass(
125            deps,
126            engine_positions,
127            balance_ledger,
128            wallet,
129            proposed_orders,
130            order_index,
131            false,
132        )
133    }
134
135    /// RFQ quote-provider margin check.
136    ///
137    /// The RFQ gateway only sends active registered quote providers to this
138    /// engine path. Let those providers open Standard-margin shorts while
139    /// keeping the normal Standard short ban for takers and orderbook orders.
140    pub fn check_margin_for_quote_provider_orders(
141        &self,
142        deps: &EngineDeps,
143        engine_positions: &EnginePositionMap,
144        balance_ledger: &HashMap<WalletAddress, Decimal>,
145        wallet: &WalletAddress,
146        proposed_orders: &[OrderInfo],
147        order_index: &EngineOrderIndex,
148    ) -> Result<(), String> {
149        self.check_margin_for_orders_with_standard_short_bypass(
150            deps,
151            engine_positions,
152            balance_ledger,
153            wallet,
154            proposed_orders,
155            order_index,
156            true,
157        )
158    }
159
160    fn check_margin_for_orders_with_standard_short_bypass(
161        &self,
162        deps: &EngineDeps,
163        engine_positions: &EnginePositionMap,
164        balance_ledger: &HashMap<WalletAddress, Decimal>,
165        wallet: &WalletAddress,
166        proposed_orders: &[OrderInfo],
167        order_index: &EngineOrderIndex,
168        allow_standard_short_bypass: bool,
169    ) -> Result<(), String> {
170        if proposed_orders.is_empty() {
171            return Ok(());
172        }
173
174        let margin_mode = self.get_margin_mode(deps, wallet)?;
175
176        debug!(
177            "Unified multi-order margin check: Starting for wallet {} ({} legs, mode: {:?})",
178            wallet,
179            proposed_orders.len(),
180            margin_mode
181        );
182
183        if matches!(margin_mode, MarginMode::Standard) {
184            return self.check_margin_standard_for_orders(
185                deps,
186                engine_positions,
187                balance_ledger,
188                wallet,
189                proposed_orders,
190                order_index,
191                allow_standard_short_bypass,
192            );
193        }
194
195        let hypothetical_account = self.build_hypothetical_account_for_orders(
196            deps,
197            engine_positions,
198            balance_ledger,
199            wallet,
200            proposed_orders,
201            order_index,
202        )?;
203
204        let margin_result = self.run_margin_on_account_with_deps(&hypothetical_account, deps)?;
205
206        match margin_result {
207            None => {
208                info!(
209                    "Multi-order margin check: PASSED for wallet {} ({} proposed legs) — no margin details returned",
210                    wallet,
211                    proposed_orders.len()
212                );
213                Ok(())
214            }
215            Some(details) => {
216                let available_collateral = details.equity;
217                let margin_required = details.initial_margin_required;
218                let excess_margin = available_collateral - margin_required;
219
220                debug!(
221                    "Multi-order margin check: wallet={}, legs={}, margin_required={:.2}, available={:.2}, excess={:.2}",
222                    wallet,
223                    proposed_orders.len(),
224                    margin_required,
225                    available_collateral,
226                    excess_margin
227                );
228
229                if excess_margin < Decimal::ZERO {
230                    let error_msg = format!(
231                        "Insufficient margin across {} legs: required={:.2}, available={:.2}, shortfall={:.2}",
232                        proposed_orders.len(),
233                        margin_required,
234                        available_collateral,
235                        -excess_margin
236                    );
237                    warn!(
238                        "Multi-order margin check: FAILED for wallet {} - {}",
239                        wallet, error_msg
240                    );
241                    Err(error_msg)
242                } else {
243                    info!(
244                        "Multi-order margin check: PASSED for wallet {} ({} legs) - excess_margin={:.2}",
245                        wallet,
246                        proposed_orders.len(),
247                        excess_margin
248                    );
249                    Ok(())
250                }
251            }
252        }
253    }
254
255    /// Get the margin mode for a wallet.
256    pub fn get_margin_mode(
257        &self,
258        deps: &EngineDeps,
259        wallet: &WalletAddress,
260    ) -> Result<MarginMode, String> {
261        if let Some(margin_mode) = deps.wallet_margin_modes.get(wallet).copied() {
262            return Ok(margin_mode);
263        }
264
265        let Some(tier_cache) = deps.tier_cache.as_ref() else {
266            return Ok(MarginMode::Standard);
267        };
268
269        tier_cache
270            .get_margin_mode_sync(wallet)
271            .map_err(|error| error.to_string())
272    }
273
274    // ===== Account building helpers =====
275
276    /// Build a risk-ready Account from engine balances and positions.
277    pub fn get_risk_account(
278        &self,
279        deps: &EngineDeps,
280        engine_positions: &EnginePositionMap,
281        balance_ledger: &HashMap<WalletAddress, Decimal>,
282        wallet: &WalletAddress,
283    ) -> Result<Account, String> {
284        let mut balance =
285            engine_positions_to_portfolio_balance(engine_positions, wallet, &deps.reference_prices);
286
287        // Merge HyperCore perp positions into the balance so PM sees
288        // the full cross-margined portfolio.
289        let wallet_lower = wallet.to_string().to_lowercase();
290        for ((account, coin), perp) in &deps.perp_positions {
291            if account == &wallet_lower {
292                let symbol = format!("{}-PERP", coin);
293                if !balance.positions.contains_key(&symbol) && perp.size != 0.0 {
294                    use crate::portfolio::PositionData;
295                    let entry_price = perp.entry_price.ok_or_else(|| {
296                        format!(
297                            "Missing HyperCore entry price for {} {} - cannot build risk account",
298                            wallet, symbol
299                        )
300                    })?;
301                    balance.positions.insert(
302                        symbol.clone(),
303                        PositionData {
304                            symbol,
305                            amount: Decimal::try_from(perp.size).map_err(|_| {
306                                format!(
307                                    "Invalid HyperCore perp size for {} {}: {}",
308                                    wallet, coin, perp.size
309                                )
310                            })?,
311                            entry_price: Decimal::try_from(entry_price).map_err(|_| {
312                                format!(
313                                    "Invalid HyperCore entry price for {} {}: {}",
314                                    wallet, coin, entry_price
315                                )
316                            })?,
317                            margin_posted: dec!(0),
318                            realized_pnl: dec!(0),
319                            unrealized_pnl: Decimal::try_from(perp.unrealized_pnl).map_err(
320                                |_| {
321                                    format!(
322                                        "Invalid HyperCore unrealized PnL for {} {}: {}",
323                                        wallet, coin, perp.unrealized_pnl
324                                    )
325                                },
326                            )?,
327                        },
328                    );
329                }
330            }
331        }
332
333        let spot_prices = self.build_spot_prices_for_balance(deps, &balance);
334        let mut account = build_account_from_balance(wallet, &balance, Some(*wallet), &spot_prices)
335            .map_err(|e| format!("Cannot build risk account for {}: {}", wallet, e))?;
336
337        // For PM users, prefer HyperCore account equity (which includes perp UPNL).
338        // Fall back to balance_ledger if no HyperCore equity is observed (faucet mode).
339        let margin_mode = self.get_margin_mode(deps, wallet).ok();
340        let (cash, using_hypercore_equity) = if margin_mode == Some(MarginMode::Portfolio) {
341            if let Some(&hc_equity) = deps.hypercore_account_equity.get(wallet) {
342                (hc_equity, true)
343            } else {
344                (
345                    balance_ledger.get(wallet).copied().unwrap_or(Decimal::ZERO),
346                    false,
347                )
348            }
349        } else {
350            (
351                balance_ledger.get(wallet).copied().unwrap_or(Decimal::ZERO),
352                false,
353            )
354        };
355
356        // When using HyperCore equity, perp UPNL is already included in account_value.
357        // Zero out perp_unrealized_pnl on all positions so SPAN equity formula
358        // (cash + option_upnl + perp_upnl) does not double-count.
359        if using_hypercore_equity {
360            for position in account.portfolio.values_mut() {
361                position.perp_unrealized_pnl = Decimal::ZERO;
362            }
363        }
364
365        account.cash = cash
366            .to_f64()
367            .ok_or_else(|| format!("Engine balance ledger {} not representable as f64", cash))?;
368        tracing::trace!(
369            wallet = %wallet,
370            cash = %cash,
371            using_hypercore_equity = using_hypercore_equity,
372            balance_wallet_count = balance_ledger.len(),
373            engine_position_count = engine_positions.len(),
374            "Built risk account cash"
375        );
376
377        Ok(account)
378    }
379
380    /// Build a risk account including open orders when PM is enabled.
381    pub fn build_account_for_risk(
382        &self,
383        deps: &EngineDeps,
384        engine_positions: &EnginePositionMap,
385        _balance_ledger: &HashMap<WalletAddress, Decimal>,
386        wallet: &WalletAddress,
387    ) -> Result<Account, String> {
388        let balance =
389            engine_positions_to_portfolio_balance(engine_positions, wallet, &deps.reference_prices);
390
391        let spot_prices = self.build_spot_prices_for_balance(deps, &balance);
392
393        build_account_from_balance(wallet, &balance, Some(*wallet), &spot_prices)
394            .map_err(|e| format!("Cannot build risk account for {}: {}", wallet, e))
395    }
396
397    /// Get SPAN margin details for a wallet's executed state.
398    pub fn get_span_margin_for_wallet(
399        &self,
400        deps: &EngineDeps,
401        engine_positions: &EnginePositionMap,
402        balance_ledger: &HashMap<WalletAddress, Decimal>,
403        wallet: &WalletAddress,
404    ) -> Result<Option<MarginDetails>, String> {
405        let account = self.get_risk_account(deps, engine_positions, balance_ledger, wallet)?;
406        self.run_margin_on_account(&account)
407    }
408
409    // ===== Internal helpers =====
410
411    /// Build spot prices map for a portfolio balance.
412    fn build_spot_prices_for_balance(
413        &self,
414        deps: &EngineDeps,
415        balance: &PortfolioBalance,
416    ) -> HashMap<String, f64> {
417        let mut spot_prices = HashMap::new();
418
419        for symbol in balance.positions.keys() {
420            let underlying = if symbol.ends_with("-PERP") {
421                symbol.trim_end_matches("-PERP").to_string()
422            } else if let Ok(parsed) = ParsedSymbol::from_symbol(symbol) {
423                parsed.underlying
424            } else {
425                continue;
426            };
427            if spot_prices.contains_key(&underlying) {
428                continue;
429            }
430            if let Some(&price) = deps.reference_prices.get(&underlying) {
431                spot_prices.insert(underlying, price);
432            }
433        }
434
435        spot_prices
436    }
437
438    /// Get spot price for an underlying, returning an error if unavailable.
439    pub fn get_spot_price_for_margin(
440        &self,
441        deps: &EngineDeps,
442        underlying: &str,
443    ) -> Result<f64, String> {
444        if let Some(&price) = deps.reference_prices.get(underlying) {
445            return Ok(price);
446        }
447        Err(format!(
448            "No spot price available for {} - no PriceUpdate command received yet",
449            underlying
450        ))
451    }
452
453    /// Fetch all open orders for a wallet from the in-process order index.
454    pub fn fetch_open_orders(
455        &self,
456        order_index: &EngineOrderIndex,
457        wallet: &WalletAddress,
458    ) -> Vec<ApiOrder> {
459        let summaries = order_index.get_all_orders_for_wallet(wallet);
460
461        let all_orders: Vec<ApiOrder> = summaries
462            .into_iter()
463            .map(|s| {
464                let timestamp = get_timestamp_millis();
465                ApiOrder {
466                    order_id: s.order_id as i64,
467                    wallet_address: *wallet,
468                    symbol: s.symbol.clone(),
469                    side: format!("{:?}", s.side),
470                    price: s.price,
471                    size: s.remaining_size,
472                    tif: "gtc".to_string(),
473                    status: Some("open".to_string()),
474                    created_at: timestamp as i64,
475                    updated_at: None,
476                    filled_size: Some(s.original_size - s.remaining_size),
477                    mmp_enabled: s.mmp_enabled,
478                }
479            })
480            .collect();
481
482        debug!(
483            "Fetched {} open orders for wallet {}",
484            all_orders.len(),
485            wallet
486        );
487
488        all_orders
489    }
490
491    /// Create a simulated order from OrderInfo for margin checking.
492    pub fn create_simulated_order(
493        &self,
494        order_info: &OrderInfo,
495        wallet: &WalletAddress,
496    ) -> ApiOrder {
497        let timestamp = get_timestamp_millis();
498        ApiOrder {
499            order_id: 0,
500            wallet_address: *wallet,
501            symbol: order_info.symbol.clone(),
502            side: format!("{:?}", order_info.side),
503            price: order_info.price,
504            size: to_human_readable_decimal(&order_info.symbol, order_info.size),
505            tif: format!("{:?}", order_info.tif).to_lowercase(),
506            status: Some("simulated".to_string()),
507            created_at: timestamp as i64,
508            updated_at: None,
509            filled_size: Some(dec!(0)),
510            mmp_enabled: order_info.mmp_enabled,
511        }
512    }
513
514    // ===== Portfolio (SPAN) margin =====
515
516    /// Portfolio margin check using SPAN scenarios.
517    fn check_margin_portfolio(
518        &self,
519        deps: &EngineDeps,
520        engine_positions: &EnginePositionMap,
521        balance_ledger: &HashMap<WalletAddress, Decimal>,
522        wallet: &WalletAddress,
523        order_info: &OrderInfo,
524        order_index: &EngineOrderIndex,
525    ) -> Result<(), String> {
526        let is_reduce_only = order_info.reduce_only == Some(true);
527        let hypothetical_account = self.build_hypothetical_account_for_order(
528            deps,
529            engine_positions,
530            balance_ledger,
531            wallet,
532            order_info,
533            order_index,
534        )?;
535
536        let margin_result = self.run_margin_on_account_with_deps(&hypothetical_account, deps)?;
537
538        match margin_result {
539            None => {
540                info!(
541                    "Portfolio margin check: PASSED for wallet {} - no margin details returned",
542                    wallet
543                );
544                Ok(())
545            }
546            Some(details) => {
547                let available_collateral = details.equity;
548                let margin_required = details.initial_margin_required;
549                let expected_initial_margin =
550                    details.scanning_risk.max(details.option_floor) + details.gamma_overlay;
551
552                debug_assert!(
553                    (details.initial_margin_required - expected_initial_margin).abs()
554                        < dec!(0.000000001),
555                    "IM ({}) should equal max(scanning_risk={}, option_floor={}) + gamma_overlay={}",
556                    details.initial_margin_required,
557                    details.scanning_risk,
558                    details.option_floor,
559                    details.gamma_overlay
560                );
561                debug!(
562                    "Portfolio margin check: equity={}, cash={}, mtm={}",
563                    details.equity, hypothetical_account.cash, details.net_option_value
564                );
565
566                let excess_margin = available_collateral - margin_required;
567
568                debug!(
569                    "Portfolio margin check: wallet={}, margin_required={:.2}, available={:.2}, excess={:.2}",
570                    wallet, margin_required, available_collateral, excess_margin
571                );
572
573                let decision = decide_portfolio_margin(PortfolioMarginAdmissionInput {
574                    is_reduce_only,
575                    available_collateral,
576                    margin_required,
577                    settlement_context: None,
578                });
579
580                if matches!(decision, MarginAdmissionDecision::Accepted) && is_reduce_only {
581                    info!(
582                        "Portfolio margin check: PASSED for wallet {} - aggregate reduce-only order allowed",
583                        wallet
584                    );
585                    return Ok(());
586                }
587
588                if let MarginAdmissionDecision::Rejected(error_msg) = decision {
589                    warn!(
590                        "Portfolio margin check: FAILED for wallet {} - {}",
591                        wallet, error_msg
592                    );
593                    Err(error_msg)
594                } else {
595                    info!(
596                        "Portfolio margin check: PASSED for wallet {} - excess_margin={:.2}",
597                        wallet, excess_margin
598                    );
599                    Ok(())
600                }
601            }
602        }
603    }
604
605    // ===== Standard margin =====
606
607    /// Standard margin check using Deribit-style linear margin.
608    fn check_margin_standard(
609        &self,
610        deps: &EngineDeps,
611        engine_positions: &EnginePositionMap,
612        balance_ledger: &HashMap<WalletAddress, Decimal>,
613        wallet: &WalletAddress,
614        order_info: &OrderInfo,
615        order_index: &EngineOrderIndex,
616    ) -> Result<(), String> {
617        if !deps.config.allow_standard_margin_shorts {
618            self.reject_standard_net_short_order(
619                engine_positions,
620                wallet,
621                order_info,
622                order_index,
623            )?;
624        }
625
626        let mut account =
627            match crate::standard_margin::StandardAccountBuilder::build_from_engine_state(
628                wallet,
629                balance_ledger,
630                engine_positions,
631                &deps.reference_prices,
632            ) {
633                Ok(a) => a,
634                Err(e) => {
635                    debug!(
636                    "StandardAccountBuilder::build_from_engine_state failed for {}: {}, falling back to portfolio margin",
637                    wallet, e
638                );
639                    return self.check_margin_portfolio(
640                        deps,
641                        engine_positions,
642                        balance_ledger,
643                        wallet,
644                        order_info,
645                        order_index,
646                    );
647                }
648            };
649
650        let wallet_lower = wallet.to_string().to_lowercase();
651        for ((account_addr, coin), perp) in &deps.perp_positions {
652            if account_addr != &wallet_lower || perp.size == 0.0 {
653                continue;
654            }
655            let symbol = format!("{}-PERP", coin);
656            if account.perp_positions.iter().any(|p| p.symbol == symbol) {
657                continue;
658            }
659            let entry_price = perp.entry_price.ok_or_else(|| {
660                format!(
661                    "Missing HyperCore entry price for {} {} - cannot check standard margin",
662                    wallet, symbol
663                )
664            })?;
665            let size = Decimal::try_from(perp.size).map_err(|_| {
666                format!(
667                    "Invalid HyperCore perp size for {} {}: {}",
668                    wallet, coin, perp.size
669                )
670            })?;
671            let entry_price_decimal = Decimal::try_from(entry_price).map_err(|_| {
672                format!(
673                    "Invalid HyperCore entry price for {} {}: {}",
674                    wallet, coin, entry_price
675                )
676            })?;
677            let mark_price = Decimal::try_from(entry_price + perp.unrealized_pnl / perp.size)
678                .map_err(|_| {
679                    format!(
680                        "Invalid HyperCore mark price for {} {} from entry={} pnl={} size={}",
681                        wallet, coin, entry_price, perp.unrealized_pnl, perp.size
682                    )
683                })?;
684            account
685                .perp_positions
686                .push(hypercall_margin::standard::PerpPosition {
687                    symbol,
688                    underlying: coin.clone(),
689                    size,
690                    mark_price,
691                    entry_price: entry_price_decimal,
692                });
693        }
694
695        let is_option = ParsedSymbol::from_symbol(&order_info.symbol).is_ok();
696        let order_quantity = to_human_readable_decimal(&order_info.symbol, order_info.size);
697        let is_risk_increasing = self.standard_margin_service.is_risk_increasing(
698            &account,
699            &order_info.symbol,
700            matches!(order_info.side, Side::Buy),
701            order_quantity,
702            is_option,
703        );
704        let is_reduce_only = order_info.reduce_only == Some(true);
705        let requested_premium = if is_option && matches!(order_info.side, Side::Buy) {
706            order_info.price * order_quantity
707        } else {
708            dec!(0)
709        };
710        let proposed_premium = if is_reduce_only {
711            dec!(0)
712        } else if matches!(order_info.side, Side::Buy) {
713            // Pro-rate: only reserve premium for the portion that exceeds
714            // an existing short position on the same symbol.
715            let short_qty = engine_positions
716                .get(&(*wallet, order_info.symbol.clone()))
717                .map(|p| {
718                    if p.quantity < dec!(0) {
719                        -p.quantity
720                    } else {
721                        dec!(0)
722                    }
723                })
724                .unwrap_or(dec!(0));
725            let opening_qty = (order_quantity - short_qty).max(dec!(0));
726            order_info.price * opening_qty
727        } else {
728            requested_premium
729        };
730
731        let existing_reserved_premium =
732            order_index.calculate_open_buy_premium(wallet, engine_positions);
733
734        let total_reserved_premium = existing_reserved_premium + proposed_premium;
735
736        let mut account_with_existing_open_orders = account.clone();
737        account_with_existing_open_orders.usdc_balance -= existing_reserved_premium;
738
739        self.add_open_sell_orders_to_standard_account(
740            deps,
741            &mut account_with_existing_open_orders,
742            wallet,
743            order_index,
744        )?;
745
746        let base_result = self.standard_margin_service.compute_margin(&account);
747        let existing_open_orders_result = self
748            .standard_margin_service
749            .compute_margin(&account_with_existing_open_orders);
750        let current_open_orders_im =
751            (existing_open_orders_result.position_im - base_result.position_im).max(dec!(0));
752
753        let mut post_trade_account = account_with_existing_open_orders.clone();
754        post_trade_account.usdc_balance -= proposed_premium;
755
756        if is_option && matches!(order_info.side, Side::Sell) {
757            if let Ok(parsed) = ParsedSymbol::from_symbol(&order_info.symbol) {
758                let qty = to_human_readable_decimal(&order_info.symbol, order_info.size);
759                let spot_price_f64 = self
760                    .get_spot_price_for_margin(deps, &parsed.underlying)
761                    .map_err(|e| {
762                        format!(
763                            "Cannot check margin for sell order on {}: {}",
764                            order_info.symbol, e
765                        )
766                    })?;
767                let spot_price = Decimal::from_f64_retain(spot_price_f64).ok_or_else(|| {
768                    format!(
769                        "Invalid spot price for {}: {} - cannot convert to Decimal",
770                        parsed.underlying, spot_price_f64
771                    )
772                })?;
773                let expiry_ts = expiry_date_to_timestamp(&parsed.underlying, parsed.expiry) as i64;
774                if expiry_ts <= 0 {
775                    return Err(format!(
776                        "invalid expiry in order symbol {} - cannot compute standard margin safely",
777                        order_info.symbol
778                    ));
779                }
780
781                let hypothetical_position = hypercall_margin::standard::OptionPosition {
782                    symbol: order_info.symbol.clone(),
783                    underlying: parsed.underlying.clone(),
784                    expiry_ts,
785                    strike: parsed.strike,
786                    is_call: matches!(parsed.option_type, crate::types::OptionType::Call),
787                    size: -qty,
788                    mark_price: order_info.price,
789                    entry_price: order_info.price,
790                    spot_price,
791                };
792                post_trade_account
793                    .option_positions
794                    .push(hypothetical_position);
795            }
796        }
797
798        if order_info.is_perp {
799            let underlying = order_info
800                .underlying
801                .clone()
802                .unwrap_or_else(|| order_info.symbol.replace("-PERP", ""));
803
804            let size = to_human_readable_decimal(&order_info.symbol, order_info.size);
805            let signed_size = if matches!(order_info.side, Side::Buy) {
806                size
807            } else {
808                -size
809            };
810
811            let hypothetical_perp = hypercall_margin::standard::PerpPosition {
812                symbol: order_info.symbol.clone(),
813                underlying,
814                size: signed_size,
815                mark_price: order_info.price,
816                entry_price: order_info.price,
817            };
818            post_trade_account.perp_positions.push(hypothetical_perp);
819        }
820
821        debug!(
822            "Standard margin check: wallet={}, existing_reserved={:.2}, requested_premium={:.2}, reserved_proposed_premium={:.2}, adjusted_balance={:.2}, option_positions={}, perp_positions={}, reduce_only={}, risk_increasing={}",
823            wallet,
824            existing_reserved_premium,
825            requested_premium,
826            proposed_premium,
827            post_trade_account.usdc_balance,
828            post_trade_account.option_positions.len(),
829            post_trade_account.perp_positions.len(),
830            is_reduce_only,
831            is_risk_increasing
832        );
833
834        let hypothetical_result = self
835            .standard_margin_service
836            .compute_margin(&post_trade_account);
837
838        let post_accept_open_orders_im =
839            (hypothetical_result.position_im - base_result.position_im).max(dec!(0));
840        let incremental_open_orders_im = post_accept_open_orders_im - current_open_orders_im;
841
842        let result = hypothetical_result;
843
844        debug!(
845            "Standard margin check: wallet={}, equity={}, position_im={}, current_open_orders_im={}, incremental_open_orders_im={}, post_accept_open_orders_im={}, total_im={}, reduce_only={}, risk_increasing={}",
846            wallet,
847            result.equity,
848            base_result.position_im,
849            current_open_orders_im,
850            incremental_open_orders_im,
851            post_accept_open_orders_im,
852            result.position_im,
853            is_reduce_only,
854            is_risk_increasing
855        );
856
857        let is_closing_position = !is_risk_increasing
858            && account
859                .option_positions
860                .iter()
861                .any(|p| p.symbol == order_info.symbol && p.size.abs() > dec!(0))
862            || !is_risk_increasing
863                && account
864                    .perp_positions
865                    .iter()
866                    .any(|p| p.symbol == order_info.symbol && p.size.abs() > dec!(0));
867
868        let is_premium_debiting_buy = is_option && matches!(order_info.side, Side::Buy);
869        let decision = decide_standard_margin(StandardMarginAdmissionInput {
870            is_reduce_only,
871            is_closing_position,
872            is_premium_debiting_buy,
873            equity: result.equity,
874            post_balance: post_trade_account.usdc_balance,
875            total_reserved_premium,
876            initial_margin: result.initial_margin,
877            position_im: base_result.position_im,
878            current_open_orders_im,
879            incremental_open_orders_im,
880            post_accept_open_orders_im,
881        });
882
883        if matches!(decision, MarginAdmissionDecision::Accepted)
884            && (is_reduce_only || is_closing_position)
885        {
886            info!(
887                "Standard margin check: PASSED for wallet {} - {} (equity={:.2})",
888                wallet,
889                if is_reduce_only {
890                    "reduce-only flag"
891                } else {
892                    "closing position"
893                },
894                result.equity
895            );
896            return Ok(());
897        }
898
899        if let MarginAdmissionDecision::Rejected(error_msg) = decision {
900            warn!(
901                "Standard margin check: FAILED for wallet {} - {}",
902                wallet, error_msg
903            );
904            return Err(error_msg);
905        }
906
907        info!(
908            "Standard margin check: PASSED for wallet {} - initial_margin={:.2}",
909            wallet, result.initial_margin
910        );
911        Ok(())
912    }
913
914    fn check_margin_standard_for_orders(
915        &self,
916        deps: &EngineDeps,
917        engine_positions: &EnginePositionMap,
918        balance_ledger: &HashMap<WalletAddress, Decimal>,
919        wallet: &WalletAddress,
920        proposed_orders: &[OrderInfo],
921        order_index: &EngineOrderIndex,
922        allow_standard_short_bypass: bool,
923    ) -> Result<(), String> {
924        if !allow_standard_short_bypass && !deps.config.allow_standard_margin_shorts {
925            self.reject_standard_net_short_orders(
926                engine_positions,
927                wallet,
928                proposed_orders,
929                order_index,
930            )?;
931        }
932
933        let mut simulated_positions = engine_positions.clone();
934        let mut simulated_cash = balance_ledger.clone();
935        let mut wallet_cash = simulated_cash.get(wallet).copied().unwrap_or(Decimal::ZERO);
936        let mut signed_premium = dec!(0);
937
938        for order_info in proposed_orders {
939            let order_quantity = to_human_readable_decimal(&order_info.symbol, order_info.size);
940            if order_quantity <= dec!(0) {
941                return Err(format!(
942                    "Invalid RFQ margin quantity {} for {}",
943                    order_quantity, order_info.symbol
944                ));
945            }
946
947            let is_option = ParsedSymbol::from_symbol(&order_info.symbol).is_ok();
948            let signed_size = if matches!(order_info.side, Side::Buy) {
949                order_quantity
950            } else {
951                -order_quantity
952            };
953
954            crate::rsm::engine_deps::apply_fill_to_positions(
955                &mut simulated_positions,
956                *wallet,
957                order_info.symbol.clone(),
958                signed_size,
959                order_info.price,
960            );
961
962            if is_option {
963                let premium = order_info.price * order_quantity;
964                if matches!(order_info.side, Side::Buy) {
965                    wallet_cash -= premium;
966                    signed_premium -= premium;
967                } else {
968                    wallet_cash += premium;
969                    signed_premium += premium;
970                }
971            }
972        }
973        simulated_cash.insert(*wallet, wallet_cash);
974
975        let account = crate::standard_margin::StandardAccountBuilder::build_from_engine_state(
976            wallet,
977            &simulated_cash,
978            &simulated_positions,
979            &deps.reference_prices,
980        )
981        .map_err(|e| {
982            format!(
983                "Cannot build standard margin account after RFQ fills for {}: {}",
984                wallet, e
985            )
986        })?;
987
988        let existing_reserved_premium =
989            order_index.calculate_open_buy_premium(wallet, &simulated_positions);
990        let mut account_with_open_orders = account.clone();
991        account_with_open_orders.usdc_balance -= existing_reserved_premium;
992        self.add_open_sell_orders_to_standard_account(
993            deps,
994            &mut account_with_open_orders,
995            wallet,
996            order_index,
997        )?;
998
999        let result = self
1000            .standard_margin_service
1001            .compute_margin(&account_with_open_orders);
1002
1003        debug!(
1004            "Standard multi-order margin check: wallet={}, legs={}, signed_premium={:.2}, reserved_open_buy_premium={:.2}, post_cash={:.2}, equity={:.2}, position_im={:.2}, initial_margin={:.2}",
1005            wallet,
1006            proposed_orders.len(),
1007            signed_premium,
1008            existing_reserved_premium,
1009            account_with_open_orders.usdc_balance,
1010            result.equity,
1011            result.position_im,
1012            result.initial_margin
1013        );
1014
1015        if result.equity < dec!(0) {
1016            let error_msg = format!(
1017                "Insufficient funds (Standard): post-trade equity is negative. \
1018                equity={:.2}, signed_premium={:.2}, shortfall={:.2}",
1019                result.equity, signed_premium, -result.equity
1020            );
1021            warn!(
1022                "Standard multi-order margin check: FAILED for wallet {} - {}",
1023                wallet, error_msg
1024            );
1025            return Err(error_msg);
1026        }
1027
1028        if result.initial_margin < dec!(0) {
1029            let error_msg = format!(
1030                "Insufficient margin (Standard): equity={:.2}, position_im={:.2}, reserved_open_buy_premium={:.2}, shortfall={:.2}",
1031                result.equity,
1032                result.position_im,
1033                existing_reserved_premium,
1034                -result.initial_margin
1035            );
1036            warn!(
1037                "Standard multi-order margin check: FAILED for wallet {} - {}",
1038                wallet, error_msg
1039            );
1040            Err(error_msg)
1041        } else {
1042            info!(
1043                "Standard multi-order margin check: PASSED for wallet {} - initial_margin={:.2}",
1044                wallet, result.initial_margin
1045            );
1046            Ok(())
1047        }
1048    }
1049
1050    fn reject_standard_net_short_order(
1051        &self,
1052        engine_positions: &EnginePositionMap,
1053        wallet: &WalletAddress,
1054        order_info: &OrderInfo,
1055        order_index: &EngineOrderIndex,
1056    ) -> Result<(), String> {
1057        self.reject_standard_net_short_orders(
1058            engine_positions,
1059            wallet,
1060            std::slice::from_ref(order_info),
1061            order_index,
1062        )
1063    }
1064
1065    fn reject_standard_net_short_orders(
1066        &self,
1067        engine_positions: &EnginePositionMap,
1068        wallet: &WalletAddress,
1069        proposed_orders: &[OrderInfo],
1070        order_index: &EngineOrderIndex,
1071    ) -> Result<(), String> {
1072        let mut proposed_net_by_contract: HashMap<String, Decimal> = HashMap::new();
1073
1074        for order_info in proposed_orders {
1075            let Some(key) = contract_key(&order_info.symbol) else {
1076                continue;
1077            };
1078            if order_info.is_perp {
1079                continue;
1080            }
1081
1082            let quantity = to_human_readable_decimal(&order_info.symbol, order_info.size);
1083            if quantity <= dec!(0) {
1084                return Err(format!(
1085                    "Invalid Standard margin option quantity {} for {}",
1086                    quantity, order_info.symbol
1087                ));
1088            }
1089
1090            let signed_quantity = if matches!(order_info.side, Side::Buy) {
1091                quantity
1092            } else {
1093                -quantity
1094            };
1095            *proposed_net_by_contract.entry(key).or_insert(dec!(0)) += signed_quantity;
1096        }
1097
1098        for (contract, proposed_net) in proposed_net_by_contract {
1099            let current_position = engine_positions
1100                .iter()
1101                .filter(|((position_wallet, position_symbol), _)| {
1102                    position_wallet == wallet
1103                        && contract_key(position_symbol).as_deref() == Some(contract.as_str())
1104                })
1105                .map(|(_, position)| position.quantity)
1106                .sum::<Decimal>();
1107            let open_sell_quantity = order_index.get_open_sells_for_contract(wallet, &contract);
1108            let pre_order_available_long = current_position - open_sell_quantity;
1109            let post_order_available_long = pre_order_available_long + proposed_net;
1110
1111            if post_order_available_long < dec!(0)
1112                && post_order_available_long < pre_order_available_long
1113            {
1114                return Err(format!(
1115                    "Standard margin accounts cannot increase net short options. symbol={}, current_position={}, proposed_net={}, open_sell_orders={}, shortfall={}",
1116                    contract,
1117                    current_position,
1118                    proposed_net,
1119                    open_sell_quantity,
1120                    -post_order_available_long
1121                ));
1122            }
1123        }
1124
1125        Ok(())
1126    }
1127
1128    fn add_open_sell_orders_to_standard_account(
1129        &self,
1130        deps: &EngineDeps,
1131        account: &mut hypercall_margin::standard::StandardAccount,
1132        wallet: &WalletAddress,
1133        order_index: &EngineOrderIndex,
1134    ) -> Result<(), String> {
1135        let open_sell_positions = order_index.get_open_sell_option_positions(wallet);
1136        for mut pos in open_sell_positions {
1137            if pos.position.spot_price == dec!(0) {
1138                let spot_f64 = self
1139                    .get_spot_price_for_margin(deps, &pos.position.underlying)
1140                    .map_err(|e| {
1141                        format!(
1142                            "Cannot check margin for open sell order on {}: {}",
1143                            pos.position.symbol, e
1144                        )
1145                    })?;
1146                pos.position.spot_price = Decimal::from_f64_retain(spot_f64).ok_or_else(|| {
1147                    format!(
1148                        "Invalid spot price for {}: {} - cannot convert to Decimal",
1149                        pos.position.underlying, spot_f64
1150                    )
1151                })?;
1152            }
1153            account.option_positions.push(pos.position);
1154        }
1155        Ok(())
1156    }
1157
1158    // ===== Hypothetical account building =====
1159
1160    /// Build a hypothetical Account representing the state if every
1161    /// `proposed_order` fills on top of the wallet's current executed
1162    /// state plus its existing open orders. Used by
1163    /// `check_margin_for_orders` to score multi-leg RFQ commands as a
1164    /// single portfolio snapshot.
1165    fn build_hypothetical_account_for_orders(
1166        &self,
1167        deps: &EngineDeps,
1168        engine_positions: &EnginePositionMap,
1169        balance_ledger: &HashMap<WalletAddress, Decimal>,
1170        wallet: &WalletAddress,
1171        proposed_orders: &[OrderInfo],
1172        order_index: &EngineOrderIndex,
1173    ) -> Result<Account, String> {
1174        let mut account = self.get_risk_account(deps, engine_positions, balance_ledger, wallet)?;
1175
1176        let open_orders = self.fetch_open_orders(order_index, wallet);
1177        let mut all_orders = open_orders;
1178        for order_info in proposed_orders {
1179            all_orders.push(self.create_simulated_order(order_info, wallet));
1180        }
1181
1182        let mut spot_prices: HashMap<String, f64> = HashMap::new();
1183        for order in &all_orders {
1184            let underlying = if let Ok(parsed) = ParsedSymbol::from_symbol(&order.symbol) {
1185                parsed.underlying.clone()
1186            } else {
1187                perp_underlying(&order.symbol)
1188                    .ok_or_else(|| {
1189                        format!(
1190                            "Unsupported non-option order symbol {} in hypothetical margin simulation",
1191                            order.symbol
1192                        )
1193                    })?
1194                    .to_string()
1195            };
1196
1197            if !spot_prices.contains_key(&underlying) {
1198                let price = self.get_spot_price_for_margin(deps, &underlying)?;
1199                spot_prices.insert(underlying.clone(), price);
1200            }
1201        }
1202
1203        debug!(
1204            "build_hypothetical_account_for_orders: wallet={}, executed_positions={}, \
1205             open_orders+proposed={}, proposed_legs={}, spot_prices={:?}",
1206            wallet,
1207            account.portfolio.len(),
1208            all_orders.len(),
1209            proposed_orders.len(),
1210            spot_prices
1211        );
1212
1213        for order in &all_orders {
1214            self.apply_order_to_account(deps, &mut account, order, &spot_prices)?;
1215        }
1216
1217        Ok(account)
1218    }
1219
1220    /// Build a hypothetical Account representing the state if the proposed order fills.
1221    fn build_hypothetical_account_for_order(
1222        &self,
1223        deps: &EngineDeps,
1224        engine_positions: &EnginePositionMap,
1225        balance_ledger: &HashMap<WalletAddress, Decimal>,
1226        wallet: &WalletAddress,
1227        order_info: &OrderInfo,
1228        order_index: &EngineOrderIndex,
1229    ) -> Result<Account, String> {
1230        let mut account = self.get_risk_account(deps, engine_positions, balance_ledger, wallet)?;
1231
1232        let open_orders = self.fetch_open_orders(order_index, wallet);
1233        let simulated_order = self.create_simulated_order(order_info, wallet);
1234
1235        let mut all_orders = open_orders;
1236        all_orders.push(simulated_order);
1237
1238        let mut spot_prices: HashMap<String, f64> = HashMap::new();
1239        for order in &all_orders {
1240            let underlying = if let Ok(parsed) = ParsedSymbol::from_symbol(&order.symbol) {
1241                parsed.underlying.clone()
1242            } else {
1243                perp_underlying(&order.symbol)
1244                    .ok_or_else(|| {
1245                        format!(
1246                            "Unsupported non-option order symbol {} in hypothetical margin simulation",
1247                            order.symbol
1248                        )
1249                    })?
1250                    .to_string()
1251            };
1252
1253            if !spot_prices.contains_key(&underlying) {
1254                let price = self.get_spot_price_for_margin(deps, &underlying)?;
1255                spot_prices.insert(underlying.clone(), price);
1256            }
1257        }
1258
1259        debug!(
1260            "build_hypothetical_account: wallet={}, executed_positions={}, open_orders={}, spot_prices={:?}",
1261            wallet,
1262            account.portfolio.len(),
1263            all_orders.len(),
1264            spot_prices
1265        );
1266
1267        for order in &all_orders {
1268            self.apply_order_to_account(deps, &mut account, order, &spot_prices)?;
1269        }
1270
1271        debug!(
1272            "build_hypothetical_account: final portfolio has {} positions, cash={}",
1273            account.portfolio.len(),
1274            account.cash
1275        );
1276
1277        for (underlying, position) in &account.portfolio {
1278            debug!(
1279                "build_hypothetical_account: underlying={}, spot={}, delta={}, options={}",
1280                underlying,
1281                position.spot,
1282                position.delta,
1283                position.options.len()
1284            );
1285            for option in &position.options {
1286                debug!(
1287                    "  option: type={:?}, strike={}, expiry={:.4}, qty={}, entry={}",
1288                    option.option_type,
1289                    option.strike,
1290                    option.expiry,
1291                    option.quantity,
1292                    option.entry_price
1293                );
1294            }
1295        }
1296
1297        Ok(account)
1298    }
1299
1300    /// Apply an order as a hypothetical fill to an account's portfolio.
1301    fn apply_order_to_account(
1302        &self,
1303        deps: &EngineDeps,
1304        account: &mut Account,
1305        order: &ApiOrder,
1306        spot_prices: &HashMap<String, f64>,
1307    ) -> Result<(), String> {
1308        let remaining_size = order.size - order.filled_size.unwrap_or(dec!(0));
1309        if remaining_size <= dec!(0) {
1310            return Ok(());
1311        }
1312        let remaining_size_u64 = to_contract_units_decimal(&order.symbol, remaining_size);
1313
1314        if let Ok(parsed) = ParsedSymbol::from_symbol(&order.symbol) {
1315            let spot_price = spot_prices
1316                .get(&parsed.underlying)
1317                .copied()
1318                .or_else(|| {
1319                    account
1320                        .portfolio
1321                        .get(&parsed.underlying)
1322                        .and_then(|position| position.spot.to_f64())
1323                })
1324                .or_else(|| deps.reference_prices.get(&parsed.underlying).copied());
1325            let spot_price = match spot_price {
1326                Some(spot_price) => spot_price,
1327                None => self
1328                    .get_spot_price_for_margin(deps, &parsed.underlying)
1329                    .map_err(|error| {
1330                        format!(
1331                            "Missing spot price for underlying {} (symbol: {}): {}",
1332                            parsed.underlying, order.symbol, error
1333                        )
1334                    })?,
1335            };
1336
1337            let position = account
1338                .portfolio
1339                .entry(parsed.underlying.clone())
1340                .or_insert_with(|| Position {
1341                    spot: Decimal::from_f64_retain(spot_price).unwrap_or_else(|| {
1342                        panic!(
1343                            "STATE_CORRUPTION: validated option spot price {} for {} is not representable as Decimal",
1344                            spot_price, parsed.underlying
1345                        )
1346                    }),
1347                    delta: Decimal::ZERO,
1348                    perp_unrealized_pnl: Decimal::ZERO,
1349                    options: Vec::new(),
1350                });
1351
1352            let size_in_units_decimal =
1353                to_human_readable_decimal(&order.symbol, remaining_size_u64);
1354
1355            let quantity_change = match order.side.as_str() {
1356                "Buy" => size_in_units_decimal,
1357                "Sell" => -size_in_units_decimal,
1358                _ => {
1359                    panic!(
1360                        "STATE_CORRUPTION: Unknown order side '{}' for order on symbol {}. \
1361                         Invalid order state - margin calculation cannot proceed safely. Restart required.",
1362                        order.side, order.symbol
1363                    );
1364                }
1365            };
1366
1367            let expiry_years = expiry_to_years(&parsed.underlying, parsed.expiry);
1368            let strike_f64 = parsed.strike.to_f64().ok_or_else(|| {
1369                format!(
1370                    "invalid strike in order symbol {} - cannot compute margin safely",
1371                    order.symbol
1372                )
1373            })?;
1374            let expiry_ts = expiry_date_to_timestamp(&parsed.underlying, parsed.expiry) as i64;
1375            if expiry_ts <= 0 {
1376                return Err(format!(
1377                    "invalid expiry in order symbol {} - cannot compute margin safely",
1378                    order.symbol
1379                ));
1380            }
1381            let theoretical_price = crate::rsm::black_scholes::black_scholes_with_moments(
1382                &parsed.option_type,
1383                spot_price,
1384                strike_f64,
1385                expiry_years,
1386                0.05,
1387                0.50,
1388                0.0,
1389                0.0,
1390            );
1391
1392            position.options.push(OptionContract {
1393                option_type: parsed.option_type.clone(),
1394                strike: parsed.strike,
1395                expiry_ts,
1396                expiry: Decimal::from_f64_retain(expiry_years).unwrap_or_else(|| {
1397                    panic!(
1398                        "STATE_CORRUPTION: validated option expiry {} for {} is not representable as Decimal",
1399                        expiry_years, order.symbol
1400                    )
1401                }),
1402                quantity: quantity_change,
1403                entry_price: Decimal::from_f64_retain(theoretical_price).unwrap_or_else(|| {
1404                    panic!(
1405                        "STATE_CORRUPTION: theoretical option price {} for {} is not representable as Decimal",
1406                        theoretical_price, order.symbol
1407                    )
1408                }),
1409            });
1410        } else {
1411            let underlying = perp_underlying(&order.symbol)
1412                .ok_or_else(|| {
1413                    format!(
1414                        "Unsupported non-option order symbol {} in hypothetical margin simulation",
1415                        order.symbol
1416                    )
1417                })?
1418                .to_string();
1419            let spot_price = spot_prices
1420                .get(&underlying)
1421                .copied()
1422                .or_else(|| {
1423                    account
1424                        .portfolio
1425                        .get(&underlying)
1426                        .and_then(|position| position.spot.to_f64())
1427                })
1428                .or_else(|| deps.reference_prices.get(&underlying).copied());
1429            let spot_price = match spot_price {
1430                Some(spot_price) => spot_price,
1431                None => self
1432                    .get_spot_price_for_margin(deps, &underlying)
1433                    .map_err(|error| {
1434                        format!(
1435                            "Missing spot price for perp underlying {} (symbol: {}): {}",
1436                            underlying, order.symbol, error
1437                        )
1438                    })?,
1439            };
1440
1441            let position = account
1442                .portfolio
1443                .entry(underlying.clone())
1444                .or_insert_with(|| Position {
1445                    spot: Decimal::from_f64_retain(spot_price).unwrap_or_else(|| {
1446                        panic!(
1447                            "STATE_CORRUPTION: validated perp spot price {} for {} is not representable as Decimal",
1448                            spot_price, underlying
1449                        )
1450                    }),
1451                    delta: Decimal::ZERO,
1452                    perp_unrealized_pnl: Decimal::ZERO,
1453                    options: Vec::new(),
1454                });
1455
1456            let size_in_units_decimal =
1457                to_human_readable_decimal(&order.symbol, remaining_size_u64);
1458            let delta_change = match order.side.as_str() {
1459                "Buy" => size_in_units_decimal,
1460                "Sell" => -size_in_units_decimal,
1461                _ => {
1462                    panic!(
1463                        "STATE_CORRUPTION: Unknown order side '{}' for perp order on symbol {}. \
1464                         Invalid order state - margin calculation cannot proceed safely. Restart required.",
1465                        order.side, order.symbol
1466                    );
1467                }
1468            };
1469
1470            position.delta += delta_change;
1471        }
1472
1473        Ok(())
1474    }
1475
1476    /// Run margin calculation on an Account using the MarginService.
1477    pub fn run_margin_on_account(
1478        &self,
1479        account: &Account,
1480    ) -> Result<Option<MarginDetails>, String> {
1481        self.run_margin_on_account_at(account, chrono::Utc::now().timestamp())
1482    }
1483
1484    pub fn run_margin_on_account_with_deps(
1485        &self,
1486        account: &Account,
1487        deps: &EngineDeps,
1488    ) -> Result<Option<MarginDetails>, String> {
1489        self.run_margin_on_account_at(account, deps.margin_timestamp_s)
1490    }
1491
1492    fn run_margin_on_account_at(
1493        &self,
1494        account: &Account,
1495        now_ts: i64,
1496    ) -> Result<Option<MarginDetails>, String> {
1497        let margin_details = self
1498            .span_margin_service
1499            .compute_margin_for_account_at(account, now_ts)
1500            .map_err(|e| e.to_string())?;
1501
1502        debug!(
1503            "run_margin_on_account: account={}, equity={:.2}, IM={:.2}, scanning_risk={:.2}",
1504            account.id,
1505            margin_details.equity,
1506            margin_details.initial_margin_required,
1507            margin_details.scanning_risk
1508        );
1509
1510        Ok(Some(margin_details))
1511    }
1512
1513    /// Check tier restrictions (tier1 can only go long).
1514    pub fn check_tier_restrictions(
1515        &self,
1516        deps: &EngineDeps,
1517        engine_positions: &EnginePositionMap,
1518        balance_ledger: &HashMap<WalletAddress, Decimal>,
1519        wallet: &WalletAddress,
1520        order_info: &OrderInfo,
1521        order_index: &EngineOrderIndex,
1522    ) -> Result<(), String> {
1523        if order_info.is_perp {
1524            return Ok(());
1525        }
1526
1527        let tier_owned = deps
1528            .wallet_tiers
1529            .get(wallet)
1530            .cloned()
1531            .unwrap_or_else(|| "tier2".to_string());
1532        let tier = tier_owned.as_str();
1533
1534        if tier == "tier2" || tier == "market_maker" {
1535            return Ok(());
1536        }
1537
1538        match order_info.side {
1539            Side::Buy => Ok(()),
1540            Side::Sell => {
1541                let parsed = ParsedSymbol::from_symbol(&order_info.symbol)
1542                    .map_err(|e| format!("Failed to parse symbol: {}", e))?;
1543
1544                let account =
1545                    self.get_risk_account(deps, engine_positions, balance_ledger, wallet)?;
1546                let position = account.portfolio.get(&parsed.underlying);
1547
1548                let mut filled_long_position = dec!(0);
1549                if let Some(pos) = position {
1550                    let target_expiry_ts = hypercall_types::expiry_date_to_timestamp(
1551                        &parsed.underlying,
1552                        parsed.expiry,
1553                    ) as i64;
1554                    if target_expiry_ts <= 0 {
1555                        return Err(format!(
1556                            "Invalid expiry in order symbol: {}",
1557                            order_info.symbol
1558                        ));
1559                    }
1560                    for option_contract in &pos.options {
1561                        if option_contract.option_type == parsed.option_type
1562                            && (option_contract.strike - parsed.strike).abs() < dec!(0.01)
1563                            && option_contract.expiry_ts == target_expiry_ts
1564                            && option_contract.quantity > Decimal::ZERO
1565                        {
1566                            filled_long_position += option_contract.quantity;
1567                        }
1568                    }
1569                }
1570
1571                hypercall_engine::admission::validate_tier_sell_restriction(
1572                    tier,
1573                    wallet,
1574                    order_info,
1575                    filled_long_position,
1576                    order_index,
1577                )
1578            }
1579        }
1580    }
1581}
1582
1583use crate::shared::order_types::perp_underlying;
1584
1585/// Convert expiry date (YYYYMMDD format) to Unix timestamp.
1586///
1587/// This is a free function so that both `MarginManager` and `ExpiryManager`
1588/// can use it without circular dependencies.
1589pub fn expiry_date_to_timestamp(underlying: &str, expiry: u64) -> u64 {
1590    u64::try_from(hypercall_types::expiry_date_to_timestamp(
1591        underlying, expiry,
1592    ))
1593    .unwrap_or(0)
1594}