use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::fmt;
use uuid::Uuid;
use super::user::ValidationError;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct LiquidityPool {
pub pool_id: Uuid,
pub token_a_id: Uuid,
pub token_b_id: Uuid,
pub reserve_a: Decimal,
pub reserve_b: Decimal,
pub total_lp_tokens: Decimal,
pub fee_percentage: Decimal,
pub status: PoolStatus,
pub cumulative_volume_a: Decimal,
pub cumulative_volume_b: Decimal,
pub total_fees_a: Decimal,
pub total_fees_b: Decimal,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq, Eq)]
#[sqlx(type_name = "varchar", rename_all = "lowercase")]
#[derive(Default)]
pub enum PoolStatus {
#[default]
Active,
Paused,
Closed,
}
impl fmt::Display for PoolStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PoolStatus::Active => write!(f, "active"),
PoolStatus::Paused => write!(f, "paused"),
PoolStatus::Closed => write!(f, "closed"),
}
}
}
impl LiquidityPool {
pub fn calculate_k(&self) -> Decimal {
self.reserve_a * self.reserve_b
}
pub fn price_a_in_b(&self) -> Decimal {
if self.reserve_a == dec!(0) {
return dec!(0);
}
self.reserve_b / self.reserve_a
}
pub fn price_b_in_a(&self) -> Decimal {
if self.reserve_b == dec!(0) {
return dec!(0);
}
self.reserve_a / self.reserve_b
}
pub fn calculate_output(&self, input_amount: Decimal, input_is_a: bool) -> Decimal {
if input_amount <= dec!(0) {
return dec!(0);
}
let (reserve_in, reserve_out) = if input_is_a {
(self.reserve_a, self.reserve_b)
} else {
(self.reserve_b, self.reserve_a)
};
if reserve_in == dec!(0) || reserve_out == dec!(0) {
return dec!(0);
}
let input_with_fee = input_amount * (dec!(1) - self.fee_percentage);
let numerator = reserve_out * input_with_fee;
let denominator = reserve_in + input_with_fee;
if denominator == dec!(0) {
return dec!(0);
}
numerator / denominator
}
pub fn calculate_input(&self, output_amount: Decimal, output_is_a: bool) -> Decimal {
if output_amount <= dec!(0) {
return dec!(0);
}
let (reserve_in, reserve_out) = if output_is_a {
(self.reserve_b, self.reserve_a)
} else {
(self.reserve_a, self.reserve_b)
};
if reserve_in == dec!(0) || reserve_out == dec!(0) || output_amount >= reserve_out {
return Decimal::MAX;
}
let numerator = reserve_in * output_amount;
let denominator = reserve_out - output_amount;
if denominator <= dec!(0) {
return Decimal::MAX;
}
let input_before_fee = numerator / denominator;
input_before_fee / (dec!(1) - self.fee_percentage)
}
pub fn calculate_price_impact(&self, input_amount: Decimal, input_is_a: bool) -> Decimal {
let price_before = if input_is_a {
self.price_a_in_b()
} else {
self.price_b_in_a()
};
if price_before == dec!(0) {
return dec!(0);
}
let output_amount = self.calculate_output(input_amount, input_is_a);
if output_amount == dec!(0) {
return dec!(0);
}
let effective_price = output_amount / input_amount;
((price_before - effective_price) / price_before).abs()
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.reserve_a < dec!(0) {
return Err(ValidationError("Reserve A cannot be negative".to_string()));
}
if self.reserve_b < dec!(0) {
return Err(ValidationError("Reserve B cannot be negative".to_string()));
}
if self.total_lp_tokens < dec!(0) {
return Err(ValidationError(
"Total LP tokens cannot be negative".to_string(),
));
}
if self.fee_percentage < dec!(0) || self.fee_percentage >= dec!(1) {
return Err(ValidationError(
"Fee percentage must be between 0 and 1".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct LpPosition {
pub position_id: Uuid,
pub pool_id: Uuid,
pub user_id: Uuid,
pub lp_tokens: Decimal,
pub initial_reserve_a: Decimal,
pub initial_reserve_b: Decimal,
pub pool_share: Decimal,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl LpPosition {
pub fn current_value(&self, pool: &LiquidityPool) -> (Decimal, Decimal) {
if pool.total_lp_tokens == dec!(0) {
return (dec!(0), dec!(0));
}
let share = self.lp_tokens / pool.total_lp_tokens;
let current_a = pool.reserve_a * share;
let current_b = pool.reserve_b * share;
(current_a, current_b)
}
pub fn impermanent_loss(&self, pool: &LiquidityPool) -> Decimal {
if self.initial_reserve_a == dec!(0) || self.initial_reserve_b == dec!(0) {
return dec!(0);
}
let (current_a, current_b) = self.current_value(pool);
let initial_price_ratio = self.initial_reserve_b / self.initial_reserve_a;
let current_price_ratio = if current_a == dec!(0) {
return dec!(1); } else {
current_b / current_a
};
let price_ratio = current_price_ratio / initial_price_ratio;
let sqrt_ratio = if let Some(val) = price_ratio.to_f64() {
Decimal::from_f64_retain(val.sqrt()).unwrap_or(dec!(1))
} else {
dec!(1)
};
let il = (dec!(2) * sqrt_ratio) / (dec!(1) + price_ratio) - dec!(1);
il.abs()
}
pub fn calculate_returns(&self, pool: &LiquidityPool) -> (Decimal, Decimal, Decimal) {
let il = self.impermanent_loss(pool);
let fee_share = if pool.total_lp_tokens == dec!(0) {
dec!(0)
} else {
self.lp_tokens / pool.total_lp_tokens
};
let fees_a = pool.total_fees_a * fee_share;
let _fees_b = pool.total_fees_b * fee_share;
let initial_value_a = self.initial_reserve_a;
let fee_return = if initial_value_a == dec!(0) {
dec!(0)
} else {
fees_a / initial_value_a
};
let total_return = fee_return - il;
(total_return, fee_return, il)
}
}
#[derive(Debug, Deserialize)]
pub struct CreatePoolRequest {
pub token_a_id: Uuid,
pub token_b_id: Uuid,
pub initial_reserve_a: Decimal,
pub initial_reserve_b: Decimal,
pub fee_percentage: Option<Decimal>,
}
impl CreatePoolRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.token_a_id == self.token_b_id {
return Err(ValidationError(
"Cannot create pool with same token".to_string(),
));
}
if self.initial_reserve_a <= dec!(0) {
return Err(ValidationError(
"Initial reserve A must be positive".to_string(),
));
}
if self.initial_reserve_b <= dec!(0) {
return Err(ValidationError(
"Initial reserve B must be positive".to_string(),
));
}
if let Some(fee) = self.fee_percentage {
if fee < dec!(0) || fee >= dec!(1) {
return Err(ValidationError(
"Fee percentage must be between 0 and 1".to_string(),
));
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
pub struct AddLiquidityRequest {
pub pool_id: Uuid,
pub amount_a: Decimal,
pub amount_b: Decimal,
pub min_lp_tokens: Option<Decimal>,
}
impl AddLiquidityRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.amount_a <= dec!(0) {
return Err(ValidationError("Amount A must be positive".to_string()));
}
if self.amount_b <= dec!(0) {
return Err(ValidationError("Amount B must be positive".to_string()));
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
pub struct RemoveLiquidityRequest {
pub pool_id: Uuid,
pub lp_tokens: Decimal,
pub min_amount_a: Option<Decimal>,
pub min_amount_b: Option<Decimal>,
}
impl RemoveLiquidityRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.lp_tokens <= dec!(0) {
return Err(ValidationError("LP tokens must be positive".to_string()));
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
pub struct SwapRequest {
pub pool_id: Uuid,
pub input_amount: Decimal,
pub input_is_a: bool,
pub min_output: Option<Decimal>,
pub max_slippage: Option<Decimal>,
}
impl SwapRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.input_amount <= dec!(0) {
return Err(ValidationError("Input amount must be positive".to_string()));
}
if let Some(slippage) = self.max_slippage {
if slippage < dec!(0) || slippage > dec!(1) {
return Err(ValidationError(
"Slippage must be between 0 and 1".to_string(),
));
}
}
Ok(())
}
}
#[derive(Debug, Serialize)]
pub struct PoolStats {
pub pool_id: Uuid,
pub reserve_a: Decimal,
pub reserve_b: Decimal,
pub price_a_in_b: Decimal,
pub price_b_in_a: Decimal,
pub total_lp_tokens: Decimal,
pub cumulative_volume_a: Decimal,
pub cumulative_volume_b: Decimal,
pub total_fees_a: Decimal,
pub total_fees_b: Decimal,
pub apr: Option<Decimal>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use rust_decimal_macros::dec;
use uuid::Uuid;
fn make_pool(reserve_a: Decimal, reserve_b: Decimal) -> LiquidityPool {
let now = Utc::now();
LiquidityPool {
pool_id: Uuid::new_v4(),
token_a_id: Uuid::new_v4(),
token_b_id: Uuid::new_v4(),
reserve_a,
reserve_b,
total_lp_tokens: dec!(1000),
fee_percentage: dec!(0.003),
status: PoolStatus::Active,
cumulative_volume_a: dec!(0),
cumulative_volume_b: dec!(0),
total_fees_a: dec!(0),
total_fees_b: dec!(0),
created_at: now,
updated_at: now,
}
}
fn make_lp_position(pool: &LiquidityPool, lp_tokens: Decimal) -> LpPosition {
let now = Utc::now();
LpPosition {
position_id: Uuid::new_v4(),
pool_id: pool.pool_id,
user_id: Uuid::new_v4(),
lp_tokens,
initial_reserve_a: pool.reserve_a,
initial_reserve_b: pool.reserve_b,
pool_share: lp_tokens / pool.total_lp_tokens,
created_at: now,
updated_at: now,
}
}
#[test]
fn test_pool_initial_constant_product() {
let pool = make_pool(dec!(1000), dec!(500));
assert_eq!(pool.calculate_k(), dec!(500000));
}
#[test]
fn test_pool_price_a_in_b() {
let pool = make_pool(dec!(1000), dec!(500));
assert_eq!(pool.price_a_in_b(), dec!(0.5));
}
#[test]
fn test_pool_price_b_in_a() {
let pool = make_pool(dec!(1000), dec!(500));
assert_eq!(pool.price_b_in_a(), dec!(2));
}
#[test]
fn test_pool_price_a_in_b_zero_reserve_returns_zero() {
let pool = make_pool(dec!(0), dec!(500));
assert_eq!(pool.price_a_in_b(), dec!(0));
}
#[test]
fn test_calculate_output_respects_constant_product_approximately() {
let mut pool = make_pool(dec!(1000), dec!(1000));
pool.fee_percentage = dec!(0);
let input = dec!(100);
let output = pool.calculate_output(input, true);
let new_k = (pool.reserve_a + input) * (pool.reserve_b - output);
let k_before = pool.calculate_k();
let diff = (new_k - k_before).abs();
assert!(
diff < dec!(1),
"Constant product should be preserved; diff={diff}"
);
}
#[test]
fn test_calculate_output_positive_for_positive_input() {
let pool = make_pool(dec!(1000), dec!(1000));
let out = pool.calculate_output(dec!(50), true);
assert!(
out > dec!(0),
"Output must be positive for a positive input"
);
}
#[test]
fn test_calculate_output_zero_for_zero_input() {
let pool = make_pool(dec!(1000), dec!(1000));
assert_eq!(pool.calculate_output(dec!(0), true), dec!(0));
}
#[test]
fn test_calculate_output_is_less_with_fee_than_without() {
let pool_with_fee = make_pool(dec!(1000), dec!(1000));
let mut pool_no_fee = make_pool(dec!(1000), dec!(1000));
pool_no_fee.pool_id = pool_with_fee.pool_id; pool_no_fee.fee_percentage = dec!(0);
let input = dec!(100);
let out_with_fee = pool_with_fee.calculate_output(input, true);
let out_no_fee = pool_no_fee.calculate_output(input, true);
assert!(
out_with_fee < out_no_fee,
"Output with fee ({out_with_fee}) must be less than without fee ({out_no_fee})"
);
}
#[test]
fn test_calculate_input_roundtrip() {
let pool = make_pool(dec!(1000), dec!(1000));
let desired_output = dec!(50);
let required_input = pool.calculate_input(desired_output, false);
let actual_output = pool.calculate_output(required_input, true);
assert!(
actual_output >= desired_output,
"Round-trip: actual_output ({actual_output}) must be >= desired ({desired_output})"
);
}
#[test]
fn test_calculate_input_for_impossible_output_returns_max() {
let pool = make_pool(dec!(100), dec!(100));
let result = pool.calculate_input(dec!(200), false);
assert_eq!(
result,
Decimal::MAX,
"Impossible output must return Decimal::MAX"
);
}
#[test]
fn test_price_impact_increases_with_trade_size() {
let pool = make_pool(dec!(1000), dec!(1000));
let small_impact = pool.calculate_price_impact(dec!(10), true);
let large_impact = pool.calculate_price_impact(dec!(500), true);
assert!(
large_impact > small_impact,
"Larger trade must produce greater price impact ({large_impact} vs {small_impact})"
);
}
#[test]
fn test_price_impact_zero_for_zero_input() {
let pool = make_pool(dec!(1000), dec!(1000));
assert_eq!(pool.calculate_price_impact(dec!(0), true), dec!(0));
}
#[test]
fn test_pool_validate_ok_for_valid_pool() {
let pool = make_pool(dec!(1000), dec!(1000));
assert!(pool.validate().is_ok(), "Valid pool must pass validation");
}
#[test]
fn test_pool_validate_rejects_invalid_fee() {
let mut pool = make_pool(dec!(1000), dec!(1000));
pool.fee_percentage = dec!(1); let err = pool.validate().expect_err("Fee >= 1 must be rejected");
assert!(err.0.contains("Fee"), "error: {}", err.0);
}
#[test]
fn test_pool_validate_rejects_negative_reserve() {
let mut pool = make_pool(dec!(1000), dec!(1000));
pool.reserve_a = dec!(-1);
let err = pool
.validate()
.expect_err("Negative reserve must be rejected");
assert!(err.0.contains("Reserve A"), "error: {}", err.0);
}
#[test]
fn test_lp_position_current_value_proportional() {
let pool = make_pool(dec!(1000), dec!(2000));
let pos = make_lp_position(&pool, dec!(100));
let (val_a, val_b) = pos.current_value(&pool);
assert_eq!(val_a, dec!(100), "10% of 1000 reserve_a must be 100");
assert_eq!(val_b, dec!(200), "10% of 2000 reserve_b must be 200");
}
#[test]
fn test_lp_position_current_value_zero_when_no_lp_tokens_minted() {
let pool = make_pool(dec!(1000), dec!(1000));
let now = Utc::now();
let pos = LpPosition {
position_id: Uuid::new_v4(),
pool_id: pool.pool_id,
user_id: Uuid::new_v4(),
lp_tokens: dec!(100),
initial_reserve_a: pool.reserve_a,
initial_reserve_b: pool.reserve_b,
pool_share: dec!(0),
created_at: now,
updated_at: now,
};
let mut empty_pool = pool.clone();
empty_pool.total_lp_tokens = dec!(0);
let (val_a, val_b) = pos.current_value(&empty_pool);
assert_eq!(val_a, dec!(0));
assert_eq!(val_b, dec!(0));
}
#[test]
fn test_impermanent_loss_is_zero_when_price_unchanged() {
let pool = make_pool(dec!(1000), dec!(1000));
let pos = make_lp_position(&pool, dec!(500));
let il = pos.impermanent_loss(&pool);
assert_eq!(
il,
dec!(0),
"No price change must produce zero impermanent loss"
);
}
#[test]
fn test_impermanent_loss_is_positive_when_price_changed() {
let initial_pool = make_pool(dec!(1000), dec!(1000));
let mut pos = make_lp_position(&initial_pool, dec!(500));
let current_pool = make_pool(dec!(500), dec!(2000));
pos.pool_id = current_pool.pool_id;
let il = pos.impermanent_loss(¤t_pool);
assert!(
il > dec!(0),
"A 4x price change must produce positive impermanent loss, got {il}"
);
}
#[test]
fn test_create_pool_request_same_token_rejected() {
let id = Uuid::new_v4();
let req = CreatePoolRequest {
token_a_id: id,
token_b_id: id,
initial_reserve_a: dec!(100),
initial_reserve_b: dec!(100),
fee_percentage: None,
};
let err = req
.validate()
.expect_err("Same token pool must be rejected");
assert!(err.0.contains("same token"), "error: {}", err.0);
}
#[test]
fn test_create_pool_request_zero_reserve_rejected() {
let req = CreatePoolRequest {
token_a_id: Uuid::new_v4(),
token_b_id: Uuid::new_v4(),
initial_reserve_a: dec!(0),
initial_reserve_b: dec!(100),
fee_percentage: None,
};
let err = req.validate().expect_err("Zero reserve must be rejected");
assert!(err.0.contains("reserve A"), "error: {}", err.0);
}
#[test]
fn test_pool_status_display() {
assert_eq!(PoolStatus::Active.to_string(), "active");
assert_eq!(PoolStatus::Paused.to_string(), "paused");
assert_eq!(PoolStatus::Closed.to_string(), "closed");
}
}