use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::CorpFinanceResult;
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, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpreadType {
Crack,
Crush,
Spark,
Calendar,
Location,
Quality,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommodityPrice {
pub name: String,
pub price: Decimal,
pub unit: String,
pub volume: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommoditySpreadInput {
pub spread_type: SpreadType,
pub input_prices: Vec<CommodityPrice>,
pub output_prices: Vec<CommodityPrice>,
pub conversion_ratios: Vec<Decimal>,
pub processing_cost: Option<Decimal>,
pub fixed_costs: Option<Decimal>,
pub capacity_utilization: Option<Decimal>,
pub heat_rate: Option<Decimal>,
pub carbon_price: Option<Decimal>,
pub emission_factor: Option<Decimal>,
pub historical_spreads: Option<Vec<Decimal>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpreadComponent {
pub name: String,
pub value: Decimal,
pub pct_of_gross: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpreadRiskMetrics {
pub var_95: Option<Decimal>,
pub expected_shortfall: Option<Decimal>,
pub max_historical_loss: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommoditySpreadOutput {
pub gross_spread: Decimal,
pub net_spread: Decimal,
pub spread_per_unit: Decimal,
pub margin_pct: Decimal,
pub breakeven_input_price: Decimal,
pub carbon_adjusted_spread: Option<Decimal>,
pub spread_components: Vec<SpreadComponent>,
pub historical_percentile: Option<Decimal>,
pub spread_volatility: Option<Decimal>,
pub risk_metrics: SpreadRiskMetrics,
}
pub fn analyze_commodity_spread(
input: &CommoditySpreadInput,
) -> CorpFinanceResult<CommoditySpreadOutput> {
if input.input_prices.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one input price is required".into(),
));
}
if input.output_prices.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one output price is required".into(),
));
}
if input.conversion_ratios.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one conversion ratio is required".into(),
));
}
if input.output_prices.len() != input.conversion_ratios.len() {
return Err(CorpFinanceError::InvalidInput {
field: "conversion_ratios".into(),
reason: format!(
"Number of conversion ratios ({}) must match number of output prices ({})",
input.conversion_ratios.len(),
input.output_prices.len()
),
});
}
if let Some(util) = input.capacity_utilization {
if util < Decimal::ZERO || util > Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "capacity_utilization".into(),
reason: "Capacity utilization must be between 0 and 1".into(),
});
}
}
if let Some(hr) = input.heat_rate {
if hr <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "heat_rate".into(),
reason: "Heat rate must be positive".into(),
});
}
}
for ip in &input.input_prices {
if ip.volume <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "input_prices.volume".into(),
reason: format!("Volume for '{}' must be positive", ip.name),
});
}
}
for op in &input.output_prices {
if op.volume <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "output_prices.volume".into(),
reason: format!("Volume for '{}' must be positive", op.name),
});
}
}
let mut input_cost = Decimal::ZERO;
let mut total_input_volume = Decimal::ZERO;
for ip in &input.input_prices {
input_cost += ip.price * ip.volume;
total_input_volume += ip.volume;
}
let mut output_value = Decimal::ZERO;
let mut components: Vec<SpreadComponent> = Vec::new();
match input.spread_type {
SpreadType::Spark => {
let heat_rate = input.heat_rate.unwrap_or(Decimal::ONE);
if let Some(power) = input.output_prices.first() {
output_value = power.price * power.volume;
}
let gas_cost = if let Some(gas) = input.input_prices.first() {
gas.price * heat_rate * gas.volume
} else {
Decimal::ZERO
};
input_cost = gas_cost;
components.push(SpreadComponent {
name: "Power Revenue".into(),
value: output_value,
pct_of_gross: Decimal::ZERO, });
components.push(SpreadComponent {
name: "Fuel Cost".into(),
value: -gas_cost,
pct_of_gross: Decimal::ZERO,
});
}
_ => {
for (i, op) in input.output_prices.iter().enumerate() {
let ratio = input.conversion_ratios[i];
let val = op.price * ratio * total_input_volume;
output_value += val;
components.push(SpreadComponent {
name: op.name.clone(),
value: val,
pct_of_gross: Decimal::ZERO,
});
}
components.push(SpreadComponent {
name: "Input Cost".into(),
value: -input_cost,
pct_of_gross: Decimal::ZERO,
});
}
}
let gross_spread = output_value - input_cost;
let proc_cost = input.processing_cost.unwrap_or(Decimal::ZERO) * total_input_volume;
let fixed = input.fixed_costs.unwrap_or(Decimal::ZERO);
let util = input.capacity_utilization.unwrap_or(Decimal::ONE);
let adjusted_fixed = if util > Decimal::ZERO {
fixed / util
} else {
fixed
};
let total_costs = proc_cost + adjusted_fixed;
if total_costs > Decimal::ZERO {
components.push(SpreadComponent {
name: "Processing Costs".into(),
value: -proc_cost,
pct_of_gross: Decimal::ZERO,
});
if adjusted_fixed > Decimal::ZERO {
components.push(SpreadComponent {
name: "Fixed Costs".into(),
value: -adjusted_fixed,
pct_of_gross: Decimal::ZERO,
});
}
}
let net_spread = gross_spread - total_costs;
let carbon_adjusted_spread = match (input.carbon_price, input.emission_factor) {
(Some(cp), Some(ef)) => {
let carbon_cost = cp * ef * total_input_volume;
components.push(SpreadComponent {
name: "Carbon Cost".into(),
value: -carbon_cost,
pct_of_gross: Decimal::ZERO,
});
Some(net_spread - carbon_cost)
}
_ => None,
};
if gross_spread != Decimal::ZERO {
for comp in &mut components {
comp.pct_of_gross = comp.value / gross_spread;
}
}
let spread_per_unit = if total_input_volume > Decimal::ZERO {
net_spread / total_input_volume
} else {
Decimal::ZERO
};
let margin_pct = if input_cost > Decimal::ZERO {
net_spread / input_cost
} else {
Decimal::ZERO
};
let breakeven_input_price = if total_input_volume > Decimal::ZERO {
match input.spread_type {
SpreadType::Spark => {
let heat_rate = input.heat_rate.unwrap_or(Decimal::ONE);
let gas_vol = input
.input_prices
.first()
.map(|g| g.volume)
.unwrap_or(Decimal::ONE);
let denom = heat_rate * gas_vol;
if denom > Decimal::ZERO {
(output_value - total_costs) / denom
} else {
Decimal::ZERO
}
}
_ => (output_value - total_costs) / total_input_volume,
}
} else {
Decimal::ZERO
};
let (historical_percentile, spread_volatility, risk_metrics) =
compute_historical_metrics(input, net_spread);
Ok(CommoditySpreadOutput {
gross_spread,
net_spread,
spread_per_unit,
margin_pct,
breakeven_input_price,
carbon_adjusted_spread,
spread_components: components,
historical_percentile,
spread_volatility,
risk_metrics,
})
}
fn compute_historical_metrics(
input: &CommoditySpreadInput,
current_spread: Decimal,
) -> (Option<Decimal>, Option<Decimal>, SpreadRiskMetrics) {
let empty_metrics = SpreadRiskMetrics {
var_95: None,
expected_shortfall: None,
max_historical_loss: None,
};
let hist = match &input.historical_spreads {
Some(h) if !h.is_empty() => h,
_ => return (None, None, empty_metrics),
};
let n = Decimal::from(hist.len() as u64);
let count_below = hist.iter().filter(|&&s| s <= current_spread).count();
let percentile = Decimal::from(count_below as u64) / n * Decimal::from(100);
let mean = hist.iter().copied().sum::<Decimal>() / n;
let variance = hist
.iter()
.map(|s| {
let diff = *s - mean;
diff * diff
})
.sum::<Decimal>()
/ n;
let vol = sqrt_decimal(variance);
let mut sorted = hist.clone();
sorted.sort();
let var_idx = (hist.len() as f64 * 0.05).floor() as usize;
let var_95 = if var_idx < sorted.len() {
Some(sorted[var_idx])
} else {
sorted.first().copied()
};
let es = if let Some(var_val) = var_95 {
let tail: Vec<Decimal> = sorted.iter().copied().filter(|&s| s <= var_val).collect();
if !tail.is_empty() {
let tail_n = Decimal::from(tail.len() as u64);
Some(tail.iter().copied().sum::<Decimal>() / tail_n)
} else {
Some(var_val)
}
} else {
None
};
let max_loss = sorted.first().copied();
let metrics = SpreadRiskMetrics {
var_95,
expected_shortfall: es,
max_historical_loss: max_loss,
};
(Some(percentile), Some(vol), metrics)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn tol() -> Decimal {
dec!(0.01)
}
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 crack_321_input() -> CommoditySpreadInput {
CommoditySpreadInput {
spread_type: SpreadType::Crack,
input_prices: vec![CommodityPrice {
name: "WTI Crude".into(),
price: dec!(80),
unit: "barrel".into(),
volume: dec!(3),
}],
output_prices: vec![
CommodityPrice {
name: "RBOB Gasoline".into(),
price: dec!(100),
unit: "barrel".into(),
volume: dec!(2),
},
CommodityPrice {
name: "Heating Oil".into(),
price: dec!(95),
unit: "barrel".into(),
volume: dec!(1),
},
],
conversion_ratios: vec![dec!(1), dec!(1)],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
}
}
#[test]
fn test_crack_spread_321_basic() {
let input = crack_321_input();
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(345), tol(), "crack gross");
assert_approx(result.net_spread, dec!(345), tol(), "crack net (no costs)");
assert_approx(result.spread_per_unit, dec!(115), tol(), "crack per unit");
}
#[test]
fn test_crack_spread_with_processing_cost() {
let mut input = crack_321_input();
input.processing_cost = Some(dec!(5)); let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(345), tol(), "crack gross");
assert_approx(result.net_spread, dec!(330), tol(), "crack net w/ proc");
}
#[test]
fn test_crack_spread_with_fixed_costs() {
let mut input = crack_321_input();
input.fixed_costs = Some(dec!(30));
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.net_spread, dec!(315), tol(), "crack net w/ fixed");
}
#[test]
fn test_crack_spread_capacity_utilization() {
let mut input = crack_321_input();
input.fixed_costs = Some(dec!(30));
input.capacity_utilization = Some(dec!(0.75));
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.net_spread, dec!(305), tol(), "crack w/ util");
}
#[test]
fn test_crush_spread_basic() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Crush,
input_prices: vec![CommodityPrice {
name: "Soybeans".into(),
price: dec!(14),
unit: "bushel".into(),
volume: dec!(1),
}],
output_prices: vec![
CommodityPrice {
name: "Soybean Meal".into(),
price: dec!(0.35),
unit: "lb".into(),
volume: dec!(44),
},
CommodityPrice {
name: "Soybean Oil".into(),
price: dec!(0.55),
unit: "lb".into(),
volume: dec!(11),
},
],
conversion_ratios: vec![dec!(44), dec!(11)],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(7.45), tol(), "crush gross");
assert_approx(
result.margin_pct,
dec!(7.45) / dec!(14),
tol(),
"crush margin",
);
}
#[test]
fn test_spark_spread_basic() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Spark,
input_prices: vec![CommodityPrice {
name: "Natural Gas".into(),
price: dec!(4),
unit: "MMBtu".into(),
volume: dec!(1),
}],
output_prices: vec![CommodityPrice {
name: "Electricity".into(),
price: dec!(50),
unit: "MWh".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
heat_rate: Some(dec!(7)),
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(22), tol(), "spark gross");
assert_approx(result.net_spread, dec!(22), tol(), "spark net");
}
#[test]
fn test_spark_spread_with_carbon() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Spark,
input_prices: vec![CommodityPrice {
name: "Natural Gas".into(),
price: dec!(4),
unit: "MMBtu".into(),
volume: dec!(1),
}],
output_prices: vec![CommodityPrice {
name: "Electricity".into(),
price: dec!(50),
unit: "MWh".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
heat_rate: Some(dec!(7)),
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
carbon_price: Some(dec!(30)),
emission_factor: Some(dec!(0.5)),
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(22), tol(), "spark gross");
assert!(result.carbon_adjusted_spread.is_some());
assert_approx(
result.carbon_adjusted_spread.unwrap(),
dec!(7),
tol(),
"clean spark",
);
}
#[test]
fn test_calendar_spread_contango() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Calendar,
input_prices: vec![CommodityPrice {
name: "WTI Near Month".into(),
price: dec!(80),
unit: "barrel".into(),
volume: dec!(1),
}],
output_prices: vec![CommodityPrice {
name: "WTI Far Month".into(),
price: dec!(84),
unit: "barrel".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(4), tol(), "calendar contango");
assert!(result.gross_spread > Decimal::ZERO);
}
#[test]
fn test_calendar_spread_backwardation() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Calendar,
input_prices: vec![CommodityPrice {
name: "WTI Near Month".into(),
price: dec!(84),
unit: "barrel".into(),
volume: dec!(1),
}],
output_prices: vec![CommodityPrice {
name: "WTI Far Month".into(),
price: dec!(80),
unit: "barrel".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(
result.gross_spread,
dec!(-4),
tol(),
"calendar backwardation",
);
assert!(result.gross_spread < Decimal::ZERO);
}
#[test]
fn test_location_spread() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Location,
input_prices: vec![CommodityPrice {
name: "WTI Cushing".into(),
price: dec!(78),
unit: "barrel".into(),
volume: dec!(1),
}],
output_prices: vec![CommodityPrice {
name: "Brent".into(),
price: dec!(82),
unit: "barrel".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(4), tol(), "location spread");
}
#[test]
fn test_quality_spread() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Quality,
input_prices: vec![CommodityPrice {
name: "Heavy Sour".into(),
price: dec!(72),
unit: "barrel".into(),
volume: dec!(1),
}],
output_prices: vec![CommodityPrice {
name: "Light Sweet".into(),
price: dec!(80),
unit: "barrel".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.gross_spread, dec!(8), tol(), "quality spread");
}
#[test]
fn test_historical_percentile_median() {
let mut input = crack_321_input();
input.historical_spreads = Some(vec![
dec!(100),
dec!(200),
dec!(300),
dec!(345),
dec!(400),
dec!(500),
dec!(600),
dec!(700),
dec!(800),
dec!(900),
]);
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.historical_percentile.is_some());
assert_approx(
result.historical_percentile.unwrap(),
dec!(40),
tol(),
"percentile",
);
}
#[test]
fn test_historical_percentile_bottom() {
let mut input = crack_321_input();
input.historical_spreads =
Some(vec![dec!(350), dec!(400), dec!(500), dec!(600), dec!(700)]);
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(
result.historical_percentile.unwrap(),
dec!(0),
tol(),
"percentile bottom",
);
}
#[test]
fn test_historical_percentile_top() {
let mut input = crack_321_input();
input.historical_spreads =
Some(vec![dec!(100), dec!(200), dec!(300), dec!(340), dec!(345)]);
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(
result.historical_percentile.unwrap(),
dec!(100),
tol(),
"percentile top",
);
}
#[test]
fn test_spread_volatility() {
let mut input = crack_321_input();
input.historical_spreads = Some(vec![dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)]);
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.spread_volatility.is_some());
assert_approx(
result.spread_volatility.unwrap(),
dec!(14.14),
dec!(0.1),
"volatility",
);
}
#[test]
fn test_var_95_small_sample() {
let mut input = crack_321_input();
input.historical_spreads = Some(vec![
dec!(-50),
dec!(-20),
dec!(10),
dec!(30),
dec!(50),
dec!(100),
dec!(150),
dec!(200),
dec!(250),
dec!(300),
dec!(350),
dec!(400),
dec!(450),
dec!(500),
dec!(550),
dec!(600),
dec!(650),
dec!(700),
dec!(750),
dec!(800),
]);
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.risk_metrics.var_95.is_some());
assert_approx(
result.risk_metrics.var_95.unwrap(),
dec!(-20),
tol(),
"VaR 95",
);
}
#[test]
fn test_expected_shortfall() {
let mut input = crack_321_input();
input.historical_spreads = Some(vec![
dec!(-50),
dec!(-20),
dec!(10),
dec!(30),
dec!(50),
dec!(100),
dec!(150),
dec!(200),
dec!(250),
dec!(300),
dec!(350),
dec!(400),
dec!(450),
dec!(500),
dec!(550),
dec!(600),
dec!(650),
dec!(700),
dec!(750),
dec!(800),
]);
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.risk_metrics.expected_shortfall.is_some());
assert_approx(
result.risk_metrics.expected_shortfall.unwrap(),
dec!(-35),
tol(),
"ES",
);
}
#[test]
fn test_max_historical_loss() {
let mut input = crack_321_input();
input.historical_spreads = Some(vec![dec!(-100), dec!(-50), dec!(0), dec!(50), dec!(100)]);
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.risk_metrics.max_historical_loss.is_some());
assert_approx(
result.risk_metrics.max_historical_loss.unwrap(),
dec!(-100),
tol(),
"max loss",
);
}
#[test]
fn test_breakeven_input_price() {
let input = crack_321_input();
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.breakeven_input_price, dec!(195), tol(), "breakeven");
}
#[test]
fn test_breakeven_with_costs() {
let mut input = crack_321_input();
input.processing_cost = Some(dec!(10)); let result = analyze_commodity_spread(&input).unwrap();
assert_approx(
result.breakeven_input_price,
dec!(185),
tol(),
"breakeven w/ costs",
);
}
#[test]
fn test_zero_processing_cost() {
let mut input = crack_321_input();
input.processing_cost = Some(Decimal::ZERO);
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(result.net_spread, dec!(345), tol(), "zero proc cost");
}
#[test]
fn test_margin_percentage() {
let input = crack_321_input();
let result = analyze_commodity_spread(&input).unwrap();
let expected_margin = dec!(345) / dec!(240);
assert_approx(result.margin_pct, expected_margin, tol(), "margin pct");
}
#[test]
fn test_spread_components_present() {
let input = crack_321_input();
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.spread_components.len() >= 3);
assert!(result
.spread_components
.iter()
.any(|c| c.name == "Input Cost"));
}
#[test]
fn test_spark_breakeven_gas_price() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Spark,
input_prices: vec![CommodityPrice {
name: "Natural Gas".into(),
price: dec!(4),
unit: "MMBtu".into(),
volume: dec!(1),
}],
output_prices: vec![CommodityPrice {
name: "Electricity".into(),
price: dec!(50),
unit: "MWh".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
heat_rate: Some(dec!(7)),
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let result = analyze_commodity_spread(&input).unwrap();
assert_approx(
result.breakeven_input_price,
dec!(50) / dec!(7),
tol(),
"spark breakeven gas",
);
}
#[test]
fn test_validation_empty_inputs() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Crack,
input_prices: vec![],
output_prices: vec![CommodityPrice {
name: "Gas".into(),
price: dec!(100),
unit: "barrel".into(),
volume: dec!(1),
}],
conversion_ratios: vec![dec!(1)],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let err = analyze_commodity_spread(&input).unwrap_err();
match err {
CorpFinanceError::InsufficientData(_) => {}
e => panic!("Expected InsufficientData, got {e:?}"),
}
}
#[test]
fn test_validation_empty_outputs() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Crack,
input_prices: vec![CommodityPrice {
name: "Crude".into(),
price: dec!(80),
unit: "barrel".into(),
volume: dec!(1),
}],
output_prices: vec![],
conversion_ratios: vec![],
processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let err = analyze_commodity_spread(&input).unwrap_err();
match err {
CorpFinanceError::InsufficientData(_) => {}
e => panic!("Expected InsufficientData, got {e:?}"),
}
}
#[test]
fn test_validation_mismatched_ratios() {
let input = CommoditySpreadInput {
spread_type: SpreadType::Crack,
input_prices: vec![CommodityPrice {
name: "Crude".into(),
price: dec!(80),
unit: "barrel".into(),
volume: dec!(1),
}],
output_prices: vec![
CommodityPrice {
name: "Gas".into(),
price: dec!(100),
unit: "barrel".into(),
volume: dec!(1),
},
CommodityPrice {
name: "Heating Oil".into(),
price: dec!(95),
unit: "barrel".into(),
volume: dec!(1),
},
],
conversion_ratios: vec![dec!(1)], processing_cost: None,
fixed_costs: None,
capacity_utilization: None,
heat_rate: None,
carbon_price: None,
emission_factor: None,
historical_spreads: None,
};
let err = analyze_commodity_spread(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "conversion_ratios");
}
e => panic!("Expected InvalidInput, got {e:?}"),
}
}
#[test]
fn test_validation_capacity_utilization_range() {
let mut input = crack_321_input();
input.capacity_utilization = Some(dec!(1.5));
let err = analyze_commodity_spread(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "capacity_utilization");
}
e => panic!("Expected InvalidInput, got {e:?}"),
}
}
#[test]
fn test_validation_negative_heat_rate() {
let mut input = crack_321_input();
input.spread_type = SpreadType::Spark;
input.heat_rate = Some(dec!(-7));
let err = analyze_commodity_spread(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "heat_rate");
}
e => panic!("Expected InvalidInput, got {e:?}"),
}
}
#[test]
fn test_no_historical_data() {
let input = crack_321_input();
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.historical_percentile.is_none());
assert!(result.spread_volatility.is_none());
assert!(result.risk_metrics.var_95.is_none());
assert!(result.risk_metrics.expected_shortfall.is_none());
assert!(result.risk_metrics.max_historical_loss.is_none());
}
#[test]
fn test_no_carbon_data() {
let input = crack_321_input();
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.carbon_adjusted_spread.is_none());
}
#[test]
fn test_full_utilization() {
let mut input1 = crack_321_input();
input1.fixed_costs = Some(dec!(30));
let mut input2 = input1.clone();
input2.capacity_utilization = Some(Decimal::ONE);
let r1 = analyze_commodity_spread(&input1).unwrap();
let r2 = analyze_commodity_spread(&input2).unwrap();
assert_approx(r1.net_spread, r2.net_spread, tol(), "100% util = default");
}
#[test]
fn test_var_large_sample() {
let mut input = crack_321_input();
let hist: Vec<Decimal> = (-50i64..50i64).map(Decimal::from).collect();
input.historical_spreads = Some(hist);
let result = analyze_commodity_spread(&input).unwrap();
assert!(result.risk_metrics.var_95.is_some());
assert_approx(
result.risk_metrics.var_95.unwrap(),
dec!(-45),
tol(),
"VaR large sample",
);
}
}