use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput, Money, Rate};
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..=25u64 {
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..30 {
let ey = exp_decimal(guess);
if ey.is_zero() {
break;
}
guess = guess - Decimal::ONE + x / ey;
}
guess
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnderlyingType {
Equity,
Commodity,
Currency,
Index,
Bond,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MarketCondition {
Contango,
Backwardation,
Flat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForwardInput {
pub spot_price: Money,
pub risk_free_rate: Rate,
pub time_to_expiry: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_cost_rate: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub convenience_yield: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dividend_yield: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub foreign_rate: Option<Rate>,
pub underlying_type: UnderlyingType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForwardOutput {
pub forward_price: Money,
pub cost_of_carry: Rate,
pub basis: Money,
pub basis_rate: Rate,
pub market_condition: MarketCondition,
pub theoretical_vs_no_arb: String,
pub present_value_of_forward: Money,
}
pub fn price_forward(input: &ForwardInput) -> CorpFinanceResult<ComputationOutput<ForwardOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.spot_price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "spot_price".into(),
reason: "Spot price must be positive".into(),
});
}
if input.time_to_expiry <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "time_to_expiry".into(),
reason: "Time to expiry must be positive".into(),
});
}
let r = input.risk_free_rate;
let q = match input.underlying_type {
UnderlyingType::Currency => input.foreign_rate.unwrap_or(Decimal::ZERO),
UnderlyingType::Equity | UnderlyingType::Index => {
input.dividend_yield.unwrap_or(Decimal::ZERO)
}
_ => Decimal::ZERO,
};
let c = input.storage_cost_rate.unwrap_or(Decimal::ZERO);
let y = input.convenience_yield.unwrap_or(Decimal::ZERO);
let t = input.time_to_expiry;
let cost_of_carry = r - q + c - y;
if cost_of_carry.abs() > dec!(0.50) {
warnings.push(format!(
"Net cost of carry {cost_of_carry} exceeds 50%; verify input rates"
));
}
let forward_price = input.spot_price * exp_decimal(cost_of_carry * t);
let basis = forward_price - input.spot_price;
let basis_rate = basis / input.spot_price;
let market_condition = if basis > Decimal::ZERO {
MarketCondition::Contango
} else if basis < Decimal::ZERO {
MarketCondition::Backwardation
} else {
MarketCondition::Flat
};
let present_value_of_forward = forward_price * exp_decimal(-r * t);
let theoretical_vs_no_arb = build_no_arb_explanation(
&input.underlying_type,
r,
q,
c,
y,
cost_of_carry,
&market_condition,
);
let output = ForwardOutput {
forward_price,
cost_of_carry,
basis,
basis_rate,
market_condition,
theoretical_vs_no_arb,
present_value_of_forward,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Forward Pricing via Continuous Cost-of-Carry Model",
&serde_json::json!({
"spot_price": input.spot_price.to_string(),
"risk_free_rate": r.to_string(),
"time_to_expiry": t.to_string(),
"dividend_yield": q.to_string(),
"storage_cost_rate": c.to_string(),
"convenience_yield": y.to_string(),
"underlying_type": format!("{:?}", input.underlying_type),
}),
warnings,
elapsed,
output,
))
}
fn build_no_arb_explanation(
underlying: &UnderlyingType,
r: Decimal,
q: Decimal,
c: Decimal,
y: Decimal,
carry: Decimal,
condition: &MarketCondition,
) -> String {
let asset_label = match underlying {
UnderlyingType::Equity => "equity",
UnderlyingType::Commodity => "commodity",
UnderlyingType::Currency => "currency pair",
UnderlyingType::Index => "index",
UnderlyingType::Bond => "bond",
};
let condition_label = match condition {
MarketCondition::Contango => "contango (F > S)",
MarketCondition::Backwardation => "backwardation (F < S)",
MarketCondition::Flat => "flat (F = S)",
};
format!(
"No-arbitrage forward price for {asset_label}: F = S * exp((r - q + c - y) * T). \
Components: r={r}, q={q}, c={c}, y={y}, net carry={carry}. \
Market is in {condition_label}. \
Any deviation from this price creates a risk-free arbitrage opportunity \
via cash-and-carry (if F is too high) or reverse cash-and-carry (if F is too low)."
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForwardPositionInput {
pub original_forward_price: Money,
pub current_spot: Money,
pub risk_free_rate: Rate,
pub remaining_time: Decimal,
pub is_long: bool,
pub contract_size: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub dividend_yield: Option<Rate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForwardPositionOutput {
pub current_forward_price: Money,
pub mark_to_market: Money,
pub profit_loss: Money,
pub annualized_return: Rate,
}
pub fn value_forward_position(
input: &ForwardPositionInput,
) -> CorpFinanceResult<ComputationOutput<ForwardPositionOutput>> {
let start = Instant::now();
let warnings: Vec<String> = Vec::new();
if input.current_spot <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "current_spot".into(),
reason: "Current spot price must be positive".into(),
});
}
if input.remaining_time <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "remaining_time".into(),
reason: "Remaining time must be positive".into(),
});
}
if input.contract_size <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "contract_size".into(),
reason: "Contract size must be positive".into(),
});
}
if input.original_forward_price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "original_forward_price".into(),
reason: "Original forward price must be positive".into(),
});
}
let r = input.risk_free_rate;
let t = input.remaining_time;
let q = input.dividend_yield.unwrap_or(Decimal::ZERO);
let current_forward_price = input.current_spot * exp_decimal((r - q) * t);
let discount_factor = exp_decimal(-r * t);
let diff = current_forward_price - input.original_forward_price;
let mtm_per_unit = diff * discount_factor;
let direction = if input.is_long {
Decimal::ONE
} else {
-Decimal::ONE
};
let mark_to_market = mtm_per_unit * direction;
let profit_loss = mark_to_market * input.contract_size;
let notional = input.original_forward_price * input.contract_size;
let annualized_return = if notional > Decimal::ZERO && t > Decimal::ZERO {
(profit_loss / notional) / t
} else {
Decimal::ZERO
};
let output = ForwardPositionOutput {
current_forward_price,
mark_to_market,
profit_loss,
annualized_return,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Forward Position Valuation — Mark-to-Market",
&serde_json::json!({
"original_forward_price": input.original_forward_price.to_string(),
"current_spot": input.current_spot.to_string(),
"risk_free_rate": r.to_string(),
"remaining_time": t.to_string(),
"is_long": input.is_long,
"contract_size": input.contract_size.to_string(),
"dividend_yield": q.to_string(),
}),
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FuturesContract {
pub expiry_months: Decimal,
pub price: Money,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BasisAnalysisInput {
pub spot_price: Money,
pub futures_prices: Vec<FuturesContract>,
pub risk_free_rate: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BasisTerm {
pub label: String,
pub expiry_months: Decimal,
pub futures_price: Money,
pub basis: Money,
pub annualised_basis_rate: Rate,
pub implied_yield: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BasisAnalysisOutput {
pub term_structure: Vec<BasisTerm>,
pub curve_shape: MarketCondition,
pub average_implied_yield: Rate,
}
pub fn futures_basis_analysis(
input: &BasisAnalysisInput,
) -> CorpFinanceResult<ComputationOutput<BasisAnalysisOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
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 contract is required".into(),
));
}
let twelve = Decimal::from(12);
let mut term_structure = Vec::with_capacity(input.futures_prices.len());
let mut yield_sum = Decimal::ZERO;
let mut contango_count: usize = 0;
let mut backwardation_count: usize = 0;
for contract in &input.futures_prices {
if contract.expiry_months <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "expiry_months".into(),
reason: format!(
"Expiry months must be positive for contract '{}'",
contract.label
),
});
}
if contract.price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "price".into(),
reason: format!(
"Futures price must be positive for contract '{}'",
contract.label
),
});
}
let t_years = contract.expiry_months / twelve;
let basis = contract.price - input.spot_price;
let annualised_basis_rate = if t_years > Decimal::ZERO {
basis / input.spot_price / t_years
} else {
Decimal::ZERO
};
let ratio = contract.price / input.spot_price;
let implied_yield = if t_years > Decimal::ZERO {
ln_decimal(ratio) / t_years
} else {
Decimal::ZERO
};
yield_sum += implied_yield;
if basis > Decimal::ZERO {
contango_count += 1;
} else if basis < Decimal::ZERO {
backwardation_count += 1;
}
term_structure.push(BasisTerm {
label: contract.label.clone(),
expiry_months: contract.expiry_months,
futures_price: contract.price,
basis,
annualised_basis_rate,
implied_yield,
});
}
let n = Decimal::from(input.futures_prices.len() as u32);
let average_implied_yield = yield_sum / n;
let curve_shape = if contango_count > backwardation_count {
MarketCondition::Contango
} else if backwardation_count > contango_count {
MarketCondition::Backwardation
} else {
MarketCondition::Flat
};
if contango_count > 0 && backwardation_count > 0 {
warnings
.push("Mixed term structure: some contracts in contango, some in backwardation".into());
}
let output = BasisAnalysisOutput {
term_structure,
curve_shape,
average_implied_yield,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Futures Basis & Term Structure Analysis",
&serde_json::json!({
"spot_price": input.spot_price.to_string(),
"num_contracts": input.futures_prices.len(),
"risk_free_rate": input.risk_free_rate.to_string(),
}),
warnings,
elapsed,
output,
))
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
const TOL: &str = "0.01";
fn tol() -> Decimal {
TOL.parse::<Decimal>().unwrap()
}
fn tight_tol() -> Decimal {
dec!(0.001)
}
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})"
);
}
#[test]
fn test_exp_decimal_basic() {
assert_eq!(exp_decimal(Decimal::ZERO), Decimal::ONE);
let e1 = exp_decimal(Decimal::ONE);
assert_approx(e1, dec!(2.71828), dec!(0.001), "exp(1)");
let em1 = exp_decimal(-Decimal::ONE);
assert_approx(em1, dec!(0.36788), dec!(0.001), "exp(-1)");
let e005 = exp_decimal(dec!(0.05));
assert_approx(e005, dec!(1.05127), dec!(0.001), "exp(0.05)");
}
#[test]
fn test_ln_decimal_basic() {
assert_eq!(ln_decimal(Decimal::ONE), Decimal::ZERO);
let e = exp_decimal(Decimal::ONE);
let ln_e = ln_decimal(e);
assert_approx(ln_e, Decimal::ONE, dec!(0.0001), "ln(e)");
let ln2 = ln_decimal(Decimal::from(2));
assert_approx(ln2, dec!(0.6931), dec!(0.001), "ln(2)");
}
#[test]
fn test_equity_forward_no_dividend() {
let input = ForwardInput {
spot_price: dec!(100),
risk_free_rate: dec!(0.05),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Equity,
};
let result = price_forward(&input).unwrap();
let out = &result.result;
assert_approx(out.forward_price, dec!(105.127), tol(), "equity fwd no div");
assert_approx(out.cost_of_carry, dec!(0.05), tight_tol(), "carry rate");
assert!(out.basis > Decimal::ZERO);
assert_eq!(out.market_condition, MarketCondition::Contango);
assert_approx(
out.present_value_of_forward,
dec!(100),
tol(),
"PV of forward",
);
}
#[test]
fn test_equity_forward_with_dividend() {
let input = ForwardInput {
spot_price: dec!(100),
risk_free_rate: dec!(0.05),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: Some(dec!(0.02)),
foreign_rate: None,
underlying_type: UnderlyingType::Equity,
};
let result = price_forward(&input).unwrap();
let out = &result.result;
assert_approx(
out.forward_price,
dec!(103.045),
tol(),
"equity fwd with div",
);
assert_approx(out.cost_of_carry, dec!(0.03), tight_tol(), "carry rate");
}
#[test]
fn test_commodity_forward_storage_cost() {
let input = ForwardInput {
spot_price: dec!(50),
risk_free_rate: dec!(0.05),
time_to_expiry: dec!(0.5),
storage_cost_rate: Some(dec!(0.03)),
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Commodity,
};
let result = price_forward(&input).unwrap();
let out = &result.result;
assert_approx(
out.forward_price,
dec!(52.04),
tol(),
"commodity fwd storage",
);
assert_approx(out.cost_of_carry, dec!(0.08), tight_tol(), "carry rate");
}
#[test]
fn test_commodity_forward_convenience_yield() {
let input = ForwardInput {
spot_price: dec!(80),
risk_free_rate: dec!(0.04),
time_to_expiry: Decimal::ONE,
storage_cost_rate: Some(dec!(0.02)),
convenience_yield: Some(dec!(0.06)),
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Commodity,
};
let result = price_forward(&input).unwrap();
let out = &result.result;
assert_approx(out.cost_of_carry, Decimal::ZERO, tight_tol(), "carry rate");
assert_approx(
out.forward_price,
dec!(80),
tol(),
"commodity fwd conv yield",
);
assert_eq!(out.market_condition, MarketCondition::Flat);
}
#[test]
fn test_currency_forward_interest_rate_parity() {
let input = ForwardInput {
spot_price: dec!(1.10),
risk_free_rate: dec!(0.05),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: Some(dec!(0.03)),
underlying_type: UnderlyingType::Currency,
};
let result = price_forward(&input).unwrap();
let out = &result.result;
assert_approx(out.forward_price, dec!(1.1222), tol(), "currency fwd IRP");
assert_approx(out.cost_of_carry, dec!(0.02), tight_tol(), "carry rate");
}
#[test]
fn test_contango_detected() {
let input = ForwardInput {
spot_price: dec!(100),
risk_free_rate: dec!(0.10),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Index,
};
let result = price_forward(&input).unwrap();
assert_eq!(result.result.market_condition, MarketCondition::Contango);
assert!(result.result.forward_price > dec!(100));
}
#[test]
fn test_backwardation_detected() {
let input = ForwardInput {
spot_price: dec!(100),
risk_free_rate: dec!(0.02),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: Some(dec!(0.08)),
foreign_rate: None,
underlying_type: UnderlyingType::Equity,
};
let result = price_forward(&input).unwrap();
assert_eq!(
result.result.market_condition,
MarketCondition::Backwardation
);
assert!(result.result.forward_price < dec!(100));
}
#[test]
fn test_basis_calculation() {
let input = ForwardInput {
spot_price: dec!(100),
risk_free_rate: dec!(0.05),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Equity,
};
let result = price_forward(&input).unwrap();
let out = &result.result;
let expected_basis = out.forward_price - dec!(100);
assert_eq!(out.basis, expected_basis);
let expected_rate = expected_basis / dec!(100);
assert_approx(out.basis_rate, expected_rate, dec!(0.0001), "basis rate");
}
#[test]
fn test_cost_of_carry_rate() {
let input = ForwardInput {
spot_price: dec!(200),
risk_free_rate: dec!(0.05),
time_to_expiry: dec!(0.5),
storage_cost_rate: Some(dec!(0.01)),
convenience_yield: Some(dec!(0.005)),
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Commodity,
};
let result = price_forward(&input).unwrap();
let carry = result.result.cost_of_carry;
assert_approx(carry, dec!(0.055), tight_tol(), "cost of carry");
}
#[test]
fn test_long_position_profit() {
let input = ForwardPositionInput {
original_forward_price: dec!(100),
current_spot: dec!(110),
risk_free_rate: dec!(0.05),
remaining_time: dec!(0.5),
is_long: true,
contract_size: dec!(10),
dividend_yield: None,
};
let result = value_forward_position(&input).unwrap();
let out = &result.result;
assert!(out.current_forward_price > dec!(112));
assert!(out.mark_to_market > Decimal::ZERO);
assert!(out.profit_loss > Decimal::ZERO);
}
#[test]
fn test_long_position_loss() {
let input = ForwardPositionInput {
original_forward_price: dec!(100),
current_spot: dec!(90),
risk_free_rate: dec!(0.05),
remaining_time: dec!(0.5),
is_long: true,
contract_size: dec!(10),
dividend_yield: None,
};
let result = value_forward_position(&input).unwrap();
let out = &result.result;
assert!(out.current_forward_price < dec!(100));
assert!(out.mark_to_market < Decimal::ZERO);
assert!(out.profit_loss < Decimal::ZERO);
}
#[test]
fn test_short_position_mtm() {
let input = ForwardPositionInput {
original_forward_price: dec!(100),
current_spot: dec!(90),
risk_free_rate: dec!(0.05),
remaining_time: dec!(0.5),
is_long: false,
contract_size: dec!(5),
dividend_yield: None,
};
let result = value_forward_position(&input).unwrap();
let out = &result.result;
assert!(out.mark_to_market > Decimal::ZERO);
assert!(out.profit_loss > Decimal::ZERO);
let long_input = ForwardPositionInput {
is_long: true,
..input.clone()
};
let long_result = value_forward_position(&long_input).unwrap();
let long_mtm = long_result.result.mark_to_market;
assert_approx(
out.mark_to_market,
-long_mtm,
dec!(0.0001),
"short/long symmetry",
);
}
#[test]
fn test_basis_analysis_term_structure() {
let input = BasisAnalysisInput {
spot_price: dec!(100),
futures_prices: vec![
FuturesContract {
expiry_months: dec!(3),
price: dec!(101),
label: "Mar-25".into(),
},
FuturesContract {
expiry_months: dec!(6),
price: dec!(102.5),
label: "Jun-25".into(),
},
FuturesContract {
expiry_months: dec!(12),
price: dec!(105),
label: "Dec-25".into(),
},
],
risk_free_rate: dec!(0.05),
};
let result = futures_basis_analysis(&input).unwrap();
let out = &result.result;
assert_eq!(out.term_structure.len(), 3);
assert_eq!(out.curve_shape, MarketCondition::Contango);
for term in &out.term_structure {
assert!(term.basis > Decimal::ZERO);
assert!(term.implied_yield > Decimal::ZERO);
}
assert!(out.term_structure[2].basis > out.term_structure[0].basis);
}
#[test]
fn test_implied_yield_calculation() {
let input = BasisAnalysisInput {
spot_price: dec!(100),
futures_prices: vec![FuturesContract {
expiry_months: dec!(12),
price: dec!(105),
label: "Dec-25".into(),
}],
risk_free_rate: dec!(0.05),
};
let result = futures_basis_analysis(&input).unwrap();
let iy = result.result.term_structure[0].implied_yield;
assert_approx(iy, dec!(0.04879), tol(), "implied yield");
assert_approx(
result.result.average_implied_yield,
iy,
dec!(0.0001),
"avg implied yield single",
);
}
#[test]
fn test_invalid_spot_price_error() {
let input = ForwardInput {
spot_price: Decimal::ZERO,
risk_free_rate: dec!(0.05),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Equity,
};
let err = price_forward(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "spot_price");
}
e => panic!("Expected InvalidInput for spot_price, got {e:?}"),
}
let input_neg = ForwardInput {
spot_price: dec!(-10),
..input
};
assert!(price_forward(&input_neg).is_err());
let input_t0 = ForwardInput {
spot_price: dec!(100),
time_to_expiry: Decimal::ZERO,
..input_neg
};
let err_t0 = price_forward(&input_t0).unwrap_err();
match err_t0 {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "time_to_expiry");
}
e => panic!("Expected InvalidInput for time_to_expiry, got {e:?}"),
}
}
#[test]
fn test_metadata_populated() {
let input = ForwardInput {
spot_price: dec!(100),
risk_free_rate: dec!(0.05),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Equity,
};
let result = price_forward(&input).unwrap();
assert!(!result.methodology.is_empty());
assert!(result.methodology.contains("Forward"));
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(!result.metadata.version.is_empty());
assert!(!result.warnings.is_empty() || result.warnings.is_empty()); }
#[test]
fn test_position_validation_errors() {
let input = ForwardPositionInput {
original_forward_price: dec!(100),
current_spot: dec!(105),
risk_free_rate: dec!(0.05),
remaining_time: dec!(0.5),
is_long: true,
contract_size: Decimal::ZERO,
dividend_yield: None,
};
let err = value_forward_position(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "contract_size");
}
e => panic!("Expected InvalidInput for contract_size, got {e:?}"),
}
let input2 = ForwardPositionInput {
contract_size: dec!(10),
current_spot: Decimal::ZERO,
..input
};
let err2 = value_forward_position(&input2).unwrap_err();
match err2 {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "current_spot");
}
e => panic!("Expected InvalidInput for current_spot, got {e:?}"),
}
}
#[test]
fn test_basis_analysis_empty_contracts_error() {
let input = BasisAnalysisInput {
spot_price: dec!(100),
futures_prices: vec![],
risk_free_rate: dec!(0.05),
};
let err = futures_basis_analysis(&input).unwrap_err();
match err {
CorpFinanceError::InsufficientData(_) => {}
e => panic!("Expected InsufficientData, got {e:?}"),
}
}
#[test]
fn test_negative_risk_free_rate_allowed() {
let input = ForwardInput {
spot_price: dec!(100),
risk_free_rate: dec!(-0.005),
time_to_expiry: Decimal::ONE,
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Bond,
};
let result = price_forward(&input).unwrap();
assert!(result.result.forward_price < dec!(100));
assert_eq!(
result.result.market_condition,
MarketCondition::Backwardation
);
}
#[test]
fn test_basis_analysis_backwardation_curve() {
let input = BasisAnalysisInput {
spot_price: dec!(100),
futures_prices: vec![
FuturesContract {
expiry_months: dec!(3),
price: dec!(99),
label: "Mar-25".into(),
},
FuturesContract {
expiry_months: dec!(6),
price: dec!(97.5),
label: "Jun-25".into(),
},
],
risk_free_rate: dec!(0.02),
};
let result = futures_basis_analysis(&input).unwrap();
let out = &result.result;
assert_eq!(out.curve_shape, MarketCondition::Backwardation);
for term in &out.term_structure {
assert!(term.basis < Decimal::ZERO);
assert!(term.implied_yield < Decimal::ZERO);
}
}
#[test]
fn test_forward_pv_equals_spot_no_dividends() {
let input = ForwardInput {
spot_price: dec!(150),
risk_free_rate: dec!(0.08),
time_to_expiry: dec!(2),
storage_cost_rate: None,
convenience_yield: None,
dividend_yield: None,
foreign_rate: None,
underlying_type: UnderlyingType::Index,
};
let result = price_forward(&input).unwrap();
assert_approx(
result.result.present_value_of_forward,
dec!(150),
tol(),
"PV(F) = S",
);
}
#[test]
fn test_exp_range_reduction_large_x() {
let e5 = exp_decimal(dec!(5));
assert_approx(e5, dec!(148.413), dec!(0.1), "exp(5)");
let em5 = exp_decimal(dec!(-5));
assert_approx(em5, dec!(0.00674), dec!(0.001), "exp(-5)");
}
#[test]
fn test_position_with_dividend_yield() {
let input = ForwardPositionInput {
original_forward_price: dec!(100),
current_spot: dec!(105),
risk_free_rate: dec!(0.05),
remaining_time: dec!(0.5),
is_long: true,
contract_size: dec!(100),
dividend_yield: Some(dec!(0.03)),
};
let result = value_forward_position(&input).unwrap();
let out = &result.result;
assert!(out.current_forward_price > dec!(105));
assert!(out.current_forward_price < dec!(107));
assert!(out.profit_loss > Decimal::ZERO);
}
}