- 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
492 lines
15 KiB
Rust
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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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"),
|
|
}
|
|
}
|