Files
foxhunt/services/trading_agent_service/tests/orders_tests.rs
jgrusewski 99e8d586a8 feat(tli): Implement agent allocate-portfolio command (WAVE 12.3.3)
- Add AllocatePortfolioArgs struct with validation
- Support 5 allocation strategies (equal-weight, risk-parity, ml-optimized, mean-variance, kelly)
- Implement constraint validation (0 < min < max < 1.0, positive capital)
- Real gRPC integration with Trading Agent Service via API Gateway
- Formatted table output with portfolio allocations and risk metrics
- JWT authentication support via Bearer token in gRPC metadata
- 15 comprehensive TDD integration tests (all passing)
- Case-insensitive strategy parsing

Test Results: cargo test -p tli --test agent_commands_test
 15 passed, 0 failed

Files:
- tli/src/commands/agent.rs (NEW - 466 lines)
- tli/src/commands/mod.rs (export AgentArgs)
- tli/src/main.rs (integrate agent command)
- tli/tests/agent_commands_test.rs (NEW - 15 tests)
- tli/proto/trading_agent.proto (NEW)

Co-authored-by: Wave 12.3.3 TDD Implementation
2025-10-16 08:18:42 +02:00

492 lines
15 KiB
Rust

//! Integration tests for order generation module
//!
//! Tests order generation from portfolio allocations, including:
//! - Order generation from allocations
//! - Delta orders with existing positions
//! - Order size validation
//! - Order persistence
//! - Performance benchmarks
use chrono::Utc;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use sqlx::PgPool;
use uuid::Uuid;
use common::{OrderSide, OrderStatus, OrderType, Position, Quantity};
use trading_agent_service::orders::{OrderError, OrderGenerator, PortfolioAllocation};
/// Setup test database connection
async fn setup_database() -> PgPool {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
});
let pool = sqlx::PgPool::connect(&database_url)
.await
.expect("Failed to connect to database");
// Run migrations
sqlx::migrate!("../../migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
pool
}
/// Create a test allocation with weights
fn create_test_allocation(weights: Vec<(&str, f64)>) -> PortfolioAllocation {
let mut symbol_weights = std::collections::HashMap::new();
for (symbol, weight) in weights {
symbol_weights.insert(symbol.to_string(), weight);
}
PortfolioAllocation {
allocation_id: format!("alloc_{}", Uuid::new_v4()),
strategy_id: "test_strategy".to_string(),
total_capital: dec!(1_000_000.0), // $1M
symbol_weights,
rebalance_threshold: 0.05, // 5%
max_position_size: 0.20, // 20%
created_at: Utc::now(),
}
}
/// Create a test position
fn create_test_position(symbol: &str, quantity: Decimal, avg_price: Decimal) -> Position {
let now = Utc::now();
Position {
id: Uuid::new_v4(),
symbol: symbol.to_string(),
quantity,
avg_price,
avg_cost: avg_price,
basis: quantity * avg_price,
average_price: avg_price,
market_value: quantity * avg_price * dec!(1.01), // Assume 1% gain
unrealized_pnl: quantity * avg_price * dec!(0.01),
realized_pnl: Decimal::ZERO,
created_at: now,
updated_at: now,
last_updated: now,
current_price: Some(avg_price * dec!(1.01)),
margin_requirement: Decimal::ZERO,
notional_value: quantity * avg_price,
}
}
#[tokio::test]
async fn test_generate_orders_from_allocation() {
let pool = setup_database().await;
let generator = OrderGenerator::new(
pool.clone(),
100.0, // min_order_size: $100
500_000.0, // max_order_size: $500K (enough for $1M allocation)
);
// Create allocation: 40% ES.FUT, 30% NQ.FUT, 30% ZN.FUT
let allocation = create_test_allocation(vec![
("ES.FUT", 0.40),
("NQ.FUT", 0.30),
("ZN.FUT", 0.30),
]);
// No existing positions - all new orders
let current_positions = vec![];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(
result.is_ok(),
"Order generation should succeed: {:?}",
result.err()
);
let orders = result.expect("Orders should be present");
// Should have 3 orders (one per symbol)
assert_eq!(orders.len(), 3, "Should generate 3 orders");
// Verify order properties
for order in &orders {
assert!(order.quantity > Quantity::ZERO, "Order quantity should be positive");
assert_eq!(order.status, OrderStatus::Created, "Order should be in Created status");
assert_eq!(order.order_type, OrderType::Market, "Should use market orders");
assert_eq!(order.side, OrderSide::Buy, "All orders should be buys for new positions");
}
// Verify ES.FUT gets ~40% of capital
let es_order = orders.iter().find(|o| o.symbol.as_str() == "ES.FUT").expect("ES order should exist");
// At ~$5000/contract, $400K should be ~80 contracts
// Allow some tolerance for rounding
let expected_value = allocation.total_capital * dec!(0.40);
assert!(
expected_value > dec!(350_000) && expected_value < dec!(450_000),
"ES allocation should be ~$400K"
);
}
#[tokio::test]
async fn test_delta_orders_with_existing_positions() {
let pool = setup_database().await;
let generator = OrderGenerator::new(pool.clone(), 100.0, 500_000.0);
// Create allocation: 50% ES.FUT, 50% NQ.FUT
let allocation = create_test_allocation(vec![
("ES.FUT", 0.50),
("NQ.FUT", 0.50),
]);
// Existing positions: 70% ES.FUT, 30% NQ.FUT (overweight ES by 20%, underweight NQ by 20%)
// Total position value: ~$900K
let current_positions = vec![
create_test_position("ES.FUT", dec!(126), dec!(5000.0)), // $630K position (~70%)
create_test_position("NQ.FUT", dec!(13.5), dec!(20000.0)), // $270K position (~30%)
];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(result.is_ok(), "Delta order generation should succeed: {:?}", result.err());
let orders = result.expect("Orders should be present");
// Should have 2 orders (one per symbol with significant delta >5% threshold)
assert_eq!(orders.len(), 2, "Should generate 2 delta orders, got {}: {:?}", orders.len(), orders.iter().map(|o| (o.symbol.as_str(), o.side.to_string())).collect::<Vec<_>>());
// Find ES order - should be SELL (reduce overweight position)
let es_order = orders
.iter()
.find(|o| o.symbol.as_str() == "ES.FUT")
.expect("ES order should exist");
assert_eq!(
es_order.side,
OrderSide::Sell,
"ES order should be SELL to reduce overweight"
);
// Find NQ order - should be BUY (increase underweight position)
let nq_order = orders
.iter()
.find(|o| o.symbol.as_str() == "NQ.FUT")
.expect("NQ order should exist");
assert_eq!(
nq_order.side,
OrderSide::Buy,
"NQ order should be BUY to increase underweight"
);
}
#[tokio::test]
async fn test_order_size_validation_min_size() {
let pool = setup_database().await;
let generator = OrderGenerator::new(
pool.clone(),
100_000.0, // min_order_size: $100K (high minimum)
500_000.0, // max_order_size: $500K
);
// Create allocation with small weights that would generate tiny orders
let allocation = create_test_allocation(vec![
("ES.FUT", 0.02), // 2% = $20K (below $100K minimum)
("NQ.FUT", 0.98), // 98% = $980K
]);
let current_positions = vec![];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(result.is_ok(), "Should succeed but filter small orders");
let orders = result.expect("Orders should be present");
// ES.FUT order should be filtered out (below minimum)
// Only NQ.FUT should remain
assert_eq!(orders.len(), 1, "Should only generate 1 order (filtered small order)");
assert_eq!(
orders[0].symbol.as_str(),
"NQ.FUT",
"Only NQ order should remain"
);
}
#[tokio::test]
async fn test_order_size_validation_max_size() {
let pool = setup_database().await;
let generator = OrderGenerator::new(
pool.clone(),
100.0, // min_order_size: $100
50_000.0, // max_order_size: $50K (low maximum)
);
// Create allocation with large weight
let allocation = create_test_allocation(vec![
("ES.FUT", 1.0), // 100% = $1M (way above $50K maximum)
]);
let current_positions = vec![];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
// Should error because order exceeds max size and can't be split
assert!(
result.is_err(),
"Should fail when order exceeds max size without splitting"
);
match result {
Err(OrderError::OrderSizeExceedsMaximum { symbol, size, max_size }) => {
assert_eq!(symbol, "ES.FUT");
assert!(size > max_size);
}
_ => panic!("Expected OrderSizeExceedsMaximum error"),
}
}
#[tokio::test]
async fn test_order_persistence() {
let pool = setup_database().await;
let generator = OrderGenerator::new(pool.clone(), 100.0, 500_000.0);
// Create allocation
let allocation = create_test_allocation(vec![
("ES.FUT", 0.50),
("NQ.FUT", 0.50),
]);
let current_positions = vec![];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(result.is_ok(), "Order generation should succeed");
let orders = result.expect("Orders should be present");
// Verify orders are persisted in database
for order in &orders {
let row = sqlx::query!(
r#"
SELECT order_id, allocation_id, symbol, side, quantity, order_type, status
FROM agent_orders
WHERE order_id = $1
"#,
order.id.to_string()
)
.fetch_optional(&pool)
.await
.expect("Database query should succeed");
assert!(
row.is_some(),
"Order {} should be persisted in database",
order.id
);
let row = row.expect("Row should exist");
assert_eq!(row.order_id, order.id.to_string());
assert_eq!(row.allocation_id, allocation.allocation_id);
assert_eq!(row.symbol, order.symbol.as_str());
}
}
#[tokio::test]
async fn test_no_orders_when_within_threshold() {
let pool = setup_database().await;
let generator = OrderGenerator::new(pool.clone(), 100.0, 100_000.0);
// Create allocation: 50% ES.FUT, 50% NQ.FUT
let allocation = create_test_allocation(vec![
("ES.FUT", 0.50),
("NQ.FUT", 0.50),
]);
// Existing positions: 49% ES.FUT, 51% NQ.FUT (within 5% rebalance threshold)
let current_positions = vec![
create_test_position("ES.FUT", dec!(98), dec!(5000.0)), // $490K
create_test_position("NQ.FUT", dec!(25.5), dec!(20000.0)), // $510K
];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(result.is_ok(), "Should succeed with no rebalancing needed");
let orders = result.expect("Orders should be present");
// No orders should be generated (deltas within threshold)
assert_eq!(
orders.len(),
0,
"Should not generate orders when within rebalance threshold"
);
}
#[tokio::test]
async fn test_orders_with_new_symbols() {
let pool = setup_database().await;
let generator = OrderGenerator::new(pool.clone(), 100.0, 500_000.0);
// Create allocation with new symbol
let allocation = create_test_allocation(vec![
("ES.FUT", 0.40),
("NQ.FUT", 0.30),
("ZN.FUT", 0.30), // New symbol not in current positions
]);
// Existing positions: only ES and NQ
let current_positions = vec![
create_test_position("ES.FUT", dec!(80), dec!(5000.0)),
create_test_position("NQ.FUT", dec!(15), dec!(20000.0)),
];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(result.is_ok(), "Should handle new symbols");
let orders = result.expect("Orders should be present");
// Should have ZN order (new position)
let zn_order = orders
.iter()
.find(|o| o.symbol.as_str() == "ZN.FUT");
assert!(zn_order.is_some(), "Should generate order for new symbol ZN.FUT");
let zn_order = zn_order.expect("ZN order should exist");
assert_eq!(zn_order.side, OrderSide::Buy, "New position should be BUY");
}
#[tokio::test]
async fn test_performance_20_symbols() {
let pool = setup_database().await;
let generator = OrderGenerator::new(pool.clone(), 100.0, 100_000.0); // 5% each = $50K max
// Create allocation with 20 symbols
let mut weights = vec![];
let symbols = vec![
"ES.FUT", "NQ.FUT", "ZN.FUT", "6E.FUT", "CL.FUT",
"GC.FUT", "SI.FUT", "YM.FUT", "RTY.FUT", "ZB.FUT",
"ZC.FUT", "ZS.FUT", "ZW.FUT", "NG.FUT", "HO.FUT",
"RB.FUT", "6A.FUT", "6B.FUT", "6C.FUT", "6J.FUT",
];
for symbol in &symbols {
weights.push((*symbol, 0.05)); // 5% each = 100%
}
let allocation = create_test_allocation(weights);
let current_positions = vec![];
// Measure performance
let start = std::time::Instant::now();
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
let duration = start.elapsed();
assert!(result.is_ok(), "Order generation should succeed");
let orders = result.expect("Orders should be present");
// Verify we got all 20 orders
assert_eq!(orders.len(), 20, "Should generate 20 orders");
// Performance check: <100ms for 20 symbols
assert!(
duration.as_millis() < 100,
"Order generation should take <100ms, took {}ms",
duration.as_millis()
);
println!("✅ Generated {} orders in {}ms", orders.len(), duration.as_millis());
}
#[tokio::test]
async fn test_invalid_allocation_total_capital_zero() {
let pool = setup_database().await;
let generator = OrderGenerator::new(pool.clone(), 100.0, 100_000.0);
let mut allocation = create_test_allocation(vec![("ES.FUT", 1.0)]);
allocation.total_capital = Decimal::ZERO;
let current_positions = vec![];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(
result.is_err(),
"Should fail with zero total capital"
);
match result {
Err(OrderError::InvalidAllocation { reason }) => {
assert!(reason.contains("capital"));
}
_ => panic!("Expected InvalidAllocation error"),
}
}
#[tokio::test]
async fn test_invalid_allocation_weights_exceed_one() {
let pool = setup_database().await;
let generator = OrderGenerator::new(pool.clone(), 100.0, 100_000.0);
// Weights sum to 1.5 (invalid)
let allocation = create_test_allocation(vec![
("ES.FUT", 0.8),
("NQ.FUT", 0.7),
]);
let current_positions = vec![];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
assert!(
result.is_err(),
"Should fail when weights exceed 1.0"
);
match result {
Err(OrderError::InvalidAllocation { reason }) => {
assert!(reason.contains("weights") || reason.contains("sum"));
}
_ => panic!("Expected InvalidAllocation error"),
}
}
#[tokio::test]
async fn test_database_error_handling() {
// Create generator with invalid database URL
let pool = sqlx::PgPool::connect_lazy("postgresql://invalid:invalid@localhost:9999/invalid")
.expect("Lazy pool creation should succeed");
let generator = OrderGenerator::new(pool, 100.0, 200_000.0); // Small max to avoid size error
let allocation = create_test_allocation(vec![("ES.FUT", 0.10)]);
let current_positions = vec![];
let result = generator
.generate_orders(&allocation, &current_positions)
.await;
// Should fail with database error
assert!(result.is_err(), "Should fail with database connection error");
match result {
Err(OrderError::Database(_)) => {
// Expected
}
Err(e) => panic!("Expected Database error, got: {:?}", e),
Ok(_) => panic!("Should not succeed with invalid database"),
}
}