use rust_decimal::Decimal;
use rustrade_instrument::asset::name::AssetNameExchange;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(
Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
)]
pub enum Liquidity {
#[default]
Taker,
Maker,
}
pub trait FeeModel {
fn compute_fee(
&self,
price: Decimal,
quantity: Decimal,
contract_size: Decimal,
liquidity: Liquidity,
) -> Decimal;
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
)]
pub struct ZeroFeeModel;
impl FeeModel for ZeroFeeModel {
fn compute_fee(
&self,
_price: Decimal,
_quantity: Decimal,
_contract_size: Decimal,
_liquidity: Liquidity,
) -> Decimal {
Decimal::ZERO
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
pub struct PerContractFeeModel {
#[serde(with = "rust_decimal::serde::str")]
pub commission_per_contract: Decimal,
}
impl FeeModel for PerContractFeeModel {
fn compute_fee(
&self,
_price: Decimal,
quantity: Decimal,
_contract_size: Decimal,
_liquidity: Liquidity,
) -> Decimal {
self.commission_per_contract * quantity.abs()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
pub struct PercentageFeeModel {
#[serde(with = "rust_decimal::serde::str")]
pub rate: Decimal,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub maker_rate: Option<Decimal>,
}
impl PercentageFeeModel {
pub fn new(rate: Decimal) -> Self {
Self {
rate,
maker_rate: None,
}
}
pub fn maker_taker(maker_rate: Decimal, taker_rate: Decimal) -> Self {
Self {
rate: taker_rate,
maker_rate: Some(maker_rate),
}
}
pub fn rate_for(&self, liquidity: Liquidity) -> Decimal {
match liquidity {
Liquidity::Maker => self.maker_rate.unwrap_or(self.rate),
Liquidity::Taker => self.rate,
}
}
}
impl FeeModel for PercentageFeeModel {
fn compute_fee(
&self,
price: Decimal,
quantity: Decimal,
contract_size: Decimal,
liquidity: Liquidity,
) -> Decimal {
self.rate_for(liquidity) * price * quantity.abs() * contract_size
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
pub enum FeeModelConfig {
Zero(ZeroFeeModel),
PerContract(PerContractFeeModel),
Percentage(PercentageFeeModel),
}
impl Default for FeeModelConfig {
fn default() -> Self {
Self::Zero(ZeroFeeModel)
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
)]
#[non_exhaustive]
pub enum SpotFeeAsset {
#[default]
Quote,
Received,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(try_from = "FeeAssetRepr", into = "FeeAssetRepr")]
pub struct FeeAssetConfig(FeeAssetRepr);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
struct FeeAssetRepr {
asset: AssetNameExchange,
#[serde(with = "rust_decimal::serde::str")]
discount: Decimal,
}
impl FeeAssetConfig {
pub fn new(asset: AssetNameExchange, discount: Decimal) -> Result<Self, FeeAssetConfigError> {
Self::try_from(FeeAssetRepr { asset, discount })
}
pub fn asset(&self) -> &AssetNameExchange {
&self.0.asset
}
pub fn discount(&self) -> Decimal {
self.0.discount
}
}
impl TryFrom<FeeAssetRepr> for FeeAssetConfig {
type Error = FeeAssetConfigError;
fn try_from(repr: FeeAssetRepr) -> Result<Self, Self::Error> {
if repr.discount < Decimal::ZERO || repr.discount > Decimal::ONE {
return Err(FeeAssetConfigError::DiscountOutOfRange(repr.discount));
}
Ok(Self(repr))
}
}
impl From<FeeAssetConfig> for FeeAssetRepr {
fn from(config: FeeAssetConfig) -> Self {
config.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum FeeAssetConfigError {
#[error("fee asset discount {0} is outside 0 to 1")]
DiscountOutOfRange(Decimal),
}
impl FeeModel for FeeModelConfig {
fn compute_fee(
&self,
price: Decimal,
quantity: Decimal,
contract_size: Decimal,
liquidity: Liquidity,
) -> Decimal {
match self {
FeeModelConfig::Zero(model) => {
model.compute_fee(price, quantity, contract_size, liquidity)
}
FeeModelConfig::PerContract(model) => {
model.compute_fee(price, quantity, contract_size, liquidity)
}
FeeModelConfig::Percentage(model) => {
model.compute_fee(price, quantity, contract_size, liquidity)
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
fn d(s: &str) -> Decimal {
s.parse().unwrap()
}
#[test]
fn zero_fee_model_always_returns_zero() {
assert_eq!(
ZeroFeeModel.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
Decimal::ZERO
);
assert_eq!(
ZeroFeeModel.compute_fee(Decimal::ZERO, Decimal::ZERO, Decimal::ONE, Liquidity::Taker),
Decimal::ZERO
);
}
#[test]
fn per_contract_fee_charges_by_quantity() {
let model = PerContractFeeModel {
commission_per_contract: d("0.65"),
};
assert_eq!(
model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
d("6.5")
);
}
#[test]
fn per_contract_fee_uses_abs_quantity() {
let model = PerContractFeeModel {
commission_per_contract: d("0.65"),
};
assert_eq!(
model.compute_fee(d("100"), d("-10"), d("100"), Liquidity::Taker),
model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
);
}
#[test]
fn fee_model_config_zero_dispatches() {
let cfg = FeeModelConfig::Zero(ZeroFeeModel);
assert_eq!(
cfg.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
Decimal::ZERO
);
}
#[test]
fn fee_model_config_per_contract_dispatches() {
let model = PerContractFeeModel {
commission_per_contract: d("0.65"),
};
let cfg = FeeModelConfig::PerContract(model);
assert_eq!(
cfg.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
);
}
#[test]
fn fee_model_config_default_is_zero() {
assert_eq!(
FeeModelConfig::default(),
FeeModelConfig::Zero(ZeroFeeModel)
);
}
#[test]
fn percentage_fee_computes_rate_times_notional() {
let model = PercentageFeeModel::new(d("0.001"));
assert_eq!(
model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
d("1")
);
}
#[test]
fn percentage_fee_uses_abs_quantity() {
let model = PercentageFeeModel::new(d("0.001"));
assert_eq!(
model.compute_fee(d("100"), d("-10"), d("1"), Liquidity::Taker),
model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
);
}
#[test]
fn percentage_fee_scales_by_contract_size() {
let model = PercentageFeeModel::new(d("0.001"));
assert_eq!(
model.compute_fee(d("5000"), d("1"), d("25"), Liquidity::Taker),
d("125")
);
}
#[test]
fn percentage_fee_is_unchanged_for_a_unit_contract_size() {
let model = PercentageFeeModel::new(d("0.001"));
assert_eq!(
model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
d("1")
);
}
#[test]
fn per_contract_fee_ignores_contract_size() {
let model = PerContractFeeModel {
commission_per_contract: d("0.65"),
};
assert_eq!(
model.compute_fee(d("5000"), d("10"), d("25"), Liquidity::Taker),
model.compute_fee(d("5000"), d("10"), Decimal::ONE, Liquidity::Taker),
);
}
#[test]
fn fee_model_config_percentage_dispatches() {
let model = PercentageFeeModel::new(d("0.001"));
let cfg = FeeModelConfig::Percentage(model);
assert_eq!(
cfg.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
);
}
#[test]
fn zero_fee_model_serde_roundtrip() {
let cfg = FeeModelConfig::Zero(ZeroFeeModel);
let json = serde_json::to_string(&cfg).unwrap();
assert_eq!(json, r#"{"Zero":null}"#);
let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cfg);
}
#[test]
fn fee_model_config_default_when_field_omitted() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
fee_model: FeeModelConfig,
}
let parsed: Wrapper = serde_json::from_str(r#"{}"#).unwrap();
assert_eq!(parsed.fee_model, FeeModelConfig::Zero(ZeroFeeModel));
}
#[test]
fn percentage_fee_model_serde_roundtrip() {
let cfg = FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001")));
let json = serde_json::to_string(&cfg).unwrap();
assert_eq!(json, r#"{"Percentage":{"rate":"0.001"}}"#);
let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cfg);
}
#[test]
fn per_contract_fee_model_serde_roundtrip() {
let cfg = FeeModelConfig::PerContract(PerContractFeeModel {
commission_per_contract: d("0.65"),
});
let json = serde_json::to_string(&cfg).unwrap();
assert_eq!(
json,
r#"{"PerContract":{"commission_per_contract":"0.65"}}"#
);
let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cfg);
}
#[test]
fn a_percentage_model_without_a_maker_rate_charges_one_rate_on_both_sides() {
let model = PercentageFeeModel::new(d("0.001"));
assert_eq!(
model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
"an unconfigured maker rate must price exactly as the model did before the field \
existed"
);
}
#[test]
fn a_configured_maker_rate_is_charged_only_to_the_maker() {
let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
assert_eq!(
model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
d("1"),
"0.1% of 1000 notional"
);
assert_eq!(
model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
d("0.2"),
"0.02% of 1000 notional"
);
}
#[test]
fn a_maker_rebate_is_a_negative_fee() {
let model = PercentageFeeModel::maker_taker(d("-0.0001"), d("0.001"));
assert_eq!(
model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
d("-0.1")
);
}
#[test]
fn a_per_contract_commission_does_not_vary_with_liquidity() {
let model = PerContractFeeModel {
commission_per_contract: d("0.65"),
};
assert_eq!(
model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Maker),
model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker)
);
}
#[test]
fn a_percentage_model_deserialises_without_a_maker_rate() {
let model: PercentageFeeModel =
serde_json::from_str(r#"{"rate":"0.001"}"#).expect("legacy shape must parse");
assert_eq!(model, PercentageFeeModel::new(d("0.001")));
assert_eq!(model.maker_rate, None);
}
#[test]
fn a_percentage_model_round_trips_a_maker_rate() {
let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
let json = serde_json::to_string(&model).expect("must serialise");
assert_eq!(
serde_json::from_str::<PercentageFeeModel>(&json).expect("must round-trip"),
model
);
}
#[test]
fn a_fee_asset_discount_must_lie_between_zero_and_one() {
let bnb = || AssetNameExchange::new("BNB");
for discount in ["0", "0.25", "1"] {
let config = FeeAssetConfig::new(bnb(), d(discount)).unwrap();
assert_eq!((config.asset(), config.discount()), (&bnb(), d(discount)));
}
for discount in ["-0.01", "1.01"] {
assert_eq!(
FeeAssetConfig::new(bnb(), d(discount)),
Err(FeeAssetConfigError::DiscountOutOfRange(d(discount)))
);
}
}
#[test]
fn a_fee_asset_config_round_trips_and_refuses_an_invalid_discount() {
let config = FeeAssetConfig::new(AssetNameExchange::new("BNB"), d("0.25")).unwrap();
let json = serde_json::to_string(&config).unwrap();
assert_eq!(json, r#"{"asset":"BNB","discount":"0.25"}"#);
assert_eq!(
serde_json::from_str::<FeeAssetConfig>(&json).unwrap(),
config
);
let error = serde_json::from_str::<FeeAssetConfig>(r#"{"asset":"BNB","discount":"2"}"#)
.unwrap_err();
assert!(error.to_string().contains("outside 0 to 1"), "{error}");
}
}