//! Position Lifecycle Integration Tests //! //! Comprehensive tests for position management covering: //! - Position opening and closing //! - Real-time PnL calculation and tracking //! - Position updates from order fills //! - Multi-symbol position management //! - Position reconciliation //! - Edge cases: zero positions, negative positions (shorts) use anyhow::Result; use std::sync::Arc; use tonic::Request; use trading_service::proto::trading::{ trading_service_server::TradingService, GetPortfolioSummaryRequest, GetPositionsRequest, OrderSide, OrderType, SubmitOrderRequest, }; use trading_service::{services::trading::TradingServiceImpl, state::TradingServiceState}; /// Setup test trading service instance async fn setup_trading_service() -> Result { let state = Arc::new(TradingServiceState::new_for_testing().await?); Ok(TradingServiceImpl::new(state)) } // ============================================================================ // Position Opening Tests // ============================================================================ #[tokio::test] async fn test_open_long_position() -> Result<()> { println!("\n=== Test: Open Long Position ==="); let service = setup_trading_service().await?; let account_id = "position_test_001"; // Submit buy order to open long position let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let buy_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "AAPL".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity: 100.0, price: None, stop_price: None, metadata, }); let buy_response = service.submit_order(buy_request).await?; let order_id = buy_response.into_inner().order_id; println!(" Buy order submitted: {}", order_id); // Get positions let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("AAPL".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); println!(" Positions found: {}", positions.positions.len()); for pos in &positions.positions { println!(" Symbol: {}, Quantity: {}", pos.symbol, pos.quantity); } Ok(()) } #[tokio::test] async fn test_open_short_position() -> Result<()> { println!("\n=== Test: Open Short Position ==="); let service = setup_trading_service().await?; let account_id = "position_test_002"; // Submit sell order to open short position let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let sell_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "TSLA".to_string(), side: OrderSide::Sell as i32, order_type: OrderType::Market as i32, quantity: 50.0, price: None, stop_price: None, metadata, }); let sell_response = service.submit_order(sell_request).await?; let order_id = sell_response.into_inner().order_id; println!(" Sell order submitted: {}", order_id); // Get positions let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("TSLA".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); println!(" Positions found: {}", positions.positions.len()); for pos in &positions.positions { println!(" Symbol: {}, Quantity: {}", pos.symbol, pos.quantity); // Short position should show negative quantity } Ok(()) } #[tokio::test] async fn test_open_multiple_positions() -> Result<()> { println!("\n=== Test: Open Multiple Positions ==="); let service = setup_trading_service().await?; let account_id = "position_test_003"; let symbols = vec![ ("AAPL", 100.0), ("GOOGL", 50.0), ("MSFT", 75.0), ("NVDA", 25.0), ]; // Open positions in multiple symbols for (symbol, quantity) in &symbols { let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: symbol.to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity: *quantity, price: None, stop_price: None, metadata, }); let response = service.submit_order(request).await?; println!(" Position opened: {} - {} shares", symbol, quantity); let _ = response.into_inner(); } // Get all positions let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: None, // All symbols }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); println!(" Total positions: {}", positions.positions.len()); for pos in &positions.positions { println!(" {}: {} shares", pos.symbol, pos.quantity); } Ok(()) } // ============================================================================ // Position Closing Tests // ============================================================================ #[tokio::test] async fn test_close_long_position() -> Result<()> { println!("\n=== Test: Close Long Position ==="); let service = setup_trading_service().await?; let account_id = "position_test_004"; // 1. Open long position let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let buy_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "AMD".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity: 100.0, price: None, stop_price: None, metadata: metadata.clone(), }); let _ = service.submit_order(buy_request).await?; println!(" 1. Long position opened: 100 AMD"); // 2. Close position let sell_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "AMD".to_string(), side: OrderSide::Sell as i32, order_type: OrderType::Market as i32, quantity: 100.0, price: None, stop_price: None, metadata, }); let _ = service.submit_order(sell_request).await?; println!(" 2. Position closed: Sold 100 AMD"); // 3. Verify position is zero or removed let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("AMD".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); println!(" 3. Positions after close: {}", positions.positions.len()); for pos in &positions.positions { println!(" {}: {} shares", pos.symbol, pos.quantity); } Ok(()) } #[tokio::test] async fn test_partial_position_close() -> Result<()> { println!("\n=== Test: Partial Position Close ==="); let service = setup_trading_service().await?; let account_id = "position_test_005"; // 1. Open position with 200 shares let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let buy_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "SPY".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity: 200.0, price: None, stop_price: None, metadata: metadata.clone(), }); let _ = service.submit_order(buy_request).await?; println!(" 1. Position opened: 200 SPY"); // 2. Partially close (sell 75 shares) let sell_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "SPY".to_string(), side: OrderSide::Sell as i32, order_type: OrderType::Market as i32, quantity: 75.0, price: None, stop_price: None, metadata, }); let _ = service.submit_order(sell_request).await?; println!(" 2. Partially closed: Sold 75 SPY"); // 3. Check remaining position (should be ~125 shares) let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("SPY".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); println!(" 3. Remaining position:"); for pos in &positions.positions { println!(" {}: {} shares", pos.symbol, pos.quantity); } Ok(()) } // ============================================================================ // PnL Tracking Tests // ============================================================================ #[tokio::test] async fn test_portfolio_summary() -> Result<()> { println!("\n=== Test: Portfolio Summary ==="); let service = setup_trading_service().await?; let account_id = "position_test_006"; // Open some positions let symbols = vec![("AAPL", 50.0), ("GOOGL", 25.0)]; for (symbol, quantity) in &symbols { let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: symbol.to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity: *quantity, price: None, stop_price: None, metadata, }); let _ = service.submit_order(request).await?; } // Get portfolio summary let summary_request = Request::new(GetPortfolioSummaryRequest { account_id: account_id.to_string(), }); let summary_response = service.get_portfolio_summary(summary_request).await?; let summary = summary_response.into_inner(); println!("\n Portfolio Summary:"); println!(" ├─ Total Value: ${:.2}", summary.total_value); println!(" ├─ Unrealized PnL: ${:.2}", summary.unrealized_pnl); println!(" ├─ Realized PnL: ${:.2}", summary.realized_pnl); println!(" ├─ Day PnL: ${:.2}", summary.day_pnl); println!(" ├─ Buying Power: ${:.2}", summary.buying_power); println!(" ├─ Margin Used: ${:.2}", summary.margin_used); println!(" └─ Positions: {}", summary.positions.len()); Ok(()) } #[tokio::test] async fn test_unrealized_pnl_calculation() -> Result<()> { println!("\n=== Test: Unrealized PnL Calculation ==="); let service = setup_trading_service().await?; let account_id = "position_test_007"; // Open position let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); metadata.insert("entry_price".to_string(), "100.00".to_string()); let request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "AAPL".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Limit as i32, quantity: 100.0, price: Some(100.00), // Entry price stop_price: None, metadata, }); let _ = service.submit_order(request).await?; println!(" Position opened at $100.00"); // Get position and check unrealized PnL let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("AAPL".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); for pos in &positions.positions { println!("\n Position Details:"); println!(" ├─ Symbol: {}", pos.symbol); println!(" ├─ Quantity: {}", pos.quantity); println!(" ├─ Average Price: ${:.2}", pos.average_price); // Note: current_price not in proto, using market_value/quantity let current_price = if pos.quantity != 0.0 { pos.market_value / pos.quantity.abs() } else { 0.0 }; println!(" ├─ Current Price: ${:.2}", current_price); println!(" ├─ Unrealized PnL: ${:.2}", pos.unrealized_pnl); println!(" └─ Total Value: ${:.2}", pos.market_value); } Ok(()) } #[tokio::test] async fn test_realized_pnl_on_close() -> Result<()> { println!("\n=== Test: Realized PnL on Close ==="); let service = setup_trading_service().await?; let account_id = "position_test_008"; // 1. Buy at specific price let mut buy_metadata = std::collections::HashMap::new(); buy_metadata.insert("time_in_force".to_string(), "GTC".to_string()); let buy_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "MSFT".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Limit as i32, quantity: 50.0, price: Some(350.00), stop_price: None, metadata: buy_metadata, }); let _ = service.submit_order(buy_request).await?; println!(" 1. Bought 50 MSFT at $350.00"); // 2. Sell at different price let mut sell_metadata = std::collections::HashMap::new(); sell_metadata.insert("time_in_force".to_string(), "GTC".to_string()); let sell_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "MSFT".to_string(), side: OrderSide::Sell as i32, order_type: OrderType::Limit as i32, quantity: 50.0, price: Some(360.00), stop_price: None, metadata: sell_metadata, }); let _ = service.submit_order(sell_request).await?; println!(" 2. Sold 50 MSFT at $360.00"); // 3. Check portfolio summary for realized PnL let summary_request = Request::new(GetPortfolioSummaryRequest { account_id: account_id.to_string(), }); let summary_response = service.get_portfolio_summary(summary_request).await?; let summary = summary_response.into_inner(); println!("\n Realized PnL: ${:.2}", summary.realized_pnl); println!(" Expected: $500.00 (50 shares × $10 profit)"); Ok(()) } // ============================================================================ // Position Reconciliation Tests // ============================================================================ #[tokio::test] async fn test_position_average_price_calculation() -> Result<()> { println!("\n=== Test: Position Average Price Calculation ==="); let service = setup_trading_service().await?; let account_id = "position_test_009"; // Buy in multiple lots at different prices let lots = vec![(50.0, 100.00), (30.0, 105.00), (20.0, 98.00)]; for (quantity, price) in &lots { let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "NVDA".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Limit as i32, quantity: *quantity, price: Some(*price), stop_price: None, metadata, }); let _ = service.submit_order(request).await?; println!(" Bought {} NVDA at ${:.2}", quantity, price); } // Check average entry price let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("NVDA".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); for pos in &positions.positions { let expected_avg = (50.0 * 100.00 + 30.0 * 105.00 + 20.0 * 98.00) / 100.0; println!("\n Total Position: {} shares", pos.quantity); println!(" Average Entry Price: ${:.2}", pos.average_price); println!(" Expected: ${:.2}", expected_avg); } Ok(()) } #[tokio::test] async fn test_zero_position_after_equal_buys_sells() -> Result<()> { println!("\n=== Test: Zero Position After Equal Buys/Sells ==="); let service = setup_trading_service().await?; let account_id = "position_test_010"; let quantity = 100.0; // Buy let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let buy_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "AMD".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity, price: None, stop_price: None, metadata: metadata.clone(), }); let _ = service.submit_order(buy_request).await?; println!(" Bought {} AMD", quantity); // Sell equal amount let sell_request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "AMD".to_string(), side: OrderSide::Sell as i32, order_type: OrderType::Market as i32, quantity, price: None, stop_price: None, metadata, }); let _ = service.submit_order(sell_request).await?; println!(" Sold {} AMD", quantity); // Position should be zero let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("AMD".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); println!("\n Positions: {}", positions.positions.len()); for pos in &positions.positions { println!( " {}: {} shares (should be 0 or absent)", pos.symbol, pos.quantity ); } Ok(()) } // ============================================================================ // Edge Cases and Error Handling // ============================================================================ #[tokio::test] async fn test_get_positions_empty_account() -> Result<()> { println!("\n=== Test: Get Positions - Empty Account ==="); let service = setup_trading_service().await?; let request = Request::new(GetPositionsRequest { account_id: Some("empty_account_999".to_string()), symbol: None, }); let response = service.get_positions(request).await?; let positions = response.into_inner(); println!( " Positions for empty account: {}", positions.positions.len() ); assert_eq!(positions.positions.len(), 0); Ok(()) } #[tokio::test] async fn test_get_positions_nonexistent_symbol() -> Result<()> { println!("\n=== Test: Get Positions - Nonexistent Symbol ==="); let service = setup_trading_service().await?; let account_id = "position_test_011"; // Open position in AAPL let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); let request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "AAPL".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity: 10.0, price: None, stop_price: None, metadata, }); let _ = service.submit_order(request).await?; // Query for different symbol let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("NONEXISTENT".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); println!( " Positions for nonexistent symbol: {}", positions.positions.len() ); assert_eq!(positions.positions.len(), 0); Ok(()) } #[tokio::test] async fn test_position_with_fractional_shares() -> Result<()> { println!("\n=== Test: Position with Fractional Shares ==="); let service = setup_trading_service().await?; let account_id = "position_test_012"; let mut metadata = std::collections::HashMap::new(); metadata.insert("time_in_force".to_string(), "GTC".to_string()); metadata.insert("allow_fractional".to_string(), "true".to_string()); let request = Request::new(SubmitOrderRequest { account_id: account_id.to_string(), symbol: "TSLA".to_string(), side: OrderSide::Buy as i32, order_type: OrderType::Market as i32, quantity: 15.75, // Fractional shares price: None, stop_price: None, metadata, }); let response = service.submit_order(request).await?; println!(" Fractional order: {}", response.into_inner().order_id); let positions_request = Request::new(GetPositionsRequest { account_id: Some(account_id.to_string()), symbol: Some("TSLA".to_string()), }); let positions_response = service.get_positions(positions_request).await?; let positions = positions_response.into_inner(); for pos in &positions.positions { println!(" Position: {} shares of {}", pos.quantity, pos.symbol); } Ok(()) }