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..8eabdb957 --- /dev/null +++ b/coordinator/src/db/funding_fee_events.rs @@ -0,0 +1,200 @@ +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. +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..0e0cf1922 --- /dev/null +++ b/coordinator/src/db/funding_rates.rs @@ -0,0 +1,97 @@ +use crate::schema::funding_rates; +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)] +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: &[xxi_node::commons::FundingRate], +) -> Result<()> { + let funding_rates = funding_rates + .iter() + .copied() + .map(NewFundingRate::from) + .collect::>(); + + 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(()) +} + +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> { + 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(xxi_node::commons::FundingRate::from)) +} + +impl From for xxi_node::commons::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 a60547752..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), @@ -362,7 +419,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 +429,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 +475,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/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 791c541e9..6de2d65dd 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, @@ -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, @@ -102,100 +115,174 @@ 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; + + db::dlc_protocols::create( + conn, + protocol_id, + None, + Some(temporary_contract_id), + temporary_channel_id, + db::dlc_protocols::DlcProtocolType::OpenChannel, + &trader_pubkey, + )?; + + db::trade_params::insert(conn, &TradeParams::new(trade_params, protocol_id, None))?; + + diesel::result::QueryResult::Ok(()) + })?; + + Ok(()) } - pub fn settle(trade_params: &commons::TradeParams, protocol_id: ProtocolId) -> Self { - Self::Settle { - trade_params: TradeParams::new(trade_params, protocol_id, None), - } + #[allow(clippy::too_many_arguments)] + pub fn start_open_position_protocol( + &self, + protocol_id: ProtocolId, + previous_protocol_id: Option, + temporary_contract_id: &ContractId, + channel_id: &DlcChannelId, + 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, + Some(temporary_contract_id), + channel_id, + db::dlc_protocols::DlcProtocolType::OpenPosition, + &trader_pubkey, + )?; + + db::trade_params::insert(conn, &TradeParams::new(trade_params, protocol_id, None))?; + + diesel::result::QueryResult::Ok(()) + })?; + + Ok(()) } -} -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 { trader } => trader, - } + #[allow(clippy::too_many_arguments)] + pub fn start_resize_protocol( + &self, + protocol_id: ProtocolId, + previous_protocol_id: Option, + temporary_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, + temporary_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 struct DlcProtocolExecutor { - pool: Pool>, -} + pub fn start_settle_protocol( + &self, + protocol_id: ProtocolId, + previous_protocol_id: Option, + contract_id: &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; -impl DlcProtocolExecutor { - pub fn new(pool: Pool>) -> Self { - DlcProtocolExecutor { pool } + db::dlc_protocols::create( + conn, + protocol_id, + previous_protocol_id, + Some(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(()) } - /// 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`] - pub fn start_dlc_protocol( + /// 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: Option<&ContractId>, + temporary_contract_id: &ContractId, channel_id: &DlcChannelId, - protocol_type: DlcProtocolType, + 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, - contract_id, + Some(temporary_contract_id), channel_id, - protocol_type.clone(), - protocol_type.get_trader_pubkey(), + db::dlc_protocols::DlcProtocolType::Rollover, + &trader_pubkey, )?; - match protocol_type { - DlcProtocolType::OpenChannel { trade_params } - | DlcProtocolType::OpenPosition { trade_params } - | DlcProtocolType::ResizePosition { trade_params } - | DlcProtocolType::Settle { trade_params } => { - db::trade_params::insert(conn, protocol_id, &trade_params)?; - } - _ => {} - } + db::protocol_funding_fee_events::insert(conn, protocol_id, &funding_fee_event_ids)?; + + db::rollover_params::insert(conn, &rollover_params)?; diesel::result::QueryResult::Ok(()) })?; @@ -203,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)?; @@ -210,7 +319,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, @@ -250,7 +360,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, @@ -260,16 +370,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 { .. } => { @@ -310,13 +422,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, @@ -368,8 +484,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) => { @@ -405,6 +521,8 @@ impl DlcProtocolExecutor { db::trades::insert(conn, new_trade)?; + db::funding_fee_events::mark_as_paid(conn, protocol_id)?; + Ok(()) } @@ -503,11 +621,13 @@ impl DlcProtocolExecutor { db::trades::insert(conn, new_trade)?; + db::funding_fee_events::mark_as_paid(conn, protocol_id)?; + 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 +635,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 +645,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..20ae3a822 --- /dev/null +++ b/coordinator/src/funding_fee.rs @@ -0,0 +1,364 @@ +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; +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); + +/// 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, +} + +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::*; + 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/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/lib.rs b/coordinator/src/lib.rs index 106f2f969..42ad71a3e 100644 --- a/coordinator/src/lib.rs +++ b/coordinator/src/lib.rs @@ -30,6 +30,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 +115,10 @@ pub struct ChannelOpeningParams { pub coordinator_reserve: Amount, pub external_funding: Option, } + +#[derive(Debug, Clone, Copy)] +pub enum FundingFee { + Zero, + CoordinatorPays(Amount), + TraderPays(Amount), +} 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 70151b19d..6655dec0d 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; @@ -76,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(()) @@ -289,6 +288,60 @@ 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)?; + + // 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) => { + 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"); + + ( + // The coordinator pays the funding fee using their margin. Thus, their + // collateral reserve remains unchanged. + 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, + // The trader pays the funding fee using their margin. Thus, their + // collateral reserve remains unchanged. + 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 353f57698..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,7 +44,19 @@ 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 { + // 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"); let trader_liquidation_price = @@ -54,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 5045cb304..079515071 100644 --- a/coordinator/src/node/rollover.rs +++ b/coordinator/src/node/rollover.rs @@ -1,55 +1,42 @@ 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::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 anyhow::bail; use anyhow::Context; use anyhow::Result; use bitcoin::secp256k1::PublicKey; -use bitcoin::secp256k1::XOnlyPublicKey; use bitcoin::Network; 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::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::commons; -use xxi_node::commons::ContractSymbol; 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,55 +82,6 @@ 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, - }) - } - - pub fn event_id(&self) -> String { - let maturity_time = self.maturity_time().unix_timestamp(); - format!("{}{maturity_time}", self.contract_symbol) - } - - /// 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) - } -} - impl Node { async fn check_if_eligible_for_rollover( &self, @@ -156,10 +94,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 +114,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 +153,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 +172,111 @@ 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); + + 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"); - tracing::debug!(node_id=%rollover.counterparty_pubkey, %protocol_id, "Rollover dlc channel"); + let funding_fee_events = + db::funding_fee_events::get_outstanding_fees(conn, trader_pubkey, position.id)?; - let contract_input: ContractInput = rollover.clone().into(); + let funding_fee = funding_fee_from_funding_fee_events(&funding_fee_events); + + 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, + 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, + leverage_trader, + 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 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 +284,55 @@ impl Node { None => None, }; - let contract_id = self + 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) + .collect(); + + let temporary_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, + &temporary_contract_id, + dlc_channel_id, + RolloverParams { + protocol_id, + trader_pubkey, + margin_coordinator, + margin_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, + ) + .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(()) @@ -289,204 +349,3 @@ impl Node { Ok(position.is_some()) } } - -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() - } -} diff --git a/coordinator/src/orderbook/websocket.rs b/coordinator/src/orderbook/websocket.rs index f4f30b2a2..01b0360a3 100644 --- a/coordinator/src/orderbook/websocket.rs +++ b/coordinator/src/orderbook/websocket.rs @@ -1,4 +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; @@ -249,6 +251,58 @@ 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}" + ); + } + } + + 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/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..222b22b9d 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; @@ -31,16 +36,16 @@ 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, } -#[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, PartialEq)] pub struct Position { pub id: i32, pub trader: PublicKey, @@ -78,8 +83,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 +146,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")?; @@ -197,6 +202,84 @@ impl Position { trade_params.average_execution_price(), ) } + + #[must_use] + pub fn apply_funding_fee( + self, + funding_fee: FundingFee, + maintenance_margin_rate: Decimal, + ) -> 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, + 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().unwrap_or(Amount::ZERO); + + let new_coordinator_leverage = + calculate_leverage(quantity, new_coordinator_margin, average_entry_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_short_liquidation_price( + new_coordinator_leverage, + average_entry_price, + maintenance_margin_rate, + ), + }; + + 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 + } + } + 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().unwrap_or(Amount::ZERO); + + let new_trader_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( + new_trader_leverage, + average_entry_price, + maintenance_margin_rate, + ), + Direction::Short => calculate_short_liquidation_price( + new_trader_leverage, + average_entry_price, + maintenance_margin_rate, + ), + }; + + 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 + } + } + } + } } /// Calculate the settlement amount for the coordinator, based on the PNL and the order-matching @@ -221,8 +304,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 +313,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 +330,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 +407,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 +430,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 { @@ -496,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() { @@ -511,7 +598,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 +609,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 +634,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 +645,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 +670,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 +681,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 +718,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -656,7 +743,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -681,7 +768,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -706,7 +793,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -731,7 +818,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -756,7 +843,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -781,7 +868,7 @@ mod tests { ) .unwrap(); - assert!(settlement_coordinator < margin_coordinator); + assert!(settlement_coordinator < margin_coordinator.to_sat()); } #[test] @@ -806,7 +893,7 @@ mod tests { ) .unwrap(); - assert!(margin_coordinator < settlement_coordinator); + assert!(margin_coordinator.to_sat() < settlement_coordinator); } #[test] @@ -1011,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, @@ -1022,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 { @@ -1034,7 +1329,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 +1340,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/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..08ac64ec5 100644 --- a/coordinator/src/routes/admin.rs +++ b/coordinator/src/routes/admin.rs @@ -1,6 +1,7 @@ use crate::collaborative_revert; use crate::db; use crate::parse_dlc_channel_id; +use crate::position::models::Position; use crate::referrals; use crate::routes::AppState; use crate::settings::SettingsFile; @@ -16,6 +17,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 +414,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 +586,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 +667,52 @@ 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(xxi_node::commons::FundingRate::from) + .collect::>(); + + db::funding_rates::insert(&mut conn, &funding_rates) + .map_err(|e| AppError::BadRequest(format!("{e:#}")))?; + + 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 xxi_node::commons::FundingRate { + fn from(value: FundingRate) -> Self { + xxi_node::commons::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..f9ac8cb72 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/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index a490fb56d..c563c6724 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -2,7 +2,7 @@ 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; use crate::orderbook::db::matches; @@ -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 { @@ -581,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 @@ -608,7 +605,7 @@ impl TradeExecutor { temporary_contract_id, leverage_coordinator, stable, - Amount::from_sat(order_matching_fee), + order_matching_fee, ) .await } @@ -618,8 +615,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 +637,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 +724,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, @@ -742,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, }; @@ -760,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 @@ -776,7 +773,7 @@ impl TradeExecutor { temporary_contract_id, leverage_coordinator, stable, - Amount::from_sat(order_matching_fee), + order_matching_fee, ) .await } @@ -800,11 +797,23 @@ impl TradeExecutor { let peer_id = trade_params.pubkey; + // 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 = funding_fee_from_funding_fee_events(&funding_fee_events); + let maintenance_margin_rate = { Decimal::try_from(self.node.settings.read().await.maintenance_margin_rate) .expect("to fit") }; + let position = position.apply_funding_fee(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, order_id = %trade_params.filled_with.order_id, @@ -812,27 +821,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, )?; @@ -865,13 +875,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, ) @@ -937,13 +947,20 @@ 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_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( @@ -1010,11 +1027,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, }; @@ -1044,6 +1061,22 @@ 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 = 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 = 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 .calculate_coordinator_settlement_amount( @@ -1051,10 +1084,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(); @@ -1070,6 +1099,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 @@ -1098,13 +1135,19 @@ 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_dlc_protocol( + protocol_executor.start_settle_protocol( protocol_id, previous_id, - Some(&contract_id), + &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( @@ -1176,8 +1219,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 +1354,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 +1391,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 +1468,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 +1492,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 +1527,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 +1589,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 +1615,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 +1625,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 +1653,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 +1691,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 +1699,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, @@ -1677,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), } } @@ -1857,8 +1883,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/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..492e56619 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,15 @@ impl Senders { native::event::EventInternal::LnPaymentReceived { .. } => { // ignored } + native::event::EventInternal::NewTrade(_) => { + // ignored + } + native::event::EventInternal::FundingFeeEvent(_) => { + // ignored + } + native::event::EventInternal::NextFundingRate(_) => { + // 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 3b78a2d27..2a695632c 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,13 +28,21 @@ 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 -/// Margin in sats, calculation in BTC +/// Calculate leverage. +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) + // 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 pub fn calculate_quantity(opening_price: f32, margin: u64, leverage: f32) -> f32 { let margin_amount = bitcoin::Amount::from_sat(margin); @@ -151,8 +160,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 +169,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -184,8 +193,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -207,8 +216,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -231,8 +240,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -254,8 +263,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -278,8 +287,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -302,8 +311,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -326,8 +335,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -350,8 +359,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -374,8 +383,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -398,8 +407,8 @@ mod tests { closing_price, quantity, Direction::Long, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -423,8 +432,8 @@ mod tests { closing_price, quantity, Direction::Short, - long_margin, - short_margin, + long_margin.to_sat(), + short_margin.to_sat(), ) .unwrap(); @@ -450,12 +459,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 +483,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/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..7b5dd35a9 --- /dev/null +++ b/crates/xxi-node/src/commons/funding_fee_event.rs @@ -0,0 +1,67 @@ +use crate::commons::to_nearest_hour_in_the_past; +use crate::commons::ContractSymbol; +use crate::commons::Direction; +use bitcoin::SignedAmount; +use rust_decimal::Decimal; +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, + 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..0f2456c7a 100644 --- a/crates/xxi-node/src/commons/message.rs +++ b/crates/xxi-node/src/commons/message.rs @@ -1,8 +1,10 @@ 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; +use crate::FundingFeeEvent; use anyhow::Result; use bitcoin::address::NetworkUnchecked; use bitcoin::Address; @@ -49,6 +51,9 @@ pub enum Message { RolloverError { error: TradingError, }, + FundingFeeEvent(FundingFeeEvent), + AllFundingFeeEvents(Vec), + NextFundingRate(FundingRate), } #[derive(Serialize, Deserialize, Clone, Error, Debug, PartialEq)] @@ -100,38 +105,23 @@ 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", + 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 52482fc2b..b23370540 100644 --- a/crates/xxi-node/src/commons/mod.rs +++ b/crates/xxi-node/src/commons/mod.rs @@ -5,9 +5,12 @@ use serde::Deserialize; use serde::Serialize; use std::fmt; use std::str::FromStr; +use time::OffsetDateTime; +use time::Time; mod backup; mod collab_revert; +mod funding_fee_event; mod liquidity_option; mod message; mod order; @@ -23,6 +26,7 @@ mod trade; pub use crate::commons::trade::*; pub use backup::*; pub use collab_revert::*; +pub use funding_fee_event::*; pub use liquidity_option::*; pub use message::*; pub use order::*; @@ -199,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; @@ -233,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/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, 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/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 diff --git a/mobile/lib/backend.dart b/mobile/lib/backend.dart index 121548ce5..e363eef2e 100644 --- a/mobile/lib/backend.dart +++ b/mobile/lib/backend.dart @@ -2,8 +2,10 @@ 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'; 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 +58,8 @@ Future fullBackup() async { 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; @@ -74,7 +78,9 @@ Future runBackend(BuildContext context) async { // these notifiers depend on the backend running await orderChangeNotifier.initialize(); await positionChangeNotifier.initialize(); + await tradeChangeNotifier.initialize(); await dlcChannelChangeNotifier.initialize(); + await fundingRateChangeNotifier.initialize(); } void _setupRustLogging() { 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..20cbb7bd2 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -8,10 +8,15 @@ 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/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'; 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 +57,8 @@ 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) => FundingRateChangeNotifier()), ChangeNotifierProvider(create: (context) => WalletChangeNotifier(const WalletService())), ChangeNotifierProvider(create: (context) => ServiceStatusNotifier()), ChangeNotifierProvider(create: (context) => DlcChannelChangeNotifier(dlcChannelService)), @@ -76,6 +83,8 @@ void subscribeToNotifiers(BuildContext context) { final EventService eventService = EventService.create(); final orderChangeNotifier = context.read(); + final tradeChangeNotifier = context.read(); + final fundingRateChangeNotifier = context.read(); final positionChangeNotifier = context.read(); final walletChangeNotifier = context.read(); final tradeValuesChangeNotifier = context.read(); @@ -88,6 +97,11 @@ void subscribeToNotifiers(BuildContext context) { eventService.subscribe( orderChangeNotifier, bridge.Event.orderUpdateNotification(Order.apiDummy())); + 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/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/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/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/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_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..5fdf9bfe6 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'; @@ -10,6 +13,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 +43,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(); @@ -62,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), ); }), @@ -90,11 +102,16 @@ class TradeScreen extends StatelessWidget { Expanded( child: TradeTabs( tabs: const [ - "Positions", + "Position", "Orders", + "Trades", ], selectedIndex: 0, - keys: const [tradeScreenTabsPositions, tradeScreenTabsOrders], + keys: const [ + tradeScreenTabsPositions, + tradeScreenTabsTrades, + tradeScreenTabsOrders + ], tabBarViewChildren: [ ListView.builder( shrinkWrap: true, @@ -114,7 +131,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 +139,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 +192,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 +227,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", @@ -285,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 0e902403b..e96b293ab 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); @@ -66,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/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/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 diff --git a/mobile/native/src/db/mod.rs b/mobile/native/src/db/mod.rs index c51b8c99d..3dea78ef0 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::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::rollover(&mut db, contract_symbol.into(), expiry_timestamp) - .context("Failed to rollover position")?; + Position::finish_rollover(&mut db, updated_position.into()) + .context("Failed to finish position rollover")?; Ok(()) } @@ -479,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(()) } @@ -503,8 +523,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..5952af264 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 { @@ -1098,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/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..3c7ca4d92 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,8 @@ pub enum Event { DlcChannelEvent(DlcChannel), FundingChannelNotification(FundingChannelTask), LnPaymentReceived { r_hash: String }, + NewTrade(Trade), + NextFundingRate(FundingRate), } #[frb] @@ -94,6 +97,12 @@ impl From for Event { Event::FundingChannelNotification(status.into()) } 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()), } } } @@ -134,6 +143,8 @@ impl Subscriber for FlutterSubscriber { EventType::FundingChannelNotification, EventType::Authenticated, EventType::DlcChannelEvent, + EventType::NewTrade, + EventType::NextFundingRate, ] } } @@ -195,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 21313ad61..9fad5afc4 100644 --- a/mobile/native/src/event/mod.rs +++ b/mobile/native/src/event/mod.rs @@ -5,10 +5,13 @@ 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; use xxi_node::commons::ContractSymbol; +use xxi_node::commons::FundingRate; use xxi_node::commons::TenTenOneConfig; mod event_hub; @@ -41,6 +44,9 @@ pub enum EventInternal { DlcChannelEvent(DlcChannel), FundingChannelNotification(FundingChannelTask), LnPaymentReceived { r_hash: String }, + NewTrade(Trade), + FundingFeeEvent(FundingFeeEvent), + NextFundingRate(FundingRate), } #[derive(Clone, Debug)] @@ -87,6 +93,9 @@ impl fmt::Display for EventInternal { EventInternal::BidPriceUpdateNotification(_) => "BidPriceUpdateNotification", EventInternal::FundingChannelNotification(_) => "FundingChannelNotification", EventInternal::LnPaymentReceived { .. } => "LnPaymentReceived", + EventInternal::NewTrade(_) => "NewTrade", + EventInternal::FundingFeeEvent(_) => "FundingFeeEvent", + EventInternal::NextFundingRate(_) => "NextFundingRate", } .fmt(f) } @@ -112,6 +121,9 @@ impl From for EventType { EventInternal::BidPriceUpdateNotification(_) => EventType::BidPriceUpdateNotification, EventInternal::FundingChannelNotification(_) => EventType::FundingChannelNotification, EventInternal::LnPaymentReceived { .. } => EventType::LnPaymentReceived, + EventInternal::NewTrade(_) => EventType::NewTrade, + EventInternal::FundingFeeEvent(_) => EventType::NewTrade, + EventInternal::NextFundingRate(_) => EventType::NextFundingRate, } } } @@ -136,4 +148,6 @@ pub enum EventType { AskPriceUpdateNotification, BidPriceUpdateNotification, FundingChannelNotification, + NewTrade, + NextFundingRate, } diff --git a/mobile/native/src/orderbook.rs b/mobile/native/src/orderbook.rs index 4dc718c09..29ed3c44a 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,33 @@ 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::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(&[ + 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..d460d9897 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_trades; +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) } @@ -137,9 +182,7 @@ pub fn update_position_after_dlc_channel_creation_or_update( } }; - for trade in trades { - db::insert_trade(trade)?; - } + new_trades(trades)?; event::publish(&EventInternal::PositionUpdateNotification(position)); @@ -150,11 +193,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"); @@ -181,9 +225,7 @@ pub fn update_position_after_dlc_closure(filled_order: Order) -> Result<()> { ); } - for trade in trades { - db::insert_trade(trade)?; - } + new_trades(trades)?; db::delete_positions()?; diff --git a/mobile/native/src/trade/position/mod.rs b/mobile/native/src/trade/position/mod.rs index 35f934457..13e8d63ba 100644 --- a/mobile/native/src/trade/position/mod.rs +++ b/mobile/native/src/trade/position/mod.rs @@ -4,6 +4,7 @@ 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; @@ -16,9 +17,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 +62,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 +560,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) + .unwrap_or_default(); + 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 +661,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 +831,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 +901,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 +977,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..4a3b8124b --- /dev/null +++ b/mobile/native/src/trade/trades/handler.rs @@ -0,0 +1,19 @@ +use crate::db; +use crate::event; +use crate::event::EventInternal; +use crate::trade::Trade; +use anyhow::Result; + +pub fn new_trades(trades: Vec) -> Result<()> { + db::insert_trades(&trades)?; + + for trade in trades { + 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 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),