Skip to main content

hypercall/portfolio/
portfolio_service.rs

1use async_trait::async_trait;
2use hypercall_types::api_models::Portfolio;
3use hypercall_types::FillAccounting;
4use hypercall_types::Side;
5use hypercall_types::WalletAddress;
6use hypercall_types::{EngineMessage, Fill};
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use serde::{Deserialize, Serialize};
10use std::any::Any;
11use std::collections::HashMap;
12
13// HypercorePositionUpdate is now defined in hypercall-types. Re-export for backward compat.
14pub use hypercall_types::HypercorePositionUpdate;
15
16pub(crate) fn canonical_perp_symbol(coin: &str) -> String {
17    let normalized = coin.trim().to_ascii_uppercase();
18    if normalized.ends_with("-PERP") {
19        normalized
20    } else {
21        format!("{}-PERP", normalized)
22    }
23}
24
25/// Core portfolio state for an account.
26///
27/// This is the internal representation for positions and cost basis.
28/// For API responses, use `api_server::models::Portfolio` instead.
29///
30/// Collateral comes from the engine-owned balance ledger published through
31/// EngineSnapshot, not this struct.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PortfolioBalance {
34    pub positions: HashMap<String, PositionData>,
35    pub total_margin_used: Decimal,
36}
37
38impl Default for PortfolioBalance {
39    fn default() -> Self {
40        Self {
41            positions: HashMap::new(),
42            total_margin_used: dec!(0),
43        }
44    }
45}
46
47/// Position data for a single instrument.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PositionData {
50    pub symbol: String,
51    pub amount: Decimal,
52    pub entry_price: Decimal,
53    pub margin_posted: Decimal,
54    pub realized_pnl: Decimal,
55    pub unrealized_pnl: Decimal,
56}
57
58/// Error type for portfolio operations.
59#[derive(Debug, Clone)]
60pub enum PortfolioError {
61    /// Internal state error
62    InternalError(String),
63    /// Ledger operation failed - CRITICAL: should halt processing
64    LedgerError(String),
65}
66
67/// Change to a single position, returned from apply_event.
68#[derive(Debug, Clone)]
69pub struct PositionChange {
70    /// Symbol of the position that changed
71    pub symbol: String,
72    /// New position amount (0 = closed)
73    pub amount: Decimal,
74    /// Entry price
75    pub entry_price: Decimal,
76    /// Margin posted
77    pub margin_posted: Decimal,
78    /// Realized PnL
79    pub realized_pnl: Decimal,
80    /// Unrealized PnL
81    pub unrealized_pnl: Decimal,
82}
83
84/// Result of applying an event to the portfolio.
85///
86/// Captures what changed so callers can send notifications without
87/// re-fetching portfolio state.
88#[derive(Debug, Clone)]
89pub struct PortfolioChange {
90    /// Wallet that was affected
91    pub wallet: WalletAddress,
92    /// Position changes (one per affected position)
93    pub position_changes: Vec<PositionChange>,
94    /// New balance if it changed (e.g., from settlement or premium)
95    pub balance_change: Option<Decimal>,
96    /// Total margin used (for balance updates)
97    pub total_margin_used: Decimal,
98}
99
100impl std::fmt::Display for PortfolioError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            PortfolioError::InternalError(msg) => write!(f, "Portfolio error: {}", msg),
104            PortfolioError::LedgerError(msg) => write!(f, "Ledger error (CRITICAL): {}", msg),
105        }
106    }
107}
108
109impl std::error::Error for PortfolioError {}
110
111/// Portfolio state management interface.
112///
113/// Pure state mutation for portfolio positions, balances, and P&L.
114/// Used by engine, API, and liquidator on the hot path.
115///
116/// Accounts are created lazily. `get_portfolio` always succeeds and returns
117/// an empty portfolio (zero balance, no positions) if the account doesn't exist.
118#[async_trait]
119pub trait PortfolioService: Send + Sync {
120    /// Get current portfolio state for an account (API format).
121    ///
122    /// Returns empty portfolio if account doesn't exist. Must be O(1).
123    async fn get_portfolio(&self, account: &WalletAddress) -> Portfolio;
124
125    /// Get internal balance state for risk/margin calculations.
126    ///
127    /// Returns None if account doesn't exist (no lazy creation).
128    /// This is the raw internal state, not the API-formatted Portfolio.
129    async fn get_portfolio_balance(&self, account: &WalletAddress) -> Option<PortfolioBalance>;
130
131    /// Get all portfolios as a snapshot.
132    ///
133    /// Returns a clone of all in-memory portfolio state.
134    /// Used for persistence/snapshots and open interest calculations.
135    async fn all_portfolios(&self) -> HashMap<WalletAddress, PortfolioBalance>;
136
137    /// Apply an event from the matching engine.
138    ///
139    /// Pure state mutation - updates positions and balances.
140    /// Events: OrderFilled, OrderUpdate, OrderCanceled, PositionExpired.
141    ///
142    /// Returns a list of `PortfolioChange` structs, one per affected wallet.
143    /// For fills, this includes both taker and maker. For other events, typically one.
144    /// Returns empty vec if no portfolio state changed (e.g., unhandled event type).
145    ///
146    /// # Errors
147    /// Returns `PortfolioError::LedgerError` if realized PnL cannot be applied to
148    /// the ledger. This is a CRITICAL failure - callers should halt processing.
149    async fn apply_event(
150        &self,
151        event: &EngineMessage,
152    ) -> Result<Vec<PortfolioChange>, PortfolioError>;
153
154    /// Remove an expired position from memory without applying a cash delta.
155    ///
156    /// Settlement accounting is owned by the settlement persistence path. Projection code
157    /// uses this to clean up positions without double-crediting the ledger.
158    async fn remove_expired_position(&self, wallet: &WalletAddress, symbol: &str);
159
160    /// Apply perp position update from Hypercore Position Service (incremental diff).
161    ///
162    /// Used for snapshot=false updates. Idempotent - safe to apply the same update multiple times.
163    async fn apply_hypercore_position_update(&self, update: &HypercorePositionUpdate);
164
165    /// Set perp position from Hypercore Position Service (snapshot).
166    ///
167    /// Used for snapshot=true updates. Sets/replaces a single perp position.
168    /// Called on service startup for initial state sync.
169    async fn set_hypercore_position(&self, update: &HypercorePositionUpdate);
170
171    /// Returns a reference to self as Any for downcasting.
172    ///
173    /// This is used to access implementation-specific methods like
174    /// `set_ledger` and `set_tier_cache` on PortfolioServiceImpl.
175    fn as_any(&self) -> &dyn Any;
176
177    // ===== Crash-Safe Fill Processing Methods =====
178
179    /// Calculate the accounting delta that would result from applying a fill.
180    ///
181    /// This is a pure calculation with no side effects - used to determine
182    /// what to write to the ledger in the atomic DB transaction.
183    async fn calculate_fill_accounting(
184        &self,
185        fill: &Fill,
186    ) -> Result<FillAccounting, PortfolioError>;
187
188    /// Apply a fill's position changes to in-memory state only.
189    ///
190    /// This does NOT update the ledger - that's handled by the atomic DB transaction.
191    /// Call this only after the DB transaction succeeds.
192    async fn apply_fill_to_memory(
193        &self,
194        wallet: &WalletAddress,
195        symbol: &str,
196        side: &Side,
197        price: Decimal,
198        quantity: Decimal,
199    );
200}