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}