use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::CorpFinanceResult;
fn exp_decimal(x: Decimal) -> Decimal {
let two = Decimal::from(2);
let mut k: u32 = 0;
let mut reduced = x;
while reduced.abs() > two {
reduced /= two;
k += 1;
}
let mut sum = Decimal::ONE;
let mut term = Decimal::ONE;
for n in 1..=30u64 {
term *= reduced / Decimal::from(n);
sum += term;
}
for _ in 0..k {
sum *= sum;
}
sum
}
fn ln_decimal(x: Decimal) -> Decimal {
if x <= Decimal::ZERO {
return Decimal::ZERO;
}
if x == Decimal::ONE {
return Decimal::ZERO;
}
let mut guess = Decimal::ZERO;
let mut temp = x;
let two = Decimal::from(2);
let ln2_approx = dec!(0.6931471805599453);
if temp > Decimal::ONE {
while temp > two {
temp /= two;
guess += ln2_approx;
}
} else {
while temp < Decimal::ONE {
temp *= two;
guess -= ln2_approx;
}
}
for _ in 0..20 {
let ey = exp_decimal(guess);
if ey.is_zero() {
break;
}
guess = guess - Decimal::ONE + x / ey;
}
guess
}
#[cfg(test)]
fn sqrt_decimal(x: Decimal) -> Decimal {
if x <= Decimal::ZERO {
return Decimal::ZERO;
}
if x == Decimal::ONE {
return Decimal::ONE;
}
let two = Decimal::from(2);
let mut guess = x / two;
for _ in 0..20 {
if guess.is_zero() {
break;
}
guess = (guess + x / guess) / two;
}
guess
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FuturesPrice {
pub month: u32,
pub price: Decimal,
pub open_interest: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeasonalFactor {
pub month: u32,
pub factor: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageEconomicsInput {
pub spot_price: Decimal,
pub futures_prices: Vec<FuturesPrice>,
pub storage_cost_per_unit_month: Decimal,
pub financing_rate: Decimal,
pub insurance_cost_pct: Option<Decimal>,
pub handling_cost: Option<Decimal>,
pub max_storage_capacity: Option<Decimal>,
pub current_inventory: Option<Decimal>,
pub injection_rate: Option<Decimal>,
pub withdrawal_rate: Option<Decimal>,
pub seasonal_factors: Option<Vec<SeasonalFactor>>,
pub commodity_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvenienceYield {
pub months: u32,
pub annualized_yield: Decimal,
pub implied_from: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeasonalOpportunity {
pub buy_month: u32,
pub sell_month: u32,
pub expected_profit: Decimal,
pub confidence: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenorEconomics {
pub months: u32,
pub futures_price: Decimal,
pub carry_cost: Decimal,
pub net_profit: Decimal,
pub annualized_return: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageEconomicsOutput {
pub market_structure: String,
pub implied_convenience_yields: Vec<ConvenienceYield>,
pub storage_arbitrage_profit: Decimal,
pub optimal_storage_months: u32,
pub total_carry_cost: Decimal,
pub full_carry_spread: Decimal,
pub carry_pct_of_theoretical: Decimal,
pub seasonal_opportunity: Option<SeasonalOpportunity>,
pub inventory_recommendation: String,
pub economics_by_tenor: Vec<TenorEconomics>,
}
pub fn analyze_storage_economics(
input: &StorageEconomicsInput,
) -> CorpFinanceResult<StorageEconomicsOutput> {
if input.spot_price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "spot_price".into(),
reason: "Spot price must be positive".into(),
});
}
if input.futures_prices.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one futures price is required".into(),
));
}
if input.storage_cost_per_unit_month < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "storage_cost_per_unit_month".into(),
reason: "Storage cost must be non-negative".into(),
});
}
for fp in &input.futures_prices {
if fp.month == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "futures_prices.month".into(),
reason: "Futures month must be positive".into(),
});
}
if fp.price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "futures_prices.price".into(),
reason: "Futures price must be positive".into(),
});
}
}
let s = input.spot_price;
let r = input.financing_rate;
let insurance = input.insurance_cost_pct.unwrap_or(Decimal::ZERO);
let handling = input.handling_cost.unwrap_or(Decimal::ZERO);
let twelve = Decimal::from(12);
let storage_annual = if s > Decimal::ZERO {
input.storage_cost_per_unit_month * twelve / s
} else {
Decimal::ZERO
};
let mut contango_count: usize = 0;
let mut backwardation_count: usize = 0;
for fp in &input.futures_prices {
if fp.price > s {
contango_count += 1;
} else if fp.price < s {
backwardation_count += 1;
}
}
let market_structure = if contango_count > 0 && backwardation_count > 0 {
"Mixed".to_string()
} else if contango_count > 0 {
"Contango".to_string()
} else if backwardation_count > 0 {
"Backwardation".to_string()
} else {
"Contango".to_string() };
let mut economics_by_tenor: Vec<TenorEconomics> = Vec::new();
let mut implied_convenience_yields: Vec<ConvenienceYield> = Vec::new();
let mut best_profit = Decimal::MIN;
let mut best_months: u32 = 0;
let mut best_carry: Decimal = Decimal::ZERO;
let mut sorted_futures = input.futures_prices.clone();
sorted_futures.sort_by_key(|f| f.month);
for fp in &sorted_futures {
let t_months = Decimal::from(fp.month);
let t_years = t_months / twelve;
let finance_cost = s * (r + insurance) * t_years;
let storage_cost = input.storage_cost_per_unit_month * t_months;
let handling_total = handling * Decimal::from(2); let carry_cost = finance_cost + storage_cost + handling_total;
let net_profit = fp.price - s - carry_cost;
let capital = s + carry_cost;
let annualized_return = if capital > Decimal::ZERO && t_years > Decimal::ZERO {
(net_profit / capital) * (twelve / t_months)
} else {
Decimal::ZERO
};
economics_by_tenor.push(TenorEconomics {
months: fp.month,
futures_price: fp.price,
carry_cost,
net_profit,
annualized_return,
});
if net_profit > best_profit {
best_profit = net_profit;
best_months = fp.month;
best_carry = carry_cost;
}
if t_years > Decimal::ZERO && s > Decimal::ZERO {
let ratio = fp.price / s;
let ln_ratio = ln_decimal(ratio);
let cy = r + storage_annual - ln_ratio / t_years;
implied_convenience_yields.push(ConvenienceYield {
months: fp.month,
annualized_yield: cy,
implied_from: format!("{} month futures at {}", fp.month, fp.price),
});
}
}
let optimal_storage_months = best_months;
let total_carry_cost = best_carry;
let storage_arbitrage_profit = if best_profit > Decimal::ZERO {
best_profit
} else {
Decimal::ZERO
};
let max_tenor_months = sorted_futures.last().map(|f| f.month).unwrap_or(1);
let max_t_months = Decimal::from(max_tenor_months);
let max_t_years = max_t_months / twelve;
let full_carry = s * (r + insurance) * max_t_years
+ input.storage_cost_per_unit_month * max_t_months
+ handling * Decimal::from(2);
let full_carry_spread = full_carry;
let actual_spread = sorted_futures
.last()
.map(|f| f.price - s)
.unwrap_or(Decimal::ZERO);
let carry_pct_of_theoretical = if full_carry_spread > Decimal::ZERO {
actual_spread / full_carry_spread
} else if actual_spread > Decimal::ZERO {
Decimal::ONE
} else {
Decimal::ZERO
};
let seasonal_opportunity = compute_seasonal_opportunity(input, s);
let inventory_recommendation = if storage_arbitrage_profit > Decimal::ZERO {
"Build".to_string()
} else if market_structure == "Backwardation" {
"Draw".to_string()
} else {
"Hold".to_string()
};
Ok(StorageEconomicsOutput {
market_structure,
implied_convenience_yields,
storage_arbitrage_profit,
optimal_storage_months,
total_carry_cost,
full_carry_spread,
carry_pct_of_theoretical,
seasonal_opportunity,
inventory_recommendation,
economics_by_tenor,
})
}
fn compute_seasonal_opportunity(
input: &StorageEconomicsInput,
spot: Decimal,
) -> Option<SeasonalOpportunity> {
let factors = match &input.seasonal_factors {
Some(f) if f.len() >= 2 => f,
_ => return None,
};
let mut min_factor = Decimal::MAX;
let mut min_month: u32 = 0;
let mut max_factor = Decimal::MIN;
let mut max_month: u32 = 0;
for sf in factors {
if sf.factor < min_factor {
min_factor = sf.factor;
min_month = sf.month;
}
if sf.factor > max_factor {
max_factor = sf.factor;
max_month = sf.month;
}
}
let storage_months = if max_month > min_month {
max_month - min_month
} else {
max_month + 12 - min_month
};
let t_months = Decimal::from(storage_months);
let twelve = Decimal::from(12);
let t_years = t_months / twelve;
let insurance = input.insurance_cost_pct.unwrap_or(Decimal::ZERO);
let handling = input.handling_cost.unwrap_or(Decimal::ZERO);
let carry = spot * (input.financing_rate + insurance) * t_years
+ input.storage_cost_per_unit_month * t_months
+ handling * Decimal::from(2);
let price_diff = spot * (max_factor - min_factor);
let expected_profit = price_diff - carry;
let factor_spread = max_factor - min_factor;
let confidence = if factor_spread > dec!(0.3) {
"High"
} else if factor_spread > dec!(0.15) {
"Medium"
} else {
"Low"
};
Some(SeasonalOpportunity {
buy_month: min_month,
sell_month: max_month,
expected_profit,
confidence: confidence.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn tol() -> Decimal {
dec!(0.01)
}
fn wide_tol() -> Decimal {
dec!(0.5)
}
fn assert_approx(actual: Decimal, expected: Decimal, tolerance: Decimal, label: &str) {
let diff = (actual - expected).abs();
assert!(
diff <= tolerance,
"{label}: expected ~{expected}, got {actual} (diff={diff}, tol={tolerance})"
);
}
fn contango_input() -> StorageEconomicsInput {
StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![
FuturesPrice {
month: 3,
price: dec!(82),
open_interest: Some(dec!(50000)),
},
FuturesPrice {
month: 6,
price: dec!(84),
open_interest: Some(dec!(30000)),
},
FuturesPrice {
month: 12,
price: dec!(88),
open_interest: Some(dec!(20000)),
},
],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: Some(dec!(0.005)),
handling_cost: Some(dec!(0.25)),
max_storage_capacity: Some(dec!(100000)),
current_inventory: Some(dec!(50000)),
injection_rate: Some(dec!(10000)),
withdrawal_rate: Some(dec!(15000)),
seasonal_factors: None,
commodity_name: "WTI Crude Oil".into(),
}
}
fn backwardation_input() -> StorageEconomicsInput {
StorageEconomicsInput {
spot_price: dec!(90),
futures_prices: vec![
FuturesPrice {
month: 3,
price: dec!(88),
open_interest: None,
},
FuturesPrice {
month: 6,
price: dec!(86),
open_interest: None,
},
FuturesPrice {
month: 12,
price: dec!(83),
open_interest: None,
},
],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Brent Crude".into(),
}
}
#[test]
fn test_contango_market_structure() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.market_structure, "Contango");
}
#[test]
fn test_backwardation_market_structure() {
let input = backwardation_input();
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.market_structure, "Backwardation");
}
#[test]
fn test_mixed_market_structure() {
let input = StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![
FuturesPrice {
month: 3,
price: dec!(82),
open_interest: None,
},
FuturesPrice {
month: 6,
price: dec!(78),
open_interest: None,
},
],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Test Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.market_structure, "Mixed");
}
#[test]
fn test_carry_cost_calculation() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
let tenor3 = result
.economics_by_tenor
.iter()
.find(|t| t.months == 3)
.unwrap();
assert_approx(tenor3.carry_cost, dec!(3.10), tol(), "3m carry");
}
#[test]
fn test_storage_arbitrage_profit_contango() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
assert_approx(
result.storage_arbitrage_profit,
Decimal::ZERO,
tol(),
"no arb profit",
);
}
#[test]
fn test_profitable_storage_arbitrage() {
let input = StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![FuturesPrice {
month: 6,
price: dec!(90),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.20),
financing_rate: dec!(0.03),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Cheap Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert_approx(
result.storage_arbitrage_profit,
dec!(7.60),
tol(),
"profitable arb",
);
assert_eq!(result.optimal_storage_months, 6);
assert_eq!(result.inventory_recommendation, "Build");
}
#[test]
fn test_optimal_storage_months() {
let input = StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![
FuturesPrice {
month: 3,
price: dec!(86),
open_interest: None,
},
FuturesPrice {
month: 6,
price: dec!(85),
open_interest: None,
},
FuturesPrice {
month: 12,
price: dec!(84),
open_interest: None,
},
],
storage_cost_per_unit_month: dec!(0.20),
financing_rate: dec!(0.03),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.optimal_storage_months, 3);
assert_approx(
result.storage_arbitrage_profit,
dec!(4.80),
tol(),
"optimal profit",
);
}
#[test]
fn test_full_carry_spread() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
assert_approx(result.full_carry_spread, dec!(10.90), tol(), "full carry");
}
#[test]
fn test_carry_pct_of_theoretical() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
let expected = dec!(8) / dec!(10.90);
assert_approx(
result.carry_pct_of_theoretical,
expected,
tol(),
"carry pct",
);
}
#[test]
fn test_implied_convenience_yield_contango() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.implied_convenience_yields.len(), 3);
let cy_12m = result
.implied_convenience_yields
.iter()
.find(|c| c.months == 12)
.unwrap();
assert_approx(
cy_12m.annualized_yield,
dec!(0.02969),
dec!(0.01),
"CY 12m contango",
);
}
#[test]
fn test_implied_convenience_yield_backwardation() {
let input = backwardation_input();
let result = analyze_storage_economics(&input).unwrap();
let cy_12m = result
.implied_convenience_yields
.iter()
.find(|c| c.months == 12)
.unwrap();
assert!(
cy_12m.annualized_yield > dec!(0.1),
"CY in backwardation should be high, got {}",
cy_12m.annualized_yield
);
}
#[test]
fn test_backwardation_no_storage_profit() {
let input = backwardation_input();
let result = analyze_storage_economics(&input).unwrap();
assert_approx(
result.storage_arbitrage_profit,
Decimal::ZERO,
tol(),
"no arb in backwardation",
);
}
#[test]
fn test_inventory_recommendation_build() {
let input = StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![FuturesPrice {
month: 6,
price: dec!(90),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.10),
financing_rate: dec!(0.02),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.inventory_recommendation, "Build");
}
#[test]
fn test_inventory_recommendation_draw() {
let input = backwardation_input();
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.inventory_recommendation, "Draw");
}
#[test]
fn test_inventory_recommendation_hold() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.inventory_recommendation, "Hold");
}
#[test]
fn test_seasonal_opportunity() {
let mut input = contango_input();
input.seasonal_factors = Some(vec![
SeasonalFactor {
month: 4,
factor: dec!(0.8),
}, SeasonalFactor {
month: 7,
factor: dec!(1.0),
},
SeasonalFactor {
month: 1,
factor: dec!(1.3),
}, ]);
let result = analyze_storage_economics(&input).unwrap();
assert!(result.seasonal_opportunity.is_some());
let opp = result.seasonal_opportunity.unwrap();
assert_eq!(opp.buy_month, 4);
assert_eq!(opp.sell_month, 1);
}
#[test]
fn test_seasonal_profit_calculation() {
let input = StorageEconomicsInput {
spot_price: dec!(100),
futures_prices: vec![FuturesPrice {
month: 6,
price: dec!(105),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: Some(vec![
SeasonalFactor {
month: 6,
factor: dec!(0.9),
}, SeasonalFactor {
month: 12,
factor: dec!(1.2),
}, ]),
commodity_name: "Gas".into(),
};
let result = analyze_storage_economics(&input).unwrap();
let opp = result.seasonal_opportunity.unwrap();
assert_approx(
opp.expected_profit,
dec!(24.50),
wide_tol(),
"seasonal profit",
);
}
#[test]
fn test_no_seasonal_factors() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
assert!(result.seasonal_opportunity.is_none());
}
#[test]
fn test_economics_by_tenor_count() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.economics_by_tenor.len(), 3);
}
#[test]
fn test_annualized_return() {
let input = StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![FuturesPrice {
month: 6,
price: dec!(90),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.20),
financing_rate: dec!(0.03),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
let tenor = &result.economics_by_tenor[0];
let expected_return = (dec!(7.60) / dec!(82.40)) * dec!(2);
assert_approx(
tenor.annualized_return,
expected_return,
tol(),
"annualised return",
);
}
#[test]
fn test_single_futures_price() {
let input = StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![FuturesPrice {
month: 3,
price: dec!(82),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.economics_by_tenor.len(), 1);
assert_eq!(result.implied_convenience_yields.len(), 1);
assert_eq!(result.optimal_storage_months, 3);
}
#[test]
fn test_zero_storage_cost() {
let input = StorageEconomicsInput {
spot_price: dec!(100),
futures_prices: vec![FuturesPrice {
month: 6,
price: dec!(105),
open_interest: None,
}],
storage_cost_per_unit_month: Decimal::ZERO,
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Gold".into(),
};
let result = analyze_storage_economics(&input).unwrap();
let tenor = &result.economics_by_tenor[0];
assert_approx(tenor.carry_cost, dec!(2.50), tol(), "zero storage carry");
assert_approx(tenor.net_profit, dec!(2.50), tol(), "zero storage profit");
}
#[test]
fn test_handling_cost_included() {
let input = StorageEconomicsInput {
spot_price: dec!(100),
futures_prices: vec![FuturesPrice {
month: 6,
price: dec!(110),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: Some(dec!(1.0)), max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
let tenor = &result.economics_by_tenor[0];
assert_approx(tenor.carry_cost, dec!(7.50), tol(), "with handling");
}
#[test]
fn test_insurance_cost_included() {
let input = StorageEconomicsInput {
spot_price: dec!(100),
futures_prices: vec![FuturesPrice {
month: 12,
price: dec!(110),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: Some(dec!(0.01)), handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
let tenor = &result.economics_by_tenor[0];
assert_approx(tenor.carry_cost, dec!(12.00), tol(), "with insurance");
}
#[test]
fn test_validation_spot_positive() {
let mut input = contango_input();
input.spot_price = Decimal::ZERO;
let err = analyze_storage_economics(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "spot_price");
}
e => panic!("Expected InvalidInput, got {e:?}"),
}
}
#[test]
fn test_validation_empty_futures() {
let mut input = contango_input();
input.futures_prices = vec![];
let err = analyze_storage_economics(&input).unwrap_err();
match err {
CorpFinanceError::InsufficientData(_) => {}
e => panic!("Expected InsufficientData, got {e:?}"),
}
}
#[test]
fn test_validation_negative_storage_cost() {
let mut input = contango_input();
input.storage_cost_per_unit_month = dec!(-1);
let err = analyze_storage_economics(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "storage_cost_per_unit_month");
}
e => panic!("Expected InvalidInput, got {e:?}"),
}
}
#[test]
fn test_validation_futures_month_zero() {
let mut input = contango_input();
input.futures_prices = vec![FuturesPrice {
month: 0,
price: dec!(82),
open_interest: None,
}];
let err = analyze_storage_economics(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "futures_prices.month");
}
e => panic!("Expected InvalidInput, got {e:?}"),
}
}
#[test]
fn test_validation_futures_price_positive() {
let mut input = contango_input();
input.futures_prices = vec![FuturesPrice {
month: 3,
price: Decimal::ZERO,
open_interest: None,
}];
let err = analyze_storage_economics(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "futures_prices.price");
}
e => panic!("Expected InvalidInput, got {e:?}"),
}
}
#[test]
fn test_seasonal_confidence_high() {
let mut input = contango_input();
input.seasonal_factors = Some(vec![
SeasonalFactor {
month: 4,
factor: dec!(0.7),
},
SeasonalFactor {
month: 1,
factor: dec!(1.4),
},
]);
let result = analyze_storage_economics(&input).unwrap();
let opp = result.seasonal_opportunity.unwrap();
assert_eq!(opp.confidence, "High");
}
#[test]
fn test_seasonal_confidence_medium() {
let mut input = contango_input();
input.seasonal_factors = Some(vec![
SeasonalFactor {
month: 4,
factor: dec!(0.9),
},
SeasonalFactor {
month: 1,
factor: dec!(1.1),
},
]);
let result = analyze_storage_economics(&input).unwrap();
let opp = result.seasonal_opportunity.unwrap();
assert_eq!(opp.confidence, "Medium");
}
#[test]
fn test_seasonal_confidence_low() {
let mut input = contango_input();
input.seasonal_factors = Some(vec![
SeasonalFactor {
month: 4,
factor: dec!(0.95),
},
SeasonalFactor {
month: 1,
factor: dec!(1.05),
},
]);
let result = analyze_storage_economics(&input).unwrap();
let opp = result.seasonal_opportunity.unwrap();
assert_eq!(opp.confidence, "Low");
}
#[test]
fn test_futures_sorted_by_month() {
let input = StorageEconomicsInput {
spot_price: dec!(80),
futures_prices: vec![
FuturesPrice {
month: 12,
price: dec!(88),
open_interest: None,
},
FuturesPrice {
month: 3,
price: dec!(82),
open_interest: None,
},
FuturesPrice {
month: 6,
price: dec!(84),
open_interest: None,
},
],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.economics_by_tenor[0].months, 3);
assert_eq!(result.economics_by_tenor[1].months, 6);
assert_eq!(result.economics_by_tenor[2].months, 12);
}
#[test]
fn test_convenience_yield_structure() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
for cy in &result.implied_convenience_yields {
assert!(cy.months > 0);
assert!(!cy.implied_from.is_empty());
}
}
#[test]
fn test_seasonal_wrap_around() {
let mut input = contango_input();
input.seasonal_factors = Some(vec![
SeasonalFactor {
month: 11,
factor: dec!(0.8),
}, SeasonalFactor {
month: 3,
factor: dec!(1.3),
}, ]);
let result = analyze_storage_economics(&input).unwrap();
let opp = result.seasonal_opportunity.unwrap();
assert_eq!(opp.buy_month, 11);
assert_eq!(opp.sell_month, 3);
}
#[test]
fn test_net_profit_sign() {
let input = backwardation_input();
let result = analyze_storage_economics(&input).unwrap();
for tenor in &result.economics_by_tenor {
assert!(
tenor.net_profit < Decimal::ZERO,
"Tenor {}m should be negative, got {}",
tenor.months,
tenor.net_profit
);
}
}
#[test]
fn test_carry_cost_increases_with_tenor() {
let input = contango_input();
let result = analyze_storage_economics(&input).unwrap();
for i in 1..result.economics_by_tenor.len() {
assert!(
result.economics_by_tenor[i].carry_cost
> result.economics_by_tenor[i - 1].carry_cost,
"Carry cost should increase with tenor"
);
}
}
#[test]
fn test_super_contango() {
let input = StorageEconomicsInput {
spot_price: dec!(20),
futures_prices: vec![FuturesPrice {
month: 12,
price: dec!(35),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.10),
financing_rate: dec!(0.02),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Super Contango Oil".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert!(
result.carry_pct_of_theoretical > Decimal::ONE,
"Super contango: pct should exceed 1.0, got {}",
result.carry_pct_of_theoretical
);
assert_eq!(result.inventory_recommendation, "Build");
}
#[test]
fn test_convenience_yield_flat() {
let input = StorageEconomicsInput {
spot_price: dec!(100),
futures_prices: vec![FuturesPrice {
month: 12,
price: dec!(100),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Flat Market".into(),
};
let result = analyze_storage_economics(&input).unwrap();
let cy = &result.implied_convenience_yields[0];
assert_approx(cy.annualized_yield, dec!(0.11), dec!(0.01), "flat CY");
}
#[test]
fn test_minimal_input() {
let input = StorageEconomicsInput {
spot_price: dec!(100),
futures_prices: vec![FuturesPrice {
month: 6,
price: dec!(103),
open_interest: None,
}],
storage_cost_per_unit_month: dec!(0.50),
financing_rate: dec!(0.05),
insurance_cost_pct: None,
handling_cost: None,
max_storage_capacity: None,
current_inventory: None,
injection_rate: None,
withdrawal_rate: None,
seasonal_factors: None,
commodity_name: "Minimal".into(),
};
let result = analyze_storage_economics(&input).unwrap();
assert_eq!(result.economics_by_tenor.len(), 1);
assert!(result.seasonal_opportunity.is_none());
assert!(!result.market_structure.is_empty());
assert!(!result.inventory_recommendation.is_empty());
}
#[test]
fn test_sqrt_decimal_helper() {
assert_approx(sqrt_decimal(dec!(4)), dec!(2), dec!(0.0001), "sqrt(4)");
assert_approx(sqrt_decimal(dec!(9)), dec!(3), dec!(0.0001), "sqrt(9)");
assert_approx(sqrt_decimal(dec!(2)), dec!(1.41421), dec!(0.001), "sqrt(2)");
assert_eq!(sqrt_decimal(Decimal::ZERO), Decimal::ZERO);
assert_eq!(sqrt_decimal(Decimal::ONE), Decimal::ONE);
}
#[test]
fn test_exp_ln_round_trip() {
let x = dec!(1.5);
let result = ln_decimal(exp_decimal(x));
assert_approx(result, x, dec!(0.001), "exp/ln round trip");
}
}