Files
foxhunt/services/trading_service/tests/position_lifecycle.rs

667 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
SubmitOrderRequest, GetPositionsRequest, GetPortfolioSummaryRequest,
OrderSide, OrderType
};
use trading_service::{
state::TradingServiceState,
services::trading::TradingServiceImpl,
};
/// Setup test trading service instance
async fn setup_trading_service() -> Result<TradingServiceImpl> {
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(())
}