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 compound_power(base: Decimal, exponent: Decimal) -> Decimal {
if exponent == exponent.trunc() && exponent >= Decimal::ZERO {
let n = exponent.to_string().parse::<u64>().unwrap_or(0);
if n == 0 {
return Decimal::ONE;
}
let mut result = Decimal::ONE;
for _ in 0..n {
result *= base;
}
result
} else {
exp_decimal(exponent * ln_decimal(base))
}
}
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..20 {
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 FxForwardType {
Deliverable,
NonDeliverable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FxForwardInput {
pub spot_rate: Decimal,
pub domestic_rate: Rate,
pub foreign_rate: Rate,
pub time_to_expiry: Decimal,
pub notional_foreign: Money,
pub forward_type: FxForwardType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FxForwardOutput {
pub forward_rate: Decimal,
pub forward_points: Decimal,
pub forward_points_pips: Decimal,
pub forward_premium_discount: Decimal,
pub notional_domestic: Money,
pub present_value: Money,
pub implied_rate_differential: Decimal,
pub covered_interest_parity_check: bool,
}
pub fn price_fx_forward(
input: &FxForwardInput,
) -> CorpFinanceResult<ComputationOutput<FxForwardOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.spot_rate <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "spot_rate".into(),
reason: "Spot rate 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 one_plus_rf = Decimal::ONE + input.foreign_rate;
if one_plus_rf <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "foreign_rate".into(),
reason: "(1 + foreign_rate) must be positive for compound interest".into(),
});
}
let one_plus_rd = Decimal::ONE + input.domestic_rate;
if one_plus_rd <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "domestic_rate".into(),
reason: "(1 + domestic_rate) must be positive for compound interest".into(),
});
}
let s = input.spot_rate;
let rd = input.domestic_rate;
let rf = input.foreign_rate;
let t = input.time_to_expiry;
let ratio = one_plus_rd / one_plus_rf;
let power = compound_power(ratio, t);
let forward_rate = s * power;
let forward_points = forward_rate - s;
let forward_points_pips = forward_points * dec!(10000);
let forward_premium_discount = (forward_points / s) / t;
let notional_domestic = input.notional_foreign * forward_rate;
let present_value = Decimal::ZERO;
let implied_rate_differential = if t > Decimal::ZERO {
ln_decimal(forward_rate / s) / t
} else {
Decimal::ZERO
};
let cip_lhs = forward_rate / s;
let cip_rhs = power;
let cip_diff = (cip_lhs - cip_rhs).abs();
let covered_interest_parity_check = cip_diff < dec!(0.0001);
if forward_premium_discount.abs() > dec!(0.10) {
warnings.push(format!(
"Annualised forward premium/discount of {:.4} exceeds 10%",
forward_premium_discount
));
}
let rate_diff_bps = (rd - rf).abs() * dec!(10000);
if rate_diff_bps > dec!(500) {
warnings.push(format!(
"Rate differential of {:.0} bps exceeds 500 bps",
rate_diff_bps
));
}
let output = FxForwardOutput {
forward_rate,
forward_points,
forward_points_pips,
forward_premium_discount,
notional_domestic,
present_value,
implied_rate_differential,
covered_interest_parity_check,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"FX Forward Pricing via Covered Interest Rate Parity (Compound)",
&serde_json::json!({
"spot_rate": s.to_string(),
"domestic_rate": rd.to_string(),
"foreign_rate": rf.to_string(),
"time_to_expiry": t.to_string(),
"notional_foreign": input.notional_foreign.to_string(),
"forward_type": format!("{:?}", input.forward_type),
}),
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossRateInput {
pub rate1: Decimal,
pub rate1_pair: String,
pub rate2: Decimal,
pub rate2_pair: String,
pub target_pair: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossRateOutput {
pub cross_rate: Decimal,
pub bid_ask_spread: Option<Decimal>,
pub derivation: String,
}
fn parse_pair(pair: &str) -> CorpFinanceResult<(String, String)> {
let parts: Vec<&str> = pair.split('/').collect();
if parts.len() != 2 {
return Err(CorpFinanceError::InvalidInput {
field: "currency_pair".into(),
reason: format!("Expected format 'CCY1/CCY2', got '{}'", pair),
});
}
Ok((
parts[0].trim().to_uppercase(),
parts[1].trim().to_uppercase(),
))
}
pub fn calculate_cross_rate(
input: &CrossRateInput,
) -> CorpFinanceResult<ComputationOutput<CrossRateOutput>> {
let start = Instant::now();
let warnings: Vec<String> = Vec::new();
if input.rate1 <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "rate1".into(),
reason: "Exchange rate must be positive".into(),
});
}
if input.rate2 <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "rate2".into(),
reason: "Exchange rate must be positive".into(),
});
}
let (base1, quote1) = parse_pair(&input.rate1_pair)?;
let (base2, quote2) = parse_pair(&input.rate2_pair)?;
let (target_base, target_quote) = parse_pair(&input.target_pair)?;
let (cross_rate, derivation) = derive_cross_rate(
&base1,
"e1,
input.rate1,
&base2,
"e2,
input.rate2,
&target_base,
&target_quote,
)?;
let output = CrossRateOutput {
cross_rate,
bid_ask_spread: None,
derivation,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Cross Rate Derivation from Two Currency Pairs",
&serde_json::json!({
"rate1_pair": input.rate1_pair,
"rate1": input.rate1.to_string(),
"rate2_pair": input.rate2_pair,
"rate2": input.rate2.to_string(),
"target_pair": input.target_pair,
}),
warnings,
elapsed,
output,
))
}
fn derive_cross_rate(
b1: &str,
q1: &str,
r1: Decimal,
b2: &str,
q2: &str,
r2: Decimal,
tb: &str,
tq: &str,
) -> CorpFinanceResult<(Decimal, String)> {
let rate_from_pair =
|x: &str, y: &str, base: &str, quote: &str, rate: Decimal| -> Option<Decimal> {
if x == base && y == quote {
Some(rate)
} else if x == quote && y == base {
Some(Decimal::ONE / rate)
} else {
None
}
};
if let Some(r) = rate_from_pair(tb, tq, b1, q1, r1) {
return Ok((
r,
format!("{tb}/{tq} obtained directly from {b1}/{q1} = {r1}"),
));
}
if let Some(r) = rate_from_pair(tb, tq, b2, q2, r2) {
return Ok((
r,
format!("{tb}/{tq} obtained directly from {b2}/{q2} = {r2}"),
));
}
let all_currencies = [b1, q1, b2, q2];
for &c in &all_currencies {
if let (Some(tb_per_c), Some(c_per_tq)) = (
rate_from_pair(tb, c, b1, q1, r1),
rate_from_pair(c, tq, b2, q2, r2),
) {
let cross = tb_per_c * c_per_tq;
return Ok((
cross,
format!(
"{tb}/{tq} = ({tb}/{c}) * ({c}/{tq}) \
= ({b1}/{q1} derived {tb_per_c}) * ({b2}/{q2} derived {c_per_tq}) \
= {cross}"
),
));
}
if let (Some(tb_per_c), Some(c_per_tq)) = (
rate_from_pair(tb, c, b2, q2, r2),
rate_from_pair(c, tq, b1, q1, r1),
) {
let cross = tb_per_c * c_per_tq;
return Ok((
cross,
format!(
"{tb}/{tq} = ({tb}/{c}) * ({c}/{tq}) \
= ({b2}/{q2} derived {tb_per_c}) * ({b1}/{q1} derived {c_per_tq}) \
= {cross}"
),
));
}
}
Err(CorpFinanceError::InvalidInput {
field: "target_pair".into(),
reason: format!(
"Cannot derive {tb}/{tq} from {b1}/{q1} and {b2}/{q2}: \
no common currency path found"
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn tol() -> Decimal {
dec!(0.01)
}
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_usd_eur_forward_basic() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
let expected_fwd = dec!(1.10) * (dec!(1.05) / dec!(1.03));
assert_approx(out.forward_rate, expected_fwd, tight_tol(), "USD/EUR fwd");
assert!(out.forward_rate > dec!(1.10), "USD at premium vs EUR");
assert!(out.covered_interest_parity_check, "CIP should hold");
}
#[test]
fn test_forward_points() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
let expected_points = out.forward_rate - dec!(1.10);
assert_eq!(out.forward_points, expected_points);
assert!(out.forward_points > Decimal::ZERO);
let expected_pips = expected_points * dec!(10000);
assert_approx(out.forward_points_pips, expected_pips, dec!(0.0001), "pips");
}
#[test]
fn test_ndf_forward() {
let input = FxForwardInput {
spot_rate: dec!(7.25),
domestic_rate: dec!(0.04),
foreign_rate: dec!(0.02),
time_to_expiry: dec!(0.5),
notional_foreign: dec!(5000000),
forward_type: FxForwardType::NonDeliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
assert!(out.forward_rate > dec!(7.25));
assert_eq!(input.forward_type, FxForwardType::NonDeliverable);
}
#[test]
fn test_forward_premium_discount() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: dec!(0.25),
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
assert_approx(
out.forward_premium_discount,
dec!(0.02),
dec!(0.005),
"annualised premium",
);
}
#[test]
fn test_negative_interest_rates() {
let input = FxForwardInput {
spot_rate: dec!(0.85),
domestic_rate: dec!(-0.005),
foreign_rate: dec!(0.02),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
assert!(
out.forward_rate < dec!(0.85),
"EUR at discount when r_d < r_f"
);
assert!(out.forward_points < Decimal::ZERO);
assert!(out.covered_interest_parity_check);
}
#[test]
fn test_cip_check_holds() {
let input = FxForwardInput {
spot_rate: dec!(150.0),
domestic_rate: dec!(0.005),
foreign_rate: dec!(0.04),
time_to_expiry: dec!(2),
notional_foreign: dec!(10000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
assert!(
result.result.covered_interest_parity_check,
"CIP must hold for a fair-value forward"
);
}
#[test]
fn test_notional_domestic() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
let expected_notional = dec!(1000000) * out.forward_rate;
assert_eq!(out.notional_domestic, expected_notional);
}
#[test]
fn test_pv_at_inception_zero() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
assert_eq!(result.result.present_value, Decimal::ZERO);
}
#[test]
fn test_large_rate_differential_warning() {
let input = FxForwardInput {
spot_rate: dec!(15.0),
domestic_rate: dec!(0.10),
foreign_rate: dec!(0.02),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
assert!(
result.warnings.iter().any(|w| w.contains("bps")),
"Should warn about large rate differential"
);
}
#[test]
fn test_large_premium_warning() {
let input = FxForwardInput {
spot_rate: dec!(1.0),
domestic_rate: dec!(0.20),
foreign_rate: dec!(0.01),
time_to_expiry: dec!(2),
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
assert!(
result.warnings.iter().any(|w| w.contains("premium")),
"Should warn about large annualised premium"
);
}
#[test]
fn test_cross_rate_eur_jpy() {
let input = CrossRateInput {
rate1: dec!(1.10),
rate1_pair: "USD/EUR".to_string(),
rate2: dec!(150.0),
rate2_pair: "JPY/USD".to_string(),
target_pair: "JPY/EUR".to_string(),
};
let result = calculate_cross_rate(&input).unwrap();
let out = &result.result;
assert_approx(out.cross_rate, dec!(165.0), tol(), "EUR/JPY cross");
}
#[test]
fn test_cross_rate_inverse() {
let input = CrossRateInput {
rate1: dec!(1.25),
rate1_pair: "GBP/USD".to_string(),
rate2: dec!(150.0),
rate2_pair: "JPY/USD".to_string(),
target_pair: "USD/GBP".to_string(),
};
let result = calculate_cross_rate(&input).unwrap();
let out = &result.result;
assert_approx(out.cross_rate, dec!(0.80), tol(), "USD/GBP inverse");
}
#[test]
fn test_cross_rate_chf() {
let input = CrossRateInput {
rate1: dec!(0.92),
rate1_pair: "USD/CHF".to_string(),
rate2: dec!(1.10),
rate2_pair: "USD/EUR".to_string(),
target_pair: "CHF/EUR".to_string(),
};
let result = calculate_cross_rate(&input).unwrap();
let out = &result.result;
let expected = dec!(1.10) / dec!(0.92);
assert_approx(out.cross_rate, expected, tol(), "CHF/EUR cross");
}
#[test]
fn test_validation_spot_rate_positive() {
let input = FxForwardInput {
spot_rate: Decimal::ZERO,
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let err = price_fx_forward(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "spot_rate");
}
e => panic!("Expected InvalidInput for spot_rate, got {e:?}"),
}
}
#[test]
fn test_validation_time_positive() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ZERO,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let err = price_fx_forward(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "time_to_expiry");
}
e => panic!("Expected InvalidInput for time_to_expiry, got {e:?}"),
}
}
#[test]
fn test_cross_rate_validation() {
let input = CrossRateInput {
rate1: Decimal::ZERO,
rate1_pair: "USD/EUR".to_string(),
rate2: dec!(150.0),
rate2_pair: "USD/JPY".to_string(),
target_pair: "EUR/JPY".to_string(),
};
let err = calculate_cross_rate(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "rate1");
}
e => panic!("Expected InvalidInput for rate1, got {e:?}"),
}
}
#[test]
fn test_cross_rate_invalid_pair_format() {
let input = CrossRateInput {
rate1: dec!(1.10),
rate1_pair: "USDEUR".to_string(), rate2: dec!(150.0),
rate2_pair: "USD/JPY".to_string(),
target_pair: "EUR/JPY".to_string(),
};
assert!(calculate_cross_rate(&input).is_err());
}
#[test]
fn test_implied_rate_differential() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
assert_approx(
out.implied_rate_differential,
dec!(0.02),
dec!(0.005),
"implied rate diff",
);
}
#[test]
fn test_metadata_populated() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.05),
foreign_rate: dec!(0.03),
time_to_expiry: Decimal::ONE,
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
assert!(result.methodology.contains("FX Forward"));
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(!result.metadata.version.is_empty());
}
#[test]
fn test_multi_year_fractional() {
let input = FxForwardInput {
spot_rate: dec!(1.10),
domestic_rate: dec!(0.04),
foreign_rate: dec!(0.02),
time_to_expiry: dec!(2.5),
notional_foreign: dec!(1000000),
forward_type: FxForwardType::Deliverable,
};
let result = price_fx_forward(&input).unwrap();
let out = &result.result;
assert!(out.forward_rate > dec!(1.15));
assert!(out.forward_rate < dec!(1.16));
assert!(out.covered_interest_parity_check);
}
#[test]
fn test_cross_rate_no_common_currency() {
let input = CrossRateInput {
rate1: dec!(1.10),
rate1_pair: "USD/EUR".to_string(),
rate2: dec!(0.85),
rate2_pair: "GBP/CHF".to_string(),
target_pair: "AUD/NZD".to_string(),
};
assert!(calculate_cross_rate(&input).is_err());
}
}