From 8559ae0ec8e2c696264f89a2501b801730195bbf Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Mon, 29 Apr 2024 18:49:25 +1000 Subject: [PATCH 01/12] chore(coordinator): Use the Amount type more often The `Position` model should be as strongly typed as possible. Converting to i64 should happen _just_ before inserting into the database. --- coordinator/src/db/positions.rs | 8 +- coordinator/src/dlc_protocol.rs | 4 +- coordinator/src/leaderboard.rs | 4 +- coordinator/src/payout_curve.rs | 75 ++++---- coordinator/src/position/models.rs | 67 +++---- coordinator/src/trade/mod.rs | 165 ++++++++---------- .../payout_curve/examples/payout_curve_csv.rs | 28 +-- crates/payout_curve/src/lib.rs | 18 +- .../tests/integration_proptests.rs | 20 +-- crates/xxi-node/src/cfd.rs | 73 ++++---- mobile/native/src/calculations/mod.rs | 2 +- 11 files changed, 225 insertions(+), 239 deletions(-) diff --git a/coordinator/src/db/positions.rs b/coordinator/src/db/positions.rs index a60547752..698ee34ad 100644 --- a/coordinator/src/db/positions.rs +++ b/coordinator/src/db/positions.rs @@ -362,7 +362,7 @@ impl From for crate::position::models::Position { value.trader_realized_pnl_sat, value.closing_price, )), - coordinator_margin: value.coordinator_margin, + coordinator_margin: Amount::from_sat(value.coordinator_margin as u64), creation_timestamp: value.creation_timestamp, expiry_timestamp: value.expiry_timestamp, update_timestamp: value.update_timestamp, @@ -372,7 +372,7 @@ impl From for crate::position::models::Position { }), closing_price: value.closing_price, coordinator_leverage: value.coordinator_leverage, - trader_margin: value.trader_margin, + trader_margin: Amount::from_sat(value.trader_margin as u64), stable: value.stable, trader_realized_pnl_sat: value.trader_realized_pnl_sat, order_matching_fees: Amount::from_sat(value.order_matching_fees as u64), @@ -418,12 +418,12 @@ impl From for NewPosition { .to_f32() .expect("to fit into f32"), position_state: PositionState::Proposed, - coordinator_margin: value.coordinator_margin, + coordinator_margin: value.coordinator_margin.to_sat() as i64, expiry_timestamp: value.expiry_timestamp, trader_pubkey: value.trader.to_string(), temporary_contract_id: hex::encode(value.temporary_contract_id), coordinator_leverage: value.coordinator_leverage, - trader_margin: value.trader_margin, + trader_margin: value.trader_margin.to_sat() as i64, stable: value.stable, order_matching_fees: value.order_matching_fees.to_sat() as i64, } diff --git a/coordinator/src/dlc_protocol.rs b/coordinator/src/dlc_protocol.rs index 791c541e9..1466bbf5b 100644 --- a/coordinator/src/dlc_protocol.rs +++ b/coordinator/src/dlc_protocol.rs @@ -368,8 +368,8 @@ impl DlcProtocolExecutor { Decimal::from_f32(trade_params.average_price).expect("to fit into decimal"), trade_params.quantity, trader_position_direction, - initial_margin_long as u64, - initial_margin_short as u64, + initial_margin_long.to_sat(), + initial_margin_short.to_sat(), ) { Ok(pnl) => pnl, Err(e) => { diff --git a/coordinator/src/leaderboard.rs b/coordinator/src/leaderboard.rs index 5f260586e..99b30f544 100644 --- a/coordinator/src/leaderboard.rs +++ b/coordinator/src/leaderboard.rs @@ -260,7 +260,7 @@ pub mod tests { trader_liquidation_price: 0.0, coordinator_liquidation_price: 0.0, position_state: PositionState::Closed { pnl: 0 }, - coordinator_margin: 0, + coordinator_margin: Amount::ZERO, creation_timestamp: OffsetDateTime::now_utc(), expiry_timestamp: OffsetDateTime::now_utc(), update_timestamp: OffsetDateTime::now_utc(), @@ -268,7 +268,7 @@ pub mod tests { coordinator_leverage: 0.0, temporary_contract_id: None, closing_price: None, - trader_margin: 0, + trader_margin: Amount::ZERO, stable: false, trader_realized_pnl_sat: Some(pnl), order_matching_fees: Amount::ZERO, diff --git a/coordinator/src/payout_curve.rs b/coordinator/src/payout_curve.rs index 3c853b15b..73198de89 100644 --- a/coordinator/src/payout_curve.rs +++ b/coordinator/src/payout_curve.rs @@ -25,13 +25,13 @@ use xxi_node::commons::Direction; #[allow(clippy::too_many_arguments)] pub fn build_contract_descriptor( initial_price: Decimal, - coordinator_margin: u64, - trader_margin: u64, + coordinator_margin: Amount, + trader_margin: Amount, leverage_coordinator: f32, leverage_trader: f32, coordinator_direction: Direction, - coordinator_collateral_reserve: u64, - trader_collateral_reserve: u64, + coordinator_collateral_reserve: Amount, + trader_collateral_reserve: Amount, quantity: f32, symbol: ContractSymbol, ) -> Result { @@ -74,13 +74,13 @@ pub fn build_contract_descriptor( fn build_inverse_payout_function( // TODO: The `coordinator_margin` and `trader_margin` are _not_ orthogonal to the other // arguments passed in. - coordinator_margin: u64, - trader_margin: u64, + coordinator_margin: Amount, + trader_margin: Amount, initial_price: Decimal, leverage_trader: f32, leverage_coordinator: f32, - coordinator_collateral_reserve: u64, - trader_collateral_reserve: u64, + coordinator_collateral_reserve: Amount, + trader_collateral_reserve: Amount, coordinator_direction: Direction, quantity: f32, ) -> Result<(PayoutFunction, RoundingIntervals)> { @@ -106,14 +106,10 @@ fn build_inverse_payout_function( short_liquidation_price, )?; - let party_params_coordinator = payout_curve::PartyParams::new( - Amount::from_sat(coordinator_margin), - Amount::from_sat(coordinator_collateral_reserve), - ); - let party_params_trader = payout_curve::PartyParams::new( - Amount::from_sat(trader_margin), - Amount::from_sat(trader_collateral_reserve), - ); + let party_params_coordinator = + payout_curve::PartyParams::new(coordinator_margin, coordinator_collateral_reserve); + let party_params_trader = + payout_curve::PartyParams::new(trader_margin, trader_collateral_reserve); let payout_points = payout_curve::build_inverse_payout_function( quantity, @@ -194,10 +190,13 @@ mod tests { let coordinator_direction = Direction::Long; - let coordinator_collateral_reserve = Amount::from_sat(1000).to_sat(); - let trader_collateral_reserve = Amount::from_sat(1000).to_sat(); + let coordinator_collateral_reserve = Amount::from_sat(1000); + let trader_collateral_reserve = Amount::from_sat(1000); - let total_collateral = coordinator_margin + trader_margin; + let total_collateral = coordinator_margin + + trader_margin + + coordinator_collateral_reserve + + trader_collateral_reserve; let symbol = ContractSymbol::BtcUsd; @@ -218,9 +217,7 @@ mod tests { let range_payouts = match descriptor { ContractDescriptor::Enum(_) => unreachable!(), ContractDescriptor::Numerical(numerical) => numerical - .get_range_payouts( - total_collateral + coordinator_collateral_reserve + trader_collateral_reserve, - ) + .get_range_payouts(total_collateral.to_sat()) .unwrap(), }; @@ -256,8 +253,8 @@ mod tests { let direction_offer = Direction::Short; - let collateral_reserve_offer = 2_120_386; - let collateral_reserve_accept = 5_115_076; + let collateral_reserve_offer = Amount::from_sat(2_120_386); + let collateral_reserve_accept = Amount::from_sat(5_115_076); let total_collateral = margin_offer + margin_accept + collateral_reserve_offer + collateral_reserve_accept; @@ -285,9 +282,9 @@ mod tests { // Extract the payouts from the generated `ContractDescriptor`. let range_payouts = match descriptor { ContractDescriptor::Enum(_) => unreachable!(), - ContractDescriptor::Numerical(numerical) => { - numerical.get_range_payouts(total_collateral).unwrap() - } + ContractDescriptor::Numerical(numerical) => numerical + .get_range_payouts(total_collateral.to_sat()) + .unwrap(), }; // The offer party gets liquidated when they get the minimum amount of sats as a payout. @@ -299,7 +296,7 @@ mod tests { .offer; // The minimum amount the offer party can get as a payout is their collateral reserve. - assert_eq!(liquidation_payout_offer, collateral_reserve_offer); + assert_eq!(liquidation_payout_offer, collateral_reserve_offer.to_sat()); // The accept party gets liquidated when they get the minimum amount of sats as a payout. let liquidation_payout_accept = range_payouts @@ -310,7 +307,10 @@ mod tests { .accept; // The minimum amount the accept party can get as a payout is their collateral reserve. - assert_eq!(liquidation_payout_accept, collateral_reserve_accept); + assert_eq!( + liquidation_payout_accept, + collateral_reserve_accept.to_sat() + ); } proptest! { @@ -337,6 +337,9 @@ mod tests { Direction::Short }; + let collateral_reserve_coordinator = Amount::from_sat(collateral_reserve_coordinator); + let collateral_reserve_trader = Amount::from_sat(collateral_reserve_trader); + let total_collateral = margin_coordinator + margin_trader + collateral_reserve_coordinator @@ -361,7 +364,7 @@ mod tests { let range_payouts = match descriptor { ContractDescriptor::Enum(_) => unreachable!(), ContractDescriptor::Numerical(numerical) => numerical - .get_range_payouts(total_collateral) + .get_range_payouts(total_collateral.to_sat()) .unwrap(), }; @@ -372,7 +375,7 @@ mod tests { .payout .offer; - assert_eq!(liquidation_payout_offer, collateral_reserve_coordinator); + assert_eq!(liquidation_payout_offer, collateral_reserve_coordinator.to_sat()); let liquidation_payout_accept = range_payouts .iter() @@ -381,7 +384,7 @@ mod tests { .payout .accept; - assert_eq!(liquidation_payout_accept, collateral_reserve_trader); + assert_eq!(liquidation_payout_accept, collateral_reserve_trader.to_sat()); } } @@ -426,15 +429,15 @@ mod tests { let initial_price = dec!(36404.5); let quantity = 20.0; let leverage_coordinator = 2.0; - let coordinator_margin = 18_313; + let coordinator_margin = Amount::from_sat(18_313); let leverage_trader = 3.0; - let trader_margin = 27_469; + let trader_margin = Amount::from_sat(27_469); let coordinator_direction = Direction::Short; - let coordinator_collateral_reserve = 0; - let trader_collateral_reserve = 0; + let coordinator_collateral_reserve = Amount::ZERO; + let trader_collateral_reserve = Amount::ZERO; let symbol = ContractSymbol::BtcUsd; diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index d721983c1..79411e65b 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -31,11 +31,11 @@ pub struct NewPosition { pub average_entry_price: f32, pub trader_liquidation_price: Decimal, pub coordinator_liquidation_price: Decimal, - pub coordinator_margin: i64, + pub coordinator_margin: Amount, pub expiry_timestamp: OffsetDateTime, pub temporary_contract_id: ContractId, pub coordinator_leverage: f32, - pub trader_margin: i64, + pub trader_margin: Amount, pub stable: bool, pub order_matching_fees: Amount, } @@ -78,8 +78,8 @@ pub struct Position { pub trader_liquidation_price: f32, pub coordinator_liquidation_price: f32, - pub trader_margin: i64, - pub coordinator_margin: i64, + pub trader_margin: Amount, + pub coordinator_margin: Amount, pub trader_leverage: f32, pub coordinator_leverage: f32, @@ -141,8 +141,8 @@ impl Position { closing_price, self.quantity, direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .context("Failed to calculate pnl for position")?; @@ -221,8 +221,8 @@ fn calculate_coordinator_settlement_amount( closing_price, quantity, coordinator_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?; let coordinator_margin = match coordinator_direction { @@ -230,7 +230,8 @@ fn calculate_coordinator_settlement_amount( Direction::Short => short_margin, }; - let coordinator_settlement_amount = Decimal::from(coordinator_margin) + Decimal::from(pnl); + let coordinator_settlement_amount = + Decimal::from(coordinator_margin.to_sat()) + Decimal::from(pnl); // Double-checking that the coordinator's payout isn't negative, although `calculate_pnl` should // guarantee this. @@ -246,7 +247,7 @@ fn calculate_coordinator_settlement_amount( // The coordinator's maximum settlement amount is capped by the total combined margin in the // contract. - let coordinator_settlement_amount = coordinator_settlement_amount.min(total_margin); + let coordinator_settlement_amount = coordinator_settlement_amount.min(total_margin.to_sat()); Ok(coordinator_settlement_amount) } @@ -323,11 +324,11 @@ fn calculate_accept_settlement_amount_partial_close( trade_average_execution_price, settled_contracts, position_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?; - ((position_trader_margin as i64) + pnl).max(0) as u64 + ((position_trader_margin.to_sat() as i64) + pnl).max(0) as u64 } // Position changed direction. else if contracts_before_relative.signum() != contracts_after_relative.signum() @@ -346,17 +347,17 @@ fn calculate_accept_settlement_amount_partial_close( trade_average_execution_price, settled_contracts, position_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?; - ((position_trader_margin as i64) + pnl).max(0) as u64 + ((position_trader_margin.to_sat() as i64) + pnl).max(0) as u64 } // Position extended. else if contracts_before_relative.signum() == contracts_after_relative.signum() && contracts_before_relative.abs() < contracts_after_relative.abs() { - position_trader_margin + position_trader_margin.to_sat() } // Position either fully settled or unchanged. This is a bug. else { @@ -511,7 +512,7 @@ mod tests { trader_liquidation_price: 20_000.0, coordinator_liquidation_price: 60_000.0, position_state: PositionState::Open, - coordinator_margin: 125_000, + coordinator_margin: Amount::from_sat(125_000), creation_timestamp: OffsetDateTime::now_utc(), expiry_timestamp: OffsetDateTime::now_utc(), update_timestamp: OffsetDateTime::now_utc(), @@ -522,7 +523,7 @@ mod tests { coordinator_leverage: 2.0, temporary_contract_id: None, closing_price: None, - trader_margin: 125_000, + trader_margin: Amount::from_sat(125_000), stable: false, trader_realized_pnl_sat: None, order_matching_fees: Amount::ZERO, @@ -547,7 +548,7 @@ mod tests { trader_liquidation_price: 20_000.0, coordinator_liquidation_price: 60_000.0, position_state: PositionState::Open, - coordinator_margin: 125_000, + coordinator_margin: Amount::from_sat(125_000), creation_timestamp: OffsetDateTime::now_utc(), expiry_timestamp: OffsetDateTime::now_utc(), update_timestamp: OffsetDateTime::now_utc(), @@ -558,7 +559,7 @@ mod tests { coordinator_leverage: 2.0, temporary_contract_id: None, closing_price: None, - trader_margin: 125_000, + trader_margin: Amount::from_sat(125_000), stable: false, trader_realized_pnl_sat: None, order_matching_fees: Amount::ZERO, @@ -583,7 +584,7 @@ mod tests { trader_liquidation_price: 20_000.0, coordinator_liquidation_price: 60_000.0, position_state: PositionState::Open, - coordinator_margin: 125_000, + coordinator_margin: Amount::from_sat(125_000), creation_timestamp: OffsetDateTime::now_utc(), expiry_timestamp: OffsetDateTime::now_utc(), update_timestamp: OffsetDateTime::now_utc(), @@ -594,7 +595,7 @@ mod tests { coordinator_leverage: 3.0, temporary_contract_id: None, closing_price: None, - trader_margin: 125_000, + trader_margin: Amount::from_sat(125_000), stable: false, trader_realized_pnl_sat: None, order_matching_fees: Amount::ZERO, @@ -631,7 +632,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -656,7 +657,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -681,7 +682,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -706,7 +707,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -731,7 +732,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -756,7 +757,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -781,7 +782,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -806,7 +807,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -1034,7 +1035,7 @@ mod tests { trader_liquidation_price: 0.0, coordinator_liquidation_price: 0.0, position_state: PositionState::Open, - coordinator_margin: 1000, + coordinator_margin: Amount::from_sat(1_000), creation_timestamp: OffsetDateTime::now_utc(), expiry_timestamp: OffsetDateTime::now_utc(), update_timestamp: OffsetDateTime::now_utc(), @@ -1045,7 +1046,7 @@ mod tests { temporary_contract_id: None, closing_price: None, coordinator_leverage: 2.0, - trader_margin: 1000, + trader_margin: Amount::from_sat(1_000), stable: false, trader_realized_pnl_sat: None, order_matching_fees: Amount::ZERO, diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index a490fb56d..57c9329d4 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -69,8 +69,8 @@ enum TradeAction { }, OpenPosition { channel_id: DlcChannelId, - own_payout: u64, - counter_payout: u64, + own_payout: Amount, + counter_payout: Amount, }, ClosePosition { channel_id: DlcChannelId, @@ -356,7 +356,7 @@ impl TradeExecutor { .coordinator_reserve .context("Missing coordinator collateral reserve")?; let order_matching_fee = params.trade_params.order_matching_fee(); - let margin_trader = Amount::from_sat(margin_trader(¶ms.trade_params)); + let margin_trader = margin_trader(¶ms.trade_params); let fee_rate = self .node @@ -465,12 +465,11 @@ impl TradeExecutor { let margin_trader = margin_trader(trade_params); let margin_coordinator = margin_coordinator(trade_params, leverage_coordinator); - let order_matching_fee = trade_params.order_matching_fee().to_sat(); + let order_matching_fee = trade_params.order_matching_fee(); // The coordinator gets the `order_matching_fee` directly in the collateral reserve. let collateral_reserve_with_fee_coordinator = - collateral_reserve_coordinator.to_sat() + order_matching_fee; - let collateral_reserve_trader = collateral_reserve_trader.to_sat(); + collateral_reserve_coordinator + order_matching_fee; let initial_price = trade_params.filled_with.average_execution_price(); @@ -481,11 +480,11 @@ impl TradeExecutor { order_id = %trade_params.filled_with.order_id, ?trade_params, leverage_coordinator, - margin_coordinator_sat = %margin_coordinator, - margin_trader_sat = %margin_trader, - order_matching_fee_sat = %order_matching_fee, - collateral_reserve_with_fee_coordinator = %collateral_reserve_with_fee_coordinator, - collateral_reserve_trader = %collateral_reserve_trader, + %margin_coordinator, + %margin_trader, + %order_matching_fee, + %collateral_reserve_with_fee_coordinator, + %collateral_reserve_trader, "Opening DLC channel and position" ); @@ -525,23 +524,22 @@ impl TradeExecutor { let (offer_collateral, accept_collateral, fee_config) = match trader_required_utxos { TraderRequiredLiquidity::ForTradeCostAndTxFees => ( - margin_coordinator + collateral_reserve_coordinator.to_sat(), - margin_trader + collateral_reserve_trader + order_matching_fee, + (margin_coordinator + collateral_reserve_coordinator).to_sat(), + (margin_trader + collateral_reserve_trader + order_matching_fee).to_sat(), dlc::FeeConfig::EvenSplit, ), - TraderRequiredLiquidity::None => { - ( - margin_coordinator - + collateral_reserve_coordinator.to_sat() - + margin_trader - + collateral_reserve_trader - // If the trader doesn't bring their own UTXOs, including the order matching fee - // is not strictly necessary, but it's simpler to do so. - + order_matching_fee, - 0, - dlc::FeeConfig::AllOffer, - ) - } + TraderRequiredLiquidity::None => ( + // If the trader doesn't bring their own UTXOs, including the `order_matching_fee` + // is not strictly necessary, but it's simpler to do so. + (margin_coordinator + + collateral_reserve_coordinator + + margin_trader + + collateral_reserve_trader + + order_matching_fee) + .to_sat(), + 0, + dlc::FeeConfig::AllOffer, + ), }; let contract_input = ContractInput { @@ -608,7 +606,7 @@ impl TradeExecutor { temporary_contract_id, leverage_coordinator, stable, - Amount::from_sat(order_matching_fee), + order_matching_fee, ) .await } @@ -618,8 +616,8 @@ impl TradeExecutor { conn: &mut PgConnection, dlc_channel_id: DlcChannelId, trade_params: &TradeParams, - coordinator_dlc_channel_collateral: u64, - trader_dlc_channel_collateral: u64, + coordinator_dlc_channel_collateral: Amount, + trader_dlc_channel_collateral: Amount, stable: bool, ) -> Result<()> { let peer_id = trade_params.pubkey; @@ -640,7 +638,7 @@ impl TradeExecutor { let margin_coordinator = margin_coordinator(trade_params, leverage_coordinator); let margin_trader = margin_trader(trade_params); - let order_matching_fee = trade_params.order_matching_fee().to_sat(); + let order_matching_fee = trade_params.order_matching_fee(); let coordinator_direction = trade_params.direction.opposite(); @@ -727,8 +725,8 @@ impl TradeExecutor { ); let contract_input = ContractInput { - offer_collateral: coordinator_dlc_channel_collateral, - accept_collateral: trader_dlc_channel_collateral, + offer_collateral: coordinator_dlc_channel_collateral.to_sat(), + accept_collateral: trader_dlc_channel_collateral.to_sat(), fee_rate, contract_infos: vec![ContractInputInfo { contract_descriptor, @@ -776,7 +774,7 @@ impl TradeExecutor { temporary_contract_id, leverage_coordinator, stable, - Amount::from_sat(order_matching_fee), + order_matching_fee, ) .await } @@ -865,13 +863,13 @@ impl TradeExecutor { let contract_descriptor = payout_curve::build_contract_descriptor( average_execution_price, - margin_coordinator.to_sat(), - margin_trader.to_sat(), + margin_coordinator, + margin_trader, leverage_coordinator, leverage_trader, coordinator_direction, - collateral_reserve_coordinator.to_sat(), - collateral_reserve_trader.to_sat(), + collateral_reserve_coordinator, + collateral_reserve_trader, contracts.to_f32().expect("to fit"), trade_params.contract_symbol, ) @@ -1010,11 +1008,11 @@ impl TradeExecutor { average_entry_price, trader_liquidation_price, coordinator_liquidation_price, - coordinator_margin: margin_coordinator as i64, + coordinator_margin: margin_coordinator, expiry_timestamp: trade_params.filled_with.expiry_timestamp, temporary_contract_id, coordinator_leverage, - trader_margin: margin_trader as i64, + trader_margin: margin_trader, stable, order_matching_fees, }; @@ -1176,8 +1174,8 @@ impl TradeExecutor { .. }) => TradeAction::OpenPosition { channel_id, - own_payout, - counter_payout, + own_payout: Amount::from_sat(own_payout), + counter_payout: Amount::from_sat(counter_payout), }, Some(SignedChannel { state: SignedChannelState::Established { .. }, @@ -1311,13 +1309,12 @@ fn apply_resize_to_position( } => { let order_contracts = contracts; - let extra_margin_coordinator = Amount::from_sat(calculate_margin( + let extra_margin_coordinator = calculate_margin( order_execution_price, order_contracts.to_f32().expect("to fit"), position.coordinator_leverage, - )); - let margin_coordinator = - Amount::from_sat(position.coordinator_margin as u64) + extra_margin_coordinator; + ); + let margin_coordinator = position.coordinator_margin + extra_margin_coordinator; let original_accumulated_order_matching_fees = position.order_matching_fees; @@ -1349,13 +1346,12 @@ fn apply_resize_to_position( + original_accumulated_order_matching_fees + order_matching_fee; - let extra_margin_trader = Amount::from_sat(calculate_margin( + let extra_margin_trader = calculate_margin( order_execution_price, order_contracts.to_f32().expect("to fit"), position.trader_leverage, - )); - let margin_trader = - Amount::from_sat(position.trader_margin as u64) + extra_margin_trader; + ); + let margin_trader = position.trader_margin + extra_margin_trader; let collateral_reserve_trader = original_trader_collateral_reserve .checked_sub(order_matching_fee) @@ -1427,27 +1423,21 @@ fn apply_resize_to_position( let coordinator_liquidation_price = Decimal::try_from(position.coordinator_liquidation_price).expect("to fit"); - let margin_coordinator = Amount::from_sat(calculate_margin( + let margin_coordinator = calculate_margin( position_average_execution_price, total_contracts.to_f32().expect("to fit"), position.coordinator_leverage, - )); + ); - let margin_trader = Amount::from_sat(calculate_margin( + let margin_trader = calculate_margin( position_average_execution_price, total_contracts.to_f32().expect("to fit"), position.trader_leverage, - )); + ); let (original_margin_long, original_margin_short) = match position.trader_direction { - Direction::Long => ( - position.trader_margin as u64, - position.coordinator_margin as u64, - ), - Direction::Short => ( - position.coordinator_margin as u64, - position.trader_margin as u64, - ), + Direction::Long => (position.trader_margin, position.coordinator_margin), + Direction::Short => (position.coordinator_margin, position.trader_margin), }; // The PNL is capped by the margin, so the coordinator should never end up eating into @@ -1457,14 +1447,13 @@ fn apply_resize_to_position( order_average_execution_price, order_contracts.to_f32().expect("to fit"), position.trader_direction, - original_margin_long, - original_margin_short, + original_margin_long.to_sat(), + original_margin_short.to_sat(), )?; let realized_pnl_trader = SignedAmount::from_sat(realized_pnl_trader); let collateral_reserve_coordinator = { - let margin_coordinator_before = - Amount::from_sat(position.coordinator_margin as u64); + let margin_coordinator_before = position.coordinator_margin; let margin_decrease = margin_coordinator_before .checked_sub(margin_coordinator) .with_context(|| { @@ -1493,7 +1482,7 @@ fn apply_resize_to_position( }; let collateral_reserve_trader = { - let margin_trader_before = Amount::from_sat(position.trader_margin as u64); + let margin_trader_before = position.trader_margin; let margin_decrease = margin_trader_before .checked_sub(margin_trader) .with_context(|| { @@ -1555,29 +1544,23 @@ fn apply_resize_to_position( maintenance_margin_rate, ); - let new_margin_coordinator = Amount::from_sat(calculate_margin( + let new_margin_coordinator = calculate_margin( order_average_execution_price, contracts_new_direction.to_f32().expect("to fit"), position.coordinator_leverage, - )); + ); - let new_margin_trader = Amount::from_sat(calculate_margin( + let new_margin_trader = calculate_margin( order_average_execution_price, contracts_new_direction.to_f32().expect("to fit"), position.trader_leverage, - )); + ); let position_average_execution_price = Decimal::try_from(position.average_entry_price).expect("to fit"); let (original_margin_long, original_margin_short) = match position.trader_direction { - Direction::Long => ( - position.trader_margin as u64, - position.coordinator_margin as u64, - ), - Direction::Short => ( - position.coordinator_margin as u64, - position.trader_margin as u64, - ), + Direction::Long => (position.trader_margin, position.coordinator_margin), + Direction::Short => (position.coordinator_margin, position.trader_margin), }; // The PNL is capped by the margin, so the coordinator should never end up eating into @@ -1587,8 +1570,8 @@ fn apply_resize_to_position( order_average_execution_price, position.quantity, position.trader_direction, - original_margin_long, - original_margin_short, + original_margin_long.to_sat(), + original_margin_short.to_sat(), )?; let realized_pnl_trader = SignedAmount::from_sat(realized_pnl_trader); @@ -1597,11 +1580,13 @@ fn apply_resize_to_position( .to_signed() .expect("to fit"); - let closed_margin = SignedAmount::from_sat(calculate_margin( + let closed_margin = calculate_margin( position_average_execution_price, position.quantity, position.trader_leverage, - ) as i64); + ) + .to_signed() + .expect("to fit"); let new_margin = new_margin_trader.to_signed().expect("to fit"); @@ -1623,10 +1608,10 @@ fn apply_resize_to_position( }; let collateral_reserve_coordinator = { - let total_channel_collateral = - Amount::from_sat((position.trader_margin + position.coordinator_margin) as u64) - + original_coordinator_collateral_reserve - + original_trader_collateral_reserve; + let total_channel_collateral = position.trader_margin + + position.coordinator_margin + + original_coordinator_collateral_reserve + + original_trader_collateral_reserve; total_channel_collateral .checked_sub( @@ -1661,7 +1646,7 @@ fn apply_resize_to_position( Ok(resized_position) } -fn margin_trader(trade_params: &TradeParams) -> u64 { +fn margin_trader(trade_params: &TradeParams) -> Amount { calculate_margin( trade_params.average_execution_price(), trade_params.quantity, @@ -1669,7 +1654,7 @@ fn margin_trader(trade_params: &TradeParams) -> u64 { ) } -fn margin_coordinator(trade_params: &TradeParams, coordinator_leverage: f32) -> u64 { +fn margin_coordinator(trade_params: &TradeParams, coordinator_leverage: f32) -> Amount { calculate_margin( trade_params.average_execution_price(), trade_params.quantity, @@ -1857,8 +1842,8 @@ mod tests { trader_realized_pnl_sat: None, coordinator_liquidation_price: coordinator_liquidation_price.to_f32().unwrap(), trader_liquidation_price: trader_liquidation_price.to_f32().unwrap(), - trader_margin: trader_margin as i64, - coordinator_margin: coordinator_margin as i64, + trader_margin, + coordinator_margin, trader_leverage, coordinator_leverage, position_state: PositionState::Open, diff --git a/crates/payout_curve/examples/payout_curve_csv.rs b/crates/payout_curve/examples/payout_curve_csv.rs index 8cf1d60f5..f6a8f41be 100644 --- a/crates/payout_curve/examples/payout_curve_csv.rs +++ b/crates/payout_curve/examples/payout_curve_csv.rs @@ -66,8 +66,8 @@ fn main() -> Result<()> { Amount::from_sat(fee) }; - let margin_short = Amount::from_sat(calculate_margin(initial_price, quantity, leverage_short)); - let margin_long = Amount::from_sat(calculate_margin(initial_price, quantity, leverage_long)); + let margin_short = calculate_margin(initial_price, quantity, leverage_short); + let margin_long = calculate_margin(initial_price, quantity, leverage_long); let direction_offer = Direction::Long; @@ -304,8 +304,8 @@ pub fn should_payouts_as_csv_short( Decimal::from(price), quantity, coordinator_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?) + coordinator_collateral_reserve) .min(total_collateral); @@ -324,8 +324,8 @@ pub fn should_payouts_as_csv_short( short_liquidation_price, quantity, coordinator_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?) + coordinator_collateral_reserve) .min(total_collateral); @@ -345,8 +345,8 @@ pub fn should_payouts_as_csv_short( Decimal::from(100_000), quantity, coordinator_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?) + coordinator_collateral_reserve) .min(total_collateral); @@ -413,8 +413,8 @@ pub fn should_payouts_as_csv_long( Decimal::from(price), quantity, coordinator_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?) + coordinator_collateral_reserve) .min(total_collateral); @@ -434,8 +434,8 @@ pub fn should_payouts_as_csv_long( short_liquidation_price, quantity, coordinator_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?) + coordinator_collateral_reserve) .min(total_collateral); @@ -454,8 +454,8 @@ pub fn should_payouts_as_csv_long( Decimal::from(100_000), quantity, coordinator_direction, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), )?) + coordinator_collateral_reserve) .min(total_collateral); diff --git a/crates/payout_curve/src/lib.rs b/crates/payout_curve/src/lib.rs index 9ded4a171..77ea009b7 100644 --- a/crates/payout_curve/src/lib.rs +++ b/crates/payout_curve/src/lib.rs @@ -575,12 +575,12 @@ mod tests { calculate_margin(initial_price, quantity, leverage_accept.to_f32().unwrap()); let offer_party = PartyParams { - margin: margin_offer, + margin: margin_offer.to_sat(), collateral_reserve: collateral_reserve_offer.to_sat(), }; let accept_party = PartyParams { - margin: margin_accept, + margin: margin_accept.to_sat(), collateral_reserve: collateral_reserve_accept.to_sat(), }; @@ -613,10 +613,8 @@ mod tests { let long_leverage = 2.0; let short_leverage = 1.0; - let offer_margin = - Amount::from_sat(calculate_margin(initial_price, quantity, long_leverage)); - let accept_margin = - Amount::from_sat(calculate_margin(initial_price, quantity, short_leverage)); + let offer_margin = calculate_margin(initial_price, quantity, long_leverage); + let accept_margin = calculate_margin(initial_price, quantity, short_leverage); let collateral_reserve_offer = Amount::from_sat(155); @@ -872,9 +870,9 @@ mod tests { let short_leverage = short_leverage as f32; let offer_margin = - Amount::from_sat(calculate_margin(initial_price, quantity, long_leverage)); + calculate_margin(initial_price, quantity, long_leverage); let accept_margin = - Amount::from_sat(calculate_margin(initial_price, quantity, short_leverage)); + calculate_margin(initial_price, quantity, short_leverage); // Collateral reserve for the offer party based on a fee calculation. let collateral_reserve_offer = { @@ -1005,12 +1003,12 @@ mod bounds_tests { calculate_margin(initial_price, quantity, leverage_accept.to_f32().unwrap()); let offer_party = PartyParams { - margin: margin_offer, + margin: margin_offer.to_sat(), collateral_reserve: collateral_reserve_offer, }; let accept_party = PartyParams { - margin: margin_accept, + margin: margin_accept.to_sat(), collateral_reserve: collateral_reserve_accept, }; diff --git a/crates/payout_curve/tests/integration_proptests.rs b/crates/payout_curve/tests/integration_proptests.rs index 2b342b53d..f5896bf70 100644 --- a/crates/payout_curve/tests/integration_proptests.rs +++ b/crates/payout_curve/tests/integration_proptests.rs @@ -57,8 +57,8 @@ fn calculating_payout_curve_doesnt_crash_1() { // act: we only test that this does not panic computed_payout_curve( quantity, - coordinator_margin, - trader_margin, + coordinator_margin.to_sat(), + trader_margin.to_sat(), initial_price, collateral_reserve_offer, coordinator_direction, @@ -99,8 +99,8 @@ fn calculating_payout_curve_doesnt_crash_2() { // act: we only test that this does not panic computed_payout_curve( quantity, - coordinator_collateral, - trader_collateral, + coordinator_collateral.to_sat(), + trader_collateral.to_sat(), initial_price, collateral_reserve_offer, coordinator_direction, @@ -141,8 +141,8 @@ fn calculating_payout_curve_doesnt_crash_3() { // act: we only test that this does not panic computed_payout_curve( quantity, - coordinator_collateral, - trader_collateral, + coordinator_collateral.to_sat(), + trader_collateral.to_sat(), initial_price, collateral_reserve_offer, coordinator_direction, @@ -196,8 +196,8 @@ proptest! { leverage_coordinator, quantity, fee, - coordinator_margin, - trader_margin, + %coordinator_margin, + %trader_margin, ?long_liquidation_price, ?short_liquidation_price, "Started computing payout curve" @@ -208,8 +208,8 @@ proptest! { computed_payout_curve( quantity, - coordinator_margin, - trader_margin, + coordinator_margin.to_sat(), + trader_margin.to_sat(), initial_price, fee, coordinator_direction, diff --git a/crates/xxi-node/src/cfd.rs b/crates/xxi-node/src/cfd.rs index 3b78a2d27..06e448955 100644 --- a/crates/xxi-node/src/cfd.rs +++ b/crates/xxi-node/src/cfd.rs @@ -1,6 +1,7 @@ use crate::commons::Direction; use anyhow::Context; use anyhow::Result; +use bitcoin::Amount; use bitcoin::Denomination; use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; @@ -11,13 +12,13 @@ use std::ops::Neg; pub const BTCUSD_MAX_PRICE: u64 = 1_048_575; /// Calculate the collateral in sats. -pub fn calculate_margin(open_price: Decimal, quantity: f32, leverage: f32) -> u64 { +pub fn calculate_margin(open_price: Decimal, quantity: f32, leverage: f32) -> Amount { let quantity = Decimal::try_from(quantity).expect("quantity to fit into decimal"); let leverage = Decimal::try_from(leverage).expect("leverage to fix into decimal"); if open_price == Decimal::ZERO || leverage == Decimal::ZERO { // just to avoid div by 0 errors - return 0; + return Amount::ZERO; } let margin = quantity / (open_price * leverage); @@ -27,9 +28,7 @@ pub fn calculate_margin(open_price: Decimal, quantity: f32, leverage: f32) -> u6 margin.round_dp_with_strategy(8, rust_decimal::RoundingStrategy::MidpointAwayFromZero); let margin = margin.to_f64().expect("collateral to fit into f64"); - bitcoin::Amount::from_btc(margin) - .expect("collateral to fit in amount") - .to_sat() + bitcoin::Amount::from_btc(margin).expect("collateral to fit in amount") } /// Calculate the quantity from price, collateral and leverage @@ -151,8 +150,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); let pnl_short = calculate_pnl( @@ -160,8 +159,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -184,8 +183,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -207,8 +206,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -231,8 +230,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -254,8 +253,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -278,8 +277,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -302,8 +301,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -326,8 +325,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -350,8 +349,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -374,8 +373,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -398,8 +397,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -423,8 +422,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -450,12 +449,12 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); - assert_eq!(pnl_short, (margin as i64).neg()); + assert_eq!(pnl_short, (margin.to_sat() as i64).neg()); } #[test] @@ -474,12 +473,12 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); - assert_eq!(pnl_short, (margin as i64).neg()); + assert_eq!(pnl_short, (margin.to_sat() as i64).neg()); } #[test] diff --git a/mobile/native/src/calculations/mod.rs b/mobile/native/src/calculations/mod.rs index a7bef4e9a..dbb5941fd 100644 --- a/mobile/native/src/calculations/mod.rs +++ b/mobile/native/src/calculations/mod.rs @@ -8,7 +8,7 @@ use xxi_node::commons::Price; /// Calculate the collateral in BTC. pub fn calculate_margin(opening_price: f32, quantity: f32, leverage: f32) -> u64 { let opening_price = Decimal::try_from(opening_price).expect("price to fit into decimal"); - cfd::calculate_margin(opening_price, quantity, leverage) + cfd::calculate_margin(opening_price, quantity, leverage).to_sat() } /// Calculate the quantity from price, collateral and leverage From ed34a16f7f80641ebdd60dc96308814f355c5a0d Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Mon, 13 May 2024 23:02:51 +1000 Subject: [PATCH 02/12] chore: Add postgrest-coordinator container to docker-compose.yml This server can be used during local development to query the coordinator database conveniently e.g. ``` $ curl http://localhost:3002/dlc_protocols | jq . [ { "id": 1, "protocol_id": "fb4c6f27-db9d-4b2d-8539-6e609e1559b9", "previous_protocol_id": null, "channel_id": "f689230c5477176961301cb22093cfd52050a52356812039f9c298fe8859eba2", "contract_id": "972c5fcafbd6bbbf874a9a5cd44d9ddd05a2e3f3ae96d5bd771e5af1b8cae1a6", "protocol_state": "Success", "trader_pubkey": "02e9f7a0e0d6eeab989dcbb4e91f0bf95f583b09d8d324cc9635807e844eea82bc", "timestamp": "2024-05-13T05:32:36.452958+00:00", "protocol_type": "open-channel" } ] ``` It can also be used in the e2e tests to easily assert on the state of the coordinator, without needing to add dedicated HTTP API endpoints. It might even be used to modify the coordinator state to create interesting test scenarios. --- docker-compose.yml | 18 ++++++++++++++++++ justfile | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index df89b497a..de2a804c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -142,6 +142,24 @@ services: vtto: ipv4_address: 10.5.0.9 + postgrest-coordinator: + container_name: postgrest-coordinator + image: postgrest/postgrest + depends_on: + - db + ports: + - "3002:3002" + environment: + PGRST_DB_URI: "postgres://postgres:mysecretpassword@db:5432/orderbook" + PGRST_DB_SCHEMA: "public" + PGRST_DB_ANON_ROLE: "postgres" + PGRST_SERVER_PORT: "3002" + restart: always + networks: + vtto: + ipv4_address: 10.5.0.10 + profiles: ['postgrest'] + networks: default: name: vtto diff --git a/justfile b/justfile index be2f492bb..907622aab 100644 --- a/justfile +++ b/justfile @@ -202,6 +202,7 @@ wipe-docker: #!/usr/bin/env bash set -euxo pipefail docker compose down -v + docker compose --profile postgrest down wipe-coordinator: pkill -9 coordinator && echo "stopped coordinator" || echo "coordinator not running, skipped" @@ -412,7 +413,7 @@ maker-logs: docker logs -f maker # Run services in the background -services: docker run-lnd-mock-detached run-coordinator-detached run-maker-detached fund +services: docker run-lnd-mock-detached run-coordinator-detached postgrest-coordinator run-maker-detached fund # Run everything at once (docker, coordinator, native build) # Note: if you have mobile simulator running, it will start that one instead of native, but will *not* rebuild the mobile rust library. @@ -764,4 +765,7 @@ ln-pay-invoice: #!/usr/bin/env bash curl -X POST http://localhost:18080/pay_invoice +postgrest-coordinator: + docker compose --profile postgrest up -d + # vim:expandtab:sw=4:ts=4 From 246db2420baa22c0739c0aee3bf6d0edb2d343a5 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Mon, 29 Apr 2024 19:04:35 +1000 Subject: [PATCH 03/12] feat(coordinator): Apply a funding fee on rollover The coordinator generates funding fee events as funding rates are added to the database and applies them on rollover. The app displays funding fee events in a dedicated `Trades` tab, together with regular trades. The funding fee events are added as soon as the app learns about them from the coordinator, which is usually before they are resolved. Additionally, the app will update the position after rolling over based on the funding fee events that were resolved with the rollover. Missing bits: - Apply funding fee events on closing and resizing. - Considering funding fee events when deciding if positions need to be liquidated. - Add some unit tests. - Expanding e2e tests. - Add fee rate to app's `Trades` tab. - Display next fee rate in app's `Trade` screen. Co-authored-by: Philipp Hoenisch --- .github/workflows/ci.yml | 4 + Cargo.lock | 56 ++ coordinator/Cargo.toml | 1 + .../prod-coordinator-settings.toml | 2 + .../test-coordinator-settings.toml | 2 + .../down.sql | 1 + .../up.sql | 13 + .../down.sql | 2 + .../up.sql | 31 + coordinator/src/bin/coordinator.rs | 16 +- coordinator/src/db/dlc_protocols.rs | 15 +- coordinator/src/db/funding_fee_events.rs | 206 ++++++ coordinator/src/db/funding_rates.rs | 90 +++ coordinator/src/db/mod.rs | 4 + coordinator/src/db/positions.rs | 61 +- .../src/db/protocol_funding_fee_events.rs | 41 ++ coordinator/src/db/rollover_params.rs | 98 +++ coordinator/src/db/trade_params.rs | 3 +- coordinator/src/dlc_protocol.rs | 92 ++- coordinator/src/funding_fee.rs | 396 +++++++++++ coordinator/src/lib.rs | 29 + coordinator/src/node/liquidated_positions.rs | 3 + coordinator/src/node/rollover.rs | 617 +++++++++--------- coordinator/src/orderbook/websocket.rs | 27 + coordinator/src/routes.rs | 2 + coordinator/src/routes/admin.rs | 104 ++- coordinator/src/scheduler.rs | 19 +- coordinator/src/schema.rs | 54 ++ coordinator/src/settings.rs | 40 +- ...__tests__calculate_funding_fee_test-2.snap | 5 + ...__tests__calculate_funding_fee_test-3.snap | 5 + ...__tests__calculate_funding_fee_test-4.snap | 5 + ...__tests__calculate_funding_fee_test-5.snap | 5 + ...__tests__calculate_funding_fee_test-6.snap | 5 + ...__tests__calculate_funding_fee_test-7.snap | 5 + ...__tests__calculate_funding_fee_test-8.snap | 5 + ...ee__tests__calculate_funding_fee_test.snap | 5 + crates/tests-e2e/Cargo.toml | 1 + crates/tests-e2e/src/coordinator.rs | 217 +++++- crates/tests-e2e/src/test_subscriber.rs | 8 +- .../tests-e2e/tests/e2e_rollover_position.rs | 117 ++++ ...y_coordinator_position_after_rollover.snap | 27 + crates/xxi-node/src/cfd.rs | 21 +- .../xxi-node/src/commons/funding_fee_event.rs | 21 + crates/xxi-node/src/commons/message.rs | 51 +- crates/xxi-node/src/commons/mod.rs | 2 + crates/xxi-node/src/lib.rs | 1 + crates/xxi-node/src/message_handler.rs | 95 ++- crates/xxi-node/src/node/dlc_channel.rs | 7 +- crates/xxi-node/src/node/mod.rs | 11 + crates/xxi-node/src/node/wallet.rs | 4 + mobile/lib/backend.dart | 3 + mobile/lib/common/amount_text.dart | 2 +- .../lib/common/application/event_service.dart | 2 +- mobile/lib/common/init_service.dart | 7 + .../trade/application/trade_service.dart | 11 + .../lib/features/trade/domain/leverage.dart | 3 +- mobile/lib/features/trade/domain/trade.dart | 88 +++ .../features/trade/trade_change_notifier.dart | 35 + .../lib/features/trade/trade_list_item.dart | 106 +++ mobile/lib/features/trade/trade_screen.dart | 45 +- mobile/lib/util/constants.dart | 2 + .../down.sql | 1 + .../up.sql | 12 + mobile/native/src/api.rs | 15 + mobile/native/src/db/mod.rs | 80 ++- mobile/native/src/db/models.rs | 84 ++- .../native/src/db/models/funding_fee_event.rs | 220 +++++++ mobile/native/src/dlc/node.rs | 52 +- mobile/native/src/emergency_kit.rs | 2 +- mobile/native/src/event/api.rs | 5 + mobile/native/src/event/mod.rs | 9 + mobile/native/src/orderbook.rs | 27 + mobile/native/src/schema.rs | 24 + .../src/trade/funding_fee_event/handler.rs | 35 + .../native/src/trade/funding_fee_event/mod.rs | 55 ++ mobile/native/src/trade/mod.rs | 43 +- mobile/native/src/trade/position/handler.rs | 126 ++-- mobile/native/src/trade/position/mod.rs | 92 ++- mobile/native/src/trade/trades/api.rs | 62 ++ mobile/native/src/trade/trades/handler.rs | 17 + mobile/native/src/trade/trades/mod.rs | 42 ++ mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 3 +- 84 files changed, 3400 insertions(+), 570 deletions(-) create mode 100644 coordinator/migrations/2024-05-01-042936_add_rollover_params_table/down.sql create mode 100644 coordinator/migrations/2024-05-01-042936_add_rollover_params_table/up.sql create mode 100644 coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/down.sql create mode 100644 coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/up.sql create mode 100644 coordinator/src/db/funding_fee_events.rs create mode 100644 coordinator/src/db/funding_rates.rs create mode 100644 coordinator/src/db/protocol_funding_fee_events.rs create mode 100644 coordinator/src/db/rollover_params.rs create mode 100644 coordinator/src/funding_fee.rs create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-2.snap create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-3.snap create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-4.snap create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-5.snap create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-6.snap create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-7.snap create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-8.snap create mode 100644 coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test.snap create mode 100644 crates/tests-e2e/tests/snapshots/e2e_rollover_position__verify_coordinator_position_after_rollover.snap create mode 100644 crates/xxi-node/src/commons/funding_fee_event.rs create mode 100644 mobile/lib/features/trade/application/trade_service.dart create mode 100644 mobile/lib/features/trade/domain/trade.dart create mode 100644 mobile/lib/features/trade/trade_change_notifier.dart create mode 100644 mobile/lib/features/trade/trade_list_item.dart create mode 100644 mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/down.sql create mode 100644 mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/up.sql create mode 100644 mobile/native/src/db/models/funding_fee_event.rs create mode 100644 mobile/native/src/trade/funding_fee_event/handler.rs create mode 100644 mobile/native/src/trade/funding_fee_event/mod.rs create mode 100644 mobile/native/src/trade/trades/api.rs create mode 100644 mobile/native/src/trade/trades/handler.rs create mode 100644 mobile/native/src/trade/trades/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a69d4606..b5f98e623 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,6 +270,10 @@ jobs: ./target/debug/coordinator &> ./data/coordinator/regtest.log & just wait-for-coordinator-to-be-ready echo "Coordinator successfully started." + + echo "Starting coordinator postgrest server" + just postgrest-coordinator + echo "Started coordinator postgrest server" - name: Run maker run: | just run-maker-detached diff --git a/Cargo.lock b/Cargo.lock index 5948d67f8..c949f2ada 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -994,6 +994,7 @@ dependencies = [ "prometheus", "proptest", "rand", + "reqwest", "rust_decimal", "rust_decimal_macros", "semver", @@ -2211,6 +2212,9 @@ dependencies = [ "console", "lazy_static", "linked-hash-map", + "pest", + "pest_derive", + "serde", "similar", ] @@ -2964,6 +2968,51 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" +[[package]] +name = "pest" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26293c9193fbca7b1a3bf9b79dc1e388e927e6cacaa78b4a3ab705a1d3d41459" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "pest_meta" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a240022f37c361ec1878d646fc5b7d7c4d28d5946e1a80ad5a7a4f4ca0bdcd" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "petname" version = "1.1.3" @@ -4109,6 +4158,7 @@ dependencies = [ "clap 4.5.0", "coordinator", "flutter_rust_bridge", + "insta", "local-ip-address", "native", "parking_lot 0.12.1", @@ -4742,6 +4792,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + [[package]] name = "unarray" version = "0.1.4" diff --git a/coordinator/Cargo.toml b/coordinator/Cargo.toml index 38c187d77..a0b97e1ac 100644 --- a/coordinator/Cargo.toml +++ b/coordinator/Cargo.toml @@ -33,6 +33,7 @@ parking_lot = { version = "0.12.1" } payout_curve = { path = "../crates/payout_curve" } prometheus = "0.13.3" rand = "0.8.5" +reqwest = { version = "0.11" } rust_decimal = { version = "1", features = ["serde-with-float"] } rust_decimal_macros = "1" semver = "1.0" diff --git a/coordinator/example-settings/prod-coordinator-settings.toml b/coordinator/example-settings/prod-coordinator-settings.toml index f276f9482..ab9939d31 100644 --- a/coordinator/example-settings/prod-coordinator-settings.toml +++ b/coordinator/example-settings/prod-coordinator-settings.toml @@ -5,11 +5,13 @@ close_expired_position_scheduler = "0 0 12 * * *" close_liquidated_position_scheduler = "0 0 12 * * *" update_user_bonus_status_scheduler = "0 0 0 * * *" collect_metrics_scheduler = "0 0 * * * *" +generate_funding_fee_events_scheduler = "* * * * *" whitelist_enabled = false whitelisted_makers = [] min_quantity = 1 maintenance_margin_rate = 0.1 order_matching_fee_rate = 0.003 +index_price_source = "Bitmex" [xxi] off_chain_sync_interval = 5 diff --git a/coordinator/example-settings/test-coordinator-settings.toml b/coordinator/example-settings/test-coordinator-settings.toml index 9c2025ac8..8729d607f 100644 --- a/coordinator/example-settings/test-coordinator-settings.toml +++ b/coordinator/example-settings/test-coordinator-settings.toml @@ -5,12 +5,14 @@ close_expired_position_scheduler = "0 0 12 * * *" close_liquidated_position_scheduler = "0 0 12 * * *" update_user_bonus_status_scheduler = "0 0 0 * * *" collect_metrics_scheduler = "0 0 * * * *" +generate_funding_fee_events_scheduler = "1/5 * * * * *" whitelist_enabled = false # Default testnet maker whitelisted_makers = ["035eccdd1f05c65b433cf38e3b2597e33715e0392cb14d183e812f1319eb7b6794"] min_quantity = 1 maintenance_margin_rate = 0.1 order_matching_fee_rate = 0.003 +index_price_source = "Test" [xxi] off_chain_sync_interval = 5 diff --git a/coordinator/migrations/2024-05-01-042936_add_rollover_params_table/down.sql b/coordinator/migrations/2024-05-01-042936_add_rollover_params_table/down.sql new file mode 100644 index 000000000..aed053010 --- /dev/null +++ b/coordinator/migrations/2024-05-01-042936_add_rollover_params_table/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS rollover_params; diff --git a/coordinator/migrations/2024-05-01-042936_add_rollover_params_table/up.sql b/coordinator/migrations/2024-05-01-042936_add_rollover_params_table/up.sql new file mode 100644 index 000000000..1b9cf7427 --- /dev/null +++ b/coordinator/migrations/2024-05-01-042936_add_rollover_params_table/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE rollover_params +( + id SERIAL PRIMARY KEY NOT NULL, + protocol_id UUID NOT NULL REFERENCES dlc_protocols (protocol_id), + trader_pubkey TEXT NOT NULL, + margin_coordinator_sat BIGINT NOT NULL, + margin_trader_sat BIGINT NOT NULL, + leverage_coordinator REAL NOT NULL, + leverage_trader REAL NOT NULL, + liquidation_price_coordinator REAL NOT NULL, + liquidation_price_trader REAL NOT NULL, + expiry_timestamp TIMESTAMP WITH TIME ZONE NOT NULL +); diff --git a/coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/down.sql b/coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/down.sql new file mode 100644 index 000000000..3c6fcbd29 --- /dev/null +++ b/coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/down.sql @@ -0,0 +1,2 @@ +drop table if exists funding_rates; +drop table if exists funding_fee_event; diff --git a/coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/up.sql b/coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/up.sql new file mode 100644 index 000000000..48fb98137 --- /dev/null +++ b/coordinator/migrations/2024-05-01-062836_add_funding_rate_tables/up.sql @@ -0,0 +1,31 @@ +CREATE TABLE funding_rates +( + id SERIAL PRIMARY KEY NOT NULL, + start_date TIMESTAMP WITH TIME ZONE NOT NULL, + end_date TIMESTAMP WITH TIME ZONE NOT NULL, + rate REAL NOT NULL, + timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE funding_fee_events +( + id SERIAL PRIMARY KEY NOT NULL, + amount_sats BIGINT NOT NULL, + trader_pubkey TEXT NOT NULL, + position_id INTEGER REFERENCES positions (id) NOT NULL, + due_date TIMESTAMP WITH TIME ZONE NOT NULL, + price REAL NOT NULL, + funding_rate REAL NOT NULL, + paid_date TIMESTAMP WITH TIME ZONE, + timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- To prevent generating duplicates for the same position. + UNIQUE (position_id, due_date) +); + +CREATE TABLE protocol_funding_fee_events +( + id SERIAL PRIMARY KEY NOT NULL, + protocol_id UUID REFERENCES dlc_protocols (protocol_id) NOT NULL, + funding_fee_event_id INTEGER REFERENCES funding_fee_events (id) NOT NULL, + timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/coordinator/src/bin/coordinator.rs b/coordinator/src/bin/coordinator.rs index cad9bda12..61c22d027 100644 --- a/coordinator/src/bin/coordinator.rs +++ b/coordinator/src/bin/coordinator.rs @@ -6,6 +6,7 @@ use coordinator::cli::Opts; use coordinator::db; use coordinator::dlc_handler; use coordinator::dlc_handler::DlcHandler; +use coordinator::funding_fee::generate_funding_fee_events_periodically; use coordinator::logger; use coordinator::message::spawn_delivering_messages_to_authenticated_users; use coordinator::message::NewUserMessage; @@ -41,6 +42,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::broadcast; use tokio::task::spawn_blocking; +use tokio_cron_scheduler::JobScheduler; use tracing::metadata::LevelFilter; use xxi_node::node::event::NodeEventHandler; use xxi_node::seed::Bip39Seed; @@ -319,12 +321,10 @@ async fn main() -> Result<()> { ); let sender = notification_service.get_sender(); - let notification_scheduler = NotificationScheduler::new(sender, settings, network, node); + let scheduler = NotificationScheduler::new(sender, settings.clone(), network, node).await; tokio::spawn({ let pool = pool.clone(); - let scheduler = notification_scheduler; async move { - let scheduler = scheduler.await; scheduler .add_rollover_window_reminder_job(pool.clone()) .await @@ -371,6 +371,16 @@ async fn main() -> Result<()> { tracing::error!("Failed to set expired hodl invoices to canceled. Error: {e:#}"); } + generate_funding_fee_events_periodically( + &JobScheduler::new().await?, + pool.clone(), + auth_users_notifier, + settings.generate_funding_fee_events_scheduler, + settings.index_price_source, + ) + .await + .expect("to start task"); + tracing::debug!("Listening on http://{}", http_address); match axum::Server::bind(&http_address) diff --git a/coordinator/src/db/dlc_protocols.rs b/coordinator/src/db/dlc_protocols.rs index 316ae2ced..88c59843a 100644 --- a/coordinator/src/db/dlc_protocols.rs +++ b/coordinator/src/db/dlc_protocols.rs @@ -102,9 +102,10 @@ pub(crate) fn get_dlc_protocol( DlcProtocolType::ForceClose => dlc_protocol::DlcProtocolType::ForceClose { trader: PublicKey::from_str(&dlc_protocol.trader_pubkey).expect("valid public key"), }, - DlcProtocolType::Rollover => dlc_protocol::DlcProtocolType::Rollover { - trader: PublicKey::from_str(&dlc_protocol.trader_pubkey).expect("valid public key"), - }, + DlcProtocolType::Rollover => { + let rollover_params = db::rollover_params::get(conn, protocol_id)?; + dlc_protocol::DlcProtocolType::Rollover { rollover_params } + } DlcProtocolType::ResizePosition => { let trade_params = db::trade_params::get(conn, protocol_id)?; dlc_protocol::DlcProtocolType::ResizePosition { trade_params } @@ -173,7 +174,7 @@ pub(crate) fn create( previous_protocol_id: Option, contract_id: Option<&ContractId>, channel_id: &DlcChannelId, - protocol_type: dlc_protocol::DlcProtocolType, + protocol_type: impl Into, trader: &PublicKey, ) -> QueryResult<()> { let affected_rows = diesel::insert_into(dlc_protocols::table) @@ -185,7 +186,7 @@ pub(crate) fn create( dlc_protocols::protocol_state.eq(DlcProtocolState::Pending), dlc_protocols::trader_pubkey.eq(trader.to_string()), dlc_protocols::timestamp.eq(OffsetDateTime::now_utc()), - dlc_protocols::protocol_type.eq(DlcProtocolType::from(protocol_type)), + dlc_protocols::protocol_type.eq(protocol_type.into()), )) .execute(conn)?; @@ -216,8 +217,8 @@ impl From for dlc_protocol::DlcProtocolState { } } -impl From for DlcProtocolType { - fn from(value: dlc_protocol::DlcProtocolType) -> Self { +impl From<&dlc_protocol::DlcProtocolType> for DlcProtocolType { + fn from(value: &dlc_protocol::DlcProtocolType) -> Self { match value { dlc_protocol::DlcProtocolType::OpenChannel { .. } => DlcProtocolType::OpenChannel, dlc_protocol::DlcProtocolType::OpenPosition { .. } => DlcProtocolType::OpenPosition, diff --git a/coordinator/src/db/funding_fee_events.rs b/coordinator/src/db/funding_fee_events.rs new file mode 100644 index 000000000..932d8e342 --- /dev/null +++ b/coordinator/src/db/funding_fee_events.rs @@ -0,0 +1,206 @@ +use crate::db::positions::Position; +use crate::db::positions::PositionState; +use crate::decimal_from_f32; +use crate::f32_from_decimal; +use crate::funding_fee; +use crate::schema::funding_fee_events; +use crate::schema::positions; +use crate::schema::protocol_funding_fee_events; +use bitcoin::secp256k1::PublicKey; +use bitcoin::SignedAmount; +use diesel::prelude::*; +use rust_decimal::Decimal; +use std::str::FromStr; +use time::OffsetDateTime; +use xxi_node::node::ProtocolId; + +#[derive(Queryable, Debug)] +struct FundingFeeEvent { + id: i32, + /// A positive amount indicates that the trader pays the coordinator; a negative amount + /// indicates that the coordinator pays the trader. + amount_sats: i64, + trader_pubkey: String, + position_id: i32, + due_date: OffsetDateTime, + price: f32, + funding_rate: f32, + paid_date: Option, + #[diesel(column_name = "timestamp")] + _timestamp: OffsetDateTime, +} + +pub(crate) fn insert( + conn: &mut PgConnection, + amount: SignedAmount, + trader_pubkey: PublicKey, + position_id: i32, + due_date: OffsetDateTime, + price: Decimal, + funding_rate: Decimal, +) -> QueryResult> { + let res = diesel::insert_into(funding_fee_events::table) + .values(&( + funding_fee_events::amount_sats.eq(amount.to_sat()), + funding_fee_events::trader_pubkey.eq(trader_pubkey.to_string()), + funding_fee_events::position_id.eq(position_id), + funding_fee_events::due_date.eq(due_date), + funding_fee_events::price.eq(f32_from_decimal(price)), + funding_fee_events::funding_rate.eq(f32_from_decimal(funding_rate)), + )) + .get_result::(conn); + + match res { + Ok(funding_fee_event) => Ok(Some(funding_fee::FundingFeeEvent::from(funding_fee_event))), + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => { + tracing::debug!( + position_id, + %trader_pubkey, + %due_date, + ?amount, + "Funding fee event already exists in funding_fee_events table" + ); + + Ok(None) + } + Err(e) => Err(e), + } +} + +/// Get all [`funding_fee::FundingFeeEvent`]s for the active positions of a given trader. +/// +/// A trader may miss multiple funding fee events, particularly when they go offline. This function +/// allows us the coordinator to catch them up on reconnect. +/// +/// # Returns +/// +/// A list of [`xxi_node::FundingFeeEvent`]s, since these are to be sent to the trader via the +/// `xxi_node::Message::AllFundingFeeEvents` message. +pub(crate) fn get_for_active_trader_positions( + conn: &mut PgConnection, + trader_pubkey: PublicKey, +) -> QueryResult> { + let funding_fee_events: Vec<(FundingFeeEvent, Position)> = funding_fee_events::table + .filter(funding_fee_events::trader_pubkey.eq(trader_pubkey.to_string())) + .inner_join(positions::table.on(positions::id.eq(funding_fee_events::position_id))) + .filter( + positions::position_state + .eq(PositionState::Open) + .or(positions::position_state.eq(PositionState::Resizing)) + .or(positions::position_state.eq(PositionState::Rollover)), + ) + .load(conn)?; + + let funding_fee_events = funding_fee_events + .into_iter() + .map(|(e, p)| xxi_node::FundingFeeEvent { + contract_symbol: p.contract_symbol.into(), + contracts: decimal_from_f32(p.quantity), + direction: p.trader_direction.into(), + price: decimal_from_f32(e.price), + fee: SignedAmount::from_sat(e.amount_sats), + due_date: e.due_date, + }) + .collect(); + + Ok(funding_fee_events) +} + +/// Get the unpaid [`funding_fee::FundingFeeEvent`]s for a trader position. +/// +/// TODO: Use outstanding fees when: +/// +/// - Deciding if positions need to be liquidated. +/// - Closing a position. +/// - Resizing a position. +pub(crate) fn get_outstanding_fees( + conn: &mut PgConnection, + trader_pubkey: PublicKey, + position_id: i32, +) -> QueryResult> { + let funding_events: Vec = funding_fee_events::table + .filter( + funding_fee_events::trader_pubkey + .eq(trader_pubkey.to_string()) + .and(funding_fee_events::position_id.eq(position_id)) + // If the `paid_date` is not set, the funding fee has not been paid. + .and(funding_fee_events::paid_date.is_null()), + ) + .load(conn)?; + + Ok(funding_events + .iter() + .map(funding_fee::FundingFeeEvent::from) + .collect()) +} + +pub(crate) fn mark_as_paid(conn: &mut PgConnection, protocol_id: ProtocolId) -> QueryResult<()> { + conn.transaction(|conn| { + // Find all funding fee event IDs that were just paid. + let funding_fee_event_ids: Vec = protocol_funding_fee_events::table + .select(protocol_funding_fee_events::funding_fee_event_id) + .filter(protocol_funding_fee_events::protocol_id.eq(protocol_id.to_uuid())) + .load(conn)?; + + if funding_fee_event_ids.is_empty() { + tracing::debug!(%protocol_id, "No funding fee events paid by protocol"); + + return QueryResult::Ok(()); + } + + let now = OffsetDateTime::now_utc(); + + // Mark funding fee events as paid. + diesel::update( + funding_fee_events::table.filter(funding_fee_events::id.eq_any(&funding_fee_event_ids)), + ) + .set(funding_fee_events::paid_date.eq(now)) + .execute(conn)?; + + // Delete entries in `protocol_funding_fee_events` table. + diesel::delete( + protocol_funding_fee_events::table + .filter(protocol_funding_fee_events::id.eq_any(&funding_fee_event_ids)), + ) + .execute(conn)?; + + QueryResult::Ok(()) + })?; + + Ok(()) +} + +impl From<&FundingFeeEvent> for funding_fee::FundingFeeEvent { + fn from(value: &FundingFeeEvent) -> Self { + Self { + id: value.id, + amount: SignedAmount::from_sat(value.amount_sats), + trader_pubkey: PublicKey::from_str(value.trader_pubkey.as_str()) + .expect("to be valid pk"), + position_id: value.position_id, + due_date: value.due_date, + price: decimal_from_f32(value.price), + funding_rate: decimal_from_f32(value.funding_rate), + paid_date: value.paid_date, + } + } +} + +impl From for funding_fee::FundingFeeEvent { + fn from(value: FundingFeeEvent) -> Self { + Self { + id: value.id, + amount: SignedAmount::from_sat(value.amount_sats), + trader_pubkey: PublicKey::from_str(value.trader_pubkey.as_str()) + .expect("to be valid pk"), + position_id: value.position_id, + due_date: value.due_date, + price: decimal_from_f32(value.price), + funding_rate: decimal_from_f32(value.funding_rate), + paid_date: value.paid_date, + } + } +} diff --git a/coordinator/src/db/funding_rates.rs b/coordinator/src/db/funding_rates.rs new file mode 100644 index 000000000..149de74c7 --- /dev/null +++ b/coordinator/src/db/funding_rates.rs @@ -0,0 +1,90 @@ +use crate::funding_fee; +use crate::schema::funding_rates; +use crate::to_nearest_hour_in_the_past; +use anyhow::Context; +use anyhow::Result; +use diesel::prelude::*; +use rust_decimal::prelude::FromPrimitive; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use time::OffsetDateTime; + +#[derive(Insertable, Debug)] +#[diesel(table_name = funding_rates)] +struct NewFundingRate { + start_date: OffsetDateTime, + end_date: OffsetDateTime, + rate: f32, +} + +#[derive(Queryable, Debug)] +struct FundingRate { + #[diesel(column_name = "id")] + _id: i32, + start_date: OffsetDateTime, + end_date: OffsetDateTime, + rate: f32, + #[diesel(column_name = "timestamp")] + _timestamp: OffsetDateTime, +} + +pub(crate) fn insert( + conn: &mut PgConnection, + funding_rates: &[funding_fee::FundingRate], +) -> Result<()> { + let funding_rates = funding_rates + .iter() + .copied() + .map(NewFundingRate::from) + .collect::>(); + + Ok(()) + }) +} + +fn insert_one(conn: &mut PgConnection, params: &funding_fee::FundingRate) -> QueryResult<()> { + let affected_rows = diesel::insert_into(funding_rates::table) + .values(funding_rates) + .execute(conn)?; + + if affected_rows == 0 { + bail!("Failed to insert funding rates"); + } + + Ok(()) +} + +/// Get the funding rate with an end date that is equal to the current date to the nearest hour. +pub(crate) fn get_funding_rate_charged_in_the_last_hour( + conn: &mut PgConnection, +) -> QueryResult> { + let now = OffsetDateTime::now_utc(); + let now = to_nearest_hour_in_the_past(now); + + let funding_rate: Option = funding_rates::table + .filter(funding_rates::end_date.eq(now)) + .first::(conn) + .optional()?; + + Ok(funding_rate.map(funding_fee::FundingRate::from)) +} + +impl From for funding_fee::FundingRate { + fn from(value: FundingRate) -> Self { + Self::new( + Decimal::from_f32(value.rate).expect("to fit"), + value.start_date, + value.end_date, + ) + } +} + +impl From for NewFundingRate { + fn from(value: xxi_node::commons::FundingRate) -> Self { + Self { + start_date: value.start_date(), + end_date: value.end_date(), + rate: value.rate().to_f32().expect("to fit"), + } + } +} diff --git a/coordinator/src/db/mod.rs b/coordinator/src/db/mod.rs index 3bce9e316..ec7e5ceb6 100644 --- a/coordinator/src/db/mod.rs +++ b/coordinator/src/db/mod.rs @@ -6,13 +6,17 @@ pub mod custom_types; pub mod dlc_channels; pub mod dlc_messages; pub mod dlc_protocols; +pub mod funding_fee_events; +pub mod funding_rates; pub mod hodl_invoice; pub mod last_outbound_dlc_message; pub mod liquidity_options; pub mod metrics; pub mod polls; pub mod positions; +pub mod protocol_funding_fee_events; pub mod reported_errors; +pub mod rollover_params; pub mod spendable_outputs; pub mod trade_params; pub mod trades; diff --git a/coordinator/src/db/positions.rs b/coordinator/src/db/positions.rs index 698ee34ad..55c290358 100644 --- a/coordinator/src/db/positions.rs +++ b/coordinator/src/db/positions.rs @@ -89,6 +89,32 @@ impl Position { Ok(positions) } + /// Get all active positions that were open before `open_before_timestamp`. + /// + /// Active positions are either [`PositionState::Open`], [`PositionState::Rollover`] or + /// [`PositionState::Resizing`]. + pub fn get_all_active_positions_open_before( + conn: &mut PgConnection, + open_before_timestamp: OffsetDateTime, + ) -> QueryResult> { + let positions = positions::table + .filter( + positions::position_state + .eq(PositionState::Open) + .or(positions::position_state.eq(PositionState::Rollover)) + .or(positions::position_state.eq(PositionState::Resizing)), + ) + .filter(positions::creation_timestamp.lt(open_before_timestamp)) + .load::(conn)?; + + let positions = positions + .into_iter() + .map(crate::position::models::Position::from) + .collect(); + + Ok(positions) + } + pub fn get_all_open_positions( conn: &mut PgConnection, ) -> QueryResult> { @@ -263,6 +289,37 @@ impl Position { .execute(conn) } + #[allow(clippy::too_many_arguments)] + pub fn finish_rollover_protocol( + conn: &mut PgConnection, + trader_pubkey: String, + temporary_contract_id: ContractId, + leverage_coordinator: Decimal, + margin_coordinator: Amount, + liquidation_price_coordinator: Decimal, + leverage_trader: Decimal, + margin_trader: Amount, + liquidation_price_trader: Decimal, + ) -> QueryResult { + diesel::update(positions::table) + .filter(positions::trader_pubkey.eq(trader_pubkey)) + .filter(positions::position_state.eq(PositionState::Rollover)) + .set(( + positions::position_state.eq(PositionState::Open), + positions::temporary_contract_id.eq(hex::encode(temporary_contract_id)), + positions::update_timestamp.eq(OffsetDateTime::now_utc()), + positions::coordinator_leverage.eq(leverage_coordinator.to_f32().expect("to fit")), + positions::coordinator_margin.eq(margin_coordinator.to_sat() as i64), + positions::coordinator_liquidation_price + .eq(liquidation_price_coordinator.to_f32().expect("to fit")), + positions::trader_leverage.eq(leverage_trader.to_f32().expect("to fit")), + positions::trader_margin.eq(margin_trader.to_sat() as i64), + positions::trader_liquidation_price + .eq(liquidation_price_trader.to_f32().expect("to fit")), + )) + .execute(conn) + } + pub fn set_position_to_open( conn: &mut PgConnection, trader_pubkey: String, @@ -301,11 +358,11 @@ impl Position { pub fn rollover_position( conn: &mut PgConnection, - trader_pubkey: String, + trader_pubkey: PublicKey, expiry_timestamp: &OffsetDateTime, ) -> Result<()> { let affected_rows = diesel::update(positions::table) - .filter(positions::trader_pubkey.eq(trader_pubkey)) + .filter(positions::trader_pubkey.eq(trader_pubkey.to_string())) .filter(positions::position_state.eq(PositionState::Open)) .set(( positions::expiry_timestamp.eq(expiry_timestamp), diff --git a/coordinator/src/db/protocol_funding_fee_events.rs b/coordinator/src/db/protocol_funding_fee_events.rs new file mode 100644 index 000000000..8e0cdd096 --- /dev/null +++ b/coordinator/src/db/protocol_funding_fee_events.rs @@ -0,0 +1,41 @@ +//! The `protocol_funding_fee_events` table defines the relationship between funding fee events and +//! the DLC protocol that will resolve them. + +use crate::schema::protocol_funding_fee_events; +use diesel::prelude::*; +use xxi_node::node::ProtocolId; + +pub(crate) fn insert( + conn: &mut PgConnection, + protocol_id: ProtocolId, + funding_fee_event_ids: &[i32], +) -> QueryResult<()> { + if funding_fee_event_ids.is_empty() { + tracing::debug!( + %protocol_id, + "Protocol without outstanding funding fee events" + ); + + return Ok(()); + } + + let values = funding_fee_event_ids + .iter() + .map(|funding_fee_event_id| { + ( + protocol_funding_fee_events::protocol_id.eq(protocol_id.to_uuid()), + protocol_funding_fee_events::funding_fee_event_id.eq(*funding_fee_event_id), + ) + }) + .collect::>(); + + let affected_rows = diesel::insert_into(protocol_funding_fee_events::table) + .values(values) + .execute(conn)?; + + if affected_rows == 0 { + return Err(diesel::result::Error::NotFound); + } + + Ok(()) +} diff --git a/coordinator/src/db/rollover_params.rs b/coordinator/src/db/rollover_params.rs new file mode 100644 index 000000000..f10caf707 --- /dev/null +++ b/coordinator/src/db/rollover_params.rs @@ -0,0 +1,98 @@ +use crate::dlc_protocol; +use crate::schema::rollover_params; +use bitcoin::Amount; +use diesel::prelude::*; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use time::OffsetDateTime; +use uuid::Uuid; +use xxi_node::node::ProtocolId; + +#[derive(Queryable, Debug)] +#[diesel(table_name = rollover_params)] +struct RolloverParams { + #[diesel(column_name = "id")] + _id: i32, + protocol_id: Uuid, + trader_pubkey: String, + margin_coordinator_sat: i64, + margin_trader_sat: i64, + leverage_coordinator: f32, + leverage_trader: f32, + liquidation_price_coordinator: f32, + liquidation_price_trader: f32, + expiry_timestamp: OffsetDateTime, +} + +pub(crate) fn insert( + conn: &mut PgConnection, + params: &dlc_protocol::RolloverParams, +) -> QueryResult<()> { + let dlc_protocol::RolloverParams { + protocol_id, + trader_pubkey, + margin_coordinator, + margin_trader, + leverage_coordinator, + leverage_trader, + liquidation_price_coordinator, + liquidation_price_trader, + expiry_timestamp, + } = params; + + let affected_rows = diesel::insert_into(rollover_params::table) + .values(&( + rollover_params::protocol_id.eq(protocol_id.to_uuid()), + rollover_params::trader_pubkey.eq(trader_pubkey.to_string()), + rollover_params::margin_coordinator_sat.eq(margin_coordinator.to_sat() as i64), + rollover_params::margin_trader_sat.eq(margin_trader.to_sat() as i64), + rollover_params::leverage_coordinator + .eq(leverage_coordinator.to_f32().expect("to fit")), + rollover_params::leverage_trader.eq(leverage_trader.to_f32().expect("to fit")), + rollover_params::liquidation_price_coordinator + .eq(liquidation_price_coordinator.to_f32().expect("to fit")), + rollover_params::liquidation_price_trader + .eq(liquidation_price_trader.to_f32().expect("to fit")), + rollover_params::expiry_timestamp.eq(expiry_timestamp), + )) + .execute(conn)?; + + if affected_rows == 0 { + return Err(diesel::result::Error::NotFound); + } + + Ok(()) +} + +pub(crate) fn get( + conn: &mut PgConnection, + protocol_id: ProtocolId, +) -> QueryResult { + let RolloverParams { + _id, + trader_pubkey, + protocol_id, + margin_coordinator_sat: margin_coordinator, + margin_trader_sat: margin_trader, + leverage_coordinator, + leverage_trader, + liquidation_price_coordinator, + liquidation_price_trader, + expiry_timestamp, + } = rollover_params::table + .filter(rollover_params::protocol_id.eq(protocol_id.to_uuid())) + .first(conn)?; + + Ok(dlc_protocol::RolloverParams { + protocol_id: protocol_id.into(), + trader_pubkey: trader_pubkey.parse().expect("valid pubkey"), + margin_coordinator: Amount::from_sat(margin_coordinator as u64), + margin_trader: Amount::from_sat(margin_trader as u64), + leverage_coordinator: Decimal::try_from(leverage_coordinator).expect("to fit"), + leverage_trader: Decimal::try_from(leverage_trader).expect("to fit"), + liquidation_price_coordinator: Decimal::try_from(liquidation_price_coordinator) + .expect("to fit"), + liquidation_price_trader: Decimal::try_from(liquidation_price_trader).expect("to fit"), + expiry_timestamp, + }) +} diff --git a/coordinator/src/db/trade_params.rs b/coordinator/src/db/trade_params.rs index 6f0b7f7d8..f29973961 100644 --- a/coordinator/src/db/trade_params.rs +++ b/coordinator/src/db/trade_params.rs @@ -32,12 +32,11 @@ pub(crate) struct TradeParams { pub(crate) fn insert( conn: &mut PgConnection, - protocol_id: ProtocolId, params: &dlc_protocol::TradeParams, ) -> QueryResult<()> { let affected_rows = diesel::insert_into(trade_params::table) .values(&( - trade_params::protocol_id.eq(protocol_id.to_uuid()), + trade_params::protocol_id.eq(params.protocol_id.to_uuid()), trade_params::quantity.eq(params.quantity), trade_params::leverage.eq(params.leverage), trade_params::trader_pubkey.eq(params.trader.to_string()), diff --git a/coordinator/src/dlc_protocol.rs b/coordinator/src/dlc_protocol.rs index 1466bbf5b..25d6a9f61 100644 --- a/coordinator/src/dlc_protocol.rs +++ b/coordinator/src/dlc_protocol.rs @@ -70,6 +70,19 @@ impl TradeParams { } } +#[derive(Clone, Debug)] +pub struct RolloverParams { + pub protocol_id: ProtocolId, + pub trader_pubkey: PublicKey, + pub margin_coordinator: Amount, + pub margin_trader: Amount, + pub leverage_coordinator: Decimal, + pub leverage_trader: Decimal, + pub liquidation_price_coordinator: Decimal, + pub liquidation_price_trader: Decimal, + pub expiry_timestamp: OffsetDateTime, +} + pub enum DlcProtocolState { Pending, Success, @@ -89,7 +102,7 @@ pub enum DlcProtocolType { trade_params: TradeParams, }, Rollover { - trader: PublicKey, + rollover_params: RolloverParams, }, Settle { trade_params: TradeParams, @@ -149,7 +162,9 @@ impl DlcProtocolType { } => trader, DlcProtocolType::Close { trader } => trader, DlcProtocolType::ForceClose { trader } => trader, - DlcProtocolType::Rollover { trader } => trader, + DlcProtocolType::Rollover { + rollover_params: RolloverParams { trader_pubkey, .. }, + } => trader_pubkey, } } } @@ -163,10 +178,7 @@ impl DlcProtocolExecutor { DlcProtocolExecutor { pool } } - /// Starts a dlc protocol, by creating a new dlc protocol and temporarily stores - /// the trade params. - /// - /// Returns a uniquely generated protocol id as [`dlc_manager::ReferenceId`] + /// Persist a new DLC protocol and update technical tables in a single transaction. pub fn start_dlc_protocol( &self, protocol_id: ProtocolId, @@ -183,7 +195,7 @@ impl DlcProtocolExecutor { previous_protocol_id, contract_id, channel_id, - protocol_type.clone(), + &protocol_type, protocol_type.get_trader_pubkey(), )?; @@ -192,7 +204,10 @@ impl DlcProtocolExecutor { | DlcProtocolType::OpenPosition { trade_params } | DlcProtocolType::ResizePosition { trade_params } | DlcProtocolType::Settle { trade_params } => { - db::trade_params::insert(conn, protocol_id, &trade_params)?; + db::trade_params::insert(conn, &trade_params)?; + } + DlcProtocolType::Rollover { rollover_params } => { + db::rollover_params::insert(conn, &rollover_params)?; } _ => {} } @@ -203,6 +218,40 @@ impl DlcProtocolExecutor { Ok(()) } + /// Persist a new rollover protocol and update technical tables in a single transaction. + pub fn start_rollover( + &self, + protocol_id: ProtocolId, + previous_protocol_id: Option, + contract_id: &ContractId, + channel_id: &DlcChannelId, + rollover_params: RolloverParams, + funding_fee_event_ids: Vec, + ) -> Result<()> { + let mut conn = self.pool.get()?; + conn.transaction(|conn| { + let trader_pubkey = rollover_params.trader_pubkey; + + db::dlc_protocols::create( + conn, + protocol_id, + previous_protocol_id, + Some(contract_id), + channel_id, + db::dlc_protocols::DlcProtocolType::Rollover, + &trader_pubkey, + )?; + + db::protocol_funding_fee_events::insert(conn, protocol_id, &funding_fee_event_ids)?; + + db::rollover_params::insert(conn, &rollover_params)?; + + diesel::result::QueryResult::Ok(()) + })?; + + Ok(()) + } + pub fn fail_dlc_protocol(&self, protocol_id: ProtocolId) -> Result<()> { let mut conn = self.pool.get()?; db::dlc_protocols::set_dlc_protocol_state_to_failed(&mut conn, protocol_id)?; @@ -210,7 +259,8 @@ impl DlcProtocolExecutor { Ok(()) } - /// Finishes a dlc protocol by the corresponding dlc protocol type handling. + /// Update the state of the database and the position feed based on the completion of a DLC + /// protocol. pub fn finish_dlc_protocol( &self, protocol_id: ProtocolId, @@ -260,16 +310,18 @@ impl DlcProtocolExecutor { channel_id, ) } - DlcProtocolType::Rollover { .. } => { + DlcProtocolType::Rollover { rollover_params } => { let contract_id = contract_id .context("missing contract id") .map_err(|_| RollbackTransaction)?; + self.finish_rollover_dlc_protocol( conn, trader_id, protocol_id, &contract_id, channel_id, + rollover_params, ) } DlcProtocolType::Close { .. } => { @@ -506,8 +558,8 @@ impl DlcProtocolExecutor { Ok(()) } - /// Completes the rollover dlc protocol as successful and updates the 10101 meta data - /// accordingly in a single database transaction. + /// Complete the rollover DLC protocol as successful and update the 10101 metadata accordingly, + /// in a single database transaction. fn finish_rollover_dlc_protocol( &self, conn: &mut PgConnection, @@ -515,6 +567,7 @@ impl DlcProtocolExecutor { protocol_id: ProtocolId, contract_id: &ContractId, channel_id: &DlcChannelId, + rollover_params: &RolloverParams, ) -> QueryResult<()> { tracing::debug!(%trader, %protocol_id, "Finalizing rollover"); db::dlc_protocols::set_dlc_protocol_state_to_success( @@ -524,7 +577,20 @@ impl DlcProtocolExecutor { channel_id, )?; - db::positions::Position::set_position_to_open(conn, trader.to_string(), *contract_id)?; + db::positions::Position::finish_rollover_protocol( + conn, + trader.to_string(), + *contract_id, + rollover_params.leverage_coordinator, + rollover_params.margin_coordinator, + rollover_params.liquidation_price_coordinator, + rollover_params.leverage_trader, + rollover_params.margin_trader, + rollover_params.liquidation_price_trader, + )?; + + db::funding_fee_events::mark_as_paid(conn, protocol_id)?; + Ok(()) } diff --git a/coordinator/src/funding_fee.rs b/coordinator/src/funding_fee.rs new file mode 100644 index 000000000..9405d2b99 --- /dev/null +++ b/coordinator/src/funding_fee.rs @@ -0,0 +1,396 @@ +use crate::db; +use crate::decimal_from_f32; +use crate::message::OrderbookMessage; +use crate::to_nearest_hour_in_the_past; +use anyhow::bail; +use anyhow::Context; +use anyhow::Result; +use bitcoin::secp256k1::PublicKey; +use bitcoin::SignedAmount; +use diesel::r2d2::ConnectionManager; +use diesel::r2d2::Pool; +use diesel::PgConnection; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use rust_decimal::RoundingStrategy; +use rust_decimal_macros::dec; +use std::time::Duration; +use time::ext::NumericalDuration; +use time::format_description; +use time::OffsetDateTime; +use tokio::task::block_in_place; +use tokio_cron_scheduler::JobScheduler; +use xxi_node::commons::ContractSymbol; +use xxi_node::commons::Direction; +use xxi_node::commons::Message; + +const RETRY_INTERVAL: Duration = Duration::from_secs(5); + +/// The funding rate for any position opened before the `end_date`, which remained open through the +/// `end_date`. +#[derive(Clone, Debug)] +pub struct FundingRate { + /// A positive funding rate indicates that longs pay shorts; a negative funding rate indicates + /// that shorts pay longs. + rate: Decimal, + /// The start date for the funding rate period. This value is only used for informational + /// purposes. + /// + /// The `start_date` is always a whole hour. + start_date: OffsetDateTime, + /// The end date for the funding rate period. When the end date has passed, all active + /// positions that were created before the end date should be charged a funding fee based + /// on the `rate`. + /// + /// The `end_date` is always a whole hour. + end_date: OffsetDateTime, +} + +impl FundingRate { + pub(crate) fn new(rate: Decimal, start_date: OffsetDateTime, end_date: OffsetDateTime) -> Self { + let start_date = to_nearest_hour_in_the_past(start_date); + let end_date = to_nearest_hour_in_the_past(end_date); + + Self { + rate, + start_date, + end_date, + } + } + + pub fn rate(&self) -> Decimal { + self.rate + } + + pub fn start_date(&self) -> OffsetDateTime { + self.start_date + } + + pub fn end_date(&self) -> OffsetDateTime { + self.end_date + } +} + +/// A record that a funding fee is owed between the coordinator and a trader. +#[derive(Clone, Copy, Debug)] +pub struct FundingFeeEvent { + pub id: i32, + /// A positive amount indicates that the trader pays the coordinator; a negative amount + /// indicates that the coordinator pays the trader. + pub amount: SignedAmount, + pub trader_pubkey: PublicKey, + pub position_id: i32, + pub due_date: OffsetDateTime, + pub price: Decimal, + pub funding_rate: Decimal, + pub paid_date: Option, +} + +impl From for xxi_node::message_handler::FundingFeeEvent { + fn from(value: FundingFeeEvent) -> Self { + Self { + due_date: value.due_date, + funding_rate: value.funding_rate, + price: value.price, + funding_fee: value.amount, + } + } +} + +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub enum IndexPriceSource { + Bitmex, + /// The index price will be hard-coded for testing. + Test, +} + +pub async fn generate_funding_fee_events_periodically( + scheduler: &JobScheduler, + pool: Pool>, + auth_users_notifier: tokio::sync::mpsc::Sender, + schedule: String, + index_price_source: IndexPriceSource, +) -> Result<()> { + scheduler + .add(tokio_cron_scheduler::Job::new( + schedule.as_str(), + move |_, _| { + let mut attempts_left = 10; + + // We want to retry + while let (Err(e), true) = ( + generate_funding_fee_events( + &pool, + index_price_source, + auth_users_notifier.clone(), + ), + attempts_left > 0, + ) { + attempts_left -= 1; + + tracing::error!( + retry_interval = ?RETRY_INTERVAL, + attempts_left, + "Failed to generate funding fee events: {e:#}. \ + Trying again" + ); + + std::thread::sleep(RETRY_INTERVAL); + } + }, + )?) + .await?; + + scheduler.start().await?; + + Ok(()) +} + +/// Generate [`FundingFeeEvent`]s for all active positions. +/// +/// When called, a [`FundingFeeEvent`] will be generated for an active position if: +/// +/// - We can get a [`FundingRate`] that is at most 1 hour old from the DB. +/// - We can get a BitMEX index price for the `end_date` of the [`FundingRate`]. +/// - There is no other [`FundingFeeEvent`] in the DB with the same `position_id` and `end_date`. +/// - The position was created _before_ the `end_date` of the [`FundingRate`]. +/// +/// This function should be safe to retry. Retry should come in handy if the index price is +/// not available. +fn generate_funding_fee_events( + pool: &Pool>, + index_price_source: IndexPriceSource, + auth_users_notifier: tokio::sync::mpsc::Sender, +) -> Result<()> { + let mut conn = pool.get()?; + + tracing::debug!("Generating funding fee events"); + + let funding_rate = db::funding_rates::get_funding_rate_charged_in_the_last_hour(&mut conn)?; + + let funding_rate = match funding_rate { + Some(funding_rate) => funding_rate, + None => { + tracing::debug!("No current funding rate for this hour"); + return Ok(()); + } + }; + + // TODO: Funding rates should be specific to contract symbols. + let contract_symbol = ContractSymbol::BtcUsd; + + let index_price = match index_price_source { + IndexPriceSource::Bitmex => block_in_place(move || { + let current_index_price = + get_bitmex_index_price(&contract_symbol, funding_rate.end_date)?; + + anyhow::Ok(current_index_price) + })?, + IndexPriceSource::Test => { + #[cfg(not(debug_assertions))] + compile_error!("Cannot use a test index price in release mode"); + + dec!(50_000) + } + }; + + if index_price.is_zero() { + bail!("Cannot generate funding fee events with zero index price"); + } + + // We exclude active positions which were open after this funding period ended. + let positions = db::positions::Position::get_all_active_positions_open_before( + &mut conn, + funding_rate.end_date, + )?; + for position in positions { + let amount = calculate_funding_fee( + position.quantity, + funding_rate.rate, + index_price, + position.trader_direction, + ); + + if let Some(funding_fee_event) = db::funding_fee_events::insert( + &mut conn, + amount, + position.trader, + position.id, + funding_rate.end_date, + index_price, + funding_rate.rate, + ) + .context("Failed to insert funding fee event")? + { + block_in_place(|| { + auth_users_notifier + .blocking_send(OrderbookMessage::TraderMessage { + trader_id: position.trader, + message: Message::FundingFeeEvent(xxi_node::FundingFeeEvent { + contract_symbol, + contracts: decimal_from_f32(position.quantity), + direction: position.trader_direction, + price: funding_fee_event.price, + fee: funding_fee_event.amount, + due_date: funding_fee_event.due_date, + }), + notification: None, + }) + .map_err(anyhow::Error::new) + .context("Could not send pending funding fee event to trader") + })?; + + tracing::debug!( + position_id = %position.id, + trader_pubkey = %position.trader, + fee_amount = ?amount, + ?funding_rate, + "Generated funding fee event" + ); + } + } + + anyhow::Ok(()) +} + +/// Calculate the funding fee. +/// +/// We assume that the `index_price` is not zero. Otherwise, the function panics. +fn calculate_funding_fee( + quantity: f32, + // Positive means longs pay shorts; negative means shorts pay longs. + funding_rate: Decimal, + index_price: Decimal, + trader_direction: Direction, +) -> SignedAmount { + // Transform the funding rate from a global perspective (longs and shorts) to a local + // perspective (the coordinator-trader position). + let funding_rate = match trader_direction { + Direction::Long => funding_rate, + Direction::Short => -funding_rate, + }; + + let quantity = Decimal::try_from(quantity).expect("to fit"); + + // E.g. 500 [$] / 20_000 [$/BTC] = 0.025 [BTC] + let mark_value = quantity / index_price; + + let funding_fee_btc = mark_value * funding_rate; + let funding_fee_btc = funding_fee_btc + .round_dp_with_strategy(8, RoundingStrategy::MidpointAwayFromZero) + .to_f64() + .expect("to fit"); + + SignedAmount::from_btc(funding_fee_btc).expect("to fit") +} + +fn get_bitmex_index_price( + contract_symbol: &ContractSymbol, + timestamp: OffsetDateTime, +) -> Result { + let symbol = bitmex_symbol(contract_symbol); + + let time_format = format_description::parse("[year]-[month]-[day] [hour]:[minute]")?; + + // Ideally we get the price indicated by `timestamp`, but if it is not available we are happy to + // take a price up to 1 minute in the past. + let start_time = (timestamp - 1.minutes()).format(&time_format)?; + let end_time = timestamp.format(&time_format)?; + + let mut url = reqwest::Url::parse("https://www.bitmex.com/api/v1/instrument/compositeIndex")?; + url.query_pairs_mut() + .append_pair("symbol", &format!(".{symbol}")) + .append_pair( + "filter", + // The `reference` is set to `BMI` to get the _composite_ index. + &format!("{{\"symbol\": \".{symbol}\", \"startTime\": \"{start_time}\", \"endTime\": \"{end_time}\", \"reference\": \"BMI\"}}"), + ) + .append_pair("columns", "lastPrice,timestamp,reference") + // Reversed to get the latest one. + .append_pair("reverse", "true") + // Only need one index. + .append_pair("count", "1"); + + let indices = reqwest::blocking::get(url)?.json::>()?; + let index = &indices[0]; + + let index_price = Decimal::try_from(index.last_price)?; + + Ok(index_price) +} + +fn bitmex_symbol(contract_symbol: &ContractSymbol) -> &str { + match contract_symbol { + ContractSymbol::BtcUsd => "BXBT", + } +} + +#[derive(serde::Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Index { + #[serde(with = "time::serde::rfc3339")] + #[serde(rename = "timestamp")] + _timestamp: OffsetDateTime, + last_price: f64, + #[serde(rename = "reference")] + _reference: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_debug_snapshot; + use rust_decimal_macros::dec; + + #[test] + fn calculate_funding_fee_test() { + assert_debug_snapshot!(calculate_funding_fee( + 500.0, + dec!(0.003), + dec!(20_000), + Direction::Long + )); + assert_debug_snapshot!(calculate_funding_fee( + 500.0, + dec!(0.003), + dec!(20_000), + Direction::Short + )); + assert_debug_snapshot!(calculate_funding_fee( + 500.0, + dec!(-0.003), + dec!(20_000), + Direction::Long + )); + assert_debug_snapshot!(calculate_funding_fee( + 500.0, + dec!(-0.003), + dec!(20_000), + Direction::Short + )); + assert_debug_snapshot!(calculate_funding_fee( + 500.0, + dec!(0.003), + dec!(40_000), + Direction::Long + )); + assert_debug_snapshot!(calculate_funding_fee( + 500.0, + dec!(0.003), + dec!(40_000), + Direction::Short + )); + assert_debug_snapshot!(calculate_funding_fee( + 100.0, + dec!(0.003), + dec!(20_000), + Direction::Long + )); + assert_debug_snapshot!(calculate_funding_fee( + 100.0, + dec!(0.003), + dec!(20_000), + Direction::Short + )); + } +} diff --git a/coordinator/src/lib.rs b/coordinator/src/lib.rs index 106f2f969..3d2416ff6 100644 --- a/coordinator/src/lib.rs +++ b/coordinator/src/lib.rs @@ -16,6 +16,8 @@ use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use serde_json::json; +use time::OffsetDateTime; +use time::Time; use xxi_node::commons; mod collaborative_revert; @@ -30,6 +32,7 @@ pub mod cli; pub mod db; pub mod dlc_handler; pub mod dlc_protocol; +pub mod funding_fee; pub mod logger; pub mod message; mod metrics; @@ -114,3 +117,29 @@ pub struct ChannelOpeningParams { pub coordinator_reserve: Amount, pub external_funding: Option, } + +/// Remove minutes, seconds and nano seconds from a given [`OffsetDateTime`]. +pub fn to_nearest_hour_in_the_past(start_date: OffsetDateTime) -> OffsetDateTime { + OffsetDateTime::new_utc( + start_date.date(), + Time::from_hms_nano(start_date.time().hour(), 0, 0, 0).expect("to be valid time"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_remove_small_units() { + let start_date = OffsetDateTime::now_utc(); + + // Act + let result = to_nearest_hour_in_the_past(start_date); + + // Assert + assert_eq!(result.hour(), start_date.time().hour()); + assert_eq!(result.minute(), 0); + assert_eq!(result.second(), 0); + } +} diff --git a/coordinator/src/node/liquidated_positions.rs b/coordinator/src/node/liquidated_positions.rs index 353f57698..b91817900 100644 --- a/coordinator/src/node/liquidated_positions.rs +++ b/coordinator/src/node/liquidated_positions.rs @@ -44,6 +44,9 @@ async fn check_if_positions_need_to_get_liquidated( orderbook::db::orders::get_best_price(&mut conn, ContractSymbol::BtcUsd)?; for position in open_positions { + // TODO: These liquidation prices do not consider the outstanding funding fee events, so + // they are not quite right for the party that owes the fees. + let coordinator_liquidation_price = Decimal::try_from(position.coordinator_liquidation_price).expect("to fit into decimal"); let trader_liquidation_price = diff --git a/coordinator/src/node/rollover.rs b/coordinator/src/node/rollover.rs index 5045cb304..d79c17449 100644 --- a/coordinator/src/node/rollover.rs +++ b/coordinator/src/node/rollover.rs @@ -1,55 +1,48 @@ use crate::check_version::check_version; use crate::db; use crate::db::positions; +use crate::decimal_from_f32; use crate::dlc_protocol; -use crate::dlc_protocol::DlcProtocolType; +use crate::dlc_protocol::RolloverParams; use crate::node::Node; use crate::notifications::Notification; use crate::notifications::NotificationKind; +use crate::payout_curve::build_contract_descriptor; +use crate::position::models::Position; use crate::position::models::PositionState; use anyhow::bail; use anyhow::Context; use anyhow::Result; use bitcoin::secp256k1::PublicKey; -use bitcoin::secp256k1::XOnlyPublicKey; +use bitcoin::Amount; use bitcoin::Network; +use bitcoin::SignedAmount; use diesel::r2d2::ConnectionManager; use diesel::r2d2::Pool; +use diesel::r2d2::PooledConnection; use diesel::PgConnection; use dlc_manager::contract::contract_input::ContractInput; use dlc_manager::contract::contract_input::ContractInputInfo; use dlc_manager::contract::contract_input::OracleInput; use dlc_manager::contract::Contract; -use dlc_manager::contract::ContractDescriptor; use dlc_manager::DlcChannelId; use futures::future::RemoteHandle; use futures::FutureExt; -use std::str::FromStr; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; use time::OffsetDateTime; use tokio::sync::broadcast; use tokio::sync::broadcast::error::RecvError; use tokio::sync::mpsc; use tokio::task::spawn_blocking; -use xxi_node::bitcoin_conversion::to_secp_pk_30; -use xxi_node::bitcoin_conversion::to_xonly_pk_29; -use xxi_node::bitcoin_conversion::to_xonly_pk_30; +use xxi_node::cfd::calculate_leverage; +use xxi_node::cfd::calculate_long_liquidation_price; +use xxi_node::cfd::calculate_short_liquidation_price; use xxi_node::commons; -use xxi_node::commons::ContractSymbol; +use xxi_node::commons::Direction; use xxi_node::node::event::NodeEvent; use xxi_node::node::ProtocolId; -#[derive(Debug, Clone)] -struct Rollover { - counterparty_pubkey: PublicKey, - contract_descriptor: ContractDescriptor, - margin_coordinator: u64, - margin_trader: u64, - contract_symbol: ContractSymbol, - oracle_pk: XOnlyPublicKey, - contract_tx_fee_rate: u64, - network: Network, -} - pub fn monitor( pool: Pool>, mut receiver: broadcast::Receiver, @@ -95,53 +88,131 @@ pub fn monitor( remote_handle } -impl Rollover { - pub fn new(contract: Contract, network: Network) -> Result { - let contract = match contract { - Contract::Confirmed(contract) => contract, - _ => bail!( - "Cannot rollover a contract that is not confirmed. {:?}", - contract - ), - }; - - let offered_contract = contract.accepted_contract.offered_contract; - let contract_info = offered_contract - .contract_info - .first() - .context("contract info to exist on a signed contract")?; - let oracle_announcement = contract_info - .oracle_announcements - .first() - .context("oracle announcement to exist on signed contract")?; - - let margin_coordinator = offered_contract.offer_params.collateral; - let margin_trader = offered_contract.total_collateral - margin_coordinator; - - let contract_tx_fee_rate = offered_contract.fee_rate_per_vb; - Ok(Rollover { - counterparty_pubkey: to_secp_pk_30(offered_contract.counter_party), - contract_descriptor: contract_info.clone().contract_descriptor, - margin_coordinator, - margin_trader, - oracle_pk: to_xonly_pk_30(oracle_announcement.oracle_public_key), - contract_symbol: ContractSymbol::from_str( - &oracle_announcement.oracle_event.event_id[..6], - )?, - contract_tx_fee_rate, - network, - }) - } +/// The [`Position`] values that can change after a rollover. +struct RolledOverPosition { + margin_coordinator: Amount, + margin_trader: Amount, + collateral_reserve_coordinator: Amount, + collateral_reserve_trader: Amount, + leverage_coordinator: Decimal, + leverage_trader: Decimal, + liquidation_price_coordinator: Decimal, + liquidation_price_trader: Decimal, +} - pub fn event_id(&self) -> String { - let maturity_time = self.maturity_time().unix_timestamp(); - format!("{}{maturity_time}", self.contract_symbol) +fn apply_rollover_to_position( + position: &Position, + collateral_reserve_coordinator: Amount, + collateral_reserve_trader: Amount, + funding_fee: FundingFee, + maintenance_margin_rate: Decimal, +) -> RolledOverPosition { + let quantity = decimal_from_f32(position.quantity); + let average_entry_price = decimal_from_f32(position.average_entry_price); + + match funding_fee { + FundingFee::Zero => RolledOverPosition { + margin_coordinator: position.coordinator_margin, + margin_trader: position.trader_margin, + collateral_reserve_coordinator, + collateral_reserve_trader, + leverage_coordinator: decimal_from_f32(position.coordinator_leverage), + leverage_trader: decimal_from_f32(position.trader_leverage), + liquidation_price_coordinator: decimal_from_f32(position.coordinator_liquidation_price), + liquidation_price_trader: decimal_from_f32(position.trader_liquidation_price), + }, + FundingFee::CoordinatorPays(funding_fee) => { + let funding_fee = funding_fee.to_signed().expect("to fit"); + + let margin_coordinator = position.coordinator_margin.to_signed().expect("to fit"); + let new_margin_coordinator = margin_coordinator - funding_fee; + let new_margin_coordinator = new_margin_coordinator.to_unsigned().expect("to fit"); + + let collateral_reserve_trader = collateral_reserve_trader.to_signed().expect("to fit"); + let new_collateral_reserve_trader = collateral_reserve_trader + funding_fee; + let new_collateral_reserve_trader = + new_collateral_reserve_trader.to_unsigned().expect("to fit"); + + let new_leverage_coordinator = + calculate_leverage(quantity, new_margin_coordinator, average_entry_price) + .expect("valid leverage"); + + let new_coordinator_liquidation_price = match position.trader_direction { + Direction::Long => calculate_short_liquidation_price( + new_leverage_coordinator, + average_entry_price, + maintenance_margin_rate, + ), + Direction::Short => calculate_long_liquidation_price( + new_leverage_coordinator, + average_entry_price, + maintenance_margin_rate, + ), + }; + + RolledOverPosition { + margin_coordinator: new_margin_coordinator, + margin_trader: position.trader_margin, + collateral_reserve_coordinator, + collateral_reserve_trader: new_collateral_reserve_trader, + leverage_coordinator: new_leverage_coordinator, + leverage_trader: decimal_from_f32(position.trader_leverage), + liquidation_price_coordinator: new_coordinator_liquidation_price, + liquidation_price_trader: decimal_from_f32(position.trader_liquidation_price), + } + } + FundingFee::TraderPays(funding_fee) => { + let funding_fee = funding_fee.to_signed().expect("to fit"); + + let margin_trader = position.trader_margin.to_signed().expect("to fit"); + let new_margin_trader = margin_trader - funding_fee; + let new_margin_trader = new_margin_trader.to_unsigned().expect("to fit"); + + let collateral_reserve_coordinator = + collateral_reserve_coordinator.to_signed().expect("to fit"); + let new_collateral_reserve_coordinator = collateral_reserve_coordinator + funding_fee; + let new_collateral_reserve_coordinator = new_collateral_reserve_coordinator + .to_unsigned() + .expect("to fit"); + + let new_leverage_trader = + calculate_leverage(quantity, new_margin_trader, average_entry_price) + .expect("valid leverage"); + + let new_trader_liquidation_price = match position.trader_direction { + Direction::Long => calculate_long_liquidation_price( + new_leverage_trader, + average_entry_price, + maintenance_margin_rate, + ), + Direction::Short => calculate_short_liquidation_price( + new_leverage_trader, + average_entry_price, + maintenance_margin_rate, + ), + }; + + RolledOverPosition { + margin_coordinator: position.coordinator_margin, + margin_trader: new_margin_trader, + collateral_reserve_coordinator: new_collateral_reserve_coordinator, + collateral_reserve_trader, + leverage_coordinator: decimal_from_f32(position.coordinator_leverage), + leverage_trader: new_leverage_trader, + liquidation_price_coordinator: decimal_from_f32( + position.coordinator_liquidation_price, + ), + liquidation_price_trader: new_trader_liquidation_price, + } + } } +} - /// Calculates the maturity time based on the current expiry timestamp. - pub fn maturity_time(&self) -> OffsetDateTime { - commons::calculate_next_expiry(OffsetDateTime::now_utc(), self.network) - } +#[derive(Debug, Clone, Copy)] +enum FundingFee { + Zero, + CoordinatorPays(Amount), + TraderPays(Amount), } impl Node { @@ -156,10 +227,14 @@ impl Node { .await .expect("task to complete")?; - tracing::debug!(%trader_id, "Checking if the users positions is eligible for rollover"); + tracing::debug!(%trader_id, "Checking if the user's position is eligible for rollover"); if check_version(&mut conn, &trader_id).is_err() { - tracing::info!(%trader_id, "User is not on the latest version. Skipping check if users position is eligible for rollover"); + tracing::info!( + %trader_id, + "User is not on the latest version. \ + Will not check if their position is eligible for rollover" + ); return Ok(()); } @@ -172,24 +247,21 @@ impl Node { None => return Ok(()), }; - self.check_rollover( - position.trader, - position.expiry_timestamp, - network, - ¬ifier, - None, - ) - .await + self.check_rollover(&mut conn, &position, network, ¬ifier, None) + .await } pub async fn check_rollover( &self, - trader_id: PublicKey, - expiry_timestamp: OffsetDateTime, + connection: &mut PooledConnection>, + position: &Position, network: Network, notifier: &mpsc::Sender, notification: Option, ) -> Result<()> { + let trader_id = position.trader; + let expiry_timestamp = position.expiry_timestamp; + let signed_channel = self.inner.get_signed_channel_by_trader_id(trader_id)?; if commons::is_eligible_for_rollover(OffsetDateTime::now_utc(), network) @@ -214,9 +286,14 @@ impl Node { } if self.is_connected(trader_id) { - tracing::info!(%trader_id, "Proposing to rollover dlc channel"); - self.propose_rollover(&signed_channel.channel_id, self.inner.network) - .await?; + tracing::info!(%trader_id, "Proposing to rollover DLC channel"); + self.propose_rollover( + connection, + &signed_channel.channel_id, + position, + self.inner.network, + ) + .await?; } else { tracing::warn!(%trader_id, "Skipping rollover, user is not connected."); } @@ -228,16 +305,135 @@ impl Node { /// Initiates the rollover protocol with the app. pub async fn propose_rollover( &self, + conn: &mut PooledConnection>, dlc_channel_id: &DlcChannelId, + position: &Position, network: Network, ) -> Result<()> { - let contract = self.inner.get_contract_by_dlc_channel_id(dlc_channel_id)?; - let rollover = Rollover::new(contract, network)?; - let protocol_id = ProtocolId::new(); + let trader_pubkey = position.trader; + + let next_expiry = commons::calculate_next_expiry(OffsetDateTime::now_utc(), network); - tracing::debug!(node_id=%rollover.counterparty_pubkey, %protocol_id, "Rollover dlc channel"); + let collateral_reserve_coordinator = + self.inner.get_dlc_channel_usable_balance(dlc_channel_id)?; + let collateral_reserve_trader = self + .inner + .get_dlc_channel_usable_balance_counterparty(dlc_channel_id)?; + + let (oracle_pk, contract_tx_fee_rate) = { + let old_contract = self.inner.get_contract_by_dlc_channel_id(dlc_channel_id)?; + + let old_offered_contract = match old_contract { + Contract::Confirmed(contract) => contract.accepted_contract.offered_contract, + _ => bail!("Cannot rollover a contract that is not confirmed"), + }; + + let contract_info = old_offered_contract + .contract_info + .first() + .context("contract info to exist on a signed contract")?; + let oracle_announcement = contract_info + .oracle_announcements + .first() + .context("oracle announcement to exist on signed contract")?; + + let expiry_timestamp = OffsetDateTime::from_unix_timestamp( + oracle_announcement.oracle_event.event_maturity_epoch as i64, + )?; + + if expiry_timestamp < OffsetDateTime::now_utc() { + bail!("Cannot rollover an expired position"); + } + + ( + oracle_announcement.oracle_public_key, + old_offered_contract.fee_rate_per_vb, + ) + }; + + let maintenance_margin_rate = { self.settings.read().await.maintenance_margin_rate }; + let maintenance_margin_rate = + Decimal::try_from(maintenance_margin_rate).expect("to fit into decimal"); + + let funding_fee_events = + db::funding_fee_events::get_outstanding_fees(conn, trader_pubkey, position.id)?; + + let funding_fee_amount = funding_fee_events + .iter() + .fold(SignedAmount::ZERO, |acc, e| acc + e.amount); + + let funding_fee_event_ids = funding_fee_events + .iter() + .map(|event| event.id) + .collect::>(); + + let funding_fee = match funding_fee_amount.to_sat() { + 0 => FundingFee::Zero, + n if n.is_positive() => FundingFee::TraderPays(Amount::from_sat(n.unsigned_abs())), + n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), + }; + + let rolled_over_position = apply_rollover_to_position( + position, + collateral_reserve_coordinator, + collateral_reserve_trader, + funding_fee, + maintenance_margin_rate, + ); + + let RolledOverPosition { + margin_coordinator, + margin_trader, + collateral_reserve_coordinator, + collateral_reserve_trader, + leverage_coordinator, + leverage_trader, + liquidation_price_coordinator, + liquidation_price_trader, + } = rolled_over_position; + + let contract_descriptor = build_contract_descriptor( + Decimal::try_from(position.average_entry_price).expect("to fit"), + margin_coordinator, + margin_trader, + leverage_coordinator.to_f32().expect("to fit"), + leverage_trader.to_f32().expect("to fit"), + position.trader_direction, + collateral_reserve_coordinator, + collateral_reserve_trader, + position.quantity, + position.contract_symbol, + ) + .context("Could not build contract descriptor")?; + + let next_event_id = format!( + "{}{}", + position.contract_symbol, + next_expiry.unix_timestamp() + ); + + let new_contract_input = ContractInput { + offer_collateral: (margin_coordinator + collateral_reserve_coordinator).to_sat(), + accept_collateral: (margin_trader + collateral_reserve_trader).to_sat(), + fee_rate: contract_tx_fee_rate, + contract_infos: vec![ContractInputInfo { + contract_descriptor, + oracles: OracleInput { + public_keys: vec![oracle_pk], + event_id: next_event_id, + threshold: 1, + }, + }], + }; - let contract_input: ContractInput = rollover.clone().into(); + let protocol_id = ProtocolId::new(); + + tracing::debug!( + %trader_pubkey, + %protocol_id, + ?funding_fee, + "DLC channel rollover" + ); let channel = self.inner.get_dlc_channel_by_id(dlc_channel_id)?; let previous_id = match channel.get_reference_id() { @@ -245,34 +441,50 @@ impl Node { None => None, }; + let funding_fee_events = funding_fee_events + .into_iter() + .map(xxi_node::message_handler::FundingFeeEvent::from) + .collect(); + let contract_id = self .inner - .propose_rollover(dlc_channel_id, contract_input, protocol_id.into()) + .propose_rollover( + dlc_channel_id, + new_contract_input, + protocol_id.into(), + funding_fee_events, + ) .await?; let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.pool.clone()); - protocol_executor.start_dlc_protocol( - protocol_id, - previous_id, - Some(&contract_id), - dlc_channel_id, - DlcProtocolType::Rollover { - trader: rollover.counterparty_pubkey, - }, - )?; + protocol_executor + .start_rollover( + protocol_id, + previous_id, + &contract_id, + dlc_channel_id, + RolloverParams { + protocol_id, + trader_pubkey, + margin_coordinator, + margin_trader, + leverage_coordinator, + leverage_trader, + liquidation_price_coordinator, + liquidation_price_trader, + expiry_timestamp: next_expiry, + }, + funding_fee_event_ids, + ) + .context("Failed to insert start of rollover protocol in dlc_protocols table")?; - // Sets the position state to rollover indicating that a rollover is in progress. - let mut connection = self.pool.get()?; - db::positions::Position::rollover_position( - &mut connection, - rollover.counterparty_pubkey.to_string(), - &rollover.maturity_time(), - )?; + db::positions::Position::rollover_position(conn, trader_pubkey, &next_expiry) + .context("Failed to set position state to rollover")?; self.inner .event_handler .publish(NodeEvent::SendLastDlcMessage { - peer: rollover.counterparty_pubkey, + peer: trader_pubkey, }); Ok(()) @@ -290,203 +502,4 @@ impl Node { } } -impl From for ContractInput { - fn from(rollover: Rollover) -> Self { - ContractInput { - offer_collateral: rollover.margin_coordinator, - accept_collateral: rollover.margin_trader, - fee_rate: rollover.contract_tx_fee_rate, - contract_infos: vec![ContractInputInfo { - contract_descriptor: rollover.clone().contract_descriptor, - oracles: OracleInput { - public_keys: vec![to_xonly_pk_29(rollover.oracle_pk)], - event_id: rollover.event_id(), - threshold: 1, - }, - }], - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use bitcoin::absolute; - use bitcoin::Transaction; - use dlc::DlcTransactions; - use dlc::PartyParams; - use dlc_manager::contract::accepted_contract::AcceptedContract; - use dlc_manager::contract::contract_info::ContractInfo; - use dlc_manager::contract::enum_descriptor::EnumDescriptor; - use dlc_manager::contract::offered_contract::OfferedContract; - use dlc_manager::contract::signed_contract::SignedContract; - use dlc_messages::oracle_msgs::EnumEventDescriptor; - use dlc_messages::oracle_msgs::EventDescriptor; - use dlc_messages::oracle_msgs::OracleAnnouncement; - use dlc_messages::oracle_msgs::OracleEvent; - use dlc_messages::FundingSignatures; - use rand::Rng; - use xxi_node::bitcoin_conversion::to_secp_pk_29; - use xxi_node::bitcoin_conversion::to_tx_29; - use xxi_node::bitcoin_conversion::to_xonly_pk_29; - - #[test] - fn test_new_rollover_from_signed_contract() { - let expiry_timestamp = OffsetDateTime::now_utc().unix_timestamp() + 10_000; - let contract = dummy_signed_contract(200, 100, expiry_timestamp as u32); - let rollover = Rollover::new(Contract::Confirmed(contract), Network::Bitcoin).unwrap(); - assert_eq!(rollover.contract_symbol, ContractSymbol::BtcUsd); - assert_eq!(rollover.margin_trader, 100); - assert_eq!(rollover.margin_coordinator, 200); - } - - #[test] - fn test_new_rollover_from_other_contract() { - let expiry_timestamp = OffsetDateTime::now_utc().unix_timestamp() + 10_000; - assert!(Rollover::new( - Contract::Offered(dummy_offered_contract(200, 100, expiry_timestamp as u32)), - Network::Bitcoin - ) - .is_err()) - } - - #[test] - fn test_from_rollover_to_contract_input() { - let margin_trader = 123; - let margin_coordinator = 234; - let rollover = Rollover { - counterparty_pubkey: dummy_pubkey(), - contract_descriptor: dummy_contract_descriptor(), - margin_coordinator, - margin_trader, - contract_symbol: ContractSymbol::BtcUsd, - oracle_pk: XOnlyPublicKey::from(dummy_pubkey()), - contract_tx_fee_rate: 1, - network: Network::Bitcoin, - }; - - let contract_input: ContractInput = rollover.into(); - assert_eq!(contract_input.accept_collateral, margin_trader); - assert_eq!(contract_input.offer_collateral, margin_coordinator); - assert_eq!(contract_input.contract_infos.len(), 1); - } - - fn dummy_signed_contract( - margin_coordinator: u64, - margin_trader: u64, - expiry_timestamp: u32, - ) -> SignedContract { - SignedContract { - accepted_contract: AcceptedContract { - offered_contract: dummy_offered_contract( - margin_coordinator, - margin_trader, - expiry_timestamp, - ), - accept_params: dummy_params(margin_trader), - funding_inputs: vec![], - adaptor_infos: vec![], - adaptor_signatures: None, - dlc_transactions: DlcTransactions { - fund: to_tx_29(dummy_tx()), - cets: vec![], - refund: to_tx_29(dummy_tx()), - funding_script_pubkey: bitcoin_old::Script::new(), - }, - accept_refund_signature: dummy_signature(), - }, - adaptor_signatures: None, - offer_refund_signature: dummy_signature(), - funding_signatures: FundingSignatures { - funding_signatures: vec![], - }, - channel_id: None, - } - } - - fn dummy_offered_contract( - margin_coordinator: u64, - margin_trader: u64, - expiry_timestamp: u32, - ) -> OfferedContract { - OfferedContract { - id: dummy_id(), - is_offer_party: false, - contract_info: vec![ContractInfo { - contract_descriptor: dummy_contract_descriptor(), - oracle_announcements: vec![OracleAnnouncement { - announcement_signature: dummy_schnorr_signature(), - oracle_public_key: to_xonly_pk_29(XOnlyPublicKey::from(dummy_pubkey())), - oracle_event: OracleEvent { - oracle_nonces: vec![], - event_maturity_epoch: expiry_timestamp, - event_descriptor: EventDescriptor::EnumEvent(EnumEventDescriptor { - outcomes: vec![], - }), - event_id: format!("btcusd{expiry_timestamp}"), - }, - }], - threshold: 0, - }], - counter_party: to_secp_pk_29(dummy_pubkey()), - offer_params: dummy_params(margin_coordinator), - total_collateral: margin_coordinator + margin_trader, - funding_inputs_info: vec![], - fund_output_serial_id: 0, - fee_rate_per_vb: 0, - cet_locktime: 0, - refund_locktime: 0, - } - } - - fn dummy_pubkey() -> PublicKey { - PublicKey::from_str("02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655") - .expect("valid pubkey") - } - - fn dummy_contract_descriptor() -> ContractDescriptor { - ContractDescriptor::Enum(EnumDescriptor { - outcome_payouts: vec![], - }) - } - - fn dummy_id() -> [u8; 32] { - let mut rng = rand::thread_rng(); - let dummy_id: [u8; 32] = rng.gen(); - dummy_id - } - - fn dummy_schnorr_signature() -> bitcoin_old::secp256k1::schnorr::Signature { - bitcoin_old::secp256k1::schnorr::Signature::from_str( - "84526253c27c7aef56c7b71a5cd25bebb66dddda437826defc5b2568bde81f0784526253c27c7aef56c7b71a5cd25bebb66dddda437826defc5b2568bde81f07", - ).unwrap() - } - - fn dummy_params(collateral: u64) -> PartyParams { - PartyParams { - collateral, - change_script_pubkey: bitcoin_old::Script::new(), - change_serial_id: 0, - fund_pubkey: to_secp_pk_29(dummy_pubkey()), - input_amount: 0, - inputs: vec![], - payout_script_pubkey: bitcoin_old::Script::new(), - payout_serial_id: 0, - } - } - - fn dummy_tx() -> Transaction { - Transaction { - version: 1, - lock_time: absolute::LockTime::ZERO, - input: vec![], - output: vec![], - } - } - - fn dummy_signature() -> bitcoin_old::secp256k1::ecdsa::Signature { - bitcoin_old::secp256k1::ecdsa::Signature::from_str( - "304402202f2545f818a5dac9311157d75065156b141e5a6437e817d1d75f9fab084e46940220757bb6f0916f83b2be28877a0d6b05c45463794e3c8c99f799b774443575910d", - ).unwrap() - } -} +// TODO: Test `apply_rollover_to_position`. diff --git a/coordinator/src/orderbook/websocket.rs b/coordinator/src/orderbook/websocket.rs index f4f30b2a2..2adf06aba 100644 --- a/coordinator/src/orderbook/websocket.rs +++ b/coordinator/src/orderbook/websocket.rs @@ -1,4 +1,5 @@ use crate::db; +use crate::db::funding_fee_events; use crate::db::user; use crate::message::NewUserMessage; use crate::orderbook::db::orders; @@ -249,6 +250,32 @@ pub async fn websocket_connection(stream: WebSocket, state: Arc) { tracing::error!(%trader_id, "Failed to send all orders to user {e:#}"); } + // Send over all the funding fee events that the trader may have missed + // whilst they were offline. + match funding_fee_events::get_for_active_trader_positions( + &mut conn, trader_id, + ) { + Ok(funding_fee_events) => { + if let Err(e) = local_sender + .send(Message::AllFundingFeeEvents(funding_fee_events)) + .await + { + tracing::error!( + %trader_id, + "Failed to send funding fee events \ + for active positions: {e}" + ); + } + } + Err(e) => { + tracing::error!( + %trader_id, + "Failed to load funding fee events \ + for active positions: {e}" + ); + } + } + let token = fcm_token.unwrap_or("unavailable".to_string()); if let Err(e) = user::login_user(&mut conn, trader_id, token, version, os) diff --git a/coordinator/src/routes.rs b/coordinator/src/routes.rs index a9ac2362f..3492f94b3 100644 --- a/coordinator/src/routes.rs +++ b/coordinator/src/routes.rs @@ -15,6 +15,7 @@ use crate::node::Node; use crate::notifications::Notification; use crate::orderbook::trading::NewOrderMessage; use crate::parse_dlc_channel_id; +use crate::routes::admin::post_funding_rates; use crate::settings::Settings; use crate::trade::websocket::InternalPositionUpdateMessage; use crate::AppError; @@ -212,6 +213,7 @@ pub fn router( "/api/admin/users/:trader_pubkey/referrals", get(get_user_referral_status), ) + .route("/api/admin/funding-rates", post(post_funding_rates)) .route("/health", get(get_health)) .route("/api/leaderboard", get(get_leaderboard)) .route( diff --git a/coordinator/src/routes/admin.rs b/coordinator/src/routes/admin.rs index 6da64f24d..47b78df3c 100644 --- a/coordinator/src/routes/admin.rs +++ b/coordinator/src/routes/admin.rs @@ -1,6 +1,8 @@ use crate::collaborative_revert; use crate::db; +use crate::funding_fee; use crate::parse_dlc_channel_id; +use crate::position::models::Position; use crate::referrals; use crate::routes::AppState; use crate::settings::SettingsFile; @@ -16,6 +18,9 @@ use bitcoin::Amount; use bitcoin::OutPoint; use bitcoin::Transaction; use bitcoin::TxOut; +use diesel::r2d2::ConnectionManager; +use diesel::r2d2::PooledConnection; +use diesel::PgConnection; use dlc_manager::channel::signed_channel::SignedChannelState; use dlc_manager::channel::Channel; use dlc_manager::DlcChannelId; @@ -410,25 +415,61 @@ pub async fn rollover( Path(dlc_channel_id): Path, ) -> Result<(), AppError> { let dlc_channel_id = DlcChannelId::from_hex(dlc_channel_id.clone()).map_err(|e| { - AppError::InternalServerError(format!( - "Could not decode dlc channel id from {dlc_channel_id}: {e:#}" - )) + AppError::InternalServerError(format!("Could not decode DLC channel ID: {e}")) })?; + let mut connection = state + .pool + .get() + .map_err(|e| AppError::InternalServerError(format!("Could not acquire DB lock: {e}")))?; + + let position = get_position_by_channel_id(&state, dlc_channel_id, &mut connection) + .map_err(|e| AppError::BadRequest(format!("Could not find position for channel: {e:#}")))?; + state .node - .propose_rollover(&dlc_channel_id, state.node.inner.network) + .propose_rollover( + &mut connection, + &dlc_channel_id, + &position, + state.node.inner.network, + ) .await .map_err(|e| { - AppError::InternalServerError(format!( - "Failed to rollover dlc channel with id {}: {e:#}", - hex::encode(dlc_channel_id) - )) + AppError::InternalServerError(format!("Failed to rollover DLC channel: {e:#}",)) })?; Ok(()) } +fn get_position_by_channel_id( + state: &Arc, + dlc_channel_id: [u8; 32], + conn: &mut PooledConnection>, +) -> anyhow::Result { + let dlc_channels = state.node.inner.list_dlc_channels()?; + + let public_key = dlc_channels + .iter() + .find_map(|channel| { + if channel.get_id() == dlc_channel_id { + Some(channel.get_counter_party_id()) + } else { + None + } + }) + .context("DLC Channel not found")?; + + let position = db::positions::Position::get_position_by_trader( + conn, + PublicKey::from_slice(&public_key.serialize()).expect("to be valid"), + vec![], + )? + .context("Position for channel not found")?; + + Ok(position) +} + // Migrate existing dlc channels. TODO(holzeis): Delete this function after the migration has been // run in prod. pub async fn migrate_dlc_channels(State(state): State>) -> Result<(), AppError> { @@ -546,12 +587,16 @@ pub async fn post_sync( Query(params): Query, ) -> Result<(), AppError> { if params.full.unwrap_or(false) { + tracing::info!("Full sync"); + let stop_gap = params.gap.unwrap_or(20); state.node.inner.full_sync(stop_gap).await.map_err(|e| { AppError::InternalServerError(format!("Could not full-sync on-chain wallet: {e:#}")) })?; } else { + tracing::info!("Regular sync"); + state.node.inner.sync_on_chain_wallet().await.map_err(|e| { AppError::InternalServerError(format!("Could not sync on-chain wallet: {e:#}")) })?; @@ -623,6 +668,49 @@ pub async fn get_user_referral_status( Ok(Json(referral_status)) } +#[instrument(skip_all, err(Debug))] +pub async fn post_funding_rates( + State(state): State>, + Json(funding_rates): Json, +) -> Result<(), AppError> { + spawn_blocking(move || { + let mut conn = state.pool.get().map_err(|e| { + AppError::InternalServerError(format!("Could not get connection: {e:#}")) + })?; + + let funding_rates = funding_rates + .0 + .iter() + .copied() + .map(funding_fee::FundingRate::from) + .collect::>(); + + Ok(()) + }) + .await + .expect("task to complete")?; + + Ok(()) +} + +#[derive(Debug, Deserialize)] +pub struct FundingRates(Vec); + +#[derive(Debug, Deserialize, Clone, Copy)] +pub struct FundingRate { + rate: Decimal, + #[serde(with = "time::serde::rfc3339")] + start_date: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + end_date: OffsetDateTime, +} + +impl From for funding_fee::FundingRate { + fn from(value: FundingRate) -> Self { + funding_fee::FundingRate::new(value.rate, value.start_date, value.end_date) + } +} + impl From for TransactionDetails { fn from(value: xxi_node::TransactionDetails) -> Self { Self { diff --git a/coordinator/src/scheduler.rs b/coordinator/src/scheduler.rs index ffb5540e1..49cace814 100644 --- a/coordinator/src/scheduler.rs +++ b/coordinator/src/scheduler.rs @@ -19,7 +19,7 @@ use tokio_cron_scheduler::JobSchedulerError; use xxi_node::commons; pub struct NotificationScheduler { - scheduler: JobScheduler, + pub scheduler: JobScheduler, sender: mpsc::Sender, settings: Settings, network: Network, @@ -56,10 +56,12 @@ impl NotificationScheduler { .scheduler .add(build_update_bonus_status_job(schedule.as_str(), pool)?) .await?; + tracing::debug!( job_id = uuid.to_string(), "Started new job to update users bonus status" ); + Ok(()) } @@ -99,10 +101,12 @@ impl NotificationScheduler { pool, )?) .await?; + tracing::debug!( job_id = uuid.to_string(), "Started new job to remind to close an expired position" ); + Ok(()) } @@ -121,10 +125,12 @@ impl NotificationScheduler { pool, )?) .await?; + tracing::debug!( job_id = uuid.to_string(), - "Started new job to remind to close an expired position" + "Started new job to remind to close a liquidated position" ); + Ok(()) } @@ -148,10 +154,12 @@ impl NotificationScheduler { sender, )?) .await?; + tracing::debug!( job_id = uuid.to_string(), "Started new job to remind rollover window is open" ); + Ok(()) } @@ -178,8 +186,9 @@ impl NotificationScheduler { tracing::debug!( job_id = uuid.to_string(), - "Started new job to remind rollover window is open" + "Started new job to remind rollover window is closing" ); + Ok(()) } @@ -230,8 +239,8 @@ fn build_rollover_notification_job( for position in positions { if let Err(e) = node .check_rollover( - position.trader, - position.expiry_timestamp, + &mut conn, + &position, node.inner.network, ¬ifier, Some(notification.clone()), diff --git a/coordinator/src/schema.rs b/coordinator/src/schema.rs index c3159501f..58da37902 100644 --- a/coordinator/src/schema.rs +++ b/coordinator/src/schema.rs @@ -233,6 +233,30 @@ diesel::table! { } } +diesel::table! { + funding_fee_events (id) { + id -> Int4, + amount_sats -> Int8, + trader_pubkey -> Text, + position_id -> Int4, + due_date -> Timestamptz, + price -> Float4, + funding_rate -> Float4, + paid_date -> Nullable, + timestamp -> Timestamptz, + } +} + +diesel::table! { + funding_rates (id) { + id -> Int4, + start_date -> Timestamptz, + end_date -> Timestamptz, + rate -> Float4, + timestamp -> Timestamptz, + } +} + diesel::table! { last_outbound_dlc_messages (peer_id) { peer_id -> Text, @@ -414,6 +438,15 @@ diesel::table! { } } +diesel::table! { + protocol_funding_fee_events (id) { + id -> Int4, + protocol_id -> Uuid, + funding_fee_event_id -> Int4, + timestamp -> Timestamptz, + } +} + diesel::table! { reported_errors (id) { id -> Int4, @@ -424,6 +457,21 @@ diesel::table! { } } +diesel::table! { + rollover_params (id) { + id -> Int4, + protocol_id -> Uuid, + trader_pubkey -> Text, + margin_coordinator_sat -> Int8, + margin_trader_sat -> Int8, + leverage_coordinator -> Float4, + leverage_trader -> Float4, + liquidation_price_coordinator -> Float4, + liquidation_price_trader -> Float4, + expiry_timestamp -> Timestamptz, + } +} + diesel::table! { routing_fees (id) { id -> Int4, @@ -508,9 +556,11 @@ diesel::table! { diesel::joinable!(answers -> choices (choice_id)); diesel::joinable!(choices -> polls (poll_id)); +diesel::joinable!(funding_fee_events -> positions (position_id)); diesel::joinable!(last_outbound_dlc_messages -> dlc_messages (message_hash)); diesel::joinable!(liquidity_request_logs -> liquidity_options (liquidity_option)); diesel::joinable!(polls_whitelist -> polls (poll_id)); +diesel::joinable!(protocol_funding_fee_events -> funding_fee_events (funding_fee_event_id)); diesel::joinable!(trades -> positions (position_id)); diesel::allow_tables_to_appear_in_same_query!( @@ -524,6 +574,8 @@ diesel::allow_tables_to_appear_in_same_query!( dlc_channels, dlc_messages, dlc_protocols, + funding_fee_events, + funding_rates, hodl_invoices, last_outbound_dlc_messages, legacy_collaborative_reverts, @@ -536,7 +588,9 @@ diesel::allow_tables_to_appear_in_same_query!( polls, polls_whitelist, positions, + protocol_funding_fee_events, reported_errors, + rollover_params, routing_fees, spendable_outputs, trade_params, diff --git a/coordinator/src/settings.rs b/coordinator/src/settings.rs index d5dcf76de..e22ee9ae9 100644 --- a/coordinator/src/settings.rs +++ b/coordinator/src/settings.rs @@ -1,3 +1,4 @@ +use crate::funding_fee::IndexPriceSource; use crate::node::NodeSettings; use anyhow::Context; use anyhow::Result; @@ -21,45 +22,45 @@ pub struct Settings { // We don't want the doc block below to be auto-formatted. #[rustfmt::skip] - /// A cron syntax for sending notifications about the rollover window being open + /// A cron syntax for sending notifications about the rollover window being open. /// - /// The format is : + /// The format is: /// sec min hour day of month month day of week year /// * * * * * * * pub rollover_window_open_scheduler: String, // We don't want the doc block below to be auto-formatted. #[rustfmt::skip] - /// A cron syntax for sending notifications about the rollover window being open + /// A cron syntax for sending notifications about the rollover window closing. /// - /// The format is : + /// The format is: /// sec min hour day of month month day of week year /// * * * * * * * pub rollover_window_close_scheduler: String, // We don't want the doc block below to be auto-formatted. #[rustfmt::skip] - /// A cron syntax for sending notifications to close an expired position + /// A cron syntax for sending notifications to close an expired position. /// - /// The format is : + /// The format is: /// sec min hour day of month month day of week year /// * * * * * * * pub close_expired_position_scheduler: String, // We don't want the doc block below to be auto-formatted. #[rustfmt::skip] - /// A cron syntax for sending notifications to close an expired position + /// A cron syntax for sending notifications to close a liquidated position. /// - /// The format is : + /// The format is: /// sec min hour day of month month day of week year /// * * * * * * * pub close_liquidated_position_scheduler: String, // We don't want the doc block below to be auto-formatted. #[rustfmt::skip] - /// A cron syntax for updating users bonus status + /// A cron syntax for updating users bonus status. /// - /// The format is : + /// The format is: /// sec min hour day of month month day of week year /// * * * * * * * pub update_user_bonus_status_scheduler: String, @@ -72,6 +73,12 @@ pub struct Settings { /// sec min hour day of month month day of week year /// * * * * * * * pub collect_metrics_scheduler: String, + /// A cron syntax for generating funding fee events. + /// + /// The format is: + /// sec min hour day of month month day of week year + /// * * * * * * * + pub generate_funding_fee_events_scheduler: String, // Location of the settings file in the file system. path: PathBuf, @@ -92,6 +99,9 @@ pub struct Settings { /// The order matching fee rate, which is charged for matching an order. Note, this is at the /// moment applied for taker and maker orders. pub order_matching_fee_rate: f32, + + /// Where to get the index price from. This value is used to calculate funding fees. + pub index_price_source: IndexPriceSource, } impl Settings { @@ -144,12 +154,14 @@ impl Settings { close_liquidated_position_scheduler: file.close_liquidated_position_scheduler, update_user_bonus_status_scheduler: file.update_user_bonus_status_scheduler, collect_metrics_scheduler: file.collect_metrics_scheduler, + generate_funding_fee_events_scheduler: file.generate_funding_fee_events_scheduler, path, whitelist_enabled: file.whitelist_enabled, whitelisted_makers: file.whitelisted_makers, min_quantity: file.min_quantity, maintenance_margin_rate: file.maintenance_margin_rate, order_matching_fee_rate: file.order_matching_fee_rate, + index_price_source: file.index_price_source, } } } @@ -168,12 +180,16 @@ pub struct SettingsFile { update_user_bonus_status_scheduler: String, collect_metrics_scheduler: String, + generate_funding_fee_events_scheduler: String, + whitelist_enabled: bool, whitelisted_makers: Vec, min_quantity: u64, maintenance_margin_rate: f32, order_matching_fee_rate: f32, + + index_price_source: IndexPriceSource, } impl From for SettingsFile { @@ -187,11 +203,13 @@ impl From for SettingsFile { close_liquidated_position_scheduler: value.close_liquidated_position_scheduler, update_user_bonus_status_scheduler: value.update_user_bonus_status_scheduler, collect_metrics_scheduler: value.collect_metrics_scheduler, + generate_funding_fee_events_scheduler: value.generate_funding_fee_events_scheduler, whitelist_enabled: false, whitelisted_makers: value.whitelisted_makers, min_quantity: value.min_quantity, maintenance_margin_rate: value.maintenance_margin_rate, order_matching_fee_rate: value.order_matching_fee_rate, + index_price_source: value.index_price_source, } } } @@ -218,6 +236,7 @@ mod tests { close_liquidated_position_scheduler: "baz".to_string(), update_user_bonus_status_scheduler: "bazinga".to_string(), collect_metrics_scheduler: "42".to_string(), + generate_funding_fee_events_scheduler: "qux".to_string(), whitelist_enabled: false, whitelisted_makers: vec![PublicKey::from_str( "0218845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", @@ -226,6 +245,7 @@ mod tests { min_quantity: 1, maintenance_margin_rate: 0.1, order_matching_fee_rate: 0.003, + index_price_source: IndexPriceSource::Bitmex, }; let serialized = toml::to_string_pretty(&original).unwrap(); diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-2.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-2.snap new file mode 100644 index 000000000..cb35db918 --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-2.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(500.0, dec!(0.003), dec!(20_000), Direction::Short)" +--- +SignedAmount(-0.00007500 BTC) diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-3.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-3.snap new file mode 100644 index 000000000..45ea17bff --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-3.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(500.0, dec!(-0.003), dec!(20_000), Direction::Long)" +--- +SignedAmount(-0.00007500 BTC) diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-4.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-4.snap new file mode 100644 index 000000000..d95f76746 --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-4.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(500.0, dec!(-0.003), dec!(20_000), Direction::Short)" +--- +SignedAmount(0.00007500 BTC) diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-5.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-5.snap new file mode 100644 index 000000000..c50ca6af8 --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-5.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(500.0, dec!(0.003), dec!(40_000), Direction::Long)" +--- +SignedAmount(0.00003750 BTC) diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-6.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-6.snap new file mode 100644 index 000000000..3f7f41ab0 --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-6.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(500.0, dec!(0.003), dec!(40_000), Direction::Short)" +--- +SignedAmount(-0.00003750 BTC) diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-7.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-7.snap new file mode 100644 index 000000000..36bf83ac2 --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-7.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(100.0, dec!(0.003), dec!(20_000), Direction::Long)" +--- +SignedAmount(0.00001500 BTC) diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-8.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-8.snap new file mode 100644 index 000000000..8e8e2034f --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test-8.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(100.0, dec!(0.003), dec!(20_000), Direction::Short)" +--- +SignedAmount(-0.00001500 BTC) diff --git a/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test.snap b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test.snap new file mode 100644 index 000000000..fc7df7a15 --- /dev/null +++ b/coordinator/src/snapshots/coordinator__funding_fee__tests__calculate_funding_fee_test.snap @@ -0,0 +1,5 @@ +--- +source: coordinator/src/funding_fee.rs +expression: "calculate_funding_fee(500.0, dec!(0.003), dec!(20_000), Direction::Long)" +--- +SignedAmount(0.00007500 BTC) diff --git a/crates/tests-e2e/Cargo.toml b/crates/tests-e2e/Cargo.toml index 68feb4f23..4e81ee97f 100644 --- a/crates/tests-e2e/Cargo.toml +++ b/crates/tests-e2e/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1" bitcoin = "0.30" coordinator = { path = "../../coordinator" } flutter_rust_bridge = "1.78.0" +insta = { version = "1", features = ["json", "redactions"] } native = { path = "../../mobile/native" } parking_lot = { version = "0.12.1" } quote = "1.0.28" diff --git a/crates/tests-e2e/src/coordinator.rs b/crates/tests-e2e/src/coordinator.rs index cd77f66d9..bb7ef7b6a 100644 --- a/crates/tests-e2e/src/coordinator.rs +++ b/crates/tests-e2e/src/coordinator.rs @@ -2,10 +2,12 @@ use anyhow::Context; use anyhow::Result; use bitcoin::address::NetworkUnchecked; use bitcoin::Address; +use native::api::ContractSymbol; use reqwest::Client; use rust_decimal::Decimal; use serde::Deserialize; use serde::Serialize; +use time::OffsetDateTime; /// A wrapper over the coordinator HTTP API. /// @@ -13,65 +15,154 @@ use serde::Serialize; pub struct Coordinator { client: Client, host: String, + db_host: String, } impl Coordinator { - pub fn new(client: Client, host: &str) -> Self { + pub fn new(client: Client, host: &str, db_host: &str) -> Self { Self { client, host: host.to_string(), + db_host: db_host.to_string(), } } pub fn new_local(client: Client) -> Self { - Self::new(client, "http://localhost:8000") + Self::new(client, "http://localhost:8000", "http://localhost:3002") } /// Check whether the coordinator is running. pub async fn is_running(&self) -> bool { - self.get("/health").await.is_ok() + self.get(format!("{}/health", self.host)).await.is_ok() } pub async fn sync_node(&self) -> Result<()> { - self.post::<()>("/api/admin/sync", None).await?; + self.post::<()>(format!("{}/api/admin/sync", self.host), None) + .await?; + Ok(()) } pub async fn get_balance(&self) -> Result { - let balance = self.get("/api/admin/wallet/balance").await?.json().await?; + let balance = self + .get(format!("{}/api/admin/wallet/balance", self.host)) + .await? + .json() + .await?; Ok(balance) } pub async fn get_new_address(&self) -> Result> { - Ok(self.get("/api/newaddress").await?.text().await?.parse()?) + Ok(self + .get(format!("{}/api/newaddress", self.host)) + .await? + .text() + .await? + .parse()?) } pub async fn get_dlc_channels(&self) -> Result> { - Ok(self.get("/api/admin/dlc_channels").await?.json().await?) + Ok(self + .get(format!("{}/api/admin/dlc_channels", self.host)) + .await? + .json() + .await?) } pub async fn rollover(&self, dlc_channel_id: &str) -> Result { self.post::<()>( - format!("/api/admin/rollover/{dlc_channel_id}").as_str(), + format!("{}/api/admin/rollover/{dlc_channel_id}", self.host), None, ) .await } + pub async fn get_positions(&self, trader_pubkey: &str) -> Result> { + let positions = self + .get(format!( + "{}/positions?trader_pubkey=eq.{trader_pubkey}", + self.db_host + )) + .await? + .json() + .await?; + + Ok(positions) + } + pub async fn collaborative_revert( &self, request: CollaborativeRevertCoordinatorRequest, ) -> Result<()> { - self.post("/api/admin/channels/revert", Some(request)) - .await?; + self.post( + format!("{}/api/admin/channels/revert", self.host), + Some(request), + ) + .await?; + + Ok(()) + } + + pub async fn post_funding_rates(&self, request: FundingRates) -> Result<()> { + self.post( + format!("{}/api/admin/funding-rates", self.host), + Some(request), + ) + .await?; + + Ok(()) + } + + /// Modify the `creation_timestamp` of the trader positions stored in the coordinator database. + /// + /// This can be used together with `post_funding_rates` to force the coordinator to generate a + /// funding fee event for a given position. + pub async fn modify_position_creation_timestamp( + &self, + timestamp: OffsetDateTime, + trader_pubkey: &str, + ) -> Result<()> { + #[derive(Serialize)] + struct Request { + #[serde(with = "time::serde::rfc3339")] + creation_timestamp: OffsetDateTime, + } + + self.patch( + format!( + "{}/positions?trader_pubkey=eq.{trader_pubkey}", + self.db_host + ), + Some(Request { + creation_timestamp: timestamp, + }), + ) + .await?; Ok(()) } - async fn get(&self, path: &str) -> Result { + pub async fn get_funding_fee_events( + &self, + trader_pubkey: &str, + position_id: u64, + ) -> Result> { + let funding_fee_events = self + .get(format!( + "{}/funding_fee_events?trader_pubkey=eq.{trader_pubkey}&position_id=eq.{position_id}", + self.db_host + )) + .await? + .json() + .await?; + + Ok(funding_fee_events) + } + + async fn get(&self, path: String) -> Result { self.client - .get(format!("{0}{path}", self.host)) + .get(path) .send() .await .context("Could not send GET request to coordinator")? @@ -79,8 +170,8 @@ impl Coordinator { .context("Coordinator did not return 200 OK") } - async fn post(&self, path: &str, body: Option) -> Result { - let request = self.client.post(format!("{0}{path}", self.host)); + async fn post(&self, path: String, body: Option) -> Result { + let request = self.client.post(path); let request = match body { Some(ref body) => { @@ -99,6 +190,31 @@ impl Coordinator { .error_for_status() .context("Coordinator did not return 200 OK") } + + async fn patch( + &self, + path: String, + body: Option, + ) -> Result { + let request = self.client.patch(path); + + let request = match body { + Some(ref body) => { + let body = serde_json::to_string(body)?; + request + .header("Content-Type", "application/json") + .body(body) + } + None => request, + }; + + request + .send() + .await + .context("Could not send PATCH request to coordinator")? + .error_for_status() + .context("Coordinator did not return 200 OK") + } } #[derive(Deserialize, Debug)] @@ -160,3 +276,76 @@ pub struct CollaborativeRevertCoordinatorRequest { pub counter_payout: u64, pub price: Decimal, } + +#[derive(Debug, Deserialize, Clone)] +// For `insta`. +#[derive(Serialize)] +pub struct Position { + pub id: u64, + pub contract_symbol: ContractSymbol, + pub trader_leverage: Decimal, + pub quantity: Decimal, + pub trader_direction: Direction, + pub average_entry_price: Decimal, + pub trader_liquidation_price: Decimal, + pub position_state: PositionState, + pub coordinator_margin: u64, + #[serde(with = "time::serde::rfc3339")] + pub creation_timestamp: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expiry_timestamp: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub update_timestamp: OffsetDateTime, + pub trader_pubkey: String, + pub temporary_contract_id: Option, + pub trader_realized_pnl_sat: Option, + pub trader_unrealized_pnl_sat: Option, + pub closing_price: Option, + pub coordinator_leverage: Decimal, + pub trader_margin: i64, + pub coordinator_liquidation_price: Decimal, + pub order_matching_fees: i64, +} + +#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] +#[serde(rename_all = "lowercase")] +// For `insta`. +#[derive(Serialize)] +pub enum Direction { + Long, + Short, +} + +#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] +// For `insta`. +#[derive(Serialize)] +pub enum PositionState { + Proposed, + Open, + Closing, + Rollover, + Closed, + Failed, + Resizing, +} + +#[derive(Debug, Serialize)] +pub struct FundingRates(pub Vec); + +#[derive(Debug, Serialize)] +pub struct FundingRate { + pub rate: Decimal, + #[serde(with = "time::serde::rfc3339")] + pub start_date: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub end_date: OffsetDateTime, +} + +#[derive(Debug, Deserialize)] +pub struct FundingFeeEvent { + pub amount_sats: i64, + pub trader_pubkey: String, + #[serde(with = "time::serde::rfc3339::option")] + pub paid_date: Option, + pub position_id: u64, +} diff --git a/crates/tests-e2e/src/test_subscriber.rs b/crates/tests-e2e/src/test_subscriber.rs index 2667e0d97..dca5531a4 100644 --- a/crates/tests-e2e/src/test_subscriber.rs +++ b/crates/tests-e2e/src/test_subscriber.rs @@ -165,7 +165,7 @@ impl Senders { self.wallet_info.send(Some(wallet_info.clone()))?; } native::event::EventInternal::PositionUpdateNotification(position) => { - self.position.send(Some(position.clone()))?; + self.position.send(Some(*position))?; } native::event::EventInternal::PositionCloseNotification(contract_symbol) => { self.position_close.send(Some(*contract_symbol))?; @@ -197,6 +197,12 @@ impl Senders { native::event::EventInternal::LnPaymentReceived { .. } => { // ignored } + native::event::EventInternal::NewTrade(_) => { + // ignored + } + native::event::EventInternal::FundingFeeEvent(_) => { + // ignored + } } Ok(()) } diff --git a/crates/tests-e2e/tests/e2e_rollover_position.rs b/crates/tests-e2e/tests/e2e_rollover_position.rs index f782e06d1..d72696997 100644 --- a/crates/tests-e2e/tests/e2e_rollover_position.rs +++ b/crates/tests-e2e/tests/e2e_rollover_position.rs @@ -6,11 +6,16 @@ use native::api::ChannelState; use native::api::SignedChannelState; use native::trade::position; use position::PositionState; +use rust_decimal_macros::dec; use tests_e2e::app::force_close_dlc_channel; use tests_e2e::app::get_dlc_channels; use tests_e2e::app::AppHandle; +use tests_e2e::coordinator; +use tests_e2e::coordinator::FundingRate; +use tests_e2e::coordinator::FundingRates; use tests_e2e::setup; use tests_e2e::wait_until; +use time::ext::NumericalDuration; use time::OffsetDateTime; use xxi_node::commons; @@ -22,6 +27,9 @@ async fn can_rollover_position() { let dlc_channels = coordinator.get_dlc_channels().await.unwrap(); let app_pubkey = api::get_node_id().0; + let position_coordinator_before = + coordinator.get_positions(&app_pubkey).await.unwrap()[0].clone(); + tracing::info!("{:?}", dlc_channels); let dlc_channel = dlc_channels @@ -31,6 +39,9 @@ async fn can_rollover_position() { let new_expiry = commons::calculate_next_expiry(OffsetDateTime::now_utc(), Network::Regtest); + generate_outstanding_funding_fee_event(&test, &app_pubkey, position_coordinator_before.id) + .await; + coordinator .rollover(&dlc_channel.dlc_channel_id.unwrap()) .await @@ -44,6 +55,17 @@ async fn can_rollover_position() { .map(|p| PositionState::Open == p.position_state) .unwrap_or(false)); + wait_until_funding_fee_event_is_paid(&test, &app_pubkey, position_coordinator_before.id).await; + + let position_coordinator_after = + coordinator.get_positions(&app_pubkey).await.unwrap()[0].clone(); + + verify_coordinator_position_after_rollover( + &position_coordinator_before, + &position_coordinator_after, + new_expiry, + ); + // Once the rollover is complete, we also want to verify that the channel can still be // force-closed. This should be tested in `rust-dlc`, but we recently encountered a bug in our // branch: https://github.com/get10101/10101/pull/2079. @@ -78,3 +100,98 @@ fn check_rollover_position(app: &AppHandle, new_expiry: OffsetDateTime) -> bool PositionState::Rollover == position.position_state && new_expiry.unix_timestamp() == position.expiry.unix_timestamp() } + +/// Verify the coordinator's position after executing a rollover, given that a funding fee was paid +/// from the trader to the coordinator. +fn verify_coordinator_position_after_rollover( + before: &coordinator::Position, + after: &coordinator::Position, + new_expiry: OffsetDateTime, +) { + assert_eq!(after.position_state, coordinator::PositionState::Open); + + assert_eq!(before.quantity, after.quantity); + assert_eq!(before.trader_direction, after.trader_direction); + assert_eq!(before.average_entry_price, after.average_entry_price); + assert_eq!(before.coordinator_leverage, after.coordinator_leverage); + assert_eq!( + before.coordinator_liquidation_price, + after.coordinator_liquidation_price + ); + assert_eq!(before.coordinator_margin, after.coordinator_margin); + assert_eq!(before.contract_symbol, after.contract_symbol); + assert_eq!(before.order_matching_fees, after.order_matching_fees); + + assert_eq!(after.expiry_timestamp, new_expiry); + + insta::assert_json_snapshot!(after, { + ".id" => "[u64]".to_string(), + ".creation_timestamp" => "[timestamp]".to_string(), + ".update_timestamp" => "[timestamp]".to_string(), + ".expiry_timestamp" => "[timestamp]".to_string(), + ".trader_pubkey" => "[public-key]".to_string(), + ".temporary_contract_id" => "[public-key]".to_string(), + }); +} + +async fn generate_outstanding_funding_fee_event( + test: &setup::TestSetup, + node_id_app: &str, + position_id: u64, +) { + let end_date = OffsetDateTime::now_utc() - 1.minutes(); + let start_date = end_date - 8.hours(); + + // Let coordinator know about past funding rate. + test.coordinator + .post_funding_rates(FundingRates(vec![FundingRate { + // The trader will owe the coordinator. + rate: dec!(0.001), + start_date, + end_date, + }])) + .await + .unwrap(); + + // Make the coordinator think that the trader's position was created before the funding period + // ended. + test.coordinator + .modify_position_creation_timestamp(end_date - 1.hours(), node_id_app) + .await + .unwrap(); + + wait_until_funding_fee_event_is_created(test, node_id_app, position_id).await; +} + +async fn wait_until_funding_fee_event_is_created( + test: &setup::TestSetup, + node_id_app: &str, + position_id: u64, +) { + wait_until!({ + test.coordinator + .get_funding_fee_events(node_id_app, position_id) + .await + .unwrap() + .first() + .is_some() + }); +} + +async fn wait_until_funding_fee_event_is_paid( + test: &setup::TestSetup, + node_id_app: &str, + position_id: u64, +) { + wait_until!({ + let funding_fee_events = test + .coordinator + .get_funding_fee_events(node_id_app, position_id) + .await + .unwrap(); + + funding_fee_events + .iter() + .all(|event| event.paid_date.is_some()) + }); +} diff --git a/crates/tests-e2e/tests/snapshots/e2e_rollover_position__verify_coordinator_position_after_rollover.snap b/crates/tests-e2e/tests/snapshots/e2e_rollover_position__verify_coordinator_position_after_rollover.snap new file mode 100644 index 000000000..254061b23 --- /dev/null +++ b/crates/tests-e2e/tests/snapshots/e2e_rollover_position__verify_coordinator_position_after_rollover.snap @@ -0,0 +1,27 @@ +--- +source: crates/tests-e2e/tests/e2e_rollover_position.rs +expression: after +--- +{ + "id": "[u64]", + "contract_symbol": "BtcUsd", + "trader_leverage": "2.004008", + "quantity": "1000", + "trader_direction": "long", + "average_entry_price": "50001", + "trader_liquidation_price": "35740.53", + "position_state": "Open", + "coordinator_margin": 999980, + "creation_timestamp": "[timestamp]", + "expiry_timestamp": "[timestamp]", + "update_timestamp": "[timestamp]", + "trader_pubkey": "[public-key]", + "temporary_contract_id": "[public-key]", + "trader_realized_pnl_sat": null, + "trader_unrealized_pnl_sat": null, + "closing_price": null, + "coordinator_leverage": "2", + "trader_margin": 997980, + "coordinator_liquidation_price": "83335", + "order_matching_fees": 6000 +} diff --git a/crates/xxi-node/src/cfd.rs b/crates/xxi-node/src/cfd.rs index 06e448955..31012ca9e 100644 --- a/crates/xxi-node/src/cfd.rs +++ b/crates/xxi-node/src/cfd.rs @@ -31,8 +31,25 @@ pub fn calculate_margin(open_price: Decimal, quantity: f32, leverage: f32) -> Am bitcoin::Amount::from_btc(margin).expect("collateral to fit in amount") } -/// Calculate the quantity from price, collateral and leverage -/// Margin in sats, calculation in BTC +/// Calculate leverage. +pub fn calculate_leverage( + quantity: Decimal, + margin: Amount, + open_price: Decimal, +) -> Result { + let margin_btc = Decimal::try_from(margin.to_btc()).expect("to fit"); + + quantity + .checked_div(margin_btc * open_price) + .with_context(|| { + format!( + "Division by zero when computing leverage. \ + Denominator: {margin_btc} * {open_price}" + ) + }) +} + +/// Calculate the quantity from price, collateral and leverage Margin in sats, calculation in BTC pub fn calculate_quantity(opening_price: f32, margin: u64, leverage: f32) -> f32 { let margin_amount = bitcoin::Amount::from_sat(margin); diff --git a/crates/xxi-node/src/commons/funding_fee_event.rs b/crates/xxi-node/src/commons/funding_fee_event.rs new file mode 100644 index 000000000..c8a78b6cf --- /dev/null +++ b/crates/xxi-node/src/commons/funding_fee_event.rs @@ -0,0 +1,21 @@ +use crate::commons::ContractSymbol; +use crate::commons::Direction; +use bitcoin::SignedAmount; +use rust_decimal::Decimal; +use serde::Deserialize; +use serde::Serialize; +use time::OffsetDateTime; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct FundingFeeEvent { + pub contract_symbol: ContractSymbol, + pub contracts: Decimal, + pub direction: Direction, + #[serde(with = "rust_decimal::serde::float")] + pub price: Decimal, + /// A positive amount indicates that the trader pays the coordinator; a negative amount + /// indicates that the coordinator pays the trader. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub fee: SignedAmount, + pub due_date: OffsetDateTime, +} diff --git a/crates/xxi-node/src/commons/message.rs b/crates/xxi-node/src/commons/message.rs index 23f4489e3..3c576e1ba 100644 --- a/crates/xxi-node/src/commons/message.rs +++ b/crates/xxi-node/src/commons/message.rs @@ -3,6 +3,7 @@ use crate::commons::signature::Signature; use crate::commons::LiquidityOption; use crate::commons::NewLimitOrder; use crate::commons::ReferralStatus; +use crate::FundingFeeEvent; use anyhow::Result; use bitcoin::address::NetworkUnchecked; use bitcoin::Address; @@ -49,6 +50,8 @@ pub enum Message { RolloverError { error: TradingError, }, + FundingFeeEvent(FundingFeeEvent), + AllFundingFeeEvents(Vec), } #[derive(Serialize, Deserialize, Clone, Error, Debug, PartialEq)] @@ -100,38 +103,22 @@ impl TryFrom for tungstenite::Message { impl Display for Message { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Message::AllOrders(_) => { - write!(f, "AllOrders") - } - Message::NewOrder(_) => { - write!(f, "NewOrder") - } - Message::DeleteOrder(_) => { - write!(f, "DeleteOrder") - } - Message::Update(_) => { - write!(f, "Update") - } - Message::InvalidAuthentication(_) => { - write!(f, "InvalidAuthentication") - } - Message::Authenticated(_) => { - write!(f, "Authenticated") - } - Message::DlcChannelCollaborativeRevert { .. } => { - write!(f, "DlcChannelCollaborativeRevert") - } - Message::TradeError { .. } => { - write!(f, "TradeError") - } - Message::RolloverError { .. } => { - write!(f, "RolloverError") - } - Message::LnPaymentReceived { .. } => { - write!(f, "LnPaymentReceived") - } - } + let s = match self { + Message::AllOrders(_) => "AllOrders", + Message::NewOrder(_) => "NewOrder", + Message::DeleteOrder(_) => "DeleteOrder", + Message::Update(_) => "Update", + Message::InvalidAuthentication(_) => "InvalidAuthentication", + Message::Authenticated(_) => "Authenticated", + Message::DlcChannelCollaborativeRevert { .. } => "DlcChannelCollaborativeRevert", + Message::TradeError { .. } => "TradeError", + Message::RolloverError { .. } => "RolloverError", + Message::LnPaymentReceived { .. } => "LnPaymentReceived", + Message::FundingFeeEvent(_) => "FundingFeeEvent", + Message::AllFundingFeeEvents(_) => "FundingFeeEvent", + }; + + f.write_str(s) } } diff --git a/crates/xxi-node/src/commons/mod.rs b/crates/xxi-node/src/commons/mod.rs index 52482fc2b..4d5e3117b 100644 --- a/crates/xxi-node/src/commons/mod.rs +++ b/crates/xxi-node/src/commons/mod.rs @@ -8,6 +8,7 @@ use std::str::FromStr; mod backup; mod collab_revert; +mod funding_fee_event; mod liquidity_option; mod message; mod order; @@ -23,6 +24,7 @@ mod trade; pub use crate::commons::trade::*; pub use backup::*; pub use collab_revert::*; +pub use funding_fee_event::FundingFeeEvent; pub use liquidity_option::*; pub use message::*; pub use order::*; diff --git a/crates/xxi-node/src/lib.rs b/crates/xxi-node/src/lib.rs index bd12287cc..5f4183aaa 100644 --- a/crates/xxi-node/src/lib.rs +++ b/crates/xxi-node/src/lib.rs @@ -28,6 +28,7 @@ pub mod seed; pub mod storage; pub mod transaction; +pub use commons::FundingFeeEvent; pub use config::CONFIRMATION_TARGET; pub use dlc::ContractDetails; pub use dlc::DlcChannelDetails; diff --git a/crates/xxi-node/src/message_handler.rs b/crates/xxi-node/src/message_handler.rs index f418be614..bfd5d7c67 100644 --- a/crates/xxi-node/src/message_handler.rs +++ b/crates/xxi-node/src/message_handler.rs @@ -5,6 +5,7 @@ use crate::commons::OrderReason; use crate::node::event::NodeEvent; use crate::node::event::NodeEventHandler; use anyhow::Result; +use bitcoin::SignedAmount; use dlc_manager::ReferenceId; use dlc_messages::channel::AcceptChannel; use dlc_messages::channel::CollaborativeCloseOffer; @@ -28,7 +29,9 @@ use dlc_messages::segmentation::get_segments; use dlc_messages::segmentation::segment_reader::SegmentReader; use dlc_messages::segmentation::SegmentChunk; use dlc_messages::segmentation::SegmentStart; +use dlc_messages::ser_impls::read_i64; use dlc_messages::ser_impls::read_string; +use dlc_messages::ser_impls::write_i64; use dlc_messages::ser_impls::write_string; use dlc_messages::ChannelMessage; use dlc_messages::Message; @@ -47,6 +50,7 @@ use lightning::util::ser::Readable; use lightning::util::ser::Writeable; use lightning::util::ser::Writer; use lightning::util::ser::MAX_BUF_SIZE; +use rust_decimal::Decimal; use secp256k1_zkp::PublicKey; use serde::Deserialize; use serde::Serialize; @@ -57,6 +61,7 @@ use std::io::Cursor; use std::str::FromStr; use std::sync::Arc; use std::sync::Mutex; +use time::OffsetDateTime; use uuid::Uuid; /// TenTenOneMessageHandler is used to send and receive messages through the custom @@ -280,6 +285,18 @@ pub struct TenTenOneRenewRevoke { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TenTenOneRolloverOffer { pub renew_offer: RenewOffer, + // TODO: The funding fee should be extracted from the `RenewOffer`, but this is more + // convenient. + pub funding_fee_events: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FundingFeeEvent { + pub due_date: OffsetDateTime, + pub funding_rate: Decimal, + pub price: Decimal, + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub funding_fee: SignedAmount, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -691,6 +708,7 @@ impl TenTenOneMessage { }) | TenTenOneMessage::RolloverOffer(TenTenOneRolloverOffer { renew_offer: RenewOffer { reference_id, .. }, + .. }) | TenTenOneMessage::RolloverAccept(TenTenOneRolloverAccept { renew_accept: RenewAccept { reference_id, .. }, @@ -776,7 +794,7 @@ impl From for ChannelMessage { TenTenOneMessage::RenewRevoke(TenTenOneRenewRevoke { renew_revoke, .. }) => { ChannelMessage::RenewRevoke(renew_revoke) } - TenTenOneMessage::RolloverOffer(TenTenOneRolloverOffer { renew_offer }) => { + TenTenOneMessage::RolloverOffer(TenTenOneRolloverOffer { renew_offer, .. }) => { ChannelMessage::RenewOffer(renew_offer) } TenTenOneMessage::RolloverAccept(TenTenOneRolloverAccept { renew_accept }) => { @@ -813,6 +831,46 @@ pub fn read_uuid(reader: &mut R) -> std::result::Result( + input: &FundingFeeEvent, + writer: &mut W, +) -> std::result::Result<(), ::std::io::Error> { + write_i64(&input.due_date.unix_timestamp(), writer)?; + // Using strings because of https://github.com/p2pderivatives/rust-dlc/issues/216. + write_string(&input.funding_rate.to_string(), writer)?; + write_string(&input.price.to_string(), writer)?; + write_i64(&input.funding_fee.to_sat(), writer)?; + + Ok(()) +} + +/// Reads a [`FundingFeeEvent`] from the given writer. +pub fn read_funding_fee_event( + reader: &mut R, +) -> std::result::Result { + let due_date = read_i64(reader)?; + let due_date = + OffsetDateTime::from_unix_timestamp(due_date).map_err(|_| DecodeError::InvalidValue)?; + + let funding_rate = read_string(reader)? + .parse() + .map_err(|_| DecodeError::InvalidValue)?; + let price = read_string(reader)? + .parse() + .map_err(|_| DecodeError::InvalidValue)?; + + let funding_fee = read_i64(reader)?; + let funding_fee = SignedAmount::from_sat(funding_fee); + + Ok(FundingFeeEvent { + due_date, + funding_rate, + price, + funding_fee, + }) +} + macro_rules! impl_type_writeable_for_enum { ($type_name: ident, {$($variant_name: ident),*}) => { impl Type for $type_name { @@ -913,7 +971,7 @@ impl_dlc_writeable!(TenTenOneRenewAccept, { (order_id, {cb_writeable, write_uuid impl_dlc_writeable!(TenTenOneRenewConfirm, { (order_id, {cb_writeable, write_uuid, read_uuid}), (renew_confirm, writeable) }); impl_dlc_writeable!(TenTenOneRenewFinalize, { (order_id, {cb_writeable, write_uuid, read_uuid}), (renew_finalize, writeable) }); impl_dlc_writeable!(TenTenOneRenewRevoke, { (order_id, {cb_writeable, write_uuid, read_uuid}), (renew_revoke, writeable) }); -impl_dlc_writeable!(TenTenOneRolloverOffer, { (renew_offer, writeable) }); +impl_dlc_writeable!(TenTenOneRolloverOffer, { (renew_offer, writeable), (funding_fee_events, { vec_cb, write_funding_fee_event, read_funding_fee_event }) }); impl_dlc_writeable!(TenTenOneRolloverAccept, { (renew_accept, writeable) }); impl_dlc_writeable!(TenTenOneRolloverConfirm, { (renew_confirm, writeable) }); impl_dlc_writeable!(TenTenOneRolloverFinalize, { (renew_finalize, writeable) }); @@ -989,15 +1047,12 @@ fn read_tentenone_message( #[cfg(test)] mod tests { - use crate::commons; + use super::*; use crate::commons::ContractSymbol; use crate::commons::Direction; use crate::commons::OrderReason; use crate::commons::OrderState; use crate::commons::OrderType; - use crate::message_handler::TenTenOneMessageHandler; - use crate::message_handler::TenTenOneReject; - use crate::message_handler::TenTenOneSettleOffer; use crate::node::event::NodeEventHandler; use anyhow::anyhow; use anyhow::Result; @@ -1009,6 +1064,7 @@ mod tests { use lightning::ln::wire::Type; use lightning::util::ser::Readable; use lightning::util::ser::Writeable; + use rust_decimal_macros::dec; use secp256k1::PublicKey; use std::io::Cursor; use std::str::FromStr; @@ -1047,8 +1103,27 @@ mod tests { assert_debug_snapshot!(json_msg); } - fn dummy_filled_with() -> commons::FilledWith { - commons::FilledWith { + #[test] + fn funding_fee_event_roundtrip() { + let original = FundingFeeEvent { + due_date: OffsetDateTime::from_unix_timestamp(100_000).unwrap(), + funding_rate: dec!(0.0003), + price: dec!(60_000.0), + funding_fee: SignedAmount::from_sat(100), + }; + + let mut buffer = vec![]; + + write_funding_fee_event(&original, &mut buffer).unwrap(); + + let mut reader = std::io::Cursor::new(buffer); + let result = read_funding_fee_event(&mut reader).unwrap(); + + assert_eq!(original, result); + } + + fn dummy_filled_with() -> FilledWith { + FilledWith { order_id: Default::default(), expiry_timestamp: dummy_timestamp(), oracle_pk: dummy_x_only_pubkey(), @@ -1056,8 +1131,8 @@ mod tests { } } - fn dummy_order() -> commons::Order { - commons::Order { + fn dummy_order() -> Order { + Order { id: Default::default(), price: Default::default(), leverage: 0.0, diff --git a/crates/xxi-node/src/node/dlc_channel.rs b/crates/xxi-node/src/node/dlc_channel.rs index f41de394a..46e7ef49a 100644 --- a/crates/xxi-node/src/node/dlc_channel.rs +++ b/crates/xxi-node/src/node/dlc_channel.rs @@ -1,6 +1,7 @@ use crate::bitcoin_conversion::to_secp_pk_29; use crate::bitcoin_conversion::to_secp_pk_30; use crate::commons; +use crate::message_handler::FundingFeeEvent; use crate::message_handler::TenTenOneCollaborativeCloseOffer; use crate::message_handler::TenTenOneMessage; use crate::message_handler::TenTenOneMessageHandler; @@ -378,6 +379,7 @@ impl, ) -> Result { tracing::info!(channel_id = %hex::encode(dlc_channel_id), "Proposing a DLC channel rollover"); spawn_blocking({ @@ -396,7 +398,10 @@ impl for Uuid { value.0 } } + +impl FromStr for ProtocolId { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let uuid = Uuid::from_str(s)?; + + Ok(ProtocolId(uuid)) + } +} diff --git a/crates/xxi-node/src/node/wallet.rs b/crates/xxi-node/src/node/wallet.rs index 64acfcd37..b99645180 100644 --- a/crates/xxi-node/src/node/wallet.rs +++ b/crates/xxi-node/src/node/wallet.rs @@ -124,6 +124,8 @@ impl Nod pub async fn full_sync(&self, stop_gap: usize) -> Result<()> { let client = &self.blockchain.esplora_client_async; + tracing::info!("Running full sync of on-chain wallet"); + let (local_chain, all_script_pubkeys) = spawn_blocking({ let wallet = self.wallet.clone(); move || { @@ -165,6 +167,8 @@ impl Nod .await .expect("task to complete")?; + tracing::info!("Finished full sync of on-chain wallet"); + Ok(()) } } diff --git a/mobile/lib/backend.dart b/mobile/lib/backend.dart index 121548ce5..373354ce6 100644 --- a/mobile/lib/backend.dart +++ b/mobile/lib/backend.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/dlc_channel_change_notifier.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; +import 'package:get_10101/features/trade/trade_change_notifier.dart'; import 'package:get_10101/ffi.dart' as rust; import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; import 'package:get_10101/util/environment.dart'; @@ -56,6 +57,7 @@ Future fullBackup() async { Future runBackend(BuildContext context) async { final orderChangeNotifier = context.read(); final positionChangeNotifier = context.read(); + final tradeChangeNotifier = context.read(); final dlcChannelChangeNotifier = context.read(); final seedDir = (await getApplicationSupportDirectory()).path; @@ -74,6 +76,7 @@ Future runBackend(BuildContext context) async { // these notifiers depend on the backend running await orderChangeNotifier.initialize(); await positionChangeNotifier.initialize(); + await tradeChangeNotifier.initialize(); await dlcChannelChangeNotifier.initialize(); } diff --git a/mobile/lib/common/amount_text.dart b/mobile/lib/common/amount_text.dart index 1c2a3e9bd..d6c11fc59 100644 --- a/mobile/lib/common/amount_text.dart +++ b/mobile/lib/common/amount_text.dart @@ -39,6 +39,6 @@ String formatSats(Amount amount) { } String formatUsd(Usd usd) { - final formatter = NumberFormat("\$ #,###,###,###,###", "en"); + final formatter = NumberFormat("\$#,###,###,###,###", "en"); return formatter.format(usd.asDouble()); } diff --git a/mobile/lib/common/application/event_service.dart b/mobile/lib/common/application/event_service.dart index 48adbe109..d90d6e7cb 100644 --- a/mobile/lib/common/application/event_service.dart +++ b/mobile/lib/common/application/event_service.dart @@ -13,7 +13,7 @@ class EventService { EventService.create() { api.subscribe().listen((Event event) { if (subscribers[event.runtimeType] == null) { - logger.d("found no subscribers, skipping event"); + logger.d("found no subscribers for $event, skipping event"); return; } diff --git a/mobile/lib/common/init_service.dart b/mobile/lib/common/init_service.dart index 1edac48e3..4c42cff1e 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -8,10 +8,13 @@ import 'package:get_10101/common/domain/funding_channel_task.dart'; import 'package:get_10101/common/domain/tentenone_config.dart'; import 'package:get_10101/common/funding_channel_task_change_notifier.dart'; import 'package:get_10101/features/brag/meme_service.dart'; +import 'package:get_10101/features/trade/application/trade_service.dart'; +import 'package:get_10101/features/trade/domain/trade.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; import 'package:get_10101/common/amount_denomination_change_notifier.dart'; import 'package:get_10101/common/service_status_notifier.dart'; +import 'package:get_10101/features/trade/trade_change_notifier.dart'; import 'package:get_10101/features/wallet/application/faucet_service.dart'; import 'package:get_10101/features/trade/trade_value_change_notifier.dart'; import 'package:get_10101/features/wallet/application/wallet_service.dart'; @@ -52,6 +55,7 @@ List createProviders() { ChangeNotifierProvider(create: (context) => SubmitOrderChangeNotifier(OrderService())), ChangeNotifierProvider(create: (context) => OrderChangeNotifier(OrderService())), ChangeNotifierProvider(create: (context) => PositionChangeNotifier(PositionService())), + ChangeNotifierProvider(create: (context) => TradeChangeNotifier(TradeService())), ChangeNotifierProvider(create: (context) => WalletChangeNotifier(const WalletService())), ChangeNotifierProvider(create: (context) => ServiceStatusNotifier()), ChangeNotifierProvider(create: (context) => DlcChannelChangeNotifier(dlcChannelService)), @@ -76,6 +80,7 @@ void subscribeToNotifiers(BuildContext context) { final EventService eventService = EventService.create(); final orderChangeNotifier = context.read(); + final tradeChangeNotifier = context.read(); final positionChangeNotifier = context.read(); final walletChangeNotifier = context.read(); final tradeValuesChangeNotifier = context.read(); @@ -88,6 +93,8 @@ void subscribeToNotifiers(BuildContext context) { eventService.subscribe( orderChangeNotifier, bridge.Event.orderUpdateNotification(Order.apiDummy())); + eventService.subscribe(tradeChangeNotifier, bridge.Event.newTrade(Trade.apiDummy())); + eventService.subscribe( positionChangeNotifier, bridge.Event.positionUpdateNotification(Position.apiDummy())); diff --git a/mobile/lib/features/trade/application/trade_service.dart b/mobile/lib/features/trade/application/trade_service.dart new file mode 100644 index 000000000..a53cd37ea --- /dev/null +++ b/mobile/lib/features/trade/application/trade_service.dart @@ -0,0 +1,11 @@ +import 'package:get_10101/features/trade/domain/trade.dart'; +import 'package:get_10101/ffi.dart' as rust; + +class TradeService { + Future> fetchTrades() async { + List apiTrades = await rust.api.getTrades(); + List trades = apiTrades.map((trade) => Trade.fromApi(trade)).toList(); + + return trades; + } +} diff --git a/mobile/lib/features/trade/domain/leverage.dart b/mobile/lib/features/trade/domain/leverage.dart index 597bf986f..a367f146c 100644 --- a/mobile/lib/features/trade/domain/leverage.dart +++ b/mobile/lib/features/trade/domain/leverage.dart @@ -3,7 +3,8 @@ class Leverage { Leverage(this.leverage); - String formatted() => "x${leverage % 1 == 0 ? leverage.toInt().toString() : leverage.toString()}"; + String formatted() => + "x${leverage % 1 == 0 ? leverage.toInt().toString() : leverage.toStringAsFixed(2)}"; String formattedReverse() => "${leverage % 1 == 0 ? leverage.toInt().toString() : leverage.toString()}x"; diff --git a/mobile/lib/features/trade/domain/trade.dart b/mobile/lib/features/trade/domain/trade.dart new file mode 100644 index 000000000..99db2c86f --- /dev/null +++ b/mobile/lib/features/trade/domain/trade.dart @@ -0,0 +1,88 @@ +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; +import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/features/trade/domain/direction.dart'; +import 'package:get_10101/features/trade/domain/contract_symbol.dart'; + +class Trade implements Comparable { + final TradeType tradeType; + final ContractSymbol contractSymbol; + final Direction direction; + final Usd quantity; + final Usd price; + final Amount fee; + Amount? pnl; + final DateTime timestamp; + final bool isDone; + + Trade({ + required this.tradeType, + required this.contractSymbol, + required this.direction, + required this.quantity, + required this.price, + required this.fee, + this.pnl, + required this.timestamp, + required this.isDone, + }); + + @override + int compareTo(Trade other) { + int comp = other.timestamp.compareTo(timestamp); + + // Sometimes two trades might have the same timestamp. This can happen + // when we change position direction. In that case, we want the trade that + // first reduces the position to zero to appear first. + if (comp == 0) { + if (pnl != null) { + return 1; + } else { + return -1; + } + } + + return comp; + } + + static Trade fromApi(bridge.Trade trade) { + return Trade( + tradeType: TradeType.fromApi(trade.tradeType), + contractSymbol: ContractSymbol.fromApi(trade.contractSymbol), + direction: Direction.fromApi(trade.direction), + quantity: Usd.fromDouble(trade.contracts), + price: Usd.fromDouble(trade.price), + // Positive fees coming from Rust are paid by the trader. We flip the sign here, because + // that is how we want to display them. + fee: Amount(-trade.fee), + timestamp: DateTime.fromMillisecondsSinceEpoch(trade.timestamp * 1000), + pnl: trade.pnl != null ? Amount(trade.pnl!) : null, + isDone: trade.isDone); + } + + static bridge.Trade apiDummy() { + return const bridge.Trade( + tradeType: bridge.TradeType.Trade, + contractSymbol: bridge.ContractSymbol.BtcUsd, + contracts: 0, + price: 0, + fee: 0, + direction: bridge.Direction.Long, + timestamp: 0, + isDone: true, + ); + } +} + +enum TradeType { + trade, + funding; + + static TradeType fromApi(bridge.TradeType tradeType) { + switch (tradeType) { + case bridge.TradeType.Trade: + return TradeType.trade; + case bridge.TradeType.Funding: + return TradeType.funding; + } + } +} diff --git a/mobile/lib/features/trade/trade_change_notifier.dart b/mobile/lib/features/trade/trade_change_notifier.dart new file mode 100644 index 000000000..110043385 --- /dev/null +++ b/mobile/lib/features/trade/trade_change_notifier.dart @@ -0,0 +1,35 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:get_10101/features/trade/application/trade_service.dart'; +import 'package:get_10101/features/trade/domain/trade.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; +import 'package:get_10101/common/application/event_service.dart'; + +class TradeChangeNotifier extends ChangeNotifier implements Subscriber { + late TradeService _tradeService; + Set trades = SplayTreeSet(); + + Future initialize() async { + trades = SplayTreeSet.from(await _tradeService.fetchTrades()); + + notifyListeners(); + } + + TradeChangeNotifier(TradeService tradeService) { + _tradeService = tradeService; + } + + @override + void notify(bridge.Event event) { + if (event is bridge.Event_NewTrade) { + Trade trade = Trade.fromApi(event.field0); + trades.add(trade); + + notifyListeners(); + } else { + logger.w("Received unexpected event: ${event.toString()}"); + } + } +} diff --git a/mobile/lib/features/trade/trade_list_item.dart b/mobile/lib/features/trade/trade_list_item.dart new file mode 100644 index 000000000..44b2f72df --- /dev/null +++ b/mobile/lib/features/trade/trade_list_item.dart @@ -0,0 +1,106 @@ +import 'package:timeago/timeago.dart' as timeago; +import 'package:flutter/material.dart'; +import 'package:get_10101/features/trade/domain/direction.dart'; +import 'package:get_10101/features/trade/domain/trade.dart'; +import 'package:get_10101/features/trade/trade_theme.dart'; +import 'package:intl/intl.dart'; +import 'package:get_10101/features/trade/contract_symbol_icon.dart'; + +class TradeListItem extends StatelessWidget { + const TradeListItem({super.key, required this.trade}); + + final Trade trade; + + @override + Widget build(BuildContext context) { + TradeTheme tradeTheme = Theme.of(context).extension()!; + + final formatter = NumberFormat(); + formatter.minimumFractionDigits = 2; + formatter.maximumFractionDigits = 2; + + String tradeTypeText; + switch (trade.tradeType) { + case TradeType.trade: + tradeTypeText = "Trade"; + case TradeType.funding: + tradeTypeText = "Funding"; + } + + var pnlTextSpan = trade.pnl != null && trade.pnl!.sats != 0 + ? [ + const TextSpan(text: "PNL: "), + TextSpan( + text: "${trade.pnl}\n", + style: TextStyle( + color: + trade.pnl!.sats.isNegative ? Colors.red.shade600 : Colors.green.shade600)), + ] + : []; + + var feeTextSpan = trade.fee.sats != 0 + ? [ + const TextSpan(text: "Fee: "), + TextSpan( + text: "${trade.fee}\n", + style: TextStyle( + color: + trade.fee.sats.isNegative ? Colors.red.shade600 : Colors.green.shade600)), + ] + : []; + + return Column( + children: [ + Card( + margin: const EdgeInsets.all(0), + elevation: 0, + child: ListTile( + leading: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ContractSymbolIcon( + height: 20, + width: 20, + paddingUsd: EdgeInsets.only(left: 12.0), + ), + ], + ), + title: RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: trade.direction.nameU, + style: TextStyle( + color: + trade.direction == Direction.long ? tradeTheme.buy : tradeTheme.sell, + fontWeight: FontWeight.bold)), + TextSpan( + text: " ${trade.quantity} ", + style: const TextStyle(fontWeight: FontWeight.bold)), + const TextSpan(text: "@ ", style: TextStyle(color: Colors.grey)), + TextSpan(text: "${trade.price}") + ], + ), + ), + trailing: RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [TextSpan(text: tradeTypeText)]), + ), + subtitle: RichText( + textWidthBasis: TextWidthBasis.longestLine, + text: TextSpan(style: DefaultTextStyle.of(context).style, children: [ + ...pnlTextSpan, + ...feeTextSpan, + TextSpan( + text: timeago.format(trade.timestamp), + style: const TextStyle(color: Colors.grey)), + ])), + ), + ), + const Divider(height: 0, thickness: 1, indent: 10, endIndent: 10) + ], + ); + } +} diff --git a/mobile/lib/features/trade/trade_screen.dart b/mobile/lib/features/trade/trade_screen.dart index e6c132b39..5ec45db99 100644 --- a/mobile/lib/features/trade/trade_screen.dart +++ b/mobile/lib/features/trade/trade_screen.dart @@ -10,6 +10,8 @@ import 'package:get_10101/features/trade/position_list_item.dart'; import 'package:get_10101/features/trade/submit_order_change_notifier.dart'; import 'package:get_10101/features/trade/trade_bottom_sheet.dart'; import 'package:get_10101/features/trade/trade_bottom_sheet_confirmation.dart'; +import 'package:get_10101/features/trade/trade_change_notifier.dart'; +import 'package:get_10101/features/trade/trade_list_item.dart'; import 'package:get_10101/features/trade/trade_tabs.dart'; import 'package:get_10101/features/trade/trade_theme.dart'; import 'package:get_10101/features/trade/trade_value_change_notifier.dart'; @@ -38,6 +40,7 @@ class TradeScreen extends StatelessWidget { OrderChangeNotifier orderChangeNotifier = context.watch(); PositionChangeNotifier positionChangeNotifier = context.watch(); + TradeChangeNotifier tradeChangeNotifier = context.watch(); TradeValuesChangeNotifier tradeValuesChangeNotifier = context.read(); SubmitOrderChangeNotifier submitOrderChangeNotifier = context.read(); @@ -92,9 +95,14 @@ class TradeScreen extends StatelessWidget { tabs: const [ "Positions", "Orders", + "Trades", ], selectedIndex: 0, - keys: const [tradeScreenTabsPositions, tradeScreenTabsOrders], + keys: const [ + tradeScreenTabsPositions, + tradeScreenTabsTrades, + tradeScreenTabsOrders + ], tabBarViewChildren: [ ListView.builder( shrinkWrap: true, @@ -114,7 +122,7 @@ class TradeScreen extends StatelessWidget { style: DefaultTextStyle.of(context).style, children: const [ TextSpan( - text: "Your order is being filled...\n\nCheck the ", + text: "Your order is being filled\n\nCheck the ", style: TextStyle(color: Colors.grey)), TextSpan(text: "Orders", style: TextStyle(color: Colors.black)), TextSpan( @@ -122,13 +130,12 @@ class TradeScreen extends StatelessWidget { style: TextStyle(color: Colors.grey)), ])); } - return RichText( text: TextSpan( style: DefaultTextStyle.of(context).style, children: [ const TextSpan( - text: "You currently don't have an open position...\n\n", + text: "You don't have an open position.\n\n", style: TextStyle(color: Colors.grey)), TextSpan( text: "Buy", @@ -176,6 +183,34 @@ class TradeScreen extends StatelessWidget { ); }, ), + tradeChangeNotifier.trades.isEmpty + ? RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + const TextSpan( + text: "You don't have any trades yet.\n\n", + style: TextStyle(color: Colors.grey)), + TextSpan( + text: "Buy", + style: TextStyle( + color: tradeTheme.buy, fontWeight: FontWeight.bold)), + const TextSpan(text: " or ", style: TextStyle(color: Colors.grey)), + TextSpan( + text: "Sell", + style: TextStyle( + color: tradeTheme.sell, fontWeight: FontWeight.bold)), + const TextSpan( + text: " to create one!", style: TextStyle(color: Colors.grey)), + ])) + : SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Card( + child: Column( + children: tradeChangeNotifier.trades + .map((trade) => TradeListItem(trade: trade)) + .toList(), + ))), // If there are no positions we early-return with placeholder orderChangeNotifier.orders.isEmpty ? RichText( @@ -183,7 +218,7 @@ class TradeScreen extends StatelessWidget { style: DefaultTextStyle.of(context).style, children: [ const TextSpan( - text: "You don't have any orders yet...\n\n", + text: "You don't have any orders yet.\n\n", style: TextStyle(color: Colors.grey)), TextSpan( text: "Buy", diff --git a/mobile/lib/util/constants.dart b/mobile/lib/util/constants.dart index 0e902403b..56c6330f8 100644 --- a/mobile/lib/util/constants.dart +++ b/mobile/lib/util/constants.dart @@ -33,10 +33,12 @@ const _positions = "positions"; const _orders = "orders"; const _confirmationButton = "confirmation_button"; const _confirmationSlider = "confirmation_slider"; +const _trades = "trades"; const _openChannel = "open_channel"; const tradeScreenTabsOrders = Key(_trade + _tabs + _orders); const tradeScreenTabsPositions = Key(_trade + _tabs + _positions); +const tradeScreenTabsTrades = Key(_trade + _tabs + _trades); const tradeScreenButtonBuy = Key(_trade + _button + _buy); const tradeScreenButtonSell = Key(_trade + _button + _sell); diff --git a/mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/down.sql b/mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/down.sql new file mode 100644 index 000000000..28faf6509 --- /dev/null +++ b/mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/down.sql @@ -0,0 +1 @@ +DROP TABLE funding_fee_events; diff --git a/mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/up.sql b/mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/up.sql new file mode 100644 index 000000000..4308f6168 --- /dev/null +++ b/mobile/native/migrations/2024-05-22-015410_add_funding_fee_events_table/up.sql @@ -0,0 +1,12 @@ +CREATE TABLE funding_fee_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + contract_symbol TEXT NOT NULL, + contracts FLOAT NOT NULL, + direction TEXT NOT NULL, + price FLOAT NOT NULL, + fee BIGINT NOT NULL, + due_date BIGINT NOT NULL, + paid_date BIGINT +); + +CREATE UNIQUE INDEX idx_unique_due_date_contract_symbol ON funding_fee_events (due_date, contract_symbol); diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index c15ac3e13..b999a2a0a 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -23,11 +23,13 @@ use crate::health; use crate::logger; use crate::max_quantity::max_quantity; use crate::polls; +use crate::trade::funding_fee_event::handler::get_funding_fee_events; use crate::trade::order; use crate::trade::order::api::NewOrder; use crate::trade::order::api::Order; use crate::trade::position; use crate::trade::position::api::Position; +use crate::trade::trades::api::Trade; use crate::trade::users; use crate::unfunded_channel_opening_order; use crate::unfunded_channel_opening_order::ExternalFunding; @@ -380,6 +382,19 @@ pub async fn get_positions() -> Result> { Ok(positions) } +#[tokio::main(flavor = "current_thread")] +pub async fn get_trades() -> Result> { + let trades = crate::trade::trades::handler::get_trades()? + .into_iter() + .map(|trade| trade.into()); + + let funding_fee_events = get_funding_fee_events()?.into_iter().map(|e| e.into()); + + let trades = trades.chain(funding_fee_events).collect(); + + Ok(trades) +} + pub fn set_filling_orders_to_failed() -> Result<()> { emergency_kit::set_filling_orders_to_failed() } diff --git a/mobile/native/src/db/mod.rs b/mobile/native/src/db/mod.rs index c51b8c99d..03689f299 100644 --- a/mobile/native/src/db/mod.rs +++ b/mobile/native/src/db/mod.rs @@ -1,5 +1,6 @@ use crate::config; use crate::db::models::FailureReason; +use crate::db::models::FundingFeeEvent; use crate::db::models::NewTrade; use crate::db::models::Order; use crate::db::models::OrderState; @@ -8,6 +9,7 @@ use crate::db::models::SpendableOutputInsertable; use crate::db::models::SpendableOutputQueryable; use crate::db::models::Trade; use crate::db::models::Transaction; +use crate::db::models::UnpaidFundingFeeEvent; use crate::trade; use anyhow::anyhow; use anyhow::Context; @@ -37,6 +39,7 @@ use uuid::Uuid; use xxi_node::commons; mod custom_types; + pub mod dlc_messages; pub mod last_outbound_dlc_messages; pub mod models; @@ -342,6 +345,15 @@ pub fn insert_position(position: trade::position::Position) -> Result Result { + let mut conn = connection()?; + + let position = Position::get_position(&mut conn, contract_symbol.into())?; + + Ok(position.into()) +} + pub fn get_positions() -> Result> { let mut db = connection()?; let positions = Position::get_all(&mut db)?; @@ -371,21 +383,27 @@ pub fn update_position_state( Ok(position.into()) } -pub fn update_position(resized_position: trade::position::Position) -> Result<()> { +pub fn update_position(updated_position: trade::position::Position) -> Result<()> { let mut db = connection()?; - Position::update_position(&mut db, resized_position.into()) - .context("Failed to update position state")?; + Position::update_position(&mut db, updated_position.into()) + .context("Failed to update position")?; Ok(()) } -pub fn rollover_position( - contract_symbol: commons::ContractSymbol, - expiry_timestamp: OffsetDateTime, -) -> Result<()> { +pub fn start_position_rollover(updated_position: trade::position::Position) -> Result<()> { let mut db = connection()?; - Position::rollover(&mut db, contract_symbol.into(), expiry_timestamp) - .context("Failed to rollover position")?; + + Position::start_rollover(&mut db, updated_position.into()) + .context("Failed to start position rollover")?; + + Ok(()) +} + +pub fn finish_position_rollover(updated_position: trade::position::Position) -> Result<()> { + let mut db = connection()?; + Position::finish_rollover(&mut db, updated_position.into()) + .context("Failed to finish position rollover")?; Ok(()) } @@ -503,8 +521,52 @@ pub fn set_poll_to_ignored_or_answered(poll_id: i32) -> Result<()> { polls::insert(&mut db, poll_id)?; Ok(()) } + pub fn delete_answered_poll_cache() -> Result<()> { let mut db = connection()?; polls::delete_all(&mut db)?; Ok(()) } + +pub fn get_all_funding_fee_events() -> Result> { + let mut db = connection()?; + + let funding_fee_events = FundingFeeEvent::get_all(&mut db)?; + + Ok(funding_fee_events) +} + +/// Attempt to insert a list of unpaid funding fee events. Unpaid funding fee events that are +/// already in the database are ignored. +/// +/// Unpaid funding fee events that are confirmed to be new are returned. +pub fn insert_unpaid_funding_fee_events( + funding_fee_events: &[crate::trade::FundingFeeEvent], +) -> Result> { + let mut db = connection()?; + + let inserted_events = funding_fee_events + .iter() + .filter_map(|e| match UnpaidFundingFeeEvent::insert(&mut db, *e) { + Ok(event) => event, + Err(e) => { + tracing::error!(?e, "Failed to insert unpaid funding fee event"); + None + } + }) + .collect(); + + Ok(inserted_events) +} + +pub fn mark_funding_fee_events_as_paid( + contract_symbol: commons::ContractSymbol, + since: OffsetDateTime, +) -> Result<()> { + let mut db = connection()?; + + UnpaidFundingFeeEvent::mark_as_paid(&mut db, contract_symbol, since) + .context("Failed to mark funding fee events as paid")?; + + Ok(()) +} diff --git a/mobile/native/src/db/models.rs b/mobile/native/src/db/models.rs index bff8f00dc..a43cb3bda 100644 --- a/mobile/native/src/db/models.rs +++ b/mobile/native/src/db/models.rs @@ -13,7 +13,6 @@ use anyhow::Result; use bitcoin::Amount; use bitcoin::SignedAmount; use bitcoin::Txid; -use diesel; use diesel::prelude::*; use diesel::sql_types::Text; use diesel::AsExpression; @@ -31,6 +30,11 @@ use time::OffsetDateTime; use uuid::Uuid; use xxi_node::commons; +mod funding_fee_event; + +pub(crate) use funding_fee_event::FundingFeeEvent; +pub(crate) use funding_fee_event::UnpaidFundingFeeEvent; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Invalid id when converting string to uuid: {0}")] @@ -375,7 +379,7 @@ fn derive_order_state( Ok(state) } -#[derive(Queryable, QueryableByName, Insertable, Debug, Clone, PartialEq)] +#[derive(Queryable, AsChangeset, QueryableByName, Insertable, Debug, Clone, PartialEq)] #[diesel(table_name = positions)] pub(crate) struct Position { pub contract_symbol: ContractSymbol, @@ -420,6 +424,15 @@ impl Position { positions::table.load(conn) } + pub fn get_position( + conn: &mut SqliteConnection, + contract_symbol: ContractSymbol, + ) -> QueryResult { + positions::table + .filter(positions::contract_symbol.eq(contract_symbol)) + .first(conn) + } + /// Update the status of the [`Position`] identified by the given [`ContractSymbol`]. pub fn update_state( contract_symbol: ContractSymbol, @@ -442,58 +455,37 @@ impl Position { Ok(position) } - // sets the position to rollover and updates the new expiry timestamp. - pub fn rollover( - conn: &mut SqliteConnection, - contract_symbol: ContractSymbol, - expiry_timestamp: OffsetDateTime, - ) -> Result<()> { + /// Update the position after the rollover protocol has started. + pub fn start_rollover(conn: &mut SqliteConnection, updated_position: Position) -> Result<()> { let affected_rows = diesel::update(positions::table) - .filter(schema::positions::contract_symbol.eq(contract_symbol)) - .set(( - positions::expiry_timestamp.eq(expiry_timestamp.unix_timestamp()), - positions::state.eq(PositionState::Rollover), - positions::updated_timestamp.eq(OffsetDateTime::now_utc().unix_timestamp()), - )) + .filter(schema::positions::contract_symbol.eq(updated_position.contract_symbol)) + .set(updated_position) .execute(conn)?; - ensure!(affected_rows > 0, "Could not set position to rollover"); + if affected_rows == 0 { + bail!("Could not start rollover in DB"); + } Ok(()) } - /// Updates the status of the given order in the DB. - pub fn update_position(conn: &mut SqliteConnection, position: Position) -> Result<()> { - let Position { - contract_symbol, - leverage, - quantity, - direction, - average_entry_price, - liquidation_price, - state, - collateral, - creation_timestamp: _, - expiry_timestamp, - updated_timestamp, - order_matching_fees, - .. - } = position; + /// Update the position after the rollover protocol has ended. + pub fn finish_rollover(conn: &mut SqliteConnection, updated_position: Position) -> Result<()> { + let affected_rows = diesel::update(positions::table) + .filter(positions::state.eq(PositionState::Rollover)) + .set(updated_position) + .execute(conn)?; + + if affected_rows == 0 { + bail!("Could not finish rollover in DB"); + } + Ok(()) + } + + pub fn update_position(conn: &mut SqliteConnection, updated_position: Position) -> Result<()> { let affected_rows = diesel::update(positions::table) - .filter(schema::positions::contract_symbol.eq(contract_symbol)) - .set(( - positions::leverage.eq(leverage), - positions::quantity.eq(quantity), - positions::direction.eq(direction), - positions::average_entry_price.eq(average_entry_price), - positions::liquidation_price.eq(liquidation_price), - positions::state.eq(state), - positions::collateral.eq(collateral), - positions::expiry_timestamp.eq(expiry_timestamp), - positions::order_matching_fees.eq(order_matching_fees), - positions::updated_timestamp.eq(updated_timestamp), - )) + .set(updated_position) .execute(conn)?; if affected_rows == 0 { @@ -917,6 +909,8 @@ pub enum ChannelState { ForceClosedLocal, } +// TODO: Get rid of `Channel` and matching table in DB. + #[derive(Insertable, QueryableByName, Queryable, Debug, Clone, PartialEq, AsChangeset)] #[diesel(table_name = channels)] pub struct Channel { diff --git a/mobile/native/src/db/models/funding_fee_event.rs b/mobile/native/src/db/models/funding_fee_event.rs new file mode 100644 index 000000000..1f66b26c2 --- /dev/null +++ b/mobile/native/src/db/models/funding_fee_event.rs @@ -0,0 +1,220 @@ +use crate::db::models::ContractSymbol; +use crate::db::models::Direction; +use crate::schema::funding_fee_events; +use bitcoin::SignedAmount; +use diesel::prelude::*; +use diesel::Queryable; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use time::OffsetDateTime; +use xxi_node::commons; + +#[derive(Insertable, Debug, Clone, PartialEq)] +#[diesel(table_name = funding_fee_events)] +pub(crate) struct UnpaidFundingFeeEvent { + contract_symbol: ContractSymbol, + contracts: f32, + direction: Direction, + price: f32, + fee: i64, + due_date: i64, +} + +#[derive(Queryable, Debug, Clone, PartialEq)] +#[diesel(table_name = funding_fee_events)] +pub(crate) struct FundingFeeEvent { + id: i32, + contract_symbol: ContractSymbol, + contracts: f32, + direction: Direction, + price: f32, + fee: i64, + due_date: i64, + paid_date: Option, +} + +impl UnpaidFundingFeeEvent { + pub fn insert( + conn: &mut SqliteConnection, + funding_fee_event: crate::trade::FundingFeeEvent, + ) -> QueryResult> { + let affected_rows = diesel::insert_into(funding_fee_events::table) + .values(UnpaidFundingFeeEvent::from(funding_fee_event)) + .on_conflict(( + funding_fee_events::contract_symbol, + funding_fee_events::due_date, + )) + .do_nothing() + .execute(conn)?; + + if affected_rows >= 1 { + Ok(Some(funding_fee_event)) + } else { + Ok(None) + } + } + + pub fn mark_as_paid( + conn: &mut SqliteConnection, + contract_symbol: commons::ContractSymbol, + since: OffsetDateTime, + ) -> QueryResult<()> { + diesel::update(funding_fee_events::table) + .filter( + funding_fee_events::contract_symbol + .eq(ContractSymbol::from(contract_symbol)) + .and(funding_fee_events::due_date.ge(since.unix_timestamp())) + .and(funding_fee_events::paid_date.is_null()), + ) + .set(funding_fee_events::paid_date.eq(OffsetDateTime::now_utc().unix_timestamp())) + .execute(conn)?; + + Ok(()) + } +} + +impl FundingFeeEvent { + pub fn get_all(conn: &mut SqliteConnection) -> QueryResult> { + let funding_fee_events: Vec = funding_fee_events::table.load(conn)?; + + let funding_fee_events = funding_fee_events + .into_iter() + .map(crate::trade::FundingFeeEvent::from) + .collect(); + + Ok(funding_fee_events) + } +} + +impl From for UnpaidFundingFeeEvent { + fn from( + crate::trade::FundingFeeEvent { + contract_symbol, + contracts, + direction, + price, + fee, + due_date, + // An unpaid funding fee event should not have a `paid_date`. + paid_date: _, + }: crate::trade::FundingFeeEvent, + ) -> Self { + Self { + contract_symbol: contract_symbol.into(), + contracts: contracts.to_f32().expect("to fit"), + direction: direction.into(), + price: price.to_f32().expect("to fit"), + fee: fee.to_sat(), + due_date: due_date.unix_timestamp(), + } + } +} + +impl From for crate::trade::FundingFeeEvent { + fn from( + FundingFeeEvent { + id: _, + contract_symbol, + contracts, + direction, + price, + fee, + due_date, + paid_date, + }: FundingFeeEvent, + ) -> Self { + Self { + contract_symbol: contract_symbol.into(), + contracts: Decimal::try_from(contracts).expect("to fit"), + direction: direction.into(), + price: Decimal::try_from(price).expect("to fit"), + fee: SignedAmount::from_sat(fee), + due_date: OffsetDateTime::from_unix_timestamp(due_date).expect("valid"), + paid_date: paid_date + .map(OffsetDateTime::from_unix_timestamp) + .transpose() + .expect("valid"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::MIGRATIONS; + use diesel::Connection; + use diesel::SqliteConnection; + use diesel_migrations::MigrationHarness; + use itertools::Itertools; + use rust_decimal_macros::dec; + use time::ext::NumericalDuration; + use time::OffsetDateTime; + + #[test] + fn test_funding_fee_event() { + let mut conn = SqliteConnection::establish(":memory:").unwrap(); + conn.run_pending_migrations(MIGRATIONS).unwrap(); + + let contract_symbol = xxi_node::commons::ContractSymbol::BtcUsd; + let due_date = OffsetDateTime::from_unix_timestamp(1_546_300_800).unwrap(); + let funding_fee_event = crate::trade::FundingFeeEvent::unpaid( + contract_symbol, + Decimal::ONE_HUNDRED, + xxi_node::commons::Direction::Long, + dec!(70_000), + SignedAmount::from_sat(100), + due_date, + ); + + UnpaidFundingFeeEvent::insert(&mut conn, funding_fee_event).unwrap(); + + // Does nothing, since `contract_symbol` and `due_date` are the same. + UnpaidFundingFeeEvent::insert( + &mut conn, + crate::trade::FundingFeeEvent { + contracts: Decimal::ONE_THOUSAND, + direction: xxi_node::commons::Direction::Short, + price: dec!(35_000), + fee: SignedAmount::from_sat(-1_000), + ..funding_fee_event + }, + ) + .unwrap(); + + let funding_fee_event_2 = crate::trade::FundingFeeEvent::unpaid( + contract_symbol, + Decimal::ONE_HUNDRED, + xxi_node::commons::Direction::Long, + dec!(70_000), + SignedAmount::from_sat(100), + due_date - 60.minutes(), + ); + + UnpaidFundingFeeEvent::insert(&mut conn, funding_fee_event_2).unwrap(); + + let funding_fee_events = FundingFeeEvent::get_all(&mut conn).unwrap(); + + assert_eq!(funding_fee_events.len(), 2); + assert!(funding_fee_events.contains(&funding_fee_event)); + assert!(funding_fee_events.contains(&funding_fee_event_2)); + + // We only mark as paid the funding fee event which has a due date after the third argument + // to `mark_as_paid`. + UnpaidFundingFeeEvent::mark_as_paid(&mut conn, contract_symbol, due_date - 30.minutes()) + .unwrap(); + + let funding_fee_events = FundingFeeEvent::get_all(&mut conn).unwrap(); + + assert!(funding_fee_events + .iter() + .filter(|event| event.paid_date.is_some()) + .exactly_one() + .is_ok()); + + assert!(funding_fee_events + .iter() + .filter(|event| event.paid_date.is_none()) + .exactly_one() + .is_ok()); + } +} diff --git a/mobile/native/src/dlc/node.rs b/mobile/native/src/dlc/node.rs index ffac0ef14..9a4836464 100644 --- a/mobile/native/src/dlc/node.rs +++ b/mobile/native/src/dlc/node.rs @@ -4,13 +4,18 @@ use crate::event::BackgroundTask; use crate::event::EventInternal; use crate::event::TaskStatus; use crate::storage::TenTenOneNodeStorage; +use crate::trade::funding_fee_event::handler::handle_unpaid_funding_fee_events; +use crate::trade::funding_fee_event::handler::mark_funding_fee_events_as_paid; use crate::trade::order; use crate::trade::order::FailureReason; use crate::trade::order::InvalidSubchannelOffer; use crate::trade::position; +use crate::trade::position::handler::get_positions; +use crate::trade::position::handler::handle_rollover_offer; use crate::trade::position::handler::update_position_after_dlc_channel_creation_or_update; use crate::trade::position::handler::update_position_after_dlc_closure; -use crate::trade::position::PositionState; +use crate::trade::position::handler::update_position_after_rollover; +use crate::trade::FundingFeeEvent; use anyhow::anyhow; use anyhow::Context; use anyhow::Result; @@ -21,11 +26,13 @@ use dlc_messages::channel::OfferChannel; use dlc_messages::channel::Reject; use dlc_messages::channel::RenewOffer; use dlc_messages::channel::SettleOffer; +use itertools::Itertools; use lightning::chain::transaction::OutPoint; use lightning::sign::DelayedPaymentOutputDescriptor; use lightning::sign::SpendableOutputDescriptor; use lightning::sign::StaticPaymentOutputDescriptor; use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; @@ -370,7 +377,11 @@ impl Node { "Finished rollover protocol" ); - position::handler::set_position_state(PositionState::Open)?; + let position = update_position_after_rollover() + .context("Failed to update position after rollover protocol finished")?; + + mark_funding_fee_events_as_paid(position.contract_symbol, position.created) + .context("Failed to mark funding fee events as paid")?; event::publish(&EventInternal::BackgroundNotification( BackgroundTask::Rollover(TaskStatus::Success), @@ -682,17 +693,15 @@ impl Node { #[instrument(fields(channel_id = hex::encode(offer.renew_offer.channel_id)),skip_all, err(Debug))] pub fn process_renew_offer(&self, offer: &TenTenOneRenewOffer) -> Result<()> { // TODO(holzeis): We should check if the offered amounts are expected. - let expiry_timestamp = OffsetDateTime::from_unix_timestamp( - offer.renew_offer.contract_info.get_closest_maturity_date() as i64, - )?; let order_id = offer.filled_with.order_id; - self.set_order_to_filling(offer.filled_with.clone())?; let channel_id = offer.renew_offer.channel_id; match self.inner.dlc_manager.accept_renew_offer(&channel_id) { Ok((renew_accept, node_id)) => { - position::handler::handle_channel_renewal_offer(expiry_timestamp)?; + self.set_order_to_filling(offer.filled_with.clone())?; + + position::handler::handle_renew_offer()?; self.send_dlc_message( to_secp_pk_30(node_id), @@ -726,7 +735,6 @@ impl Node { #[instrument(fields(channel_id = hex::encode(offer.renew_offer.channel_id)),skip_all, err(Debug))] pub fn process_rollover_offer(&self, offer: &TenTenOneRolloverOffer) -> Result<()> { - tracing::info!("Received a rollover notification from orderbook."); event::publish(&EventInternal::BackgroundNotification( BackgroundTask::Rollover(TaskStatus::Pending), )); @@ -736,9 +744,30 @@ impl Node { )?; let channel_id = offer.renew_offer.channel_id; + match self.inner.dlc_manager.accept_renew_offer(&channel_id) { Ok((renew_accept, node_id)) => { - position::handler::handle_channel_renewal_offer(expiry_timestamp)?; + let positions = get_positions()?; + let position = positions.first().context("No position to roll over")?; + + let new_unpaid_funding_fee_events = handle_unpaid_funding_fee_events( + &offer + .funding_fee_events + .iter() + .map(|e| { + FundingFeeEvent::unpaid( + position.contract_symbol, + Decimal::try_from(position.quantity).expect("to fit"), + position.direction, + e.price, + e.funding_fee, + e.due_date, + ) + }) + .collect_vec(), + )?; + + handle_rollover_offer(expiry_timestamp, &new_unpaid_funding_fee_events)?; self.send_dlc_message( to_secp_pk_30(node_id), @@ -746,9 +775,10 @@ impl Node { )?; } Err(e) => { - tracing::error!("Failed to accept dlc channel rollover offer. {e:#}"); + tracing::error!("Failed to accept DLC channel rollover offer: {e}"); + event::publish(&EventInternal::BackgroundNotification( - BackgroundTask::Rollover(TaskStatus::Failed(format!("{e:#}"))), + BackgroundTask::Rollover(TaskStatus::Failed(format!("{e}"))), )); self.reject_rollover_offer(&channel_id)?; diff --git a/mobile/native/src/emergency_kit.rs b/mobile/native/src/emergency_kit.rs index 19dda00ab..3670bd281 100644 --- a/mobile/native/src/emergency_kit.rs +++ b/mobile/native/src/emergency_kit.rs @@ -138,7 +138,7 @@ pub fn recreate_position() -> Result<()> { stable: false, order_matching_fees: order.matching_fee().unwrap_or(Amount::ZERO), }; - db::insert_position(position.clone())?; + db::insert_position(position)?; event::publish(&EventInternal::PositionUpdateNotification(position)); diff --git a/mobile/native/src/event/api.rs b/mobile/native/src/event/api.rs index 1563e19a5..23f09b619 100644 --- a/mobile/native/src/event/api.rs +++ b/mobile/native/src/event/api.rs @@ -9,6 +9,7 @@ use crate::event::EventType; use crate::health::ServiceUpdate; use crate::trade::order::api::Order; use crate::trade::position::api::Position; +use crate::trade::trades::api::Trade; use core::convert::From; use flutter_rust_bridge::frb; use flutter_rust_bridge::StreamSink; @@ -32,6 +33,7 @@ pub enum Event { DlcChannelEvent(DlcChannel), FundingChannelNotification(FundingChannelTask), LnPaymentReceived { r_hash: String }, + NewTrade(Trade), } #[frb] @@ -94,6 +96,8 @@ impl From for Event { Event::FundingChannelNotification(status.into()) } EventInternal::LnPaymentReceived { r_hash } => Event::LnPaymentReceived { r_hash }, + EventInternal::NewTrade(trade) => Event::NewTrade(trade.into()), + EventInternal::FundingFeeEvent(event) => Event::NewTrade(event.into()), } } } @@ -134,6 +138,7 @@ impl Subscriber for FlutterSubscriber { EventType::FundingChannelNotification, EventType::Authenticated, EventType::DlcChannelEvent, + EventType::NewTrade, ] } } diff --git a/mobile/native/src/event/mod.rs b/mobile/native/src/event/mod.rs index 21313ad61..277a1e52a 100644 --- a/mobile/native/src/event/mod.rs +++ b/mobile/native/src/event/mod.rs @@ -5,6 +5,8 @@ use crate::event::subscriber::Subscriber; use crate::health::ServiceUpdate; use crate::trade::order::Order; use crate::trade::position::Position; +use crate::trade::FundingFeeEvent; +use crate::trade::Trade; use rust_decimal::Decimal; use std::fmt; use std::hash::Hash; @@ -41,6 +43,8 @@ pub enum EventInternal { DlcChannelEvent(DlcChannel), FundingChannelNotification(FundingChannelTask), LnPaymentReceived { r_hash: String }, + NewTrade(Trade), + FundingFeeEvent(FundingFeeEvent), } #[derive(Clone, Debug)] @@ -87,6 +91,8 @@ impl fmt::Display for EventInternal { EventInternal::BidPriceUpdateNotification(_) => "BidPriceUpdateNotification", EventInternal::FundingChannelNotification(_) => "FundingChannelNotification", EventInternal::LnPaymentReceived { .. } => "LnPaymentReceived", + EventInternal::NewTrade(_) => "NewTrade", + EventInternal::FundingFeeEvent(_) => "FundingFeeEvent", } .fmt(f) } @@ -112,6 +118,8 @@ impl From for EventType { EventInternal::BidPriceUpdateNotification(_) => EventType::BidPriceUpdateNotification, EventInternal::FundingChannelNotification(_) => EventType::FundingChannelNotification, EventInternal::LnPaymentReceived { .. } => EventType::LnPaymentReceived, + EventInternal::NewTrade(_) => EventType::NewTrade, + EventInternal::FundingFeeEvent(_) => EventType::NewTrade, } } } @@ -136,4 +144,5 @@ pub enum EventType { AskPriceUpdateNotification, BidPriceUpdateNotification, FundingChannelNotification, + NewTrade, } diff --git a/mobile/native/src/orderbook.rs b/mobile/native/src/orderbook.rs index 4dc718c09..3cf3e4ff3 100644 --- a/mobile/native/src/orderbook.rs +++ b/mobile/native/src/orderbook.rs @@ -6,14 +6,18 @@ use crate::event::EventInternal; use crate::event::TaskStatus; use crate::health::ServiceStatus; use crate::state; +use crate::trade::funding_fee_event; +use crate::trade::funding_fee_event::FundingFeeEvent; use crate::trade::order; use crate::trade::order::FailureReason; +use crate::trade::position; use anyhow::Context; use anyhow::Result; use bitcoin::secp256k1::SecretKey; use bitcoin::secp256k1::SECP256K1; use futures::SinkExt; use futures::TryStreamExt; +use itertools::Itertools; use parking_lot::Mutex; use rust_decimal::Decimal; use std::collections::HashMap; @@ -234,6 +238,29 @@ async fn handle_orderbook_message( update_both_prices_if_needed(cached_best_price, &orders); } + Message::AllFundingFeeEvents(funding_fee_events) => { + let funding_fee_events = funding_fee_events + .into_iter() + .map(FundingFeeEvent::from) + .collect_vec(); + + let new_funding_fee_events = + funding_fee_event::handler::handle_unpaid_funding_fee_events(&funding_fee_events) + .context("Failed to handle funding fee events from coordinator")?; + + position::handler::handle_funding_fee_events(&new_funding_fee_events) + .context("Failed to apply all funding fee events from coordinator")?; + } + Message::FundingFeeEvent(funding_fee_event) => { + let new_funding_fee_events = + funding_fee_event::handler::handle_unpaid_funding_fee_events(&[ + funding_fee_event.into() + ]) + .context("Failed to handle funding fee event from coordinator")?; + + position::handler::handle_funding_fee_events(&new_funding_fee_events) + .context("Failed to apply new funding fee event from coordinator")?; + } Message::DlcChannelCollaborativeRevert { channel_id, coordinator_address, diff --git a/mobile/native/src/schema.rs b/mobile/native/src/schema.rs index c1b943141..479ad3690 100644 --- a/mobile/native/src/schema.rs +++ b/mobile/native/src/schema.rs @@ -35,6 +35,19 @@ diesel::table! { } } +diesel::table! { + funding_fee_events (id) { + id -> Integer, + contract_symbol -> Text, + contracts -> Float, + direction -> Text, + price -> Float, + fee -> BigInt, + due_date -> BigInt, + paid_date -> Nullable, + } +} + diesel::table! { ignored_polls (id) { id -> Integer, @@ -108,6 +121,15 @@ diesel::table! { } } +diesel::table! { + rollover_params (protocol_id) { + protocol_id -> Text, + contract_symbol -> Text, + funding_fee_sat -> BigInt, + expiry -> BigInt, + } +} + diesel::table! { spendable_outputs (id) { id -> Integer, @@ -147,11 +169,13 @@ diesel::allow_tables_to_appear_in_same_query!( answered_polls, channels, dlc_messages, + funding_fee_events, ignored_polls, last_outbound_dlc_messages, orders, payments, positions, + rollover_params, spendable_outputs, trades, transactions, diff --git a/mobile/native/src/trade/funding_fee_event/handler.rs b/mobile/native/src/trade/funding_fee_event/handler.rs new file mode 100644 index 000000000..5154e2c68 --- /dev/null +++ b/mobile/native/src/trade/funding_fee_event/handler.rs @@ -0,0 +1,35 @@ +use crate::db; +use crate::event; +use crate::event::EventInternal; +use crate::trade::FundingFeeEvent; +use anyhow::Result; +use time::OffsetDateTime; +use xxi_node::commons::ContractSymbol; + +pub fn get_funding_fee_events() -> Result> { + db::get_all_funding_fee_events() +} + +/// Attempt to insert a list of unpaid funding fee events. Unpaid funding fee events that are +/// already in the database are ignored. +/// +/// Unpaid funding fee events that are confirmed to be new are propagated via an [`EventInternal`] +/// and returned. +pub fn handle_unpaid_funding_fee_events( + funding_fee_events: &[FundingFeeEvent], +) -> Result> { + let new_events = db::insert_unpaid_funding_fee_events(funding_fee_events)?; + + for event in new_events.iter() { + event::publish(&EventInternal::FundingFeeEvent(*event)); + } + + Ok(new_events) +} + +pub fn mark_funding_fee_events_as_paid( + contract_symbol: ContractSymbol, + since: OffsetDateTime, +) -> Result<()> { + db::mark_funding_fee_events_as_paid(contract_symbol, since) +} diff --git a/mobile/native/src/trade/funding_fee_event/mod.rs b/mobile/native/src/trade/funding_fee_event/mod.rs new file mode 100644 index 000000000..a66aedc19 --- /dev/null +++ b/mobile/native/src/trade/funding_fee_event/mod.rs @@ -0,0 +1,55 @@ +use bitcoin::SignedAmount; +use rust_decimal::Decimal; +use time::OffsetDateTime; +use xxi_node::commons::ContractSymbol; +use xxi_node::commons::Direction; + +pub mod handler; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FundingFeeEvent { + pub contract_symbol: ContractSymbol, + pub contracts: Decimal, + pub direction: Direction, + pub price: Decimal, + /// A positive amount indicates that the trader pays the coordinator; a negative amount + /// indicates that the coordinator pays the trader. + pub fee: SignedAmount, + pub due_date: OffsetDateTime, + pub paid_date: Option, +} + +impl FundingFeeEvent { + pub fn unpaid( + contract_symbol: ContractSymbol, + contracts: Decimal, + direction: Direction, + price: Decimal, + fee: SignedAmount, + due_date: OffsetDateTime, + ) -> Self { + Self { + contract_symbol, + contracts, + direction, + price, + fee, + due_date, + paid_date: None, + } + } +} + +impl From for FundingFeeEvent { + fn from(value: xxi_node::FundingFeeEvent) -> Self { + Self { + contract_symbol: value.contract_symbol, + contracts: value.contracts, + direction: value.direction, + price: value.price, + fee: value.fee, + due_date: value.due_date, + paid_date: None, + } + } +} diff --git a/mobile/native/src/trade/mod.rs b/mobile/native/src/trade/mod.rs index 9d0560eaf..59e4b829a 100644 --- a/mobile/native/src/trade/mod.rs +++ b/mobile/native/src/trade/mod.rs @@ -1,43 +1,8 @@ -use bitcoin::Amount; -use bitcoin::SignedAmount; -use rust_decimal::Decimal; -use time::OffsetDateTime; -use uuid::Uuid; -use xxi_node::commons::ContractSymbol; -use xxi_node::commons::Direction; - +pub mod funding_fee_event; pub mod order; pub mod position; +pub mod trades; pub mod users; -/// A trade is an event that moves funds between the DLC channel collateral reserve and a DLC -/// channel. -/// -/// Every trade is associated with a single market order, but an order can be associated with -/// multiple trades. -/// -/// If an order changes the direction of the underlying position, it must be split into _two_ -/// trades: one to close the original position and another one to open the new position in the -/// opposite direction. We do so to keep the model as simple as possible. -#[derive(Debug, Clone, PartialEq)] -pub struct Trade { - /// The executed order which resulted in this trade. - pub order_id: Uuid, - pub contract_symbol: ContractSymbol, - pub contracts: Decimal, - /// Direction of the associated order. - pub direction: Direction, - /// How many coins were moved between the DLC channel collateral reserve and the DLC. - /// - /// A positive value indicates that the money moved out of the reserve; a negative value - /// indicates that the money moved into the reserve. - pub trade_cost: SignedAmount, - pub fee: Amount, - /// If a position was reduced or closed because of this trade, how profitable it was. - /// - /// Set to [`None`] if the position was extended. - pub pnl: Option, - /// The price at which the associated order was executed. - pub price: Decimal, - pub timestamp: OffsetDateTime, -} +pub use funding_fee_event::FundingFeeEvent; +pub use trades::Trade; diff --git a/mobile/native/src/trade/position/handler.rs b/mobile/native/src/trade/position/handler.rs index b77bf64e2..a4482e09b 100644 --- a/mobile/native/src/trade/position/handler.rs +++ b/mobile/native/src/trade/position/handler.rs @@ -4,6 +4,8 @@ use crate::event::EventInternal; use crate::trade::order::Order; use crate::trade::position::Position; use crate::trade::position::PositionState; +use crate::trade::trades::handler::new_trade; +use crate::trade::FundingFeeEvent; use anyhow::bail; use anyhow::Context; use anyhow::Result; @@ -35,7 +37,7 @@ pub fn get_position_matching_order(order: &Order) -> Result> { Some(position) if position.direction != order.direction && position.quantity == order.quantity => { - Ok(Some(position.clone())) + Ok(Some(*position)) } _ => Ok(None), } @@ -51,47 +53,90 @@ pub fn set_position_state(state: PositionState) -> Result<()> { Ok(()) } -/// A channel renewal could be triggered to: -/// -/// - Roll over (no offer associated). -/// - Open a new position. -/// - Resize a position. -pub fn handle_channel_renewal_offer(expiry_timestamp: OffsetDateTime) -> Result<()> { +pub fn handle_renew_offer() -> Result<()> { if let Some(position) = db::get_positions()?.first() { - // Assume that if there is an order in filling we are dealing with position resizing. - // - // TODO: This has caused problems in the past. Any other ideas? We could generate - // `ProtocolId`s using `OrderId`s whenever possible on the coordinator, and compare the two - // values here to be sure. - if db::get_order_in_filling()?.is_some() { - tracing::debug!("Setting position to resizing"); - - let position = - db::update_position_state(position.contract_symbol, PositionState::Resizing)?; - - event::publish(&EventInternal::PositionUpdateNotification(position)); - } - // Without an order, we must be rolling over. - else { - tracing::debug!("Setting position to rollover"); - - db::rollover_position(position.contract_symbol, expiry_timestamp)?; + tracing::debug!("Received renew offer to resize position"); - let mut position = position.clone(); - position.position_state = PositionState::Rollover; - position.expiry = expiry_timestamp; + let position = + db::update_position_state(position.contract_symbol, PositionState::Resizing)?; - event::publish(&EventInternal::PositionUpdateNotification(position)); - } + event::publish(&EventInternal::PositionUpdateNotification(position)); } else { // If we have no position, we must be opening a new one. - tracing::info!("Received channel renewal proposal to open new position"); + tracing::info!("Received renew offer to open new position"); + } + + Ok(()) +} + +pub fn handle_rollover_offer( + expiry_timestamp: OffsetDateTime, + funding_fee_events: &[FundingFeeEvent], +) -> Result<()> { + tracing::debug!("Setting position state to rollover"); + + let positions = &db::get_positions()?; + let position = positions.first().context("No position to roll over")?; + + // TODO: Update the `expiry_timestamp` only after the rollover protocol is finished. We only do + // it so that we don't have to store the `expiry_timestamp` in the database. + let position = position + .start_rollover(expiry_timestamp) + .apply_funding_fee_events(funding_fee_events)?; + + db::start_position_rollover(position)?; + + event::publish(&EventInternal::PositionUpdateNotification(position)); + + Ok(()) +} + +/// Update position after completing rollover protocol. +pub fn update_position_after_rollover() -> Result { + tracing::debug!("Setting position state from rollover back to open"); + + let positions = &db::get_positions()?; + let position = positions + .first() + .context("No position to finish rollover")?; + + let position = position.finish_rollover(); + + db::finish_position_rollover(position)?; + + event::publish(&EventInternal::PositionUpdateNotification(position)); + + Ok(position) +} + +/// The app will sometimes receive [`FundingFeeEvent`]s from the coordinator which are not directly +/// linked to a channel update. These need to be applied to the [`Position`] to keep it in sync with +/// the coordinator. +pub fn handle_funding_fee_events(funding_fee_events: &[FundingFeeEvent]) -> Result<()> { + if funding_fee_events.is_empty() { + return Ok(()); } + tracing::debug!( + ?funding_fee_events, + "Applying funding fee events to position" + ); + + let positions = &db::get_positions()?; + let position = positions + .first() + .context("No position to apply funding fee events")?; + + let position = position.apply_funding_fee_events(funding_fee_events)?; + + db::update_position(position)?; + + event::publish(&EventInternal::PositionUpdateNotification(position)); + Ok(()) } -/// Create a position after creating or updating a DLC channel. +/// Create or insert a position after filling an order. pub fn update_position_after_dlc_channel_creation_or_update( filled_order: Order, expiry: OffsetDateTime, @@ -109,7 +154,7 @@ pub fn update_position_after_dlc_channel_creation_or_update( tracing::info!(?trade, ?position, "Position created"); - db::insert_position(position.clone())?; + db::insert_position(position)?; (position, vec![trade]) } @@ -121,11 +166,11 @@ pub fn update_position_after_dlc_channel_creation_or_update( ) => { tracing::info!("Calculating new position after DLC channel has been resized"); - let (position, trades) = position.clone().apply_order(filled_order, expiry)?; + let (position, trades) = position.apply_order(filled_order, expiry)?; let position = position.context("Resized position has vanished")?; - db::update_position(position.clone())?; + db::update_position(position)?; (position, trades) } @@ -138,7 +183,7 @@ pub fn update_position_after_dlc_channel_creation_or_update( }; for trade in trades { - db::insert_trade(trade)?; + new_trade(trade)?; } event::publish(&EventInternal::PositionUpdateNotification(position)); @@ -150,11 +195,12 @@ pub fn update_position_after_dlc_channel_creation_or_update( pub fn update_position_after_dlc_closure(filled_order: Order) -> Result<()> { tracing::debug!(?filled_order, "Removing position after DLC channel closure"); - let position = match db::get_positions()?.as_slice() { - [position] => position.clone(), + let positions = &db::get_positions()?; + let position = match positions.as_slice() { + [position] => position, [position, ..] => { tracing::warn!("Found more than one position. Taking the first one"); - position.clone() + position } [] => { tracing::warn!("No position to remove"); @@ -182,7 +228,7 @@ pub fn update_position_after_dlc_closure(filled_order: Order) -> Result<()> { } for trade in trades { - db::insert_trade(trade)?; + new_trade(trade)?; } db::delete_positions()?; diff --git a/mobile/native/src/trade/position/mod.rs b/mobile/native/src/trade/position/mod.rs index 35f934457..0b7a3819b 100644 --- a/mobile/native/src/trade/position/mod.rs +++ b/mobile/native/src/trade/position/mod.rs @@ -4,9 +4,11 @@ use crate::get_maintenance_margin_rate; use crate::trade::order::Order; use crate::trade::order::OrderState; use crate::trade::order::OrderType; +use crate::trade::FundingFeeEvent; use crate::trade::Trade; use anyhow::bail; use anyhow::ensure; +use anyhow::Context; use anyhow::Result; use bitcoin::Amount; use bitcoin::SignedAmount; @@ -16,9 +18,11 @@ use rust_decimal::Decimal; use rust_decimal::RoundingStrategy; use serde::Serialize; use time::OffsetDateTime; +use xxi_node::cfd::calculate_leverage; use xxi_node::commons; use xxi_node::commons::ContractSymbol; use xxi_node::commons::Direction; +use xxi_node::node::ProtocolId; pub mod api; pub mod handler; @@ -59,7 +63,7 @@ pub enum PositionState { Resizing, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Copy, Serialize)] pub struct Position { pub leverage: f32, pub quantity: f32, @@ -557,6 +561,71 @@ impl Position { position.apply_order_recursive(order, expiry, leftover_matching_fee, trades) } + + /// Apply [`FundingFeeEvent`]s to a [`Position`]. + fn apply_funding_fee_events(self, funding_fee_events: &[FundingFeeEvent]) -> Result { + let funding_fee: SignedAmount = funding_fee_events.iter().map(|event| event.fee).sum(); + + let (collateral, leverage, liquidation_price) = if funding_fee.is_positive() { + // Trader pays. + + let collateral = self + .collateral + .checked_sub(funding_fee.to_sat() as u64) + .context("Cannot cover funding fee with margin")?; + let collateral = Amount::from_sat(collateral); + + let leverage = { + let quantity = Decimal::try_from(self.quantity).expect("to fit"); + let average_entry_price = + Decimal::try_from(self.average_entry_price).expect("to fit"); + + let leverage = calculate_leverage(quantity, collateral, average_entry_price)?; + + leverage.to_f32().expect("to fit") + }; + + let maintenance_margin_rate = get_maintenance_margin_rate(); + let liquidation_price = calculate_liquidation_price( + self.average_entry_price, + leverage, + self.direction, + maintenance_margin_rate, + ); + + (collateral.to_sat(), leverage, liquidation_price) + } else { + // Coordinator pays. + (self.collateral, self.leverage, self.liquidation_price) + }; + + Ok(Self { + collateral, + leverage, + liquidation_price, + updated: OffsetDateTime::now_utc(), + ..self + }) + } + + /// Start rollover protocol. + fn start_rollover(self, expiry: OffsetDateTime) -> Self { + Self { + expiry, + position_state: PositionState::Rollover, + updated: OffsetDateTime::now_utc(), + ..self + } + } + + /// Finish rollover protocol. + fn finish_rollover(self) -> Self { + Self { + position_state: PositionState::Open, + updated: OffsetDateTime::now_utc(), + ..self + } + } } /// The _cost_ of a trade is computed as the change in margin (positive if the margin _increases_), @@ -593,6 +662,21 @@ fn compute_relative_contracts(contracts: f32, direction: Direction) -> Decimal { } } +/// The rollover parameters can be stored after receiving a [`TenTenOneRolloverOffer`], so that they +/// can be used to modify the [`Position`] after the rollover has been finalized. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct RolloverParams { + pub protocol_id: ProtocolId, + /// The contract symbol identifies the position, since we can only have one position per + /// contract symbol. + pub contract_symbol: ContractSymbol, + /// The sign determines who pays who. A positive signs denotes that the trader pays the + /// coordinator. A negative sign denotes that the coordinator pays the trader. + pub funding_fee: SignedAmount, + /// Rolling over sets a new expiry time. + pub expiry: OffsetDateTime, +} + #[cfg(test)] mod tests { use super::*; @@ -748,7 +832,7 @@ mod tests { failure_reason: None, }; - let (updated_position, trades) = position.clone().apply_order(order.clone(), now).unwrap(); + let (updated_position, trades) = position.apply_order(order.clone(), now).unwrap(); let updated_position = updated_position.unwrap(); assert_eq!(updated_position.leverage, 2.0); @@ -818,7 +902,7 @@ mod tests { failure_reason: None, }; - let (updated_position, trades) = position.clone().apply_order(order.clone(), now).unwrap(); + let (updated_position, trades) = position.apply_order(order.clone(), now).unwrap(); let updated_position = updated_position.unwrap(); assert_eq!(updated_position.leverage, 2.0); @@ -894,7 +978,7 @@ mod tests { failure_reason: None, }; - let (updated_position, trades) = position.clone().apply_order(order.clone(), now).unwrap(); + let (updated_position, trades) = position.apply_order(order.clone(), now).unwrap(); let updated_position = updated_position.unwrap(); assert_eq!(updated_position.leverage, 2.0); diff --git a/mobile/native/src/trade/trades/api.rs b/mobile/native/src/trade/trades/api.rs new file mode 100644 index 000000000..c0c4dc798 --- /dev/null +++ b/mobile/native/src/trade/trades/api.rs @@ -0,0 +1,62 @@ +use bitcoin::SignedAmount; +use flutter_rust_bridge::frb; +use rust_decimal::prelude::ToPrimitive; +use xxi_node::commons::ContractSymbol; +use xxi_node::commons::Direction; + +// TODO: Include fee rate. +#[frb] +#[derive(Debug, Clone)] +pub struct Trade { + pub trade_type: TradeType, + pub contract_symbol: ContractSymbol, + pub contracts: f32, + pub price: f32, + /// Either a funding fee or an order-matching fee. + pub fee: i64, + /// Direction of the associated order. + pub direction: Direction, + /// Some trades may have a PNL associated with them. + pub pnl: Option, + pub timestamp: i64, + pub is_done: bool, +} + +#[frb] +#[derive(Debug, Clone)] +pub enum TradeType { + Funding, + Trade, +} + +impl From for Trade { + fn from(value: crate::trade::Trade) -> Self { + Self { + trade_type: TradeType::Trade, + contract_symbol: value.contract_symbol, + contracts: value.contracts.to_f32().expect("to fit"), + price: value.price.to_f32().expect("to fit"), + fee: value.fee.to_sat() as i64, + direction: value.direction, + pnl: value.pnl.map(SignedAmount::to_sat), + timestamp: value.timestamp.unix_timestamp(), + is_done: true, + } + } +} + +impl From for Trade { + fn from(value: crate::trade::FundingFeeEvent) -> Self { + Self { + trade_type: TradeType::Funding, + contract_symbol: value.contract_symbol, + contracts: value.contracts.to_f32().expect("to fit"), + price: value.price.to_f32().expect("to fit"), + fee: value.fee.to_sat(), + direction: value.direction, + pnl: None, + timestamp: value.due_date.unix_timestamp(), + is_done: value.paid_date.is_some(), + } + } +} diff --git a/mobile/native/src/trade/trades/handler.rs b/mobile/native/src/trade/trades/handler.rs new file mode 100644 index 000000000..93abe2221 --- /dev/null +++ b/mobile/native/src/trade/trades/handler.rs @@ -0,0 +1,17 @@ +use crate::db; +use crate::event; +use crate::event::EventInternal; +use crate::trade::Trade; +use anyhow::Result; + +pub fn new_trade(trade: Trade) -> Result<()> { + db::insert_trade(trade)?; + + event::publish(&EventInternal::NewTrade(trade)); + + Ok(()) +} + +pub fn get_trades() -> Result> { + db::get_all_trades() +} diff --git a/mobile/native/src/trade/trades/mod.rs b/mobile/native/src/trade/trades/mod.rs new file mode 100644 index 000000000..d3c41b662 --- /dev/null +++ b/mobile/native/src/trade/trades/mod.rs @@ -0,0 +1,42 @@ +use bitcoin::Amount; +use bitcoin::SignedAmount; +use rust_decimal::Decimal; +use time::OffsetDateTime; +use uuid::Uuid; +use xxi_node::commons::ContractSymbol; +use xxi_node::commons::Direction; + +pub mod api; +pub mod handler; + +/// A trade is an event that moves funds between the DLC channel collateral reserve and a DLC +/// channel. +/// +/// Every trade is associated with a single market order, but an order can be associated with +/// multiple trades. +/// +/// If an order changes the direction of the underlying position, it must be split into _two_ +/// trades: one to close the original position and another one to open the new position in the +/// opposite direction. We do so to keep the model as simple as possible. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Trade { + /// The executed order which resulted in this trade. + pub order_id: Uuid, + pub contract_symbol: ContractSymbol, + pub contracts: Decimal, + /// Direction of the associated order. + pub direction: Direction, + /// How many coins were moved between the DLC channel collateral reserve and the DLC. + /// + /// A positive value indicates that the money moved out of the reserve; a negative value + /// indicates that the money moved into the reserve. + pub trade_cost: SignedAmount, + pub fee: Amount, + /// If a position was reduced or closed because of this trade, how profitable it was. + /// + /// Set to [`None`] if the position was extended. + pub pnl: Option, + /// The price at which the associated order was executed. + pub price: Decimal, + pub timestamp: OffsetDateTime, +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5b1e7757f..d440fe325 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1229,6 +1229,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.1" + timeago_flutter: + dependency: "direct main" + description: + name: timeago_flutter + sha256: "8faa66867090d37f7ccb9489bd6d083d14a588005eafa3fadf2d1c86760dbc27" + url: "https://pub.dev" + source: hosted + version: "3.6.0" timezone: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9bb166a50..5d7c755a1 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: logger: ^2.0.2+1 carousel_slider: ^4.2.1 http: ^1.0.0 - timeago: ^3.3.0 + timeago: ^3.6.1 shared_preferences: ^2.1.2 package_info_plus: ^4.0.2 uuid: ^3.0.7 @@ -51,6 +51,7 @@ dependencies: syncfusion_flutter_core: ^24.2.9 html: ^0.15.4 flutter_inappwebview: ^6.0.0-beta.23 + timeago_flutter: ^3.6.0 dev_dependencies: analyzer: ^6.4.1 flutter_launcher_icons: ^0.13.1 From 4dce21463e321222dc998af85402f49431c62b37 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Wed, 29 May 2024 14:24:11 +1000 Subject: [PATCH 04/12] feat(coordinator): Apply funding fee events on position settlement --- coordinator/src/db/funding_fee_events.rs | 1 - coordinator/src/dlc_protocol.rs | 67 +++++++-- coordinator/src/lib.rs | 7 + coordinator/src/node/rollover.rs | 184 ++++------------------- coordinator/src/position/models.rs | 118 ++++++++++++++- coordinator/src/routes/admin.rs | 2 +- coordinator/src/scheduler.rs | 2 +- coordinator/src/trade/mod.rs | 58 ++++++- crates/xxi-node/src/commons/trade.rs | 2 +- 9 files changed, 258 insertions(+), 183 deletions(-) diff --git a/coordinator/src/db/funding_fee_events.rs b/coordinator/src/db/funding_fee_events.rs index 932d8e342..27325c9d4 100644 --- a/coordinator/src/db/funding_fee_events.rs +++ b/coordinator/src/db/funding_fee_events.rs @@ -114,7 +114,6 @@ pub(crate) fn get_for_active_trader_positions( /// TODO: Use outstanding fees when: /// /// - Deciding if positions need to be liquidated. -/// - Closing a position. /// - Resizing a position. pub(crate) fn get_outstanding_fees( conn: &mut PgConnection, diff --git a/coordinator/src/dlc_protocol.rs b/coordinator/src/dlc_protocol.rs index 25d6a9f61..2a8f02c30 100644 --- a/coordinator/src/dlc_protocol.rs +++ b/coordinator/src/dlc_protocol.rs @@ -36,7 +36,7 @@ pub struct DlcProtocol { pub protocol_type: DlcProtocolType, } -#[derive(Clone, Debug)] +#[derive(Clone, Copy, Debug)] pub struct TradeParams { pub protocol_id: ProtocolId, pub trader: PublicKey, @@ -202,12 +202,14 @@ impl DlcProtocolExecutor { match protocol_type { DlcProtocolType::OpenChannel { trade_params } | DlcProtocolType::OpenPosition { trade_params } - | DlcProtocolType::ResizePosition { trade_params } - | DlcProtocolType::Settle { trade_params } => { + | DlcProtocolType::ResizePosition { trade_params } => { db::trade_params::insert(conn, &trade_params)?; } - DlcProtocolType::Rollover { rollover_params } => { - db::rollover_params::insert(conn, &rollover_params)?; + DlcProtocolType::Settle { .. } => { + unimplemented!("Use dedicated start_settle_protocol method") + } + DlcProtocolType::Rollover { .. } => { + unimplemented!("Use dedicated start_rollover method") } _ => {} } @@ -218,6 +220,39 @@ impl DlcProtocolExecutor { Ok(()) } + pub fn start_settle_protocol( + &self, + protocol_id: ProtocolId, + previous_protocol_id: Option, + contract_id: Option<&ContractId>, + channel_id: &DlcChannelId, + trade_params: &commons::TradeParams, + funding_fee_event_ids: Vec, + ) -> Result<()> { + let mut conn = self.pool.get()?; + conn.transaction(|conn| { + let trader_pubkey = trade_params.pubkey; + + db::dlc_protocols::create( + conn, + protocol_id, + previous_protocol_id, + contract_id, + channel_id, + db::dlc_protocols::DlcProtocolType::Settle, + &trader_pubkey, + )?; + + db::protocol_funding_fee_events::insert(conn, protocol_id, &funding_fee_event_ids)?; + + db::trade_params::insert(conn, &TradeParams::new(trade_params, protocol_id, None))?; + + diesel::result::QueryResult::Ok(()) + })?; + + Ok(()) + } + /// Persist a new rollover protocol and update technical tables in a single transaction. pub fn start_rollover( &self, @@ -300,7 +335,7 @@ impl DlcProtocolExecutor { } DlcProtocolType::Settle { trade_params } => { let settled_contract = dlc_protocol.contract_id; - self.finish_close_trade_dlc_protocol( + self.finish_settle_dlc_protocol( conn, trade_params, protocol_id, @@ -362,13 +397,17 @@ impl DlcProtocolExecutor { Ok(()) } - /// Completes the close trade dlc protocol as successful and updates the 10101 meta data - /// accordingly in a single database transaction. - /// - Set dlc protocol to success - /// - Calculates the pnl and sets the `[PositionState::Closing`] position state to - /// `[PositionState::Closed`] - /// - Creates and inserts the new trade - fn finish_close_trade_dlc_protocol( + /// Complete the settle DLC protocol as successful and update the 10101 metadata accordingly in + /// a single database transaction. + /// + /// - Set settle DLC protocol to success. + /// + /// - Calculate the PNL and update the `[PositionState::Closing`] to `[PositionState::Closed`]. + /// + /// - Create and insert new trade. + /// + /// - Mark relevant funding fee events as paid. + fn finish_settle_dlc_protocol( &self, conn: &mut PgConnection, trade_params: &TradeParams, @@ -457,6 +496,8 @@ impl DlcProtocolExecutor { db::trades::insert(conn, new_trade)?; + db::funding_fee_events::mark_as_paid(conn, protocol_id)?; + Ok(()) } diff --git a/coordinator/src/lib.rs b/coordinator/src/lib.rs index 3d2416ff6..15791d193 100644 --- a/coordinator/src/lib.rs +++ b/coordinator/src/lib.rs @@ -118,6 +118,13 @@ pub struct ChannelOpeningParams { pub external_funding: Option, } +#[derive(Debug, Clone, Copy)] +pub enum FundingFee { + Zero, + CoordinatorPays(Amount), + TraderPays(Amount), +} + /// Remove minutes, seconds and nano seconds from a given [`OffsetDateTime`]. pub fn to_nearest_hour_in_the_past(start_date: OffsetDateTime) -> OffsetDateTime { OffsetDateTime::new_utc( diff --git a/coordinator/src/node/rollover.rs b/coordinator/src/node/rollover.rs index d79c17449..f6ddfccc0 100644 --- a/coordinator/src/node/rollover.rs +++ b/coordinator/src/node/rollover.rs @@ -10,6 +10,7 @@ use crate::notifications::NotificationKind; use crate::payout_curve::build_contract_descriptor; use crate::position::models::Position; use crate::position::models::PositionState; +use crate::FundingFee; use anyhow::bail; use anyhow::Context; use anyhow::Result; @@ -28,18 +29,13 @@ use dlc_manager::contract::Contract; use dlc_manager::DlcChannelId; use futures::future::RemoteHandle; use futures::FutureExt; -use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use time::OffsetDateTime; use tokio::sync::broadcast; use tokio::sync::broadcast::error::RecvError; use tokio::sync::mpsc; use tokio::task::spawn_blocking; -use xxi_node::cfd::calculate_leverage; -use xxi_node::cfd::calculate_long_liquidation_price; -use xxi_node::cfd::calculate_short_liquidation_price; use xxi_node::commons; -use xxi_node::commons::Direction; use xxi_node::node::event::NodeEvent; use xxi_node::node::ProtocolId; @@ -88,133 +84,6 @@ pub fn monitor( remote_handle } -/// The [`Position`] values that can change after a rollover. -struct RolledOverPosition { - margin_coordinator: Amount, - margin_trader: Amount, - collateral_reserve_coordinator: Amount, - collateral_reserve_trader: Amount, - leverage_coordinator: Decimal, - leverage_trader: Decimal, - liquidation_price_coordinator: Decimal, - liquidation_price_trader: Decimal, -} - -fn apply_rollover_to_position( - position: &Position, - collateral_reserve_coordinator: Amount, - collateral_reserve_trader: Amount, - funding_fee: FundingFee, - maintenance_margin_rate: Decimal, -) -> RolledOverPosition { - let quantity = decimal_from_f32(position.quantity); - let average_entry_price = decimal_from_f32(position.average_entry_price); - - match funding_fee { - FundingFee::Zero => RolledOverPosition { - margin_coordinator: position.coordinator_margin, - margin_trader: position.trader_margin, - collateral_reserve_coordinator, - collateral_reserve_trader, - leverage_coordinator: decimal_from_f32(position.coordinator_leverage), - leverage_trader: decimal_from_f32(position.trader_leverage), - liquidation_price_coordinator: decimal_from_f32(position.coordinator_liquidation_price), - liquidation_price_trader: decimal_from_f32(position.trader_liquidation_price), - }, - FundingFee::CoordinatorPays(funding_fee) => { - let funding_fee = funding_fee.to_signed().expect("to fit"); - - let margin_coordinator = position.coordinator_margin.to_signed().expect("to fit"); - let new_margin_coordinator = margin_coordinator - funding_fee; - let new_margin_coordinator = new_margin_coordinator.to_unsigned().expect("to fit"); - - let collateral_reserve_trader = collateral_reserve_trader.to_signed().expect("to fit"); - let new_collateral_reserve_trader = collateral_reserve_trader + funding_fee; - let new_collateral_reserve_trader = - new_collateral_reserve_trader.to_unsigned().expect("to fit"); - - let new_leverage_coordinator = - calculate_leverage(quantity, new_margin_coordinator, average_entry_price) - .expect("valid leverage"); - - let new_coordinator_liquidation_price = match position.trader_direction { - Direction::Long => calculate_short_liquidation_price( - new_leverage_coordinator, - average_entry_price, - maintenance_margin_rate, - ), - Direction::Short => calculate_long_liquidation_price( - new_leverage_coordinator, - average_entry_price, - maintenance_margin_rate, - ), - }; - - RolledOverPosition { - margin_coordinator: new_margin_coordinator, - margin_trader: position.trader_margin, - collateral_reserve_coordinator, - collateral_reserve_trader: new_collateral_reserve_trader, - leverage_coordinator: new_leverage_coordinator, - leverage_trader: decimal_from_f32(position.trader_leverage), - liquidation_price_coordinator: new_coordinator_liquidation_price, - liquidation_price_trader: decimal_from_f32(position.trader_liquidation_price), - } - } - FundingFee::TraderPays(funding_fee) => { - let funding_fee = funding_fee.to_signed().expect("to fit"); - - let margin_trader = position.trader_margin.to_signed().expect("to fit"); - let new_margin_trader = margin_trader - funding_fee; - let new_margin_trader = new_margin_trader.to_unsigned().expect("to fit"); - - let collateral_reserve_coordinator = - collateral_reserve_coordinator.to_signed().expect("to fit"); - let new_collateral_reserve_coordinator = collateral_reserve_coordinator + funding_fee; - let new_collateral_reserve_coordinator = new_collateral_reserve_coordinator - .to_unsigned() - .expect("to fit"); - - let new_leverage_trader = - calculate_leverage(quantity, new_margin_trader, average_entry_price) - .expect("valid leverage"); - - let new_trader_liquidation_price = match position.trader_direction { - Direction::Long => calculate_long_liquidation_price( - new_leverage_trader, - average_entry_price, - maintenance_margin_rate, - ), - Direction::Short => calculate_short_liquidation_price( - new_leverage_trader, - average_entry_price, - maintenance_margin_rate, - ), - }; - - RolledOverPosition { - margin_coordinator: position.coordinator_margin, - margin_trader: new_margin_trader, - collateral_reserve_coordinator: new_collateral_reserve_coordinator, - collateral_reserve_trader, - leverage_coordinator: decimal_from_f32(position.coordinator_leverage), - leverage_trader: new_leverage_trader, - liquidation_price_coordinator: decimal_from_f32( - position.coordinator_liquidation_price, - ), - liquidation_price_trader: new_trader_liquidation_price, - } - } - } -} - -#[derive(Debug, Clone, Copy)] -enum FundingFee { - Zero, - CoordinatorPays(Amount), - TraderPays(Amount), -} - impl Node { async fn check_if_eligible_for_rollover( &self, @@ -247,14 +116,14 @@ impl Node { None => return Ok(()), }; - self.check_rollover(&mut conn, &position, network, ¬ifier, None) + self.check_rollover(&mut conn, position, network, ¬ifier, None) .await } pub async fn check_rollover( &self, connection: &mut PooledConnection>, - position: &Position, + position: Position, network: Network, notifier: &mpsc::Sender, notification: Option, @@ -307,7 +176,7 @@ impl Node { &self, conn: &mut PooledConnection>, dlc_channel_id: &DlcChannelId, - position: &Position, + position: Position, network: Network, ) -> Result<()> { let trader_pubkey = position.trader; @@ -373,31 +242,30 @@ impl Node { n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), }; - let rolled_over_position = apply_rollover_to_position( - position, - collateral_reserve_coordinator, - collateral_reserve_trader, - funding_fee, - maintenance_margin_rate, - ); + let (position, collateral_reserve_coordinator, collateral_reserve_trader) = position + .apply_funding_fee_to_position( + collateral_reserve_coordinator, + collateral_reserve_trader, + funding_fee, + maintenance_margin_rate, + ); - let RolledOverPosition { - margin_coordinator, - margin_trader, - collateral_reserve_coordinator, - collateral_reserve_trader, - leverage_coordinator, - leverage_trader, - liquidation_price_coordinator, - liquidation_price_trader, - } = rolled_over_position; + let Position { + coordinator_margin: margin_coordinator, + trader_margin: margin_trader, + coordinator_leverage: leverage_coordinator, + trader_leverage: leverage_trader, + coordinator_liquidation_price: liquidation_price_coordinator, + trader_liquidation_price: liquidation_price_trader, + .. + } = position; let contract_descriptor = build_contract_descriptor( Decimal::try_from(position.average_entry_price).expect("to fit"), margin_coordinator, margin_trader, - leverage_coordinator.to_f32().expect("to fit"), - leverage_trader.to_f32().expect("to fit"), + leverage_coordinator, + leverage_trader, position.trader_direction, collateral_reserve_coordinator, collateral_reserve_trader, @@ -468,10 +336,10 @@ impl Node { trader_pubkey, margin_coordinator, margin_trader, - leverage_coordinator, - leverage_trader, - liquidation_price_coordinator, - liquidation_price_trader, + leverage_coordinator: decimal_from_f32(leverage_coordinator), + leverage_trader: decimal_from_f32(leverage_trader), + liquidation_price_coordinator: decimal_from_f32(liquidation_price_coordinator), + liquidation_price_trader: decimal_from_f32(liquidation_price_trader), expiry_timestamp: next_expiry, }, funding_fee_event_ids, diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index 79411e65b..d70a01b70 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -1,5 +1,7 @@ use crate::compute_relative_contracts; use crate::decimal_from_f32; +use crate::f32_from_decimal; +use crate::FundingFee; use anyhow::bail; use anyhow::Context; use anyhow::Result; @@ -15,8 +17,11 @@ use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use time::OffsetDateTime; use xxi_node::bitmex_client::Quote; +use xxi_node::cfd::calculate_leverage; +use xxi_node::cfd::calculate_long_liquidation_price; use xxi_node::cfd::calculate_margin; use xxi_node::cfd::calculate_pnl; +use xxi_node::cfd::calculate_short_liquidation_price; use xxi_node::commons::ContractSymbol; use xxi_node::commons::Direction; use xxi_node::commons::TradeParams; @@ -40,7 +45,7 @@ pub struct NewPosition { pub order_matching_fees: Amount, } -#[derive(Clone, PartialEq, Debug)] +#[derive(Clone, Copy, PartialEq, Debug)] pub enum PositionState { /// The position is in the process of being opened. /// @@ -63,7 +68,7 @@ pub enum PositionState { } /// The trading position for a user identified by `trader`. -#[derive(Clone)] +#[derive(Clone, Copy)] pub struct Position { pub id: i32, pub trader: PublicKey, @@ -197,6 +202,115 @@ impl Position { trade_params.average_execution_price(), ) } + + pub fn apply_funding_fee_to_position( + self, + collateral_reserve_coordinator: Amount, + collateral_reserve_trader: Amount, + funding_fee: FundingFee, + maintenance_margin_rate: Decimal, + ) -> (Self, Amount, Amount) { + let quantity = decimal_from_f32(self.quantity); + let average_entry_price = decimal_from_f32(self.average_entry_price); + + match funding_fee { + FundingFee::Zero => ( + self, + collateral_reserve_coordinator, + collateral_reserve_trader, + ), + FundingFee::CoordinatorPays(funding_fee) => { + let funding_fee = funding_fee.to_signed().expect("to fit"); + + let coordinator_margin = self.coordinator_margin.to_signed().expect("to fit"); + let new_coordinator_margin = coordinator_margin - funding_fee; + let new_coordinator_margin = new_coordinator_margin.to_unsigned().expect("to fit"); + + let collateral_reserve_trader = + collateral_reserve_trader.to_signed().expect("to fit"); + let new_collateral_reserve_trader = collateral_reserve_trader + funding_fee; + let new_collateral_reserve_trader = + new_collateral_reserve_trader.to_unsigned().expect("to fit"); + + let new_coordinator_leverage = + calculate_leverage(quantity, new_coordinator_margin, average_entry_price) + .expect("valid leverage"); + + let new_coordinator_liquidation_price = match self.trader_direction { + Direction::Long => calculate_short_liquidation_price( + new_coordinator_leverage, + average_entry_price, + maintenance_margin_rate, + ), + Direction::Short => calculate_long_liquidation_price( + new_coordinator_leverage, + average_entry_price, + maintenance_margin_rate, + ), + }; + + let position = Self { + coordinator_margin: new_coordinator_margin, + coordinator_leverage: f32_from_decimal(new_coordinator_leverage), + coordinator_liquidation_price: f32_from_decimal( + new_coordinator_liquidation_price, + ), + ..self + }; + + ( + position, + collateral_reserve_coordinator, + new_collateral_reserve_trader, + ) + } + FundingFee::TraderPays(funding_fee) => { + let funding_fee = funding_fee.to_signed().expect("to fit"); + + let margin_trader = self.trader_margin.to_signed().expect("to fit"); + let new_trader_margin = margin_trader - funding_fee; + let new_trader_margin = new_trader_margin.to_unsigned().expect("to fit"); + + let collateral_reserve_coordinator = + collateral_reserve_coordinator.to_signed().expect("to fit"); + let new_collateral_reserve_coordinator = + collateral_reserve_coordinator + funding_fee; + let new_collateral_reserve_coordinator = new_collateral_reserve_coordinator + .to_unsigned() + .expect("to fit"); + + let new_trader_leverage = + calculate_leverage(quantity, new_trader_margin, average_entry_price) + .expect("valid leverage"); + + let new_trader_liquidation_price = match self.trader_direction { + Direction::Long => calculate_long_liquidation_price( + new_trader_leverage, + average_entry_price, + maintenance_margin_rate, + ), + Direction::Short => calculate_short_liquidation_price( + new_trader_leverage, + average_entry_price, + maintenance_margin_rate, + ), + }; + + let position = Self { + trader_margin: new_trader_margin, + trader_leverage: f32_from_decimal(new_trader_leverage), + trader_liquidation_price: f32_from_decimal(new_trader_liquidation_price), + ..self + }; + + ( + position, + new_collateral_reserve_coordinator, + collateral_reserve_trader, + ) + } + } + } } /// Calculate the settlement amount for the coordinator, based on the PNL and the order-matching diff --git a/coordinator/src/routes/admin.rs b/coordinator/src/routes/admin.rs index 47b78df3c..98a3c3dc7 100644 --- a/coordinator/src/routes/admin.rs +++ b/coordinator/src/routes/admin.rs @@ -431,7 +431,7 @@ pub async fn rollover( .propose_rollover( &mut connection, &dlc_channel_id, - &position, + position, state.node.inner.network, ) .await diff --git a/coordinator/src/scheduler.rs b/coordinator/src/scheduler.rs index 49cace814..f9ac8cb72 100644 --- a/coordinator/src/scheduler.rs +++ b/coordinator/src/scheduler.rs @@ -240,7 +240,7 @@ fn build_rollover_notification_job( if let Err(e) = node .check_rollover( &mut conn, - &position, + position, node.inner.network, ¬ifier, Some(notification.clone()), diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index 57c9329d4..a4fa9001a 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -11,6 +11,7 @@ use crate::payout_curve; use crate::position::models::NewPosition; use crate::position::models::Position; use crate::position::models::PositionState; +use crate::FundingFee; use anyhow::anyhow; use anyhow::bail; use anyhow::ensure; @@ -1042,6 +1043,46 @@ impl TradeExecutor { bail!("Underlying DLC channel not yet confirmed."); } + // Update position based on the outstanding funding fee events _before_ calculating + // `position_settlement_amount_coordinator`. + let funding_fee_events = + db::funding_fee_events::get_outstanding_fees(conn, position.trader, position.id)?; + + let funding_fee_amount = funding_fee_events + .iter() + .fold(SignedAmount::ZERO, |acc, e| acc + e.amount); + + let funding_fee_event_ids = funding_fee_events + .iter() + .map(|event| event.id) + .collect::>(); + + let funding_fee = match funding_fee_amount.to_sat() { + 0 => FundingFee::Zero, + n if n.is_positive() => FundingFee::TraderPays(Amount::from_sat(n.unsigned_abs())), + n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), + }; + + let collateral_reserve_coordinator = self + .node + .inner + .get_dlc_channel_usable_balance(&channel_id)?; + + let collateral_reserve_trader = self + .node + .inner + .get_dlc_channel_usable_balance_counterparty(&channel_id)?; + + let maintenance_margin_rate = { self.node.settings.read().await.maintenance_margin_rate }; + let maintenance_margin_rate = decimal_from_f32(maintenance_margin_rate); + + let (position, collateral_reserve_coordinator, _) = position.apply_funding_fee_to_position( + collateral_reserve_coordinator, + collateral_reserve_trader, + funding_fee, + maintenance_margin_rate, + ); + let closing_price = trade_params.average_execution_price(); let position_settlement_amount_coordinator = position .calculate_coordinator_settlement_amount( @@ -1049,10 +1090,6 @@ impl TradeExecutor { trade_params.order_matching_fee(), )?; - let collateral_reserve_coordinator = self - .node - .inner - .get_dlc_channel_usable_balance(&channel_id)?; let dlc_channel_settlement_amount_coordinator = position_settlement_amount_coordinator + collateral_reserve_coordinator.to_sat(); @@ -1068,6 +1105,14 @@ impl TradeExecutor { "Closing position by settling DLC channel off-chain", ); + if !funding_fee_events.is_empty() { + tracing::debug!( + ?funding_fee, + ?funding_fee_events, + "Resolving funding fee events when closing position" + ); + } + let total_collateral = self .node .inner @@ -1097,12 +1142,13 @@ impl TradeExecutor { .await?; let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.node.pool.clone()); - protocol_executor.start_dlc_protocol( + protocol_executor.start_settle_protocol( protocol_id, previous_id, Some(&contract_id), &channel.get_id(), - DlcProtocolType::settle(trade_params, protocol_id), + trade_params, + funding_fee_event_ids, )?; db::positions::Position::set_open_position_to_closing( diff --git a/crates/xxi-node/src/commons/trade.rs b/crates/xxi-node/src/commons/trade.rs index a79c27800..722c8ef2f 100644 --- a/crates/xxi-node/src/commons/trade.rs +++ b/crates/xxi-node/src/commons/trade.rs @@ -72,7 +72,7 @@ impl TradeParams { /// /// The match defines the execution price and the quantity to be used of the order with the /// corresponding order id. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] pub struct Match { /// The id of the match pub id: Uuid, From 596e575e18eaa2c1a40127dc4d61f26e3bb12b39 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Wed, 29 May 2024 15:24:27 +1000 Subject: [PATCH 05/12] feat(coordinator): Apply funding fee events on position resizing --- coordinator/src/db/funding_fee_events.rs | 1 - coordinator/src/dlc_protocol.rs | 40 ++++++++++++++ coordinator/src/trade/mod.rs | 67 +++++++++++++++++++----- 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/coordinator/src/db/funding_fee_events.rs b/coordinator/src/db/funding_fee_events.rs index 27325c9d4..fc8faf982 100644 --- a/coordinator/src/db/funding_fee_events.rs +++ b/coordinator/src/db/funding_fee_events.rs @@ -114,7 +114,6 @@ pub(crate) fn get_for_active_trader_positions( /// TODO: Use outstanding fees when: /// /// - Deciding if positions need to be liquidated. -/// - Resizing a position. pub(crate) fn get_outstanding_fees( conn: &mut PgConnection, trader_pubkey: PublicKey, diff --git a/coordinator/src/dlc_protocol.rs b/coordinator/src/dlc_protocol.rs index 2a8f02c30..d7ea9828d 100644 --- a/coordinator/src/dlc_protocol.rs +++ b/coordinator/src/dlc_protocol.rs @@ -220,6 +220,44 @@ impl DlcProtocolExecutor { Ok(()) } + #[allow(clippy::too_many_arguments)] + pub fn start_resize_protocol( + &self, + protocol_id: ProtocolId, + previous_protocol_id: Option, + contract_id: Option<&ContractId>, + channel_id: &DlcChannelId, + trade_params: &commons::TradeParams, + realized_pnl: Option, + funding_fee_event_ids: Vec, + ) -> Result<()> { + let mut conn = self.pool.get()?; + conn.transaction(|conn| { + let trader_pubkey = trade_params.pubkey; + + db::dlc_protocols::create( + conn, + protocol_id, + previous_protocol_id, + contract_id, + channel_id, + db::dlc_protocols::DlcProtocolType::ResizePosition, + &trader_pubkey, + )?; + + db::protocol_funding_fee_events::insert(conn, protocol_id, &funding_fee_event_ids)?; + + db::trade_params::insert( + conn, + &TradeParams::new(trade_params, protocol_id, realized_pnl), + )?; + + diesel::result::QueryResult::Ok(()) + })?; + + Ok(()) + } + pub fn start_settle_protocol( &self, protocol_id: ProtocolId, @@ -596,6 +634,8 @@ impl DlcProtocolExecutor { db::trades::insert(conn, new_trade)?; + db::funding_fee_events::mark_as_paid(conn, protocol_id)?; + Ok(()) } diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index a4fa9001a..452bc7f6d 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -804,6 +804,42 @@ impl TradeExecutor { .expect("to fit") }; + // Update position based on the outstanding funding fee events _before_ applying resize. + let funding_fee_events = + db::funding_fee_events::get_outstanding_fees(conn, position.trader, position.id)?; + + let funding_fee_amount = funding_fee_events + .iter() + .fold(SignedAmount::ZERO, |acc, e| acc + e.amount); + + let funding_fee_event_ids = funding_fee_events + .iter() + .map(|event| event.id) + .collect::>(); + + let funding_fee = match funding_fee_amount.to_sat() { + 0 => FundingFee::Zero, + n if n.is_positive() => FundingFee::TraderPays(Amount::from_sat(n.unsigned_abs())), + n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), + }; + + let collateral_reserve_coordinator = self + .node + .inner + .get_dlc_channel_usable_balance(&dlc_channel_id)?; + let collateral_reserve_trader = self + .node + .inner + .get_dlc_channel_usable_balance_counterparty(&dlc_channel_id)?; + + let (position, original_collateral_reserve_coordinator, original_collateral_reserve_trader) = + position.apply_funding_fee_to_position( + collateral_reserve_coordinator, + collateral_reserve_trader, + funding_fee, + maintenance_margin_rate, + ); + tracing::info!( %peer_id, order_id = %trade_params.filled_with.order_id, @@ -811,27 +847,28 @@ impl TradeExecutor { ?resize_action, ?position, ?trade_params, + ?collateral_reserve_coordinator, + ?collateral_reserve_trader, "Resizing position" ); + if !funding_fee_events.is_empty() { + tracing::debug!( + ?funding_fee, + ?funding_fee_events, + "Resolving funding fee events when resizing position" + ); + } + let order_matching_fee = trade_params.order_matching_fee(); // The leverage does not change when we resize a position. - let original_coordinator_collateral_reserve = self - .node - .inner - .get_dlc_channel_usable_balance(&dlc_channel_id)?; - let original_trader_collateral_reserve = self - .node - .inner - .get_dlc_channel_usable_balance_counterparty(&dlc_channel_id)?; - let resized_position = apply_resize_to_position( resize_action, - position, - original_coordinator_collateral_reserve, - original_trader_collateral_reserve, + &position, + collateral_reserve_coordinator, + collateral_reserve_trader, order_matching_fee, maintenance_margin_rate, )?; @@ -937,12 +974,14 @@ impl TradeExecutor { .context("Could not propose resize DLC channel update")?; let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.node.pool.clone()); - protocol_executor.start_dlc_protocol( + protocol_executor.start_resize_protocol( protocol_id, previous_id, Some(&temporary_contract_id), &channel.get_id(), - DlcProtocolType::resize_position(trade_params, protocol_id, realized_pnl), + trade_params, + realized_pnl, + funding_fee_event_ids, )?; db::positions::Position::set_position_to_resizing( From 1158e2326936b8b795c807459027cdea39dc707d Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Wed, 29 May 2024 17:17:27 +1000 Subject: [PATCH 06/12] feat: Display next funding rate in app --- coordinator/src/db/funding_rates.rs | 31 ++++++---- coordinator/src/funding_fee.rs | 56 ++----------------- coordinator/src/lib.rs | 28 ---------- coordinator/src/orderbook/websocket.rs | 27 +++++++++ coordinator/src/routes/admin.rs | 20 ++++--- crates/tests-e2e/src/test_subscriber.rs | 3 + .../xxi-node/src/commons/funding_fee_event.rs | 46 +++++++++++++++ crates/xxi-node/src/commons/message.rs | 3 + crates/xxi-node/src/commons/mod.rs | 28 +++++++++- mobile/lib/backend.dart | 3 + mobile/lib/common/init_service.dart | 7 +++ .../features/trade/domain/funding_rate.dart | 21 +++++++ .../trade/funding_rate_change_notifier.dart | 26 +++++++++ mobile/lib/features/trade/trade_screen.dart | 56 ++++++++++++++++++- mobile/lib/util/constants.dart | 2 + mobile/native/src/event/api.rs | 13 +++++ mobile/native/src/event/mod.rs | 5 ++ mobile/native/src/orderbook.rs | 4 ++ 18 files changed, 274 insertions(+), 105 deletions(-) create mode 100644 mobile/lib/features/trade/domain/funding_rate.dart create mode 100644 mobile/lib/features/trade/funding_rate_change_notifier.dart diff --git a/coordinator/src/db/funding_rates.rs b/coordinator/src/db/funding_rates.rs index 149de74c7..0e0cf1922 100644 --- a/coordinator/src/db/funding_rates.rs +++ b/coordinator/src/db/funding_rates.rs @@ -1,13 +1,12 @@ -use crate::funding_fee; use crate::schema::funding_rates; -use crate::to_nearest_hour_in_the_past; -use anyhow::Context; +use anyhow::bail; use anyhow::Result; use diesel::prelude::*; use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use time::OffsetDateTime; +use xxi_node::commons::to_nearest_hour_in_the_past; #[derive(Insertable, Debug)] #[diesel(table_name = funding_rates)] @@ -30,7 +29,7 @@ struct FundingRate { pub(crate) fn insert( conn: &mut PgConnection, - funding_rates: &[funding_fee::FundingRate], + funding_rates: &[xxi_node::commons::FundingRate], ) -> Result<()> { let funding_rates = funding_rates .iter() @@ -38,11 +37,6 @@ pub(crate) fn insert( .map(NewFundingRate::from) .collect::>(); - Ok(()) - }) -} - -fn insert_one(conn: &mut PgConnection, params: &funding_fee::FundingRate) -> QueryResult<()> { let affected_rows = diesel::insert_into(funding_rates::table) .values(funding_rates) .execute(conn)?; @@ -54,10 +48,23 @@ fn insert_one(conn: &mut PgConnection, params: &funding_fee::FundingRate) -> Que Ok(()) } +pub(crate) fn get_next_funding_rate( + conn: &mut PgConnection, +) -> QueryResult> { + let funding_rate: Option = funding_rates::table + .order(funding_rates::end_date.desc()) + .first::(conn) + .optional()?; + + let funding_rate = funding_rate.map(xxi_node::commons::FundingRate::from); + + Ok(funding_rate) +} + /// Get the funding rate with an end date that is equal to the current date to the nearest hour. pub(crate) fn get_funding_rate_charged_in_the_last_hour( conn: &mut PgConnection, -) -> QueryResult> { +) -> QueryResult> { let now = OffsetDateTime::now_utc(); let now = to_nearest_hour_in_the_past(now); @@ -66,10 +73,10 @@ pub(crate) fn get_funding_rate_charged_in_the_last_hour( .first::(conn) .optional()?; - Ok(funding_rate.map(funding_fee::FundingRate::from)) + Ok(funding_rate.map(xxi_node::commons::FundingRate::from)) } -impl From for funding_fee::FundingRate { +impl From for xxi_node::commons::FundingRate { fn from(value: FundingRate) -> Self { Self::new( Decimal::from_f32(value.rate).expect("to fit"), diff --git a/coordinator/src/funding_fee.rs b/coordinator/src/funding_fee.rs index 9405d2b99..101370a82 100644 --- a/coordinator/src/funding_fee.rs +++ b/coordinator/src/funding_fee.rs @@ -1,7 +1,6 @@ use crate::db; use crate::decimal_from_f32; use crate::message::OrderbookMessage; -use crate::to_nearest_hour_in_the_past; use anyhow::bail; use anyhow::Context; use anyhow::Result; @@ -26,51 +25,6 @@ use xxi_node::commons::Message; const RETRY_INTERVAL: Duration = Duration::from_secs(5); -/// The funding rate for any position opened before the `end_date`, which remained open through the -/// `end_date`. -#[derive(Clone, Debug)] -pub struct FundingRate { - /// A positive funding rate indicates that longs pay shorts; a negative funding rate indicates - /// that shorts pay longs. - rate: Decimal, - /// The start date for the funding rate period. This value is only used for informational - /// purposes. - /// - /// The `start_date` is always a whole hour. - start_date: OffsetDateTime, - /// The end date for the funding rate period. When the end date has passed, all active - /// positions that were created before the end date should be charged a funding fee based - /// on the `rate`. - /// - /// The `end_date` is always a whole hour. - end_date: OffsetDateTime, -} - -impl FundingRate { - pub(crate) fn new(rate: Decimal, start_date: OffsetDateTime, end_date: OffsetDateTime) -> Self { - let start_date = to_nearest_hour_in_the_past(start_date); - let end_date = to_nearest_hour_in_the_past(end_date); - - Self { - rate, - start_date, - end_date, - } - } - - pub fn rate(&self) -> Decimal { - self.rate - } - - pub fn start_date(&self) -> OffsetDateTime { - self.start_date - } - - pub fn end_date(&self) -> OffsetDateTime { - self.end_date - } -} - /// A record that a funding fee is owed between the coordinator and a trader. #[derive(Clone, Copy, Debug)] pub struct FundingFeeEvent { @@ -182,7 +136,7 @@ fn generate_funding_fee_events( let index_price = match index_price_source { IndexPriceSource::Bitmex => block_in_place(move || { let current_index_price = - get_bitmex_index_price(&contract_symbol, funding_rate.end_date)?; + get_bitmex_index_price(&contract_symbol, funding_rate.end_date())?; anyhow::Ok(current_index_price) })?, @@ -201,12 +155,12 @@ fn generate_funding_fee_events( // We exclude active positions which were open after this funding period ended. let positions = db::positions::Position::get_all_active_positions_open_before( &mut conn, - funding_rate.end_date, + funding_rate.end_date(), )?; for position in positions { let amount = calculate_funding_fee( position.quantity, - funding_rate.rate, + funding_rate.rate(), index_price, position.trader_direction, ); @@ -216,9 +170,9 @@ fn generate_funding_fee_events( amount, position.trader, position.id, - funding_rate.end_date, + funding_rate.end_date(), index_price, - funding_rate.rate, + funding_rate.rate(), ) .context("Failed to insert funding fee event")? { diff --git a/coordinator/src/lib.rs b/coordinator/src/lib.rs index 15791d193..42ad71a3e 100644 --- a/coordinator/src/lib.rs +++ b/coordinator/src/lib.rs @@ -16,8 +16,6 @@ use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use serde_json::json; -use time::OffsetDateTime; -use time::Time; use xxi_node::commons; mod collaborative_revert; @@ -124,29 +122,3 @@ pub enum FundingFee { CoordinatorPays(Amount), TraderPays(Amount), } - -/// Remove minutes, seconds and nano seconds from a given [`OffsetDateTime`]. -pub fn to_nearest_hour_in_the_past(start_date: OffsetDateTime) -> OffsetDateTime { - OffsetDateTime::new_utc( - start_date.date(), - Time::from_hms_nano(start_date.time().hour(), 0, 0, 0).expect("to be valid time"), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_remove_small_units() { - let start_date = OffsetDateTime::now_utc(); - - // Act - let result = to_nearest_hour_in_the_past(start_date); - - // Assert - assert_eq!(result.hour(), start_date.time().hour()); - assert_eq!(result.minute(), 0); - assert_eq!(result.second(), 0); - } -} diff --git a/coordinator/src/orderbook/websocket.rs b/coordinator/src/orderbook/websocket.rs index 2adf06aba..01b0360a3 100644 --- a/coordinator/src/orderbook/websocket.rs +++ b/coordinator/src/orderbook/websocket.rs @@ -1,5 +1,6 @@ use crate::db; use crate::db::funding_fee_events; +use crate::db::funding_rates; use crate::db::user; use crate::message::NewUserMessage; use crate::orderbook::db::orders; @@ -276,6 +277,32 @@ pub async fn websocket_connection(stream: WebSocket, state: Arc) { } } + match funding_rates::get_next_funding_rate(&mut conn) { + Ok(Some(funding_rate)) => { + if let Err(e) = local_sender + .send(Message::NextFundingRate(funding_rate)) + .await + { + tracing::error!( + %trader_id, + "Failed to send next funding rate: {e}" + ); + } + } + Ok(None) => { + tracing::error!( + %trader_id, + "No next funding rate found in DB" + ); + } + Err(e) => { + tracing::error!( + %trader_id, + "Failed to load next funding rate: {e}" + ); + } + } + let token = fcm_token.unwrap_or("unavailable".to_string()); if let Err(e) = user::login_user(&mut conn, trader_id, token, version, os) diff --git a/coordinator/src/routes/admin.rs b/coordinator/src/routes/admin.rs index 98a3c3dc7..08ac64ec5 100644 --- a/coordinator/src/routes/admin.rs +++ b/coordinator/src/routes/admin.rs @@ -1,6 +1,5 @@ use crate::collaborative_revert; use crate::db; -use crate::funding_fee; use crate::parse_dlc_channel_id; use crate::position::models::Position; use crate::referrals; @@ -678,12 +677,15 @@ pub async fn post_funding_rates( AppError::InternalServerError(format!("Could not get connection: {e:#}")) })?; - let funding_rates = funding_rates - .0 - .iter() - .copied() - .map(funding_fee::FundingRate::from) - .collect::>(); + let funding_rates = funding_rates + .0 + .iter() + .copied() + .map(xxi_node::commons::FundingRate::from) + .collect::>(); + + db::funding_rates::insert(&mut conn, &funding_rates) + .map_err(|e| AppError::BadRequest(format!("{e:#}")))?; Ok(()) }) @@ -705,9 +707,9 @@ pub struct FundingRate { end_date: OffsetDateTime, } -impl From for funding_fee::FundingRate { +impl From for xxi_node::commons::FundingRate { fn from(value: FundingRate) -> Self { - funding_fee::FundingRate::new(value.rate, value.start_date, value.end_date) + xxi_node::commons::FundingRate::new(value.rate, value.start_date, value.end_date) } } diff --git a/crates/tests-e2e/src/test_subscriber.rs b/crates/tests-e2e/src/test_subscriber.rs index dca5531a4..492e56619 100644 --- a/crates/tests-e2e/src/test_subscriber.rs +++ b/crates/tests-e2e/src/test_subscriber.rs @@ -203,6 +203,9 @@ impl Senders { native::event::EventInternal::FundingFeeEvent(_) => { // ignored } + native::event::EventInternal::NextFundingRate(_) => { + // ignored + } } Ok(()) } diff --git a/crates/xxi-node/src/commons/funding_fee_event.rs b/crates/xxi-node/src/commons/funding_fee_event.rs index c8a78b6cf..7b5dd35a9 100644 --- a/crates/xxi-node/src/commons/funding_fee_event.rs +++ b/crates/xxi-node/src/commons/funding_fee_event.rs @@ -1,3 +1,4 @@ +use crate::commons::to_nearest_hour_in_the_past; use crate::commons::ContractSymbol; use crate::commons::Direction; use bitcoin::SignedAmount; @@ -6,6 +7,51 @@ use serde::Deserialize; use serde::Serialize; use time::OffsetDateTime; +/// The funding rate for any position opened before the `end_date`, which remained open through the +/// `end_date`. +#[derive(Serialize, Clone, Copy, Deserialize, Debug)] +pub struct FundingRate { + /// A positive funding rate indicates that longs pay shorts; a negative funding rate indicates + /// that shorts pay longs. + rate: Decimal, + /// The start date for the funding rate period. This value is only used for informational + /// purposes. + /// + /// The `start_date` is always a whole hour. + start_date: OffsetDateTime, + /// The end date for the funding rate period. When the end date has passed, all active + /// positions that were created before the end date should be charged a funding fee based + /// on the `rate`. + /// + /// The `end_date` is always a whole hour. + end_date: OffsetDateTime, +} + +impl FundingRate { + pub fn new(rate: Decimal, start_date: OffsetDateTime, end_date: OffsetDateTime) -> Self { + let start_date = to_nearest_hour_in_the_past(start_date); + let end_date = to_nearest_hour_in_the_past(end_date); + + Self { + rate, + start_date, + end_date, + } + } + + pub fn rate(&self) -> Decimal { + self.rate + } + + pub fn start_date(&self) -> OffsetDateTime { + self.start_date + } + + pub fn end_date(&self) -> OffsetDateTime { + self.end_date + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct FundingFeeEvent { pub contract_symbol: ContractSymbol, diff --git a/crates/xxi-node/src/commons/message.rs b/crates/xxi-node/src/commons/message.rs index 3c576e1ba..0f2456c7a 100644 --- a/crates/xxi-node/src/commons/message.rs +++ b/crates/xxi-node/src/commons/message.rs @@ -1,5 +1,6 @@ use crate::commons::order::Order; use crate::commons::signature::Signature; +use crate::commons::FundingRate; use crate::commons::LiquidityOption; use crate::commons::NewLimitOrder; use crate::commons::ReferralStatus; @@ -52,6 +53,7 @@ pub enum Message { }, FundingFeeEvent(FundingFeeEvent), AllFundingFeeEvents(Vec), + NextFundingRate(FundingRate), } #[derive(Serialize, Deserialize, Clone, Error, Debug, PartialEq)] @@ -116,6 +118,7 @@ impl Display for Message { Message::LnPaymentReceived { .. } => "LnPaymentReceived", Message::FundingFeeEvent(_) => "FundingFeeEvent", Message::AllFundingFeeEvents(_) => "FundingFeeEvent", + Message::NextFundingRate(_) => "NextFundingRate", }; f.write_str(s) diff --git a/crates/xxi-node/src/commons/mod.rs b/crates/xxi-node/src/commons/mod.rs index 4d5e3117b..b23370540 100644 --- a/crates/xxi-node/src/commons/mod.rs +++ b/crates/xxi-node/src/commons/mod.rs @@ -5,6 +5,8 @@ use serde::Deserialize; use serde::Serialize; use std::fmt; use std::str::FromStr; +use time::OffsetDateTime; +use time::Time; mod backup; mod collab_revert; @@ -24,7 +26,7 @@ mod trade; pub use crate::commons::trade::*; pub use backup::*; pub use collab_revert::*; -pub use funding_fee_event::FundingFeeEvent; +pub use funding_fee_event::*; pub use liquidity_option::*; pub use message::*; pub use order::*; @@ -201,10 +203,17 @@ impl fmt::Display for ContractSymbol { } } +/// Remove minutes, seconds and nano seconds from a given [`OffsetDateTime`]. +pub fn to_nearest_hour_in_the_past(start_date: OffsetDateTime) -> OffsetDateTime { + OffsetDateTime::new_utc( + start_date.date(), + Time::from_hms_nano(start_date.time().hour(), 0, 0, 0).expect("to be valid time"), + ) +} + #[cfg(test)] pub mod tests { - use crate::commons::referral_from_pubkey; - use crate::commons::ContractSymbol; + use super::*; use secp256k1::PublicKey; use std::str::FromStr; @@ -235,4 +244,17 @@ pub mod tests { let referral = referral_from_pubkey(pk); assert_eq!(referral, "DDD166".to_string()); } + + #[test] + fn test_remove_small_units() { + let start_date = OffsetDateTime::now_utc(); + + // Act + let result = to_nearest_hour_in_the_past(start_date); + + // Assert + assert_eq!(result.hour(), start_date.time().hour()); + assert_eq!(result.minute(), 0); + assert_eq!(result.second(), 0); + } } diff --git a/mobile/lib/backend.dart b/mobile/lib/backend.dart index 373354ce6..e363eef2e 100644 --- a/mobile/lib/backend.dart +++ b/mobile/lib/backend.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get_10101/common/dlc_channel_change_notifier.dart'; +import 'package:get_10101/features/trade/funding_rate_change_notifier.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; import 'package:get_10101/features/trade/trade_change_notifier.dart'; @@ -58,6 +59,7 @@ Future runBackend(BuildContext context) async { final orderChangeNotifier = context.read(); final positionChangeNotifier = context.read(); final tradeChangeNotifier = context.read(); + final fundingRateChangeNotifier = context.read(); final dlcChannelChangeNotifier = context.read(); final seedDir = (await getApplicationSupportDirectory()).path; @@ -78,6 +80,7 @@ Future runBackend(BuildContext context) async { await positionChangeNotifier.initialize(); await tradeChangeNotifier.initialize(); await dlcChannelChangeNotifier.initialize(); + await fundingRateChangeNotifier.initialize(); } void _setupRustLogging() { diff --git a/mobile/lib/common/init_service.dart b/mobile/lib/common/init_service.dart index 4c42cff1e..20cbb7bd2 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -9,7 +9,9 @@ import 'package:get_10101/common/domain/tentenone_config.dart'; import 'package:get_10101/common/funding_channel_task_change_notifier.dart'; import 'package:get_10101/features/brag/meme_service.dart'; import 'package:get_10101/features/trade/application/trade_service.dart'; +import 'package:get_10101/features/trade/domain/funding_rate.dart'; import 'package:get_10101/features/trade/domain/trade.dart'; +import 'package:get_10101/features/trade/funding_rate_change_notifier.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; import 'package:get_10101/common/amount_denomination_change_notifier.dart'; @@ -56,6 +58,7 @@ List createProviders() { ChangeNotifierProvider(create: (context) => OrderChangeNotifier(OrderService())), ChangeNotifierProvider(create: (context) => PositionChangeNotifier(PositionService())), ChangeNotifierProvider(create: (context) => TradeChangeNotifier(TradeService())), + ChangeNotifierProvider(create: (context) => FundingRateChangeNotifier()), ChangeNotifierProvider(create: (context) => WalletChangeNotifier(const WalletService())), ChangeNotifierProvider(create: (context) => ServiceStatusNotifier()), ChangeNotifierProvider(create: (context) => DlcChannelChangeNotifier(dlcChannelService)), @@ -81,6 +84,7 @@ void subscribeToNotifiers(BuildContext context) { final orderChangeNotifier = context.read(); final tradeChangeNotifier = context.read(); + final fundingRateChangeNotifier = context.read(); final positionChangeNotifier = context.read(); final walletChangeNotifier = context.read(); final tradeValuesChangeNotifier = context.read(); @@ -95,6 +99,9 @@ void subscribeToNotifiers(BuildContext context) { eventService.subscribe(tradeChangeNotifier, bridge.Event.newTrade(Trade.apiDummy())); + eventService.subscribe( + fundingRateChangeNotifier, bridge.Event.nextFundingRate(FundingRate.apiDummy())); + eventService.subscribe( positionChangeNotifier, bridge.Event.positionUpdateNotification(Position.apiDummy())); diff --git a/mobile/lib/features/trade/domain/funding_rate.dart b/mobile/lib/features/trade/domain/funding_rate.dart new file mode 100644 index 000000000..156a84349 --- /dev/null +++ b/mobile/lib/features/trade/domain/funding_rate.dart @@ -0,0 +1,21 @@ +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; + +class FundingRate { + final double rate; + final DateTime endDate; + + FundingRate({ + required this.rate, + required this.endDate, + }); + + static FundingRate fromApi(bridge.FundingRate fundingRate) { + return FundingRate( + rate: fundingRate.rate, + endDate: DateTime.fromMillisecondsSinceEpoch(fundingRate.endDate * 1000)); + } + + static bridge.FundingRate apiDummy() { + return const bridge.FundingRate(rate: 0.0, endDate: 0); + } +} diff --git a/mobile/lib/features/trade/funding_rate_change_notifier.dart b/mobile/lib/features/trade/funding_rate_change_notifier.dart new file mode 100644 index 000000000..91dedf5f7 --- /dev/null +++ b/mobile/lib/features/trade/funding_rate_change_notifier.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/features/trade/domain/funding_rate.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; +import 'package:get_10101/common/application/event_service.dart'; + +class FundingRateChangeNotifier extends ChangeNotifier implements Subscriber { + FundingRate? nextRate; + + Future initialize() async { + notifyListeners(); + } + + FundingRateChangeNotifier(); + + @override + void notify(bridge.Event event) { + if (event is bridge.Event_NextFundingRate) { + nextRate = FundingRate.fromApi(event.field0); + + notifyListeners(); + } else { + logger.w("Received unexpected event: ${event.toString()}"); + } + } +} diff --git a/mobile/lib/features/trade/trade_screen.dart b/mobile/lib/features/trade/trade_screen.dart index 5ec45db99..db66c2372 100644 --- a/mobile/lib/features/trade/trade_screen.dart +++ b/mobile/lib/features/trade/trade_screen.dart @@ -1,8 +1,11 @@ +import 'package:get_10101/features/trade/domain/funding_rate.dart'; +import 'package:timeago/timeago.dart' as timeago; import 'package:flutter/material.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/features/trade/domain/direction.dart'; import 'package:get_10101/features/trade/domain/order.dart'; import 'package:get_10101/features/trade/domain/position.dart'; +import 'package:get_10101/features/trade/funding_rate_change_notifier.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/order_list_item.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; @@ -65,16 +68,22 @@ class TradeScreen extends StatelessWidget { }, builder: (context, price, child) { return LatestPriceWidget( innerKey: tradeScreenAskPrice, - label: "Latest Ask: ", + label: "Ask: ", price: Usd.fromDouble(price ?? 0.0), ); }), + Selector(selector: (_, provider) { + return provider.nextRate; + }, builder: (context, rate, child) { + return FundingRateWidget( + rate: rate, label: "Funding Rate: ", innerKey: tradeScreenFundingRate); + }), Selector(selector: (_, provider) { return provider.getBidPrice(); }, builder: (context, price, child) { return LatestPriceWidget( innerKey: tradeScreenBidPrice, - label: "Latest Bid: ", + label: "Bid: ", price: Usd.fromDouble(price ?? 0.0), ); }), @@ -320,3 +329,46 @@ class LatestPriceWidget extends StatelessWidget { ); } } + +class FundingRateWidget extends StatelessWidget { + final FundingRate? rate; + final String label; + final Key innerKey; + + const FundingRateWidget( + {super.key, required this.rate, required this.label, required this.innerKey}); + + @override + Widget build(BuildContext context) { + timeago.setLocaleMessages('en_custom', CustomEnMessages()); + + return RichText( + key: innerKey, + text: TextSpan( + text: label, + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: rate != null ? "${(rate!.rate * 100).toStringAsFixed(2)}%" : "n/a", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan( + text: rate != null + ? "\n${timeago.format(rate!.endDate, locale: 'en_custom', allowFromNow: true)}" + : null, + style: const TextStyle(fontWeight: FontWeight.w300), + ) + ], + ), + textAlign: TextAlign.center, + ); + } +} + +class CustomEnMessages extends timeago.EnMessages { + @override + String prefixFromNow() => 'in'; + + @override + String suffixFromNow() => ''; +} diff --git a/mobile/lib/util/constants.dart b/mobile/lib/util/constants.dart index 56c6330f8..e96b293ab 100644 --- a/mobile/lib/util/constants.dart +++ b/mobile/lib/util/constants.dart @@ -68,12 +68,14 @@ const tabTrade = Key(_tabs + _trade); const _ask = "ask"; const _bid = "bid"; +const _fundingRate = "fundingRate"; const _marketPrice = "marketPrice"; const _quantityInput = "quantityInput"; const _marginField = "marginField"; const tradeScreenAskPrice = Key(_trade + _tabs + _ask); const tradeScreenBidPrice = Key(_trade + _tabs + _bid); +const tradeScreenFundingRate = Key(_trade + _tabs + _fundingRate); const tradeButtonSheetMarketPrice = Key(_trade + _tabs + _bottomSheet + _marketPrice); const tradeButtonSheetQuantityInput = Key(_trade + _tabs + _bottomSheet + _quantityInput); diff --git a/mobile/native/src/event/api.rs b/mobile/native/src/event/api.rs index 23f09b619..3c7ca4d92 100644 --- a/mobile/native/src/event/api.rs +++ b/mobile/native/src/event/api.rs @@ -34,6 +34,7 @@ pub enum Event { FundingChannelNotification(FundingChannelTask), LnPaymentReceived { r_hash: String }, NewTrade(Trade), + NextFundingRate(FundingRate), } #[frb] @@ -97,6 +98,10 @@ impl From for Event { } EventInternal::LnPaymentReceived { r_hash } => Event::LnPaymentReceived { r_hash }, EventInternal::NewTrade(trade) => Event::NewTrade(trade.into()), + EventInternal::NextFundingRate(funding_rate) => Event::NextFundingRate(FundingRate { + rate: funding_rate.rate().to_f32().expect("to fit"), + end_date: funding_rate.end_date().unix_timestamp(), + }), EventInternal::FundingFeeEvent(event) => Event::NewTrade(event.into()), } } @@ -139,6 +144,7 @@ impl Subscriber for FlutterSubscriber { EventType::Authenticated, EventType::DlcChannelEvent, EventType::NewTrade, + EventType::NextFundingRate, ] } } @@ -200,6 +206,13 @@ pub struct Balances { pub off_chain: Option, } +#[frb] +#[derive(Clone)] +pub struct FundingRate { + pub rate: f32, + pub end_date: i64, +} + #[frb] #[derive(Clone)] pub enum FundingChannelTask { diff --git a/mobile/native/src/event/mod.rs b/mobile/native/src/event/mod.rs index 277a1e52a..9fad5afc4 100644 --- a/mobile/native/src/event/mod.rs +++ b/mobile/native/src/event/mod.rs @@ -11,6 +11,7 @@ use rust_decimal::Decimal; use std::fmt; use std::hash::Hash; use xxi_node::commons::ContractSymbol; +use xxi_node::commons::FundingRate; use xxi_node::commons::TenTenOneConfig; mod event_hub; @@ -45,6 +46,7 @@ pub enum EventInternal { LnPaymentReceived { r_hash: String }, NewTrade(Trade), FundingFeeEvent(FundingFeeEvent), + NextFundingRate(FundingRate), } #[derive(Clone, Debug)] @@ -93,6 +95,7 @@ impl fmt::Display for EventInternal { EventInternal::LnPaymentReceived { .. } => "LnPaymentReceived", EventInternal::NewTrade(_) => "NewTrade", EventInternal::FundingFeeEvent(_) => "FundingFeeEvent", + EventInternal::NextFundingRate(_) => "NextFundingRate", } .fmt(f) } @@ -120,6 +123,7 @@ impl From for EventType { EventInternal::LnPaymentReceived { .. } => EventType::LnPaymentReceived, EventInternal::NewTrade(_) => EventType::NewTrade, EventInternal::FundingFeeEvent(_) => EventType::NewTrade, + EventInternal::NextFundingRate(_) => EventType::NextFundingRate, } } } @@ -145,4 +149,5 @@ pub enum EventType { BidPriceUpdateNotification, FundingChannelNotification, NewTrade, + NextFundingRate, } diff --git a/mobile/native/src/orderbook.rs b/mobile/native/src/orderbook.rs index 3cf3e4ff3..29ed3c44a 100644 --- a/mobile/native/src/orderbook.rs +++ b/mobile/native/src/orderbook.rs @@ -251,6 +251,10 @@ async fn handle_orderbook_message( position::handler::handle_funding_fee_events(&new_funding_fee_events) .context("Failed to apply all funding fee events from coordinator")?; } + Message::NextFundingRate(funding_rate) => { + tracing::info!(?funding_rate, "Got next funding rate"); + event::publish(&EventInternal::NextFundingRate(funding_rate)); + } Message::FundingFeeEvent(funding_fee_event) => { let new_funding_fee_events = funding_fee_event::handler::handle_unpaid_funding_fee_events(&[ From dca01eff1aff7bf5131960df81f6dc1e91d29a60 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Wed, 29 May 2024 22:53:21 +1000 Subject: [PATCH 07/12] feat(coordinator): Apply funding fee events to consider liquidation --- coordinator/src/db/funding_fee_events.rs | 4 - coordinator/src/funding_fee.rs | 14 ++++ coordinator/src/node/channel.rs | 49 +++++++++++ coordinator/src/node/liquidated_positions.rs | 15 +++- coordinator/src/node/rollover.rs | 40 +++------ coordinator/src/position/models.rs | 57 +++---------- coordinator/src/trade/mod.rs | 88 ++++++-------------- crates/xxi-node/src/cfd.rs | 15 +--- mobile/native/src/trade/position/mod.rs | 5 +- 9 files changed, 130 insertions(+), 157 deletions(-) diff --git a/coordinator/src/db/funding_fee_events.rs b/coordinator/src/db/funding_fee_events.rs index fc8faf982..8eabdb957 100644 --- a/coordinator/src/db/funding_fee_events.rs +++ b/coordinator/src/db/funding_fee_events.rs @@ -110,10 +110,6 @@ pub(crate) fn get_for_active_trader_positions( } /// Get the unpaid [`funding_fee::FundingFeeEvent`]s for a trader position. -/// -/// TODO: Use outstanding fees when: -/// -/// - Deciding if positions need to be liquidated. pub(crate) fn get_outstanding_fees( conn: &mut PgConnection, trader_pubkey: PublicKey, diff --git a/coordinator/src/funding_fee.rs b/coordinator/src/funding_fee.rs index 101370a82..20ae3a822 100644 --- a/coordinator/src/funding_fee.rs +++ b/coordinator/src/funding_fee.rs @@ -1,10 +1,12 @@ use crate::db; use crate::decimal_from_f32; use crate::message::OrderbookMessage; +use crate::FundingFee; use anyhow::bail; use anyhow::Context; use anyhow::Result; use bitcoin::secp256k1::PublicKey; +use bitcoin::Amount; use bitcoin::SignedAmount; use diesel::r2d2::ConnectionManager; use diesel::r2d2::Pool; @@ -290,6 +292,18 @@ struct Index { _reference: String, } +pub fn funding_fee_from_funding_fee_events(events: &[FundingFeeEvent]) -> FundingFee { + let funding_fee_amount = events + .iter() + .fold(SignedAmount::ZERO, |acc, e| acc + e.amount); + + match funding_fee_amount.to_sat() { + 0 => FundingFee::Zero, + n if n.is_positive() => FundingFee::TraderPays(Amount::from_sat(n.unsigned_abs())), + n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/coordinator/src/node/channel.rs b/coordinator/src/node/channel.rs index 70151b19d..b9ce472a7 100644 --- a/coordinator/src/node/channel.rs +++ b/coordinator/src/node/channel.rs @@ -3,6 +3,7 @@ use crate::dlc_protocol; use crate::dlc_protocol::DlcProtocolType; use crate::node::Node; use crate::position::models::PositionState; +use crate::FundingFee; use anyhow::bail; use anyhow::Context; use anyhow::Result; @@ -289,6 +290,54 @@ impl Node { Ok(()) } + pub fn apply_funding_fee_to_channel( + &self, + dlc_channel_id: DlcChannelId, + funding_fee: FundingFee, + ) -> Result<(Amount, Amount)> { + let collateral_reserve_coordinator = + self.inner.get_dlc_channel_usable_balance(&dlc_channel_id)?; + let collateral_reserve_trader = self + .inner + .get_dlc_channel_usable_balance_counterparty(&dlc_channel_id)?; + + let reserves = match funding_fee { + FundingFee::Zero => (collateral_reserve_coordinator, collateral_reserve_trader), + FundingFee::CoordinatorPays(funding_fee) => { + let funding_fee = funding_fee.to_signed().expect("to fit"); + + let collateral_reserve_trader = + collateral_reserve_trader.to_signed().expect("to fit"); + let new_collateral_reserve_trader = collateral_reserve_trader + funding_fee; + let new_collateral_reserve_trader = + new_collateral_reserve_trader.to_unsigned().expect("to fit"); + + ( + collateral_reserve_coordinator, + new_collateral_reserve_trader, + ) + } + FundingFee::TraderPays(funding_fee) => { + let funding_fee = funding_fee.to_signed().expect("to fit"); + + let collateral_reserve_coordinator = + collateral_reserve_coordinator.to_signed().expect("to fit"); + let new_collateral_reserve_coordinator = + collateral_reserve_coordinator + funding_fee; + let new_collateral_reserve_coordinator = new_collateral_reserve_coordinator + .to_unsigned() + .expect("to fit"); + + ( + new_collateral_reserve_coordinator, + collateral_reserve_trader, + ) + } + }; + + Ok(reserves) + } + fn handle_closing_event(&self, conn: &mut PgConnection, channel: &Channel) -> Result<()> { // If a channel is set to closing it means the buffer transaction got broadcasted, // which will only happen if the channel got force closed while the diff --git a/coordinator/src/node/liquidated_positions.rs b/coordinator/src/node/liquidated_positions.rs index b91817900..deed50526 100644 --- a/coordinator/src/node/liquidated_positions.rs +++ b/coordinator/src/node/liquidated_positions.rs @@ -1,4 +1,5 @@ use crate::db; +use crate::funding_fee::funding_fee_from_funding_fee_events; use crate::node::Node; use crate::orderbook; use crate::orderbook::db::orders; @@ -43,9 +44,18 @@ async fn check_if_positions_need_to_get_liquidated( let best_current_price = orderbook::db::orders::get_best_price(&mut conn, ContractSymbol::BtcUsd)?; + let maintenance_margin_rate = + { Decimal::try_from(node.settings.read().await.maintenance_margin_rate).expect("to fit") }; + for position in open_positions { - // TODO: These liquidation prices do not consider the outstanding funding fee events, so - // they are not quite right for the party that owes the fees. + // Update position based on the outstanding funding fee events _before_ considering + // liquidation. + let funding_fee_events = + db::funding_fee_events::get_outstanding_fees(&mut conn, position.trader, position.id)?; + + let funding_fee = funding_fee_from_funding_fee_events(&funding_fee_events); + + let position = position.apply_funding_fee(funding_fee, maintenance_margin_rate); let coordinator_liquidation_price = Decimal::try_from(position.coordinator_liquidation_price).expect("to fit into decimal"); @@ -57,6 +67,7 @@ async fn check_if_positions_need_to_get_liquidated( &best_current_price, trader_liquidation_price, ); + let coordinator_liquidation = check_if_position_needs_to_get_liquidated( position.trader_direction.opposite(), &best_current_price, diff --git a/coordinator/src/node/rollover.rs b/coordinator/src/node/rollover.rs index f6ddfccc0..69068e6cd 100644 --- a/coordinator/src/node/rollover.rs +++ b/coordinator/src/node/rollover.rs @@ -4,20 +4,18 @@ use crate::db::positions; use crate::decimal_from_f32; use crate::dlc_protocol; use crate::dlc_protocol::RolloverParams; +use crate::funding_fee::funding_fee_from_funding_fee_events; use crate::node::Node; use crate::notifications::Notification; use crate::notifications::NotificationKind; use crate::payout_curve::build_contract_descriptor; use crate::position::models::Position; use crate::position::models::PositionState; -use crate::FundingFee; use anyhow::bail; use anyhow::Context; use anyhow::Result; use bitcoin::secp256k1::PublicKey; -use bitcoin::Amount; use bitcoin::Network; -use bitcoin::SignedAmount; use diesel::r2d2::ConnectionManager; use diesel::r2d2::Pool; use diesel::r2d2::PooledConnection; @@ -183,12 +181,6 @@ impl Node { let next_expiry = commons::calculate_next_expiry(OffsetDateTime::now_utc(), network); - let collateral_reserve_coordinator = - self.inner.get_dlc_channel_usable_balance(dlc_channel_id)?; - let collateral_reserve_trader = self - .inner - .get_dlc_channel_usable_balance_counterparty(dlc_channel_id)?; - let (oracle_pk, contract_tx_fee_rate) = { let old_contract = self.inner.get_contract_by_dlc_channel_id(dlc_channel_id)?; @@ -227,28 +219,11 @@ impl Node { let funding_fee_events = db::funding_fee_events::get_outstanding_fees(conn, trader_pubkey, position.id)?; - let funding_fee_amount = funding_fee_events - .iter() - .fold(SignedAmount::ZERO, |acc, e| acc + e.amount); - - let funding_fee_event_ids = funding_fee_events - .iter() - .map(|event| event.id) - .collect::>(); - - let funding_fee = match funding_fee_amount.to_sat() { - 0 => FundingFee::Zero, - n if n.is_positive() => FundingFee::TraderPays(Amount::from_sat(n.unsigned_abs())), - n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), - }; + let funding_fee = funding_fee_from_funding_fee_events(&funding_fee_events); - let (position, collateral_reserve_coordinator, collateral_reserve_trader) = position - .apply_funding_fee_to_position( - collateral_reserve_coordinator, - collateral_reserve_trader, - funding_fee, - maintenance_margin_rate, - ); + let position = position.apply_funding_fee(funding_fee, maintenance_margin_rate); + let (collateral_reserve_coordinator, collateral_reserve_trader) = + self.apply_funding_fee_to_channel(*dlc_channel_id, funding_fee)?; let Position { coordinator_margin: margin_coordinator, @@ -309,6 +284,11 @@ impl Node { None => None, }; + let funding_fee_event_ids = funding_fee_events + .iter() + .map(|event| event.id) + .collect::>(); + let funding_fee_events = funding_fee_events .into_iter() .map(xxi_node::message_handler::FundingFeeEvent::from) diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index d70a01b70..2b4e070d6 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -203,38 +203,28 @@ impl Position { ) } - pub fn apply_funding_fee_to_position( + #[must_use] + pub fn apply_funding_fee( self, - collateral_reserve_coordinator: Amount, - collateral_reserve_trader: Amount, funding_fee: FundingFee, maintenance_margin_rate: Decimal, - ) -> (Self, Amount, Amount) { + ) -> Self { let quantity = decimal_from_f32(self.quantity); let average_entry_price = decimal_from_f32(self.average_entry_price); match funding_fee { - FundingFee::Zero => ( - self, - collateral_reserve_coordinator, - collateral_reserve_trader, - ), + FundingFee::Zero => self, FundingFee::CoordinatorPays(funding_fee) => { let funding_fee = funding_fee.to_signed().expect("to fit"); let coordinator_margin = self.coordinator_margin.to_signed().expect("to fit"); let new_coordinator_margin = coordinator_margin - funding_fee; - let new_coordinator_margin = new_coordinator_margin.to_unsigned().expect("to fit"); - let collateral_reserve_trader = - collateral_reserve_trader.to_signed().expect("to fit"); - let new_collateral_reserve_trader = collateral_reserve_trader + funding_fee; - let new_collateral_reserve_trader = - new_collateral_reserve_trader.to_unsigned().expect("to fit"); + let new_coordinator_margin = + new_coordinator_margin.to_unsigned().unwrap_or(Amount::ZERO); let new_coordinator_leverage = - calculate_leverage(quantity, new_coordinator_margin, average_entry_price) - .expect("valid leverage"); + calculate_leverage(quantity, new_coordinator_margin, average_entry_price); let new_coordinator_liquidation_price = match self.trader_direction { Direction::Long => calculate_short_liquidation_price( @@ -249,39 +239,24 @@ impl Position { ), }; - let position = Self { + Self { coordinator_margin: new_coordinator_margin, coordinator_leverage: f32_from_decimal(new_coordinator_leverage), coordinator_liquidation_price: f32_from_decimal( new_coordinator_liquidation_price, ), ..self - }; - - ( - position, - collateral_reserve_coordinator, - new_collateral_reserve_trader, - ) + } } FundingFee::TraderPays(funding_fee) => { let funding_fee = funding_fee.to_signed().expect("to fit"); let margin_trader = self.trader_margin.to_signed().expect("to fit"); let new_trader_margin = margin_trader - funding_fee; - let new_trader_margin = new_trader_margin.to_unsigned().expect("to fit"); - - let collateral_reserve_coordinator = - collateral_reserve_coordinator.to_signed().expect("to fit"); - let new_collateral_reserve_coordinator = - collateral_reserve_coordinator + funding_fee; - let new_collateral_reserve_coordinator = new_collateral_reserve_coordinator - .to_unsigned() - .expect("to fit"); + let new_trader_margin = new_trader_margin.to_unsigned().unwrap_or(Amount::ZERO); let new_trader_leverage = - calculate_leverage(quantity, new_trader_margin, average_entry_price) - .expect("valid leverage"); + calculate_leverage(quantity, new_trader_margin, average_entry_price); let new_trader_liquidation_price = match self.trader_direction { Direction::Long => calculate_long_liquidation_price( @@ -296,18 +271,12 @@ impl Position { ), }; - let position = Self { + Self { trader_margin: new_trader_margin, trader_leverage: f32_from_decimal(new_trader_leverage), trader_liquidation_price: f32_from_decimal(new_trader_liquidation_price), ..self - }; - - ( - position, - new_collateral_reserve_coordinator, - collateral_reserve_trader, - ) + } } } } diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index 452bc7f6d..1986d5223 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -3,6 +3,7 @@ use crate::db; use crate::decimal_from_f32; use crate::dlc_protocol; use crate::dlc_protocol::DlcProtocolType; +use crate::funding_fee::funding_fee_from_funding_fee_events; use crate::message::OrderbookMessage; use crate::node::Node; use crate::orderbook::db::matches; @@ -11,7 +12,6 @@ use crate::payout_curve; use crate::position::models::NewPosition; use crate::position::models::Position; use crate::position::models::PositionState; -use crate::FundingFee; use anyhow::anyhow; use anyhow::bail; use anyhow::ensure; @@ -799,46 +799,22 @@ impl TradeExecutor { let peer_id = trade_params.pubkey; - let maintenance_margin_rate = { - Decimal::try_from(self.node.settings.read().await.maintenance_margin_rate) - .expect("to fit") - }; - // Update position based on the outstanding funding fee events _before_ applying resize. let funding_fee_events = db::funding_fee_events::get_outstanding_fees(conn, position.trader, position.id)?; - let funding_fee_amount = funding_fee_events - .iter() - .fold(SignedAmount::ZERO, |acc, e| acc + e.amount); - - let funding_fee_event_ids = funding_fee_events - .iter() - .map(|event| event.id) - .collect::>(); + let funding_fee = funding_fee_from_funding_fee_events(&funding_fee_events); - let funding_fee = match funding_fee_amount.to_sat() { - 0 => FundingFee::Zero, - n if n.is_positive() => FundingFee::TraderPays(Amount::from_sat(n.unsigned_abs())), - n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), + let maintenance_margin_rate = { + Decimal::try_from(self.node.settings.read().await.maintenance_margin_rate) + .expect("to fit") }; - let collateral_reserve_coordinator = self - .node - .inner - .get_dlc_channel_usable_balance(&dlc_channel_id)?; - let collateral_reserve_trader = self - .node - .inner - .get_dlc_channel_usable_balance_counterparty(&dlc_channel_id)?; + let position = position.apply_funding_fee(funding_fee, maintenance_margin_rate); - let (position, original_collateral_reserve_coordinator, original_collateral_reserve_trader) = - position.apply_funding_fee_to_position( - collateral_reserve_coordinator, - collateral_reserve_trader, - funding_fee, - maintenance_margin_rate, - ); + let (collateral_reserve_coordinator, collateral_reserve_trader) = self + .node + .apply_funding_fee_to_channel(dlc_channel_id, funding_fee)?; tracing::info!( %peer_id, @@ -973,6 +949,11 @@ impl TradeExecutor { .await .context("Could not propose resize DLC channel update")?; + let funding_fee_event_ids = funding_fee_events + .iter() + .map(|event| event.id) + .collect::>(); + let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.node.pool.clone()); protocol_executor.start_resize_protocol( protocol_id, @@ -1087,40 +1068,16 @@ impl TradeExecutor { let funding_fee_events = db::funding_fee_events::get_outstanding_fees(conn, position.trader, position.id)?; - let funding_fee_amount = funding_fee_events - .iter() - .fold(SignedAmount::ZERO, |acc, e| acc + e.amount); - - let funding_fee_event_ids = funding_fee_events - .iter() - .map(|event| event.id) - .collect::>(); - - let funding_fee = match funding_fee_amount.to_sat() { - 0 => FundingFee::Zero, - n if n.is_positive() => FundingFee::TraderPays(Amount::from_sat(n.unsigned_abs())), - n => FundingFee::CoordinatorPays(Amount::from_sat(n.unsigned_abs())), - }; - - let collateral_reserve_coordinator = self - .node - .inner - .get_dlc_channel_usable_balance(&channel_id)?; - - let collateral_reserve_trader = self - .node - .inner - .get_dlc_channel_usable_balance_counterparty(&channel_id)?; + let funding_fee = funding_fee_from_funding_fee_events(&funding_fee_events); let maintenance_margin_rate = { self.node.settings.read().await.maintenance_margin_rate }; let maintenance_margin_rate = decimal_from_f32(maintenance_margin_rate); - let (position, collateral_reserve_coordinator, _) = position.apply_funding_fee_to_position( - collateral_reserve_coordinator, - collateral_reserve_trader, - funding_fee, - maintenance_margin_rate, - ); + let position = position.apply_funding_fee(funding_fee, maintenance_margin_rate); + + let (collateral_reserve_coordinator, _) = self + .node + .apply_funding_fee_to_channel(channel_id, funding_fee)?; let closing_price = trade_params.average_execution_price(); let position_settlement_amount_coordinator = position @@ -1180,6 +1137,11 @@ impl TradeExecutor { ) .await?; + let funding_fee_event_ids = funding_fee_events + .iter() + .map(|event| event.id) + .collect::>(); + let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.node.pool.clone()); protocol_executor.start_settle_protocol( protocol_id, diff --git a/crates/xxi-node/src/cfd.rs b/crates/xxi-node/src/cfd.rs index 31012ca9e..2a695632c 100644 --- a/crates/xxi-node/src/cfd.rs +++ b/crates/xxi-node/src/cfd.rs @@ -32,21 +32,14 @@ pub fn calculate_margin(open_price: Decimal, quantity: f32, leverage: f32) -> Am } /// Calculate leverage. -pub fn calculate_leverage( - quantity: Decimal, - margin: Amount, - open_price: Decimal, -) -> Result { +pub fn calculate_leverage(quantity: Decimal, margin: Amount, open_price: Decimal) -> Decimal { let margin_btc = Decimal::try_from(margin.to_btc()).expect("to fit"); quantity .checked_div(margin_btc * open_price) - .with_context(|| { - format!( - "Division by zero when computing leverage. \ - Denominator: {margin_btc} * {open_price}" - ) - }) + // We use a leverage of 10_000 to represent a kind of maximum leverage that we can work + // with. + .unwrap_or(Decimal::TEN * Decimal::ONE_THOUSAND) } /// Calculate the quantity from price, collateral and leverage Margin in sats, calculation in BTC diff --git a/mobile/native/src/trade/position/mod.rs b/mobile/native/src/trade/position/mod.rs index 0b7a3819b..13e8d63ba 100644 --- a/mobile/native/src/trade/position/mod.rs +++ b/mobile/native/src/trade/position/mod.rs @@ -8,7 +8,6 @@ use crate::trade::FundingFeeEvent; use crate::trade::Trade; use anyhow::bail; use anyhow::ensure; -use anyhow::Context; use anyhow::Result; use bitcoin::Amount; use bitcoin::SignedAmount; @@ -572,7 +571,7 @@ impl Position { let collateral = self .collateral .checked_sub(funding_fee.to_sat() as u64) - .context("Cannot cover funding fee with margin")?; + .unwrap_or_default(); let collateral = Amount::from_sat(collateral); let leverage = { @@ -580,7 +579,7 @@ impl Position { let average_entry_price = Decimal::try_from(self.average_entry_price).expect("to fit"); - let leverage = calculate_leverage(quantity, collateral, average_entry_price)?; + let leverage = calculate_leverage(quantity, collateral, average_entry_price); leverage.to_f32().expect("to fit") }; From 2544772ab7e1f6b8748944ea08889245d395a867 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Tue, 4 Jun 2024 13:51:31 +1000 Subject: [PATCH 08/12] chore: Get rid of generic start_dlc_protocol method --- coordinator/src/dlc_protocol.rs | 147 ++++++++++++++----------------- coordinator/src/node.rs | 6 +- coordinator/src/node/channel.rs | 8 +- coordinator/src/node/rollover.rs | 4 +- coordinator/src/trade/mod.rs | 20 ++--- 5 files changed, 83 insertions(+), 102 deletions(-) diff --git a/coordinator/src/dlc_protocol.rs b/coordinator/src/dlc_protocol.rs index d7ea9828d..6de2d65dd 100644 --- a/coordinator/src/dlc_protocol.rs +++ b/coordinator/src/dlc_protocol.rs @@ -115,104 +115,69 @@ pub enum DlcProtocolType { }, } -impl DlcProtocolType { - pub fn open_channel(trade_params: &commons::TradeParams, protocol_id: ProtocolId) -> Self { - Self::OpenChannel { - trade_params: TradeParams::new(trade_params, protocol_id, None), - } - } +pub struct DlcProtocolExecutor { + pool: Pool>, +} - pub fn open_position(trade_params: &commons::TradeParams, protocol_id: ProtocolId) -> Self { - Self::OpenPosition { - trade_params: TradeParams::new(trade_params, protocol_id, None), - } +impl DlcProtocolExecutor { + pub fn new(pool: Pool>) -> Self { + DlcProtocolExecutor { pool } } - pub fn resize_position( - trade_params: &commons::TradeParams, + #[allow(clippy::too_many_arguments)] + pub fn start_open_channel_protocol( + &self, protocol_id: ProtocolId, - trader_pnl: Option, - ) -> Self { - Self::ResizePosition { - trade_params: TradeParams::new(trade_params, protocol_id, trader_pnl), - } - } + temporary_contract_id: &ContractId, + temporary_channel_id: &DlcChannelId, + trade_params: &commons::TradeParams, + ) -> Result<()> { + let mut conn = self.pool.get()?; + conn.transaction(|conn| { + let trader_pubkey = trade_params.pubkey; - pub fn settle(trade_params: &commons::TradeParams, protocol_id: ProtocolId) -> Self { - Self::Settle { - trade_params: TradeParams::new(trade_params, protocol_id, None), - } - } -} + db::dlc_protocols::create( + conn, + protocol_id, + None, + Some(temporary_contract_id), + temporary_channel_id, + db::dlc_protocols::DlcProtocolType::OpenChannel, + &trader_pubkey, + )?; -impl DlcProtocolType { - pub fn get_trader_pubkey(&self) -> &PublicKey { - match self { - DlcProtocolType::OpenChannel { - trade_params: TradeParams { trader, .. }, - } => trader, - DlcProtocolType::OpenPosition { - trade_params: TradeParams { trader, .. }, - } => trader, - DlcProtocolType::ResizePosition { - trade_params: TradeParams { trader, .. }, - } => trader, - DlcProtocolType::Settle { - trade_params: TradeParams { trader, .. }, - } => trader, - DlcProtocolType::Close { trader } => trader, - DlcProtocolType::ForceClose { trader } => trader, - DlcProtocolType::Rollover { - rollover_params: RolloverParams { trader_pubkey, .. }, - } => trader_pubkey, - } - } -} + db::trade_params::insert(conn, &TradeParams::new(trade_params, protocol_id, None))?; -pub struct DlcProtocolExecutor { - pool: Pool>, -} + diesel::result::QueryResult::Ok(()) + })?; -impl DlcProtocolExecutor { - pub fn new(pool: Pool>) -> Self { - DlcProtocolExecutor { pool } + Ok(()) } - /// Persist a new DLC protocol and update technical tables in a single transaction. - pub fn start_dlc_protocol( + #[allow(clippy::too_many_arguments)] + pub fn start_open_position_protocol( &self, protocol_id: ProtocolId, previous_protocol_id: Option, - contract_id: Option<&ContractId>, + temporary_contract_id: &ContractId, channel_id: &DlcChannelId, - protocol_type: DlcProtocolType, + trade_params: &commons::TradeParams, ) -> Result<()> { let mut conn = self.pool.get()?; conn.transaction(|conn| { + let trader_pubkey = trade_params.pubkey; + db::dlc_protocols::create( conn, protocol_id, previous_protocol_id, - contract_id, + Some(temporary_contract_id), channel_id, - &protocol_type, - protocol_type.get_trader_pubkey(), + db::dlc_protocols::DlcProtocolType::OpenPosition, + &trader_pubkey, )?; - match protocol_type { - DlcProtocolType::OpenChannel { trade_params } - | DlcProtocolType::OpenPosition { trade_params } - | DlcProtocolType::ResizePosition { trade_params } => { - db::trade_params::insert(conn, &trade_params)?; - } - DlcProtocolType::Settle { .. } => { - unimplemented!("Use dedicated start_settle_protocol method") - } - DlcProtocolType::Rollover { .. } => { - unimplemented!("Use dedicated start_rollover method") - } - _ => {} - } + db::trade_params::insert(conn, &TradeParams::new(trade_params, protocol_id, None))?; diesel::result::QueryResult::Ok(()) })?; @@ -225,7 +190,7 @@ impl DlcProtocolExecutor { &self, protocol_id: ProtocolId, previous_protocol_id: Option, - contract_id: Option<&ContractId>, + temporary_contract_id: Option<&ContractId>, channel_id: &DlcChannelId, trade_params: &commons::TradeParams, realized_pnl: Option, @@ -239,7 +204,7 @@ impl DlcProtocolExecutor { conn, protocol_id, previous_protocol_id, - contract_id, + temporary_contract_id, channel_id, db::dlc_protocols::DlcProtocolType::ResizePosition, &trader_pubkey, @@ -262,7 +227,7 @@ impl DlcProtocolExecutor { &self, protocol_id: ProtocolId, previous_protocol_id: Option, - contract_id: Option<&ContractId>, + contract_id: &ContractId, channel_id: &DlcChannelId, trade_params: &commons::TradeParams, funding_fee_event_ids: Vec, @@ -275,7 +240,7 @@ impl DlcProtocolExecutor { conn, protocol_id, previous_protocol_id, - contract_id, + Some(contract_id), channel_id, db::dlc_protocols::DlcProtocolType::Settle, &trader_pubkey, @@ -296,7 +261,7 @@ impl DlcProtocolExecutor { &self, protocol_id: ProtocolId, previous_protocol_id: Option, - contract_id: &ContractId, + temporary_contract_id: &ContractId, channel_id: &DlcChannelId, rollover_params: RolloverParams, funding_fee_event_ids: Vec, @@ -309,7 +274,7 @@ impl DlcProtocolExecutor { conn, protocol_id, previous_protocol_id, - Some(contract_id), + Some(temporary_contract_id), channel_id, db::dlc_protocols::DlcProtocolType::Rollover, &trader_pubkey, @@ -325,6 +290,28 @@ impl DlcProtocolExecutor { Ok(()) } + #[allow(clippy::too_many_arguments)] + pub fn start_close_channel_protocol( + &self, + protocol_id: ProtocolId, + previous_protocol_id: Option, + channel_id: &DlcChannelId, + trader_id: &PublicKey, + ) -> Result<()> { + let mut conn = self.pool.get()?; + db::dlc_protocols::create( + &mut conn, + protocol_id, + previous_protocol_id, + None, + channel_id, + db::dlc_protocols::DlcProtocolType::Close, + trader_id, + )?; + + Ok(()) + } + pub fn fail_dlc_protocol(&self, protocol_id: ProtocolId) -> Result<()> { let mut conn = self.pool.get()?; db::dlc_protocols::set_dlc_protocol_state_to_failed(&mut conn, protocol_id)?; diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index d487d7abb..1861a645f 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -1,6 +1,5 @@ use crate::db; use crate::dlc_protocol; -use crate::dlc_protocol::DlcProtocolType; use crate::message::OrderbookMessage; use crate::node::storage::NodeStorage; use crate::position::models::PositionState; @@ -596,12 +595,11 @@ impl Node { .transpose()?; let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.pool.clone()); - protocol_executor.start_dlc_protocol( + protocol_executor.start_close_channel_protocol( protocol_id, previous_id, - None, &channel.get_id(), - DlcProtocolType::Close { trader: *node_id }, + node_id, )?; Ok(()) diff --git a/coordinator/src/node/channel.rs b/coordinator/src/node/channel.rs index b9ce472a7..ed393aff2 100644 --- a/coordinator/src/node/channel.rs +++ b/coordinator/src/node/channel.rs @@ -77,14 +77,12 @@ impl Node { let protocol_id = self.inner.close_dlc_channel(channel_id, false).await?; let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.pool.clone()); - protocol_executor.start_dlc_protocol( + + protocol_executor.start_close_channel_protocol( protocol_id, previous_id, - None, &channel.get_id(), - DlcProtocolType::Close { - trader: to_secp_pk_30(channel.get_counter_party_id()), - }, + &to_secp_pk_30(channel.get_counter_party_id()), )?; Ok(()) diff --git a/coordinator/src/node/rollover.rs b/coordinator/src/node/rollover.rs index 69068e6cd..631726113 100644 --- a/coordinator/src/node/rollover.rs +++ b/coordinator/src/node/rollover.rs @@ -294,7 +294,7 @@ impl Node { .map(xxi_node::message_handler::FundingFeeEvent::from) .collect(); - let contract_id = self + let temporary_contract_id = self .inner .propose_rollover( dlc_channel_id, @@ -309,7 +309,7 @@ impl Node { .start_rollover( protocol_id, previous_id, - &contract_id, + &temporary_contract_id, dlc_channel_id, RolloverParams { protocol_id, diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index 1986d5223..15fc17060 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -2,7 +2,6 @@ use crate::compute_relative_contracts; use crate::db; use crate::decimal_from_f32; use crate::dlc_protocol; -use crate::dlc_protocol::DlcProtocolType; use crate::funding_fee::funding_fee_from_funding_fee_events; use crate::message::OrderbookMessage; use crate::node::Node; @@ -580,12 +579,11 @@ impl TradeExecutor { .context("Could not propose DLC channel")?; let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.node.pool.clone()); - protocol_executor.start_dlc_protocol( + protocol_executor.start_open_channel_protocol( protocol_id, - None, - Some(&temporary_contract_id), + &temporary_contract_id, &temporary_channel_id, - DlcProtocolType::open_channel(trade_params, protocol_id), + trade_params, )?; // After the DLC channel has been proposed the position can be created. This fixes @@ -741,7 +739,7 @@ impl TradeExecutor { let protocol_id = ProtocolId::new(); let channel = self.node.inner.get_dlc_channel_by_id(&dlc_channel_id)?; - let previous_id = match channel.get_reference_id() { + let previous_protocol_id = match channel.get_reference_id() { Some(reference_id) => Some(ProtocolId::try_from(reference_id)?), None => None, }; @@ -759,12 +757,12 @@ impl TradeExecutor { .context("Could not propose reopen DLC channel update")?; let protocol_executor = dlc_protocol::DlcProtocolExecutor::new(self.node.pool.clone()); - protocol_executor.start_dlc_protocol( + protocol_executor.start_open_position_protocol( protocol_id, - previous_id, - Some(&temporary_contract_id), + previous_protocol_id, + &temporary_contract_id, &channel.get_id(), - DlcProtocolType::open_position(trade_params, protocol_id), + trade_params, )?; // TODO(holzeis): The position should only get created after the dlc protocol has finished @@ -1146,7 +1144,7 @@ impl TradeExecutor { protocol_executor.start_settle_protocol( protocol_id, previous_id, - Some(&contract_id), + &contract_id, &channel.get_id(), trade_params, funding_fee_event_ids, From 305d8b20f28517de271b1ef960624ca719357858 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Tue, 4 Jun 2024 16:17:24 +1000 Subject: [PATCH 09/12] chore(coordinator): Test Position::apply_funding_fee --- coordinator/src/node/channel.rs | 6 + coordinator/src/node/rollover.rs | 2 - coordinator/src/position/models.rs | 219 ++++++++++++++++++++++++++++- coordinator/src/trade/mod.rs | 12 +- 4 files changed, 225 insertions(+), 14 deletions(-) diff --git a/coordinator/src/node/channel.rs b/coordinator/src/node/channel.rs index ed393aff2..6655dec0d 100644 --- a/coordinator/src/node/channel.rs +++ b/coordinator/src/node/channel.rs @@ -299,6 +299,8 @@ impl Node { .inner .get_dlc_channel_usable_balance_counterparty(&dlc_channel_id)?; + // The party earning the funding fee receives adds it to their collateral reserve. + // Conversely, the party paying the funding fee subtracts it from their margin. let reserves = match funding_fee { FundingFee::Zero => (collateral_reserve_coordinator, collateral_reserve_trader), FundingFee::CoordinatorPays(funding_fee) => { @@ -311,6 +313,8 @@ impl Node { new_collateral_reserve_trader.to_unsigned().expect("to fit"); ( + // The coordinator pays the funding fee using their margin. Thus, their + // collateral reserve remains unchanged. collateral_reserve_coordinator, new_collateral_reserve_trader, ) @@ -328,6 +332,8 @@ impl Node { ( new_collateral_reserve_coordinator, + // The trader pays the funding fee using their margin. Thus, their + // collateral reserve remains unchanged. collateral_reserve_trader, ) } diff --git a/coordinator/src/node/rollover.rs b/coordinator/src/node/rollover.rs index 631726113..079515071 100644 --- a/coordinator/src/node/rollover.rs +++ b/coordinator/src/node/rollover.rs @@ -349,5 +349,3 @@ impl Node { Ok(position.is_some()) } } - -// TODO: Test `apply_rollover_to_position`. diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index 2b4e070d6..222b22b9d 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -68,7 +68,7 @@ pub enum PositionState { } /// The trading position for a user identified by `trader`. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq)] pub struct Position { pub id: i32, pub trader: PublicKey, @@ -226,13 +226,13 @@ impl Position { let new_coordinator_leverage = calculate_leverage(quantity, new_coordinator_margin, average_entry_price); - let new_coordinator_liquidation_price = match self.trader_direction { - Direction::Long => calculate_short_liquidation_price( + let new_coordinator_liquidation_price = match self.trader_direction.opposite() { + Direction::Long => calculate_long_liquidation_price( new_coordinator_leverage, average_entry_price, maintenance_margin_rate, ), - Direction::Short => calculate_long_liquidation_price( + Direction::Short => calculate_short_liquidation_price( new_coordinator_leverage, average_entry_price, maintenance_margin_rate, @@ -580,8 +580,11 @@ impl std::fmt::Debug for Position { #[cfg(test)] mod tests { use super::*; + use crate::trade::liquidation_price; + use proptest::prelude::*; use rust_decimal_macros::dec; use std::str::FromStr; + use xxi_node::cfd::BTCUSD_MAX_PRICE; #[test] fn position_calculate_coordinator_settlement_amount() { @@ -1095,6 +1098,160 @@ mod tests { ); } + proptest! { + #[test] + fn zero_funding_fee_has_no_effect( + is_long_trader in proptest::bool::ANY, + coordinator_leverage in 1u8..5, + trader_leverage in 1u8..5, + initial_price in 10_000u64..BTCUSD_MAX_PRICE, + quantity in 1u64..20_000, + maintenance_margin_rate in 0.05f32..0.3, + ) { + let maintenance_margin_rate = Decimal::try_from(maintenance_margin_rate).unwrap(); + + let before = new_position( + is_long_trader, + initial_price, + quantity, + coordinator_leverage, + trader_leverage, + maintenance_margin_rate + ); + + let after = before.apply_funding_fee(FundingFee::Zero, maintenance_margin_rate); + + prop_assert_eq!(before, after); + } + } + + proptest! { + #[test] + fn coordinator_pays_funding_fee( + is_long_trader in proptest::bool::ANY, + coordinator_leverage in 1u8..5, + trader_leverage in 1u8..5, + initial_price in 10_000u64..BTCUSD_MAX_PRICE, + quantity in 1u64..20_000, + maintenance_margin_rate in 0.05f32..0.3, + funding_fee in 1u64..100_000, + ) { + let maintenance_margin_rate = Decimal::try_from(maintenance_margin_rate).unwrap(); + + let before = new_position( + is_long_trader, + initial_price, + quantity, + coordinator_leverage, + trader_leverage, + maintenance_margin_rate + ); + + let after = before + .apply_funding_fee( + FundingFee::CoordinatorPays(Amount::from_sat(funding_fee)), + maintenance_margin_rate + ); + + prop_assert_eq!(before.trader_margin, after.trader_margin); + prop_assert_eq!(before.trader_leverage, after.trader_leverage); + prop_assert_eq!(before.trader_liquidation_price, after.trader_liquidation_price); + + prop_assert!(before.coordinator_margin > after.coordinator_margin); + prop_assert!(after.coordinator_leverage > before.coordinator_leverage); + + // We cannot assert on the liquidation price approaching the initial price because the + // rounding error can make the liquidation price go ever so slightly the wrong way for + // very small funding fees (relative to the coordinator margin). + } + } + + proptest! { + #[test] + fn trader_pays_funding_fee( + is_long_trader in proptest::bool::ANY, + coordinator_leverage in 1u8..5, + trader_leverage in 1u8..5, + initial_price in 10_000u64..BTCUSD_MAX_PRICE, + quantity in 1u64..20_000, + maintenance_margin_rate in 0.05f32..0.3, + funding_fee in 1u64..100_000, + ) { + let maintenance_margin_rate = Decimal::try_from(maintenance_margin_rate).unwrap(); + + let before = new_position( + is_long_trader, + initial_price, + quantity, + coordinator_leverage, + trader_leverage, + maintenance_margin_rate + ); + + let after = before + .apply_funding_fee( + FundingFee::TraderPays(Amount::from_sat(funding_fee)), + maintenance_margin_rate + ); + + prop_assert_eq!(before.coordinator_margin, after.coordinator_margin); + prop_assert_eq!(before.coordinator_leverage, after.coordinator_leverage); + prop_assert_eq!(before.coordinator_liquidation_price, after.coordinator_liquidation_price); + + prop_assert!(before.trader_margin > after.trader_margin); + prop_assert!(after.trader_leverage > before.trader_leverage); + + // We cannot assert on the liquidation price approaching the initial price because the + // rounding error can make the liquidation price go ever so slightly the wrong way for + // very small funding fees (relative to the trader margin). + } + } + + #[test] + fn liquidation_price_gets_worse_if_party_pays_funding_fee() { + let maintenance_margin_rate = Decimal::try_from(0.05).unwrap(); + + // Long coordinator pays funding fee. + let before = new_position(false, 50_000, 1_000, 2, 2, maintenance_margin_rate); + + let after = before.apply_funding_fee( + FundingFee::CoordinatorPays(Amount::from_sat(50_000)), + maintenance_margin_rate, + ); + + assert!(after.coordinator_liquidation_price > before.coordinator_liquidation_price); + + // Short coordinator pays funding fee. + let before = new_position(true, 50_000, 1_000, 2, 2, maintenance_margin_rate); + + let after = before.apply_funding_fee( + FundingFee::CoordinatorPays(Amount::from_sat(50_000)), + maintenance_margin_rate, + ); + + assert!(after.coordinator_liquidation_price < before.coordinator_liquidation_price); + + // Long trader pays funding fee. + let before = new_position(true, 50_000, 1_000, 2, 2, maintenance_margin_rate); + + let after = before.apply_funding_fee( + FundingFee::TraderPays(Amount::from_sat(50_000)), + maintenance_margin_rate, + ); + + assert!(after.trader_liquidation_price > before.trader_liquidation_price); + + // Short trader pays funding fee. + let before = new_position(false, 50_000, 1_000, 2, 2, maintenance_margin_rate); + + let after = before.apply_funding_fee( + FundingFee::TraderPays(Amount::from_sat(50_000)), + maintenance_margin_rate, + ); + + assert!(after.trader_liquidation_price < before.trader_liquidation_price); + } + fn dummy_quote(bid: u64, ask: u64) -> Quote { Quote { bid_size: 0, @@ -1106,6 +1263,60 @@ mod tests { } } + // TODO: We desperately need a function `Position::new` to ensure that a `Position` can only be + // created with values that match, so that, for example, the leverage depends on the margin. Atm + // we trust whoever builds `Position` to respect certain invariants. And it means that we have + // to duplicate the logic in tests. + fn new_position( + is_long_trader: bool, + initial_price: u64, + quantity: u64, + coordinator_leverage: u8, + trader_leverage: u8, + maintenance_margin_rate: Decimal, + ) -> Position { + let trader_direction = if is_long_trader { + Direction::Long + } else { + Direction::Short + }; + + let initial_price = Decimal::from(initial_price); + let quantity = quantity as f32; + + let coordinator_margin = + calculate_margin(initial_price, quantity, coordinator_leverage as f32); + + let trader_margin = calculate_margin(initial_price, quantity, trader_leverage as f32); + + let coordinator_liquidation_price = liquidation_price( + initial_price, + Decimal::from(coordinator_leverage), + trader_direction.opposite(), + maintenance_margin_rate, + ); + + let trader_liquidation_price = liquidation_price( + initial_price, + Decimal::from(trader_leverage), + trader_direction, + maintenance_margin_rate, + ); + + Position { + trader_direction, + quantity, + coordinator_leverage: coordinator_leverage as f32, + trader_leverage: trader_leverage as f32, + coordinator_margin, + trader_margin, + coordinator_liquidation_price: f32_from_decimal(coordinator_liquidation_price), + trader_liquidation_price: f32_from_decimal(trader_liquidation_price), + average_entry_price: f32_from_decimal(initial_price), + ..Position::dummy() + } + } + impl Position { fn dummy() -> Self { Position { diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index 15fc17060..c563c6724 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -1707,19 +1707,15 @@ fn margin_coordinator(trade_params: &TradeParams, coordinator_leverage: f32) -> ) } -fn liquidation_price( +pub fn liquidation_price( price: Decimal, - coordinator_leverage: Decimal, + leverage: Decimal, direction: Direction, maintenance_margin: Decimal, ) -> Decimal { match direction { - Direction::Long => { - calculate_long_liquidation_price(coordinator_leverage, price, maintenance_margin) - } - Direction::Short => { - calculate_short_liquidation_price(coordinator_leverage, price, maintenance_margin) - } + Direction::Long => calculate_long_liquidation_price(leverage, price, maintenance_margin), + Direction::Short => calculate_short_liquidation_price(leverage, price, maintenance_margin), } } From c2cdb647c84e8ee24840ac7c2cfd59010a5ce3cb Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Tue, 4 Jun 2024 16:28:22 +1000 Subject: [PATCH 10/12] chore(app): Rename Positions tab to Position We only support one position at a time at the moment. Co-authored-by: Richard Holzeis --- mobile/lib/features/trade/trade_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/features/trade/trade_screen.dart b/mobile/lib/features/trade/trade_screen.dart index db66c2372..5fdf9bfe6 100644 --- a/mobile/lib/features/trade/trade_screen.dart +++ b/mobile/lib/features/trade/trade_screen.dart @@ -102,7 +102,7 @@ class TradeScreen extends StatelessWidget { Expanded( child: TradeTabs( tabs: const [ - "Positions", + "Position", "Orders", "Trades", ], From 3a85beee0049ed684095a1755991f1ed7bfad398 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Tue, 4 Jun 2024 16:40:14 +1000 Subject: [PATCH 11/12] chore(app): Insert Trades in batches --- mobile/native/src/db/mod.rs | 6 ++++-- mobile/native/src/db/models.rs | 8 +++++--- mobile/native/src/trade/position/handler.rs | 10 +++------- mobile/native/src/trade/trades/handler.rs | 8 +++++--- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/mobile/native/src/db/mod.rs b/mobile/native/src/db/mod.rs index 03689f299..3dea78ef0 100644 --- a/mobile/native/src/db/mod.rs +++ b/mobile/native/src/db/mod.rs @@ -497,10 +497,12 @@ pub fn get_all_trades() -> Result> { Ok(trades) } -pub fn insert_trade(trade: crate::trade::Trade) -> Result<()> { +pub fn insert_trades(trades: &[crate::trade::Trade]) -> Result<()> { let mut db = connection()?; - NewTrade::insert(&mut db, trade.into())?; + let trades = trades.iter().copied().map(|trade| trade.into()).collect(); + + NewTrade::insert(&mut db, trades)?; Ok(()) } diff --git a/mobile/native/src/db/models.rs b/mobile/native/src/db/models.rs index a43cb3bda..5952af264 100644 --- a/mobile/native/src/db/models.rs +++ b/mobile/native/src/db/models.rs @@ -1092,12 +1092,14 @@ impl Trade { } impl NewTrade { - pub fn insert(conn: &mut SqliteConnection, trade: Self) -> Result<()> { + pub fn insert(conn: &mut SqliteConnection, trades: Vec) -> Result<()> { + let len = trades.len(); + let affected_rows = diesel::insert_into(trades::table) - .values(trade) + .values(trades) .execute(conn)?; - ensure!(affected_rows > 0, "Could not insert trade"); + ensure!(affected_rows >= len, "Could not insert trade(s)"); Ok(()) } diff --git a/mobile/native/src/trade/position/handler.rs b/mobile/native/src/trade/position/handler.rs index a4482e09b..d460d9897 100644 --- a/mobile/native/src/trade/position/handler.rs +++ b/mobile/native/src/trade/position/handler.rs @@ -4,7 +4,7 @@ use crate::event::EventInternal; use crate::trade::order::Order; use crate::trade::position::Position; use crate::trade::position::PositionState; -use crate::trade::trades::handler::new_trade; +use crate::trade::trades::handler::new_trades; use crate::trade::FundingFeeEvent; use anyhow::bail; use anyhow::Context; @@ -182,9 +182,7 @@ pub fn update_position_after_dlc_channel_creation_or_update( } }; - for trade in trades { - new_trade(trade)?; - } + new_trades(trades)?; event::publish(&EventInternal::PositionUpdateNotification(position)); @@ -227,9 +225,7 @@ pub fn update_position_after_dlc_closure(filled_order: Order) -> Result<()> { ); } - for trade in trades { - new_trade(trade)?; - } + new_trades(trades)?; db::delete_positions()?; diff --git a/mobile/native/src/trade/trades/handler.rs b/mobile/native/src/trade/trades/handler.rs index 93abe2221..4a3b8124b 100644 --- a/mobile/native/src/trade/trades/handler.rs +++ b/mobile/native/src/trade/trades/handler.rs @@ -4,10 +4,12 @@ use crate::event::EventInternal; use crate::trade::Trade; use anyhow::Result; -pub fn new_trade(trade: Trade) -> Result<()> { - db::insert_trade(trade)?; +pub fn new_trades(trades: Vec) -> Result<()> { + db::insert_trades(&trades)?; - event::publish(&EventInternal::NewTrade(trade)); + for trade in trades { + event::publish(&EventInternal::NewTrade(trade)); + } Ok(()) } From c8a55a866443204cf6edda16469aa51ac4a2a8bd Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Tue, 4 Jun 2024 20:57:45 +1000 Subject: [PATCH 12/12] fix(app): Fix UI tests in trade_test.dart --- mobile/test/trade_test.dart | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/mobile/test/trade_test.dart b/mobile/test/trade_test.dart index d2f8b56be..f3e362fd9 100644 --- a/mobile/test/trade_test.dart +++ b/mobile/test/trade_test.dart @@ -8,6 +8,8 @@ import 'package:get_10101/common/application/channel_info_service.dart'; import 'package:get_10101/common/application/tentenone_config_change_notifier.dart'; @GenerateNiceMocks([MockSpec()]) import 'package:get_10101/common/dlc_channel_service.dart'; +@GenerateNiceMocks([MockSpec()]) +import 'package:get_10101/features/trade/application/trade_service.dart'; import 'package:get_10101/common/dlc_channel_change_notifier.dart'; import 'package:get_10101/common/domain/dlc_channel.dart'; import 'package:get_10101/common/domain/model.dart'; @@ -20,9 +22,11 @@ import 'package:get_10101/features/trade/application/trade_values_service.dart'; import 'package:get_10101/features/trade/channel_creation_flow/channel_configuration_screen.dart'; import 'package:get_10101/features/trade/domain/direction.dart'; import 'package:get_10101/features/trade/domain/leverage.dart'; +import 'package:get_10101/features/trade/funding_rate_change_notifier.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; import 'package:get_10101/features/trade/submit_order_change_notifier.dart'; +import 'package:get_10101/features/trade/trade_change_notifier.dart'; import 'package:get_10101/features/trade/trade_screen.dart'; import 'package:get_10101/features/trade/trade_theme.dart'; import 'package:get_10101/features/trade/trade_value_change_notifier.dart'; @@ -93,11 +97,14 @@ void main() { MockWalletService walletService = MockWalletService(); MockDlcChannelService dlcChannelService = MockDlcChannelService(); MockOrderService orderService = MockOrderService(); + MockTradeService tradeService = MockTradeService(); testWidgets('Given rates, the trade screen show bid/ask price', (tester) async { final tradeValuesChangeNotifier = TradeValuesChangeNotifier(tradeValueService); SubmitOrderChangeNotifier submitOrderChangeNotifier = SubmitOrderChangeNotifier(orderService); PositionChangeNotifier positionChangeNotifier = PositionChangeNotifier(positionService); + TradeChangeNotifier tradeChangeNotifier = TradeChangeNotifier(tradeService); + FundingRateChangeNotifier fundingRateChangeNotifier = FundingRateChangeNotifier(); const askPrice = 30001.0; const bidPrice = 30000.0; @@ -114,6 +121,8 @@ void main() { ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), ChangeNotifierProvider(create: (context) => OrderChangeNotifier(orderService)), ChangeNotifierProvider(create: (context) => positionChangeNotifier), + ChangeNotifierProvider(create: (context) => tradeChangeNotifier), + ChangeNotifierProvider(create: (context) => fundingRateChangeNotifier), ], child: TestWrapperWithTradeTheme( router: buildGoRouterMock(TradeScreen.route), @@ -124,11 +133,11 @@ void main() { // We check if all the widgets are here which we want to see var tradeScreenAskPriceWidget = find.byKey(tradeScreenAskPrice); expect(tradeScreenAskPriceWidget, findsOneWidget); - var assertedPrice = assertPrice(tester, tradeScreenAskPriceWidget, "\$ 30,001"); + var assertedPrice = assertPrice(tester, tradeScreenAskPriceWidget, "\$30,001"); logger.i("Ask price found: $assertedPrice"); var tradeScreenBidPriceWidget = find.byKey(tradeScreenBidPrice); expect(tradeScreenBidPriceWidget, findsOneWidget); - assertedPrice = assertPrice(tester, tradeScreenBidPriceWidget, "\$ 30,000"); + assertedPrice = assertPrice(tester, tradeScreenBidPriceWidget, "\$30,000"); logger.i("Bid price found: $assertedPrice"); // Buy and sell buttons are also here @@ -152,6 +161,8 @@ void main() { TenTenOneConfigChangeNotifier(channelConstraintsService); DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); OrderChangeNotifier orderChangeNotifier = OrderChangeNotifier(orderService); + TradeChangeNotifier tradeChangeNotifier = TradeChangeNotifier(tradeService); + FundingRateChangeNotifier fundingRateChangeNotifier = FundingRateChangeNotifier(); const askPrice = 30001.0; const bidPrice = 30000.0; @@ -209,6 +220,8 @@ void main() { ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), ChangeNotifierProvider(create: (context) => positionChangeNotifier), ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), + ChangeNotifierProvider(create: (context) => tradeChangeNotifier), + ChangeNotifierProvider(create: (context) => fundingRateChangeNotifier), ], child: TestWrapperWithTradeTheme( router: buildGoRouterMock(TradeScreen.route), @@ -322,6 +335,8 @@ void main() { TenTenOneConfigChangeNotifier(channelConstraintsService); DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); OrderChangeNotifier orderChangeNotifier = OrderChangeNotifier(orderService); + TradeChangeNotifier tradeChangeNotifier = TradeChangeNotifier(tradeService); + FundingRateChangeNotifier fundingRateChangeNotifier = FundingRateChangeNotifier(); const askPrice = 30001.0; const bidPrice = 30000.0; @@ -380,6 +395,8 @@ void main() { ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), ChangeNotifierProvider(create: (context) => positionChangeNotifier), ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), + ChangeNotifierProvider(create: (context) => tradeChangeNotifier), + ChangeNotifierProvider(create: (context) => fundingRateChangeNotifier), ], child: TestWrapperWithTradeTheme( router: buildGoRouterMock(ChannelConfigurationScreen.route), @@ -472,6 +489,10 @@ void main() { DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); dlcChannelChangeNotifier.initialize(); + TradeChangeNotifier tradeChangeNotifier = TradeChangeNotifier(tradeService); + + FundingRateChangeNotifier fundingRateChangeNotifier = FundingRateChangeNotifier(); + final tradeValuesChangeNotifier = TradeValuesChangeNotifier(tradeValueService); const askPrice = 30000.0; @@ -493,6 +514,8 @@ void main() { ChangeNotifierProvider(create: (context) => walletChangeNotifier), ChangeNotifierProvider(create: (context) => configChangeNotifier), ChangeNotifierProvider(create: (context) => dlcChannelChangeNotifier), + ChangeNotifierProvider(create: (context) => tradeChangeNotifier), + ChangeNotifierProvider(create: (context) => fundingRateChangeNotifier), ], child: TestWrapperWithTradeTheme( router: buildGoRouterMock(TradeScreen.route),