From 00a9b358fe0bd4e4cad4a71916fc7a4c0afbbc47 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Fri, 10 May 2024 11:15:31 +0200 Subject: [PATCH 1/6] chore: Add order id to trades This will allow us to implement partial order matching, by summing the quantity of trades related to an order. --- .../down.sql | 10 ++++++ .../up.sql | 12 +++++++ coordinator/src/db/trade_params.rs | 3 ++ coordinator/src/db/trades.rs | 8 +++-- coordinator/src/dlc_protocol.rs | 6 ++++ coordinator/src/orderbook/db/orders.rs | 34 +++++++++---------- coordinator/src/orderbook/trading.rs | 7 ++-- coordinator/src/schema.rs | 6 ++-- coordinator/src/trade/models.rs | 3 ++ 9 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/down.sql create mode 100644 coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/up.sql diff --git a/coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/down.sql b/coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/down.sql new file mode 100644 index 000000000..dbfb41993 --- /dev/null +++ b/coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/down.sql @@ -0,0 +1,10 @@ +ALTER TABLE trades DROP COLUMN IF EXISTS order_id; + +ALTER TABLE trade_params DROP COLUMN IF EXISTS order_id; + +ALTER TABLE orders + RENAME COLUMN trader_pubkey TO trader_id; + +ALTER TABLE orders + RENAME COLUMN order_id TO trader_order_id; + diff --git a/coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/up.sql b/coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/up.sql new file mode 100644 index 000000000..6bf54bf21 --- /dev/null +++ b/coordinator/migrations/2024-05-10-102426_add_order_id_to_trades/up.sql @@ -0,0 +1,12 @@ +-- Your SQL goes here +ALTER TABLE orders + RENAME COLUMN trader_order_id TO order_id; + +ALTER TABLE orders + RENAME COLUMN trader_id TO trader_pubkey; + +ALTER TABLE trade_params + ADD COLUMN order_id UUID REFERENCES orders(order_id); + +ALTER TABLE trades + ADD COLUMN order_id UUID REFERENCES orders(order_id); diff --git a/coordinator/src/db/trade_params.rs b/coordinator/src/db/trade_params.rs index f29973961..d9623a55f 100644 --- a/coordinator/src/db/trade_params.rs +++ b/coordinator/src/db/trade_params.rs @@ -28,6 +28,7 @@ pub(crate) struct TradeParams { pub direction: Direction, pub matching_fee: i64, pub trader_pnl: Option, + pub order_id: Option, } pub(crate) fn insert( @@ -44,6 +45,7 @@ pub(crate) fn insert( trade_params::average_price.eq(params.average_price), trade_params::matching_fee.eq(params.matching_fee.to_sat() as i64), trade_params::trader_pnl_sat.eq(params.trader_pnl.map(|pnl| pnl.to_sat())), + trade_params::order_id.eq(params.order_id), )) .execute(conn)?; @@ -76,6 +78,7 @@ impl From for dlc_protocol::TradeParams { direction: commons::Direction::from(value.direction), matching_fee: Amount::from_sat(value.matching_fee as u64), trader_pnl: value.trader_pnl.map(SignedAmount::from_sat), + order_id: value.order_id, } } } diff --git a/coordinator/src/db/trades.rs b/coordinator/src/db/trades.rs index 856d83ad1..48f758177 100644 --- a/coordinator/src/db/trades.rs +++ b/coordinator/src/db/trades.rs @@ -7,6 +7,7 @@ use bitcoin::Amount; use diesel::prelude::*; use std::str::FromStr; use time::OffsetDateTime; +use uuid::Uuid; #[derive(Queryable, Debug, Clone)] #[diesel(table_name = trades)] @@ -22,6 +23,7 @@ struct Trade { timestamp: OffsetDateTime, order_matching_fee_sat: i64, trader_realized_pnl_sat: Option, + order_id: Option, } #[derive(Insertable, Debug, Clone)] @@ -36,6 +38,7 @@ struct NewTrade { average_price: f32, order_matching_fee_sat: i64, trader_realized_pnl_sat: Option, + order_id: Option, } pub fn insert( @@ -90,6 +93,7 @@ impl From for NewTrade { average_price: value.average_price, order_matching_fee_sat: value.order_matching_fee.to_sat() as i64, trader_realized_pnl_sat: value.trader_realized_pnl_sat, + order_id: value.order_id, } } } @@ -100,8 +104,7 @@ impl From for crate::trade::models::Trade { id: value.id, position_id: value.position_id, contract_symbol: value.contract_symbol.into(), - trader_pubkey: PublicKey::from_str(value.trader_pubkey.as_str()) - .expect("public key to decode"), + trader_pubkey: PublicKey::from_str(value.trader_pubkey.as_str()).expect("to fit"), quantity: value.quantity, trader_leverage: value.trader_leverage, direction: value.direction.into(), @@ -109,6 +112,7 @@ impl From for crate::trade::models::Trade { timestamp: value.timestamp, order_matching_fee: Amount::from_sat(value.order_matching_fee_sat as u64), trader_realized_pnl_sat: value.trader_realized_pnl_sat, + order_id: value.order_id, } } } diff --git a/coordinator/src/dlc_protocol.rs b/coordinator/src/dlc_protocol.rs index 2ce0f7923..f228da4d8 100644 --- a/coordinator/src/dlc_protocol.rs +++ b/coordinator/src/dlc_protocol.rs @@ -21,6 +21,7 @@ use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use time::OffsetDateTime; use tokio::sync::broadcast::Sender; +use uuid::Uuid; use xxi_node::cfd::calculate_pnl; use xxi_node::commons; use xxi_node::commons::Direction; @@ -48,6 +49,7 @@ pub struct TradeParams { pub direction: Direction, pub matching_fee: Amount, pub trader_pnl: Option, + pub order_id: Option, } impl TradeParams { @@ -68,6 +70,7 @@ impl TradeParams { direction: trade_params.direction, matching_fee: trade_params.order_matching_fee(), trader_pnl, + order_id: Some(trade_params.filled_with.order_id), } } } @@ -519,6 +522,7 @@ impl DlcProtocolExecutor { average_price: trade_params.average_price, order_matching_fee, trader_realized_pnl_sat: Some(trader_realized_pnl_sat), + order_id: trade_params.order_id, }; db::trades::insert(conn, new_trade)?; @@ -573,6 +577,7 @@ impl DlcProtocolExecutor { average_price: trade_params.average_price, order_matching_fee, trader_realized_pnl_sat: None, + order_id: trade_params.order_id, }; db::trades::insert(conn, new_trade)?; @@ -619,6 +624,7 @@ impl DlcProtocolExecutor { average_price: trade_params.average_price, order_matching_fee, trader_realized_pnl_sat: trade_params.trader_pnl.map(|pnl| pnl.to_sat()), + order_id: trade_params.order_id, }; db::trades::insert(conn, new_trade)?; diff --git a/coordinator/src/orderbook/db/orders.rs b/coordinator/src/orderbook/db/orders.rs index 698223560..02bba5df0 100644 --- a/coordinator/src/orderbook/db/orders.rs +++ b/coordinator/src/orderbook/db/orders.rs @@ -155,9 +155,9 @@ impl From for OrderReason { #[derive(Insertable, Debug, PartialEq)] #[diesel(table_name = orders)] struct NewOrder { - pub trader_order_id: Uuid, + pub order_id: Uuid, pub price: f32, - pub trader_id: String, + pub trader_pubkey: String, pub direction: Direction, pub quantity: f32, pub order_type: OrderType, @@ -171,13 +171,13 @@ struct NewOrder { impl From for NewOrder { fn from(value: NewLimitOrder) -> Self { NewOrder { - trader_order_id: value.id, + order_id: value.id, price: value .price .round_dp(2) .to_f32() .expect("To be able to convert decimal to f32"), - trader_id: value.trader_id.to_string(), + trader_pubkey: value.trader_id.to_string(), direction: value.direction.into(), quantity: value .quantity @@ -200,10 +200,10 @@ impl From for NewOrder { impl From for NewOrder { fn from(value: NewMarketOrder) -> Self { NewOrder { - trader_order_id: value.id, + order_id: value.id, // TODO: it would be cool to get rid of this as well price: 0.0, - trader_id: value.trader_id.to_string(), + trader_pubkey: value.trader_id.to_string(), direction: value.direction.into(), quantity: value .quantity @@ -394,11 +394,11 @@ pub fn set_is_taken( pub fn delete_trader_order( conn: &mut PgConnection, id: Uuid, - trader_id: PublicKey, + trader_pubkey: PublicKey, ) -> QueryResult { let order: Order = diesel::update(orders::table) - .filter(orders::trader_order_id.eq(id)) - .filter(orders::trader_id.eq(trader_id.to_string())) + .filter(orders::order_id.eq(id)) + .filter(orders::trader_pubkey.eq(trader_pubkey.to_string())) .set(orders::order_state.eq(OrderState::Deleted)) .get_result(conn)?; @@ -417,7 +417,7 @@ pub fn set_order_state( order_state: commons::OrderState, ) -> QueryResult { let order: Order = diesel::update(orders::table) - .filter(orders::trader_order_id.eq(id)) + .filter(orders::order_id.eq(id)) .set((orders::order_state.eq(OrderState::from(order_state)),)) .get_result(conn)?; @@ -443,7 +443,7 @@ pub fn set_expired_limit_orders_to_expired( /// Returns the order by id pub fn get_with_id(conn: &mut PgConnection, uid: Uuid) -> QueryResult> { let x = orders::table - .filter(orders::trader_order_id.eq(uid)) + .filter(orders::order_id.eq(uid)) .load::(conn)?; let option = x.first().map(|order| OrderbookOrder::from(order.clone())); @@ -452,11 +452,11 @@ pub fn get_with_id(conn: &mut PgConnection, uid: Uuid) -> QueryResult QueryResult> { orders::table - .filter(orders::trader_id.eq(trader_id.to_string())) + .filter(orders::trader_pubkey.eq(trader_pubkey.to_string())) .filter(orders::order_state.eq(OrderState::from(order_state))) .order_by(orders::timestamp.desc()) .first::(conn) @@ -470,16 +470,16 @@ pub fn get_by_trader_id_and_state( /// matches were executed. pub fn get_all_limit_order_filled_matches( conn: &mut PgConnection, - trader_id: PublicKey, + trader_pubkey: PublicKey, ) -> QueryResult> { let orders = orders::table // We use `matches::match_order_id` so that we can verify that the corresponding app trader // order is in `match_state` _`Filled`_. The maker's match remains in `Pending` (since the // trade is not actually executed yet), which is not very informative. - .inner_join(matches::table.on(matches::match_order_id.eq(orders::trader_order_id))) + .inner_join(matches::table.on(matches::match_order_id.eq(orders::order_id))) .filter( - orders::trader_id - .eq(trader_id.to_string()) + orders::trader_pubkey + .eq(trader_pubkey.to_string()) // Looking for `Matched`, `Limit` orders only, corresponding to the maker. .and(orders::order_type.eq(OrderType::Limit)) .and(orders::order_state.eq(OrderState::Matched)) diff --git a/coordinator/src/orderbook/trading.rs b/coordinator/src/orderbook/trading.rs index 7803dc130..216263287 100644 --- a/coordinator/src/orderbook/trading.rs +++ b/coordinator/src/orderbook/trading.rs @@ -27,6 +27,7 @@ use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::task::spawn_blocking; use uuid::Uuid; +use xxi_node::commons; use xxi_node::commons::ContractSymbol; use xxi_node::commons::Direction; use xxi_node::commons::FilledWith; @@ -277,7 +278,7 @@ pub async fn process_new_market_order( let notification = match &order.order_reason { OrderReason::Expired => Some(NotificationKind::PositionExpired), OrderReason::TraderLiquidated => Some(NotificationKind::Custom { - title: "Woops, you got liquidated 💸".to_string(), + title: "Oops, you got liquidated 💸".to_string(), message: "Open your app to execute the liquidation".to_string(), }), OrderReason::CoordinatorLiquidated => Some(NotificationKind::Custom { @@ -362,7 +363,6 @@ pub async fn process_new_market_order( /// The caller is expected to provide a list of `opposite_direction_orders` of [`OrderType::Limit`] /// and opposite [`Direction`] to the `market_order`. We nevertheless ensure that this is the case /// to be on the safe side. - fn match_order( market_order: &Order, opposite_direction_orders: Vec, @@ -403,8 +403,7 @@ fn match_order( return Ok(None); } - let expiry_timestamp = - xxi_node::commons::calculate_next_expiry(OffsetDateTime::now_utc(), network); + let expiry_timestamp = commons::calculate_next_expiry(OffsetDateTime::now_utc(), network); let matches = matched_orders .iter() diff --git a/coordinator/src/schema.rs b/coordinator/src/schema.rs index 58da37902..a0d1ebb95 100644 --- a/coordinator/src/schema.rs +++ b/coordinator/src/schema.rs @@ -346,9 +346,9 @@ diesel::table! { orders (id) { id -> Int4, - trader_order_id -> Uuid, + order_id -> Uuid, price -> Float4, - trader_id -> Text, + trader_pubkey -> Text, direction -> DirectionType, quantity -> Float4, timestamp -> Timestamptz, @@ -505,6 +505,7 @@ diesel::table! { direction -> DirectionType, matching_fee -> Int8, trader_pnl_sat -> Nullable, + order_id -> Nullable, } } @@ -525,6 +526,7 @@ diesel::table! { timestamp -> Timestamptz, order_matching_fee_sat -> Int8, trader_realized_pnl_sat -> Nullable, + order_id -> Nullable, } } diff --git a/coordinator/src/trade/models.rs b/coordinator/src/trade/models.rs index f074ee715..ce1d1bfbd 100644 --- a/coordinator/src/trade/models.rs +++ b/coordinator/src/trade/models.rs @@ -1,6 +1,7 @@ use bitcoin::secp256k1::PublicKey; use bitcoin::Amount; use time::OffsetDateTime; +use uuid::Uuid; use xxi_node::commons::ContractSymbol; use xxi_node::commons::Direction; @@ -15,6 +16,7 @@ pub struct NewTrade { pub average_price: f32, pub order_matching_fee: Amount, pub trader_realized_pnl_sat: Option, + pub order_id: Option, } #[derive(Debug)] @@ -30,4 +32,5 @@ pub struct Trade { pub timestamp: OffsetDateTime, pub order_matching_fee: Amount, pub trader_realized_pnl_sat: Option, + pub order_id: Option, } From 8ca29e4ea4b54b4f57f139ad30f755f924ad8106 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Fri, 10 May 2024 14:54:21 +0200 Subject: [PATCH 2/6] chore: Remove delete expired orders This is already done by the maker, also expired orders are not considered for matching. --- coordinator/src/orderbook/trading.rs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/coordinator/src/orderbook/trading.rs b/coordinator/src/orderbook/trading.rs index 216263287..718b08a2b 100644 --- a/coordinator/src/orderbook/trading.rs +++ b/coordinator/src/orderbook/trading.rs @@ -113,7 +113,6 @@ pub fn start( } OrderType::Limit => { process_new_limit_order( - node, tx_orderbook_feed, new_order.clone(), ) @@ -150,29 +149,9 @@ pub fn start( } pub async fn process_new_limit_order( - node: Node, tx_orderbook_feed: broadcast::Sender, order: Order, ) -> Result<(), TradingError> { - let mut conn = spawn_blocking(move || node.pool.get()) - .await - .expect("task to complete") - .map_err(|e| anyhow!("{e:#}"))?; - - // Before processing any match we set all expired limit orders to failed, to ensure they do not - // get matched. - // - // TODO(holzeis): Orders should probably not have an expiry, but should either be replaced or - // deleted if not wanted anymore. - // TODO: I don't think this is necessary anymore. We are manually deleting orders now. - let expired_limit_orders = - orders::set_expired_limit_orders_to_expired(&mut conn).map_err(|e| anyhow!("{e:#}"))?; - for expired_limit_order in expired_limit_orders { - tx_orderbook_feed - .send(Message::DeleteOrder(expired_limit_order.id)) - .context("Could not update price feed")?; - } - tx_orderbook_feed .send(Message::NewOrder(order)) .map_err(|e| anyhow!(e)) From 8cf4dae9a93ab12ef01913e81e64739158b9283f Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Mon, 13 May 2024 09:44:16 +0200 Subject: [PATCH 3/6] refactor: Rename orderbook message to trader message --- coordinator/src/collaborative_revert.rs | 6 +- coordinator/src/funding_fee.rs | 8 +- coordinator/src/message.rs | 89 +++++++++---------- coordinator/src/node.rs | 10 +-- coordinator/src/node/invoice.rs | 6 +- coordinator/src/orderbook/async_match.rs | 6 +- .../src/orderbook/collaborative_revert.rs | 8 +- coordinator/src/orderbook/trading.rs | 8 +- coordinator/src/routes.rs | 6 +- coordinator/src/trade/mod.rs | 10 +-- 10 files changed, 74 insertions(+), 83 deletions(-) diff --git a/coordinator/src/collaborative_revert.rs b/coordinator/src/collaborative_revert.rs index 468628047..742662cff 100644 --- a/coordinator/src/collaborative_revert.rs +++ b/coordinator/src/collaborative_revert.rs @@ -1,6 +1,6 @@ use crate::db; use crate::db::positions::Position; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::node::storage::NodeStorage; use crate::notifications::NotificationKind; use crate::position; @@ -56,7 +56,7 @@ pub async fn propose_collaborative_revert( >, >, pool: Pool>, - sender: mpsc::Sender, + sender: mpsc::Sender, channel_id: DlcChannelId, fee_rate_sats_vb: u64, trader_amount_sats: u64, @@ -130,7 +130,7 @@ pub async fn propose_collaborative_revert( }; sender - .send(OrderbookMessage::TraderMessage { + .send(TraderMessage { trader_id: to_secp_pk_30(peer_id), message: Message::DlcChannelCollaborativeRevert { channel_id, diff --git a/coordinator/src/funding_fee.rs b/coordinator/src/funding_fee.rs index 154fa5d60..c358668f5 100644 --- a/coordinator/src/funding_fee.rs +++ b/coordinator/src/funding_fee.rs @@ -1,5 +1,5 @@ use crate::decimal_from_f32; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::FundingFee; use anyhow::bail; use anyhow::Context; @@ -71,7 +71,7 @@ pub enum IndexPriceSource { pub async fn generate_funding_fee_events_periodically( scheduler: &JobScheduler, pool: Pool>, - auth_users_notifier: tokio::sync::mpsc::Sender, + auth_users_notifier: tokio::sync::mpsc::Sender, schedule: String, index_price_source: IndexPriceSource, ) -> Result<()> { @@ -124,7 +124,7 @@ pub async fn generate_funding_fee_events_periodically( fn generate_funding_fee_events( pool: &Pool>, index_price_source: IndexPriceSource, - auth_users_notifier: tokio::sync::mpsc::Sender, + auth_users_notifier: tokio::sync::mpsc::Sender, ) -> Result<()> { let mut conn = pool.get()?; @@ -189,7 +189,7 @@ fn generate_funding_fee_events( { block_in_place(|| { auth_users_notifier - .blocking_send(OrderbookMessage::TraderMessage { + .blocking_send(TraderMessage { trader_id: position.trader, message: Message::FundingFeeEvent(xxi_node::FundingFeeEvent { contract_symbol, diff --git a/coordinator/src/message.rs b/coordinator/src/message.rs index 1601e6e9f..e75a92840 100644 --- a/coordinator/src/message.rs +++ b/coordinator/src/message.rs @@ -20,12 +20,10 @@ const NOTIFICATION_BUFFER_SIZE: usize = 100; /// Message sent to users via the websocket. #[derive(Debug)] -pub enum OrderbookMessage { - TraderMessage { - trader_id: PublicKey, - message: Message, - notification: Option, - }, +pub struct TraderMessage { + pub trader_id: PublicKey, + pub message: Message, + pub notification: Option, } #[derive(Clone)] @@ -37,8 +35,8 @@ pub struct NewUserMessage { pub fn spawn_delivering_messages_to_authenticated_users( notification_sender: Sender, tx_user_feed: broadcast::Sender, -) -> (RemoteHandle<()>, Sender) { - let (sender, mut receiver) = mpsc::channel::(NOTIFICATION_BUFFER_SIZE); +) -> (RemoteHandle<()>, Sender) { + let (sender, mut receiver) = mpsc::channel::(NOTIFICATION_BUFFER_SIZE); let authenticated_users = Arc::new(RwLock::new(HashMap::new())); @@ -67,15 +65,15 @@ pub fn spawn_delivering_messages_to_authenticated_users( let (fut, remote_handle) = { async move { - while let Some(notification) = receiver.recv().await { - if let Err(e) = process_orderbook_message( + while let Some(trader_message) = receiver.recv().await { + if let Err(e) = process_trader_message( &authenticated_users, ¬ification_sender, - notification, + trader_message, ) .await { - tracing::error!("Failed to process orderbook message: {e:#}"); + tracing::error!("Failed to process trader message: {e:#}"); } } @@ -89,48 +87,41 @@ pub fn spawn_delivering_messages_to_authenticated_users( (remote_handle, sender) } -async fn process_orderbook_message( +async fn process_trader_message( authenticated_users: &RwLock>>, notification_sender: &Sender, - notification: OrderbookMessage, + trader_message: TraderMessage, ) -> Result<()> { - match notification { - OrderbookMessage::TraderMessage { - trader_id, - message, - notification, - } => { - tracing::info!(%trader_id, ?message, "Sending trader message"); - - let trader = authenticated_users.read().get(&trader_id).cloned(); - - match trader { - Some(sender) => { - if let Err(e) = sender.send(message).await { - tracing::warn!(%trader_id, "Connection lost to trader: {e:#}"); - } else { - tracing::trace!( - %trader_id, - "Skipping optional push notifications as the user was successfully \ - notified via the websocket" - ); - return Ok(()); - } - } - None => tracing::warn!(%trader_id, "Trader is not connected"), - }; - - if let Some(notification_kind) = notification { - tracing::debug!(%trader_id, "Sending push notification to user"); - - notification_sender - .send(Notification::new(trader_id, notification_kind)) - .await - .with_context(|| { - format!("Failed to send push notification to trader {trader_id}") - })?; + let trader_id = trader_message.trader_id; + let message = trader_message.message; + let notification = trader_message.notification; + tracing::info!(%trader_id, ?message, "Sending trader message"); + + let trader = authenticated_users.read().get(&trader_id).cloned(); + + match trader { + Some(sender) => { + if let Err(e) = sender.send(message).await { + tracing::warn!(%trader_id, "Connection lost to trader: {e:#}"); + } else { + tracing::trace!( + %trader_id, + "Skipping optional push notifications as the user was successfully \ + notified via the websocket" + ); + return Ok(()); } } + None => tracing::warn!(%trader_id, "Trader is not connected"), + }; + + if let Some(notification_kind) = notification { + tracing::debug!(%trader_id, "Sending push notification to user"); + + notification_sender + .send(Notification::new(trader_id, notification_kind)) + .await + .with_context(|| format!("Failed to send push notification to trader {trader_id}"))?; } Ok(()) diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index 1861a645f..af535ea5c 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -1,6 +1,6 @@ use crate::db; use crate::dlc_protocol; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::node::storage::NodeStorage; use crate::position::models::PositionState; use crate::storage::CoordinatorTenTenOneStorage; @@ -77,7 +77,7 @@ pub struct Node { pub pool: Pool>, pub settings: Arc>, pub tx_position_feed: Sender, - trade_notifier: mpsc::Sender, + trade_notifier: mpsc::Sender, pub lnd_bridge: LndBridge, } @@ -94,7 +94,7 @@ impl Node { pool: Pool>, settings: NodeSettings, tx_position_feed: Sender, - trade_notifier: mpsc::Sender, + trade_notifier: mpsc::Sender, lnd_bridge: LndBridge, ) -> Self { Self { @@ -150,7 +150,7 @@ impl Node { | TenTenOneMessageType::Expire | TenTenOneMessageType::Liquidate => { if let Some(order_id) = msg.get_order_id() { - OrderbookMessage::TraderMessage { + TraderMessage { trader_id: to_secp_pk_30(node_id), message: TradeError { order_id, error }, notification: None, @@ -160,7 +160,7 @@ impl Node { return; } } - TenTenOneMessageType::Rollover => OrderbookMessage::TraderMessage { + TenTenOneMessageType::Rollover => TraderMessage { trader_id: to_secp_pk_30(node_id), message: RolloverError { error }, notification: None, diff --git a/coordinator/src/node/invoice.rs b/coordinator/src/node/invoice.rs index 731b455bc..a2705f197 100644 --- a/coordinator/src/node/invoice.rs +++ b/coordinator/src/node/invoice.rs @@ -1,5 +1,5 @@ use crate::db; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::notifications::NotificationKind; use bitcoin::Amount; use diesel::r2d2::ConnectionManager; @@ -16,7 +16,7 @@ use xxi_node::commons::Message; /// Watches a hodl invoice with the given r_hash pub fn spawn_invoice_watch( pool: Pool>, - trader_sender: mpsc::Sender, + trader_sender: mpsc::Sender, lnd_bridge: LndBridge, invoice_params: commons::HodlInvoiceParams, ) { @@ -79,7 +79,7 @@ pub fn spawn_invoice_watch( } InvoiceState::Accepted => { tracing::info!(%trader_pubkey, r_hash, "Pending hodl invoice has been accepted."); - if let Err(e) = trader_sender.send(OrderbookMessage::TraderMessage { + if let Err(e) = trader_sender.send(TraderMessage { trader_id: trader_pubkey, message: Message::LnPaymentReceived { r_hash: r_hash.clone(), diff --git a/coordinator/src/orderbook/async_match.rs b/coordinator/src/orderbook/async_match.rs index e6a0e9e3d..6dcd5e07c 100644 --- a/coordinator/src/orderbook/async_match.rs +++ b/coordinator/src/orderbook/async_match.rs @@ -1,6 +1,6 @@ use crate::check_version::check_version; use crate::db; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::node::Node; use crate::orderbook::db::matches; use crate::orderbook::db::orders; @@ -31,7 +31,7 @@ use xxi_node::node::event::NodeEvent; pub fn monitor( node: Node, mut receiver: broadcast::Receiver, - notifier: mpsc::Sender, + notifier: mpsc::Sender, network: Network, oracle_pk: XOnlyPublicKey, ) -> RemoteHandle<()> { @@ -77,7 +77,7 @@ pub fn monitor( /// Checks if there are any pending matches async fn process_pending_match( node: Node, - notifier: mpsc::Sender, + notifier: mpsc::Sender, trader_id: PublicKey, network: Network, oracle_pk: XOnlyPublicKey, diff --git a/coordinator/src/orderbook/collaborative_revert.rs b/coordinator/src/orderbook/collaborative_revert.rs index d0f62bc09..21b4d043a 100644 --- a/coordinator/src/orderbook/collaborative_revert.rs +++ b/coordinator/src/orderbook/collaborative_revert.rs @@ -1,6 +1,6 @@ use crate::db::collaborative_reverts; use crate::message::NewUserMessage; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use anyhow::bail; use anyhow::Result; use bitcoin::secp256k1::PublicKey; @@ -20,7 +20,7 @@ use xxi_node::commons::Message; pub fn monitor( pool: Pool>, tx_user_feed: broadcast::Sender, - notifier: mpsc::Sender, + notifier: mpsc::Sender, network: Network, ) -> RemoteHandle<()> { let mut user_feed = tx_user_feed.subscribe(); @@ -73,7 +73,7 @@ pub fn monitor( /// Checks if there are any pending collaborative reverts async fn process_pending_collaborative_revert( pool: Pool>, - notifier: mpsc::Sender, + notifier: mpsc::Sender, trader_id: PublicKey, network: Network, ) -> Result<()> { @@ -98,7 +98,7 @@ async fn process_pending_collaborative_revert( // Sending no optional push notification as this is only executed if the user just // registered on the websocket. So we can assume that the user is still online. - let msg = OrderbookMessage::TraderMessage { + let msg = TraderMessage { trader_id, message: Message::DlcChannelCollaborativeRevert { channel_id: revert.channel_id, diff --git a/coordinator/src/orderbook/trading.rs b/coordinator/src/orderbook/trading.rs index 718b08a2b..7dfbf4997 100644 --- a/coordinator/src/orderbook/trading.rs +++ b/coordinator/src/orderbook/trading.rs @@ -1,5 +1,5 @@ use crate::db; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::node::Node; use crate::notifications::Notification; use crate::notifications::NotificationKind; @@ -71,7 +71,7 @@ pub struct TraderMatchParams { pub fn start( node: Node, tx_orderbook_feed: broadcast::Sender, - trade_notifier: mpsc::Sender, + trade_notifier: mpsc::Sender, notifier: mpsc::Sender, network: Network, oracle_pk: XOnlyPublicKey, @@ -124,7 +124,7 @@ pub fn start( // TODO(holzeis): the maker is currently not subscribed to the websocket // api, hence it wouldn't receive the error message. if let Err(e) = trade_notifier - .send(OrderbookMessage::TraderMessage { + .send(TraderMessage { trader_id, message: TradeError { order_id, error }, notification: None, @@ -165,7 +165,7 @@ pub async fn process_new_limit_order( pub async fn process_new_market_order( node: Node, notifier: mpsc::Sender, - trade_notifier: mpsc::Sender, + trade_notifier: mpsc::Sender, order: &Order, network: Network, oracle_pk: XOnlyPublicKey, diff --git a/coordinator/src/routes.rs b/coordinator/src/routes.rs index 1b968ae90..f24ea287a 100644 --- a/coordinator/src/routes.rs +++ b/coordinator/src/routes.rs @@ -9,7 +9,7 @@ use crate::leaderboard::LeaderBoard; use crate::leaderboard::LeaderBoardCategory; use crate::leaderboard::LeaderBoardQueryParams; use crate::message::NewUserMessage; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::node::invoice; use crate::node::Node; use crate::notifications::Notification; @@ -108,7 +108,7 @@ pub struct AppState { pub pool: Pool>, pub settings: RwLock, pub node_alias: String, - pub auth_users_notifier: mpsc::Sender, + pub auth_users_notifier: mpsc::Sender, pub notification_sender: mpsc::Sender, pub user_backup: SledBackup, pub secp: Secp256k1, @@ -125,7 +125,7 @@ pub fn router( tx_orderbook_feed: broadcast::Sender, tx_position_feed: broadcast::Sender, tx_user_feed: broadcast::Sender, - auth_users_notifier: mpsc::Sender, + auth_users_notifier: mpsc::Sender, notification_sender: mpsc::Sender, user_backup: SledBackup, lnd_bridge: LndBridge, diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index a8205f2ef..8d2d8755a 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -4,7 +4,7 @@ use crate::decimal_from_f32; use crate::dlc_protocol; use crate::funding_fee::funding_fee_from_funding_fee_events; use crate::funding_fee::get_outstanding_funding_fee_events; -use crate::message::OrderbookMessage; +use crate::message::TraderMessage; use crate::node::Node; use crate::orderbook::db::matches; use crate::orderbook::db::orders; @@ -105,7 +105,7 @@ enum ResizeAction { pub struct TradeExecutor { node: Node, - notifier: mpsc::Sender, + notifier: mpsc::Sender, } /// The funds the trader will need to provide to open a DLC channel with the coordinator. @@ -121,7 +121,7 @@ enum TraderRequiredLiquidity { } impl TradeExecutor { - pub fn new(node: Node, notifier: mpsc::Sender) -> Self { + pub fn new(node: Node, notifier: mpsc::Sender) -> Self { Self { node, notifier } } @@ -161,7 +161,7 @@ impl TradeExecutor { tracing::error!(%trader_id, %order_id, "Failed to cancel hodl invoice. Error: {e:#}"); } - let message = OrderbookMessage::TraderMessage { + let message = TraderMessage { trader_id, message: Message::TradeError { order_id, @@ -205,7 +205,7 @@ impl TradeExecutor { tracing::error!(%trader_id, %order_id, "Failed to update order and match: {e}"); }; - let message = OrderbookMessage::TraderMessage { + let message = TraderMessage { trader_id, message: Message::TradeError { order_id, From f0cae92d9b00b0e5e38981527de5be3c8a7ad6be Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Mon, 27 May 2024 16:21:50 +0200 Subject: [PATCH 4/6] feat: Rewrite order matching strategy Refactors the current trading component into a clearly separated orderbook component and a trade execution component. The linking part is the `ExecutableMatch` which can be derived from the matches stored into the database. At the moment we assume optimistically that the trade execution will succeed. However, we should consider that a pending match may never get filled or it fails at execution in such a scenario we would need to rollback the matched orders. --- Cargo.lock | 2 + coordinator/Cargo.toml | 1 + coordinator/src/bin/coordinator.rs | 50 +- coordinator/src/message.rs | 19 + coordinator/src/node/expired_positions.rs | 62 +- coordinator/src/node/liquidated_positions.rs | 78 +- coordinator/src/orderbook/async_match.rs | 163 ---- coordinator/src/orderbook/db/matches.rs | 85 +- coordinator/src/orderbook/db/orders.rs | 29 +- coordinator/src/orderbook/match_order.rs | 373 ++++++++ coordinator/src/orderbook/mod.rs | 735 ++++++++++++++- coordinator/src/orderbook/trading.rs | 896 ++----------------- coordinator/src/orderbook/websocket.rs | 42 +- coordinator/src/routes.rs | 8 +- coordinator/src/routes/orderbook.rs | 151 ++-- coordinator/src/trade/mod.rs | 477 +++++----- crates/dev-maker/src/main.rs | 2 +- crates/xxi-node/src/commons/order.rs | 58 +- crates/xxi-node/src/commons/trade.rs | 15 +- crates/xxi-node/src/message_handler.rs | 2 +- crates/xxi-node/src/tests/dlc_channel.rs | 2 +- mobile/native/src/dlc/node.rs | 2 +- mobile/native/src/trade/order/handler.rs | 2 +- 23 files changed, 1769 insertions(+), 1485 deletions(-) delete mode 100644 coordinator/src/orderbook/async_match.rs create mode 100644 coordinator/src/orderbook/match_order.rs diff --git a/Cargo.lock b/Cargo.lock index c949f2ada..cc32dd8e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1008,6 +1008,7 @@ dependencies = [ "tokio", "tokio-cron-scheduler", "tokio-metrics", + "tokio-stream", "tokio-util", "toml 0.8.8", "tracing", @@ -4375,6 +4376,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/coordinator/Cargo.toml b/coordinator/Cargo.toml index a0b97e1ac..0beeca8b3 100644 --- a/coordinator/Cargo.toml +++ b/coordinator/Cargo.toml @@ -46,6 +46,7 @@ time = { version = "0.3", features = ["serde", "parsing", "std", "formatting", " tokio = { version = "1", features = ["full", "tracing"] } tokio-cron-scheduler = { version = "0.9.4" } tokio-metrics = "0.2.2" +tokio-stream = { version = "0.1.14", features = ["sync"] } tokio-util = { version = "0.7", features = ["io"] } toml = "0.8" tracing = "0.1.37" diff --git a/coordinator/src/bin/coordinator.rs b/coordinator/src/bin/coordinator.rs index 61c22d027..52a350ddb 100644 --- a/coordinator/src/bin/coordinator.rs +++ b/coordinator/src/bin/coordinator.rs @@ -10,6 +10,7 @@ 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; +use coordinator::message::TraderSender; use coordinator::node::expired_positions; use coordinator::node::liquidated_positions; use coordinator::node::rollover; @@ -17,9 +18,10 @@ use coordinator::node::storage::NodeStorage; use coordinator::node::unrealized_pnl; use coordinator::node::Node; use coordinator::notifications::NotificationService; -use coordinator::orderbook::async_match; use coordinator::orderbook::collaborative_revert; +use coordinator::orderbook::match_order; use coordinator::orderbook::trading; +use coordinator::orderbook::OrderMatchingFeeRate; use coordinator::routes::router; use coordinator::run_migration; use coordinator::scheduler::NotificationScheduler; @@ -32,6 +34,8 @@ use diesel::PgConnection; use lnd_bridge::LndBridge; use rand::thread_rng; use rand::RngCore; +use rust_decimal::prelude::FromPrimitive; +use rust_decimal::Decimal; use std::backtrace::Backtrace; use std::net::IpAddr; use std::net::Ipv4Addr; @@ -247,21 +251,31 @@ async fn main() -> Result<()> { let (tx_orderbook_feed, _rx) = broadcast::channel(100); - let (_handle, trading_sender) = trading::start( - node.clone(), - tx_orderbook_feed.clone(), - auth_users_notifier.clone(), - notification_service.get_sender(), - network, - node.inner.oracle_pubkey, - ); - let _handle = async_match::monitor( + let order_matching_fee_rate = OrderMatchingFeeRate { + // TODO(holzeis): Split up order matching fee rate into taker and maker fees. + taker: Decimal::from_f32(node.settings.read().await.order_matching_fee_rate) + .expect("to fit"), + maker: Decimal::from_f32(node.settings.read().await.order_matching_fee_rate) + .expect("to fit"), + }; + + let match_executor = match_order::spawn_match_executor( node.clone(), node_event_handler.subscribe(), + order_matching_fee_rate, + TraderSender { + sender: auth_users_notifier.clone(), + }, + ); + + let orderbook_sender = trading::spawn_orderbook( + node.pool.clone(), + notification_service.get_sender(), + match_executor.clone(), + tx_orderbook_feed.clone(), auth_users_notifier.clone(), - network, - node.inner.oracle_pubkey, ); + let _handle = rollover::monitor( pool.clone(), node_event_handler.subscribe(), @@ -269,6 +283,7 @@ async fn main() -> Result<()> { network, node.clone(), ); + let _handle = collaborative_revert::monitor( pool.clone(), tx_user_feed.clone(), @@ -280,11 +295,12 @@ async fn main() -> Result<()> { tokio::spawn({ let node = node.clone(); - let trading_sender = trading_sender.clone(); + let orderbook_sender = orderbook_sender.clone(); async move { loop { tokio::time::sleep(EXPIRED_POSITION_SYNC_INTERVAL).await; - if let Err(e) = expired_positions::close(node.clone(), trading_sender.clone()).await + if let Err(e) = + expired_positions::close(node.clone(), orderbook_sender.clone()).await { tracing::error!("Failed to close expired positions! Error: {e:#}"); } @@ -294,11 +310,11 @@ async fn main() -> Result<()> { tokio::spawn({ let node = node.clone(); - let trading_sender = trading_sender.clone(); + let orderbook_sender = orderbook_sender.clone(); async move { loop { tokio::time::sleep(LIQUIDATED_POSITION_SYNC_INTERVAL).await; - liquidated_positions::monitor(node.clone(), trading_sender.clone()).await + liquidated_positions::monitor(node.clone(), orderbook_sender.clone()).await } } }); @@ -310,7 +326,7 @@ async fn main() -> Result<()> { pool.clone(), settings.clone(), NODE_ALIAS, - trading_sender, + orderbook_sender, tx_orderbook_feed, tx_position_feed, tx_user_feed, diff --git a/coordinator/src/message.rs b/coordinator/src/message.rs index e75a92840..a2195846e 100644 --- a/coordinator/src/message.rs +++ b/coordinator/src/message.rs @@ -26,6 +26,25 @@ pub struct TraderMessage { pub notification: Option, } +#[derive(Clone)] +pub struct TraderSender { + pub sender: Sender, +} + +impl TraderSender { + pub fn send(&self, message: TraderMessage) { + tokio::spawn({ + let sender = self.sender.clone(); + async move { + let trader = message.trader_id; + if let Err(e) = sender.send(message).await { + tracing::error!(%trader, "Failed to send trader message. Error: {e:#}"); + } + } + }); + } +} + #[derive(Clone)] pub struct NewUserMessage { pub new_user: PublicKey, diff --git a/coordinator/src/node/expired_positions.rs b/coordinator/src/node/expired_positions.rs index 56fb094cb..42c7c681e 100644 --- a/coordinator/src/node/expired_positions.rs +++ b/coordinator/src/node/expired_positions.rs @@ -1,11 +1,10 @@ use crate::db; use crate::node::Node; use crate::orderbook; -use crate::orderbook::db::orders; -use crate::orderbook::trading::NewOrderMessage; +use crate::orderbook::db::matches; +use crate::orderbook::trading::OrderbookMessage; use crate::position::models::Position; use crate::position::models::PositionState; -use anyhow::anyhow; use anyhow::Context; use anyhow::Result; use rust_decimal::prelude::FromPrimitive; @@ -18,14 +17,14 @@ use xxi_node::commons::average_execution_price; use xxi_node::commons::Match; use xxi_node::commons::MatchState; use xxi_node::commons::NewMarketOrder; +use xxi_node::commons::NewOrder; use xxi_node::commons::OrderReason; -use xxi_node::commons::OrderState; /// The timeout before we give up on closing an expired position collaboratively. This value should /// not be larger than our refund transaction time lock. pub const EXPIRED_POSITION_TIMEOUT: Duration = Duration::days(7); -pub async fn close(node: Node, trading_sender: mpsc::Sender) -> Result<()> { +pub async fn close(node: Node, orderbook_sender: mpsc::Sender) -> Result<()> { let mut conn = node.pool.get()?; let positions = db::positions::Position::get_all_open_positions(&mut conn) @@ -40,46 +39,44 @@ pub async fn close(node: Node, trading_sender: mpsc::Sender) -> .collect::>(); for position in positions.into_iter() { - if let Some(order) = orderbook::db::orders::get_by_trader_id_and_state( - &mut conn, - position.trader, - OrderState::Matched, - )? { - let trader_id = order.trader_id.to_string(); - let order_id = order.id.to_string(); + let matches = matches::get_pending_matches_by_trader(&mut conn, position.trader)?; + if !matches.is_empty() { + // we can assume that all matches belong to the same order id since a user can + // only have one active order at the time. Meaning there can't be multiple pending + // matches for different orders. + let order_id = matches.first().expect("list not empty").order_id; + let order = orderbook::db::orders::get_with_id(&mut conn, order_id)? + .context("missing order")?; + let trader = order.trader_id; if order.expiry < OffsetDateTime::now_utc() { - tracing::warn!(trader_id, order_id, "Matched order expired! Giving up on that position, looks like the corresponding dlc channel has to get force closed."); - orderbook::db::orders::set_order_state(&mut conn, order.id, OrderState::Expired)?; + tracing::warn!(%trader, %order_id, "Matched order expired! Giving up on that position, looks like the corresponding dlc channel has to get force closed."); - orderbook::db::matches::set_match_state_by_order_id( - &mut conn, - order.id, - MatchState::Failed, - )?; - - let matches = orderbook::db::matches::get_matches_by_order_id(&mut conn, order.id)?; - let matches: Vec = matches.into_iter().map(Match::from).collect(); + matches::set_match_state(&mut conn, order_id, MatchState::Failed)?; + let closing_price = + average_execution_price(matches.into_iter().map(Match::from).collect()); db::positions::Position::set_open_position_to_closing( &mut conn, &position.trader, - Some(average_execution_price(matches)), + Some(closing_price), )?; continue; } else { - tracing::trace!(trader_id, order_id, "Skipping expired position as match has already been found. Waiting for trader to come online to execute the trade."); + tracing::trace!(%trader, %order_id, "Skipping expired position as match has already been found. Waiting for trader to come online to execute the trade."); continue; } } tracing::debug!(trader_pk=%position.trader, %position.expiry_timestamp, "Attempting to close expired position"); + let order_id = uuid::Uuid::new_v4(); + let trader = position.trader; let new_order = NewMarketOrder { - id: uuid::Uuid::new_v4(), + id: order_id, contract_symbol: position.contract_symbol, quantity: Decimal::try_from(position.quantity).expect("to fit into decimal"), - trader_id: position.trader, + trader_id: trader, direction: position.trader_direction.opposite(), leverage: Decimal::from_f32(position.trader_leverage).expect("to fit into decimal"), // This order can basically not expire, but if the user does not come back online within @@ -89,18 +86,13 @@ pub async fn close(node: Node, trading_sender: mpsc::Sender) -> stable: position.stable, }; - let order = orders::insert_market_order(&mut conn, new_order.clone(), OrderReason::Expired) - .map_err(|e| anyhow!(e)) - .context("Failed to insert expired order into DB")?; - - let message = NewOrderMessage { - order, - channel_opening_params: None, + let message = OrderbookMessage::NewOrder { + new_order: NewOrder::Market(new_order), order_reason: OrderReason::Expired, }; - if let Err(e) = trading_sender.send(message).await { - tracing::error!(order_id=%new_order.id, trader_id=%new_order.trader_id, "Failed to submit new order for closing expired position. Error: {e:#}"); + if let Err(e) = orderbook_sender.send(message).await { + tracing::error!(%trader, %order_id, "Failed to submit new order for closing expired position. Error: {e:#}"); continue; } } diff --git a/coordinator/src/node/liquidated_positions.rs b/coordinator/src/node/liquidated_positions.rs index fb9888a5b..cae2e76f1 100644 --- a/coordinator/src/node/liquidated_positions.rs +++ b/coordinator/src/node/liquidated_positions.rs @@ -3,8 +3,9 @@ use crate::funding_fee::funding_fee_from_funding_fee_events; use crate::funding_fee::get_outstanding_funding_fee_events; use crate::node::Node; use crate::orderbook; -use crate::orderbook::db::orders; -use crate::orderbook::trading::NewOrderMessage; +use crate::orderbook::db::matches; +use crate::orderbook::trading::OrderbookMessage; +use anyhow::Context; use anyhow::Result; use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; @@ -19,16 +20,16 @@ use xxi_node::commons::Direction; use xxi_node::commons::Match; use xxi_node::commons::MatchState; use xxi_node::commons::NewMarketOrder; +use xxi_node::commons::NewOrder; use xxi_node::commons::OrderReason; -use xxi_node::commons::OrderState; /// The timeout before we give up on closing a liquidated position collaboratively. This value /// should not be larger than our refund transaction time lock. pub const LIQUIDATION_POSITION_TIMEOUT: Duration = Duration::days(7); -pub async fn monitor(node: Node, trading_sender: mpsc::Sender) { +pub async fn monitor(node: Node, orderbook_sender: mpsc::Sender) { if let Err(e) = - check_if_positions_need_to_get_liquidated(trading_sender.clone(), node.clone()).await + check_if_positions_need_to_get_liquidated(orderbook_sender.clone(), node.clone()).await { tracing::error!("Failed to check if positions need to get liquidated. Error: {e:#}"); } @@ -37,7 +38,7 @@ pub async fn monitor(node: Node, trading_sender: mpsc::Sender) /// For all open positions, check if the maintenance margin has been reached. Send a liquidation /// async match to the traders whose positions have been liquidated. async fn check_if_positions_need_to_get_liquidated( - trading_sender: mpsc::Sender, + orderbook_sender: mpsc::Sender, node: Node, ) -> Result<()> { let mut conn = node.pool.get()?; @@ -76,33 +77,23 @@ async fn check_if_positions_need_to_get_liquidated( ); if trader_liquidation || coordinator_liquidation { - if let Some(order) = orderbook::db::orders::get_by_trader_id_and_state( - &mut conn, - position.trader, - OrderState::Matched, - )? { - let trader_id = order.trader_id.to_string(); - let order_id = order.id.to_string(); + let matches = matches::get_pending_matches_by_trader(&mut conn, position.trader)?; + if !matches.is_empty() { + // we can assume that all matches belong to the same order id since a user + // can only have one active order at the time. Meaning there can't + // be multiple pending matches for different orders. + let order_id = matches.first().expect("list not empty").order_id; + let order = orderbook::db::orders::get_with_id(&mut conn, order_id)? + .context("missing order")?; + let trader = order.trader_id; if order.expiry < OffsetDateTime::now_utc() { - tracing::warn!(trader_id, order_id, "Matched order expired! Giving up on that position, looks like the corresponding dlc channel has to get force closed."); - orderbook::db::orders::set_order_state( - &mut conn, - order.id, - OrderState::Expired, - )?; - - orderbook::db::matches::set_match_state_by_order_id( - &mut conn, - order.id, - MatchState::Failed, - )?; + tracing::warn!(%trader, %order_id, "Matched order expired! Giving up on that position, looks like the corresponding dlc channel has to get force closed."); - let matches = - orderbook::db::matches::get_matches_by_order_id(&mut conn, order.id)?; - let matches: Vec = matches.into_iter().map(Match::from).collect(); + matches::set_match_state(&mut conn, order_id, MatchState::Failed)?; - let closing_price = average_execution_price(matches); + let closing_price = + average_execution_price(matches.into_iter().map(Match::from).collect()); db::positions::Position::set_open_position_to_closing( &mut conn, &position.trader, @@ -110,7 +101,7 @@ async fn check_if_positions_need_to_get_liquidated( )?; continue; } else { - tracing::trace!(trader_id, order_id, "Skipping liquidated position as match has already been found. Waiting for trader to come online to execute the trade."); + tracing::trace!(%trader, %order_id, "Skipping liquidated position as match has already been found. Waiting for trader to come online to execute the trade."); continue; } } @@ -137,11 +128,13 @@ async fn check_if_positions_need_to_get_liquidated( } } + let trader = position.trader; + let order_id = uuid::Uuid::new_v4(); let new_order = NewMarketOrder { - id: uuid::Uuid::new_v4(), + id: order_id, contract_symbol: position.contract_symbol, quantity: Decimal::try_from(position.quantity).expect("to fit into decimal"), - trader_id: position.trader, + trader_id: trader, direction: position.trader_direction.opposite(), leverage: Decimal::from_f32(position.trader_leverage).expect("to fit into decimal"), // This order can basically not expire, but if the user does not come back online @@ -156,26 +149,13 @@ async fn check_if_positions_need_to_get_liquidated( false => OrderReason::CoordinatorLiquidated, }; - let order = match orders::insert_market_order( - &mut conn, - new_order.clone(), - order_reason.clone(), - ) { - Ok(order) => order, - Err(e) => { - tracing::error!("Failed to insert liquidation order into DB. Error: {e:#}"); - continue; - } - }; - - let message = NewOrderMessage { - order, - channel_opening_params: None, + let message = OrderbookMessage::NewOrder { + new_order: NewOrder::Market(new_order), order_reason, }; - if let Err(e) = trading_sender.send(message).await { - tracing::error!(order_id=%new_order.id, trader_id=%new_order.trader_id, "Failed to submit new order for closing liquidated position. Error: {e:#}"); + if let Err(e) = orderbook_sender.send(message).await { + tracing::error!(%trader, %order_id, "Failed to submit new order for closing liquidated position. Error: {e:#}"); continue; } } diff --git a/coordinator/src/orderbook/async_match.rs b/coordinator/src/orderbook/async_match.rs deleted file mode 100644 index 6dcd5e07c..000000000 --- a/coordinator/src/orderbook/async_match.rs +++ /dev/null @@ -1,163 +0,0 @@ -use crate::check_version::check_version; -use crate::db; -use crate::message::TraderMessage; -use crate::node::Node; -use crate::orderbook::db::matches; -use crate::orderbook::db::orders; -use crate::trade::TradeExecutor; -use anyhow::ensure; -use anyhow::Result; -use bitcoin::secp256k1::PublicKey; -use bitcoin::secp256k1::XOnlyPublicKey; -use bitcoin::Network; -use futures::future::RemoteHandle; -use futures::FutureExt; -use rust_decimal::prelude::ToPrimitive; -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::commons; -use xxi_node::commons::ContractSymbol; -use xxi_node::commons::FilledWith; -use xxi_node::commons::Match; -use xxi_node::commons::Matches; -use xxi_node::commons::OrderState; -use xxi_node::commons::TradeAndChannelParams; -use xxi_node::commons::TradeParams; -use xxi_node::node::event::NodeEvent; - -pub fn monitor( - node: Node, - mut receiver: broadcast::Receiver, - notifier: mpsc::Sender, - network: Network, - oracle_pk: XOnlyPublicKey, -) -> RemoteHandle<()> { - let (fut, remote_handle) = async move { - loop { - match receiver.recv().await { - Ok(NodeEvent::Connected { peer: trader_id }) => { - tokio::spawn({ - let notifier = notifier.clone(); - let node = node.clone(); - async move { - tracing::debug!( - %trader_id, - "Checking if the user needs to be notified about pending matches" - ); - if let Err(e) = - process_pending_match(node, notifier, trader_id, network, oracle_pk) - .await - { - tracing::error!("Failed to process pending match. Error: {e:#}"); - } - } - }); - } - Ok(_) => {} // ignoring other node events - Err(RecvError::Closed) => { - tracing::error!("Node event sender died! Channel closed."); - break; - } - Err(RecvError::Lagged(skip)) => { - tracing::warn!(%skip, "Lagging behind on node events.") - } - } - } - } - .remote_handle(); - - tokio::spawn(fut); - - remote_handle -} - -/// Checks if there are any pending matches -async fn process_pending_match( - node: Node, - notifier: mpsc::Sender, - trader_id: PublicKey, - network: Network, - oracle_pk: XOnlyPublicKey, -) -> Result<()> { - let mut conn = spawn_blocking({ - let node = node.clone(); - move || node.pool.get() - }) - .await - .expect("task to complete")?; - - if check_version(&mut conn, &trader_id).is_err() { - tracing::info!(%trader_id, "User is not on the latest version. Skipping check if user needs to be informed about pending matches."); - return Ok(()); - } - - if let Some(order) = - orders::get_by_trader_id_and_state(&mut conn, trader_id, OrderState::Matched)? - { - tracing::debug!(%trader_id, order_id=%order.id, "Executing pending match"); - - let matches = matches::get_matches_by_order_id(&mut conn, order.id)?; - let filled_with = get_filled_with_from_matches(matches, network, oracle_pk)?; - - let channel_opening_params = - db::channel_opening_params::get_by_order_id(&mut conn, order.id)?; - - tracing::info!(trader_id = %order.trader_id, order_id = %order.id, order_reason = ?order.order_reason, "Executing trade for match"); - let trade_executor = TradeExecutor::new(node, notifier); - trade_executor - .execute(&TradeAndChannelParams { - trade_params: TradeParams { - pubkey: trader_id, - contract_symbol: ContractSymbol::BtcUsd, - leverage: order.leverage, - quantity: order.quantity.to_f32().expect("to fit into f32"), - direction: order.direction, - filled_with, - }, - trader_reserve: channel_opening_params.map(|c| c.trader_reserve), - coordinator_reserve: channel_opening_params.map(|c| c.coordinator_reserve), - external_funding: channel_opening_params.and_then(|c| c.external_funding), - }) - .await; - } - - Ok(()) -} - -fn get_filled_with_from_matches( - matches: Vec, - network: Network, - oracle_pk: XOnlyPublicKey, -) -> Result { - ensure!( - !matches.is_empty(), - "Need at least one matches record to construct a FilledWith" - ); - - let order_id = matches - .first() - .expect("to have at least one match") - .order_id; - - let expiry_timestamp = commons::calculate_next_expiry(OffsetDateTime::now_utc(), network); - - Ok(FilledWith { - order_id, - expiry_timestamp, - oracle_pk, - matches: matches - .iter() - .map(|m| Match { - id: m.id, - order_id: m.order_id, - quantity: m.quantity, - pubkey: m.match_trader_id, - execution_price: m.execution_price, - matching_fee: m.matching_fee, - }) - .collect(), - }) -} diff --git a/coordinator/src/orderbook/db/matches.rs b/coordinator/src/orderbook/db/matches.rs index 5de73144c..da9c926b8 100644 --- a/coordinator/src/orderbook/db/matches.rs +++ b/coordinator/src/orderbook/db/matches.rs @@ -1,8 +1,5 @@ use crate::orderbook::db::custom_types::MatchState; -use crate::orderbook::trading::TraderMatchParams; use crate::schema::matches; -use anyhow::ensure; -use anyhow::Result; use bitcoin::secp256k1::PublicKey; use bitcoin::Amount; use diesel::ExpressionMethods; @@ -37,16 +34,37 @@ struct Matches { pub matching_fee_sats: i64, } -pub fn insert(conn: &mut PgConnection, match_params: &TraderMatchParams) -> Result<()> { - for record in Matches::new(match_params, MatchState::Pending) { - let affected_rows = diesel::insert_into(matches::table) - .values(record.clone()) - .execute(conn)?; +pub fn insert( + conn: &mut PgConnection, + order: &commons::Order, + matched_order: &commons::Order, + matching_fee: Amount, + match_state: commons::MatchState, + quantity: Decimal, +) -> QueryResult { + let match_ = Matches { + id: Uuid::new_v4(), + match_state: match_state.into(), + order_id: order.id, + trader_id: order.trader_id.to_string(), + match_order_id: matched_order.id, + match_trader_id: matched_order.trader_id.to_string(), + execution_price: matched_order.price.to_f32().expect("to fit into f32"), + quantity: quantity.to_f32().expect("to fit into f32"), + created_at: OffsetDateTime::now_utc(), + updated_at: OffsetDateTime::now_utc(), + matching_fee_sats: matching_fee.to_sat() as i64, + }; + + let affected_rows = diesel::insert_into(matches::table) + .values(match_.clone()) + .execute(conn)?; - ensure!(affected_rows > 0, "Could not insert matches"); + if affected_rows == 0 { + return Err(diesel::NotFound); } - Ok(()) + Ok(match_.into()) } pub fn set_match_state( @@ -62,12 +80,13 @@ pub fn set_match_state( Ok(()) } -pub fn get_matches_by_order_id( +pub fn get_pending_matches_by_trader( conn: &mut PgConnection, - order_id: Uuid, + trader: PublicKey, ) -> QueryResult> { let matches: Vec = matches::table - .filter(matches::order_id.eq(order_id)) + .filter(matches::trader_id.eq(trader.to_string())) + .filter(matches::match_state.eq(MatchState::Pending)) .load(conn)?; let matches = matches.into_iter().map(commons::Matches::from).collect(); @@ -75,45 +94,17 @@ pub fn get_matches_by_order_id( Ok(matches) } -pub fn set_match_state_by_order_id( +pub fn get_matches_by_order_id( conn: &mut PgConnection, order_id: Uuid, - match_state: commons::MatchState, -) -> Result<()> { - let affected_rows = diesel::update(matches::table) +) -> QueryResult> { + let matches: Vec = matches::table .filter(matches::order_id.eq(order_id)) - .set(matches::match_state.eq(MatchState::from(match_state))) - .execute(conn)?; + .load(conn)?; - ensure!(affected_rows > 0, "Could not update matches"); - Ok(()) -} + let matches = matches.into_iter().map(commons::Matches::from).collect(); -impl Matches { - pub fn new(match_params: &TraderMatchParams, match_state: MatchState) -> Vec { - let order_id = match_params.filled_with.order_id; - let updated_at = OffsetDateTime::now_utc(); - let trader_id = match_params.trader_id; - - match_params - .filled_with - .matches - .iter() - .map(|m| Matches { - id: m.id, - match_state, - order_id, - trader_id: trader_id.to_string(), - match_order_id: m.order_id, - match_trader_id: m.pubkey.to_string(), - execution_price: m.execution_price.to_f32().expect("to fit into f32"), - quantity: m.quantity.to_f32().expect("to fit into f32"), - created_at: updated_at, - updated_at, - matching_fee_sats: m.matching_fee.to_sat() as i64, - }) - .collect() - } + Ok(matches) } impl From for Matches { diff --git a/coordinator/src/orderbook/db/orders.rs b/coordinator/src/orderbook/db/orders.rs index 02bba5df0..6ff9e692b 100644 --- a/coordinator/src/orderbook/db/orders.rs +++ b/coordinator/src/orderbook/db/orders.rs @@ -410,6 +410,22 @@ pub fn delete(conn: &mut PgConnection, id: Uuid) -> QueryResult set_order_state(conn, id, commons::OrderState::Deleted) } +pub fn set_order_state_partially_taken( + conn: &mut PgConnection, + id: Uuid, + quantity: Decimal, +) -> QueryResult { + let order: Order = diesel::update(orders::table) + .filter(orders::order_id.eq(id)) + .set(( + orders::order_state.eq(OrderState::Taken), + orders::quantity.eq(quantity.to_f32().expect("to fit")), + )) + .get_result(conn)?; + + Ok(OrderbookOrder::from(order)) +} + /// Returns the number of affected rows: 1. pub fn set_order_state( conn: &mut PgConnection, @@ -418,12 +434,23 @@ pub fn set_order_state( ) -> QueryResult { let order: Order = diesel::update(orders::table) .filter(orders::order_id.eq(id)) - .set((orders::order_state.eq(OrderState::from(order_state)),)) + .set(orders::order_state.eq(OrderState::from(order_state))) .get_result(conn)?; Ok(OrderbookOrder::from(order)) } +pub fn update_quantity( + conn: &mut PgConnection, + order_id: Uuid, + quantity: Decimal, +) -> QueryResult { + diesel::update(orders::table) + .filter(orders::order_id.eq(order_id)) + .set(orders::quantity.eq(quantity.to_f32().expect("to fit"))) + .execute(conn) +} + pub fn set_expired_limit_orders_to_expired( conn: &mut PgConnection, ) -> QueryResult> { diff --git a/coordinator/src/orderbook/match_order.rs b/coordinator/src/orderbook/match_order.rs new file mode 100644 index 000000000..376e727be --- /dev/null +++ b/coordinator/src/orderbook/match_order.rs @@ -0,0 +1,373 @@ +use crate::check_version::check_version; +use crate::message::TraderMessage; +use crate::message::TraderSender; +use crate::node::Node; +use crate::orderbook::db::matches; +use crate::orderbook::db::orders; +use crate::orderbook::OrderMatchingFeeRate; +use crate::referrals; +use crate::trade::ExecutableMatch; +use crate::trade::TradeExecutor; +use anyhow::bail; +use anyhow::Context; +use anyhow::Result; +use bitcoin::secp256k1::PublicKey; +use bitcoin::Amount; +use diesel::r2d2::ConnectionManager; +use diesel::r2d2::Pool; +use diesel::result::Error::RollbackTransaction; +use diesel::Connection; +use diesel::PgConnection; +use futures::stream::StreamExt; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::RoundingStrategy; +use std::collections::HashMap; +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::task::spawn_blocking; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::ReceiverStream; +use xxi_node::commons::MatchState; +use xxi_node::commons::Message::TradeError; +use xxi_node::commons::Order; +use xxi_node::commons::OrderReason; +use xxi_node::node::event::NodeEvent; + +enum MatchExecutorEvent { + MatchedOrder(MatchedOrder), + NodeEvent(NodeEvent), +} + +pub fn spawn_match_executor( + node: Node, + node_event_receiver: broadcast::Receiver, + order_matching_fee_rate: OrderMatchingFeeRate, + trader_sender: TraderSender, +) -> mpsc::Sender { + let (sender, receiver) = mpsc::channel::(100); + + tokio::spawn({ + let pool = node.pool.clone(); + let trade_executor = TradeExecutor::new(node.clone()); + let trader_sender = trader_sender.clone(); + + async move { + let match_executor = MatchExecutor { + pool: pool.clone(), + order_matching_fee_rate, + trade_executor, + }; + let receiver_stream = + ReceiverStream::new(receiver).map(MatchExecutorEvent::MatchedOrder); + let node_event_stream = + BroadcastStream::new(node_event_receiver).filter_map(|event| async { + match event { + Ok(event) => Some(MatchExecutorEvent::NodeEvent(event)), + Err(BroadcastStreamRecvError::Lagged(skip)) => { + tracing::warn!(%skip, "Lagging behind on node events."); + None + } + } + }); + + let mut merged_stream = + futures::stream::select(Box::pin(receiver_stream), Box::pin(node_event_stream)); + + while let Some(event) = merged_stream.next().await { + match event { + MatchExecutorEvent::MatchedOrder(matched_order) => { + let trader = matched_order.order.trader_id; + let order_id = matched_order.order.id; + let order_reason = matched_order.order.order_reason; + tracing::info!(%trader, %order_id, "Processing matched order."); + let match_executor = match_executor.clone(); + let trader_sender = trader_sender.clone(); + match match_executor.process_matched_order(matched_order).await { + Ok(matches) => { + for executeable_match in matches { + if let Err(e) = match_executor + .execute_match(executeable_match, order_reason) + .await + { + tracing::error!(%trader, %order_id, "Failed to execute match. Error: {e:#}"); + + trader_sender.send(TraderMessage { + trader_id: trader, + message: TradeError { + order_id, + error: e.into(), + }, + notification: None, + }); + } + } + } + Err(e) => { + tracing::error!(%trader, %order_id, "Failed to process matched order. Error: {e:#}"); + + trader_sender.send(TraderMessage { + trader_id: trader, + message: TradeError { + order_id, + error: e.into(), + }, + notification: None, + }); + } + } + } + MatchExecutorEvent::NodeEvent(NodeEvent::Connected { peer: trader }) => { + tracing::info!(%trader, "Checking if user has a pending match."); + match match_executor.process_pending_match(trader).await { + Ok(Some(executeable_match)) => { + let trader = executeable_match.order.trader_id; + let order_id = executeable_match.order.id; + let order_reason = executeable_match.order.order_reason; + tracing::info!(%trader, "Found pending matches."); + if let Err(e) = match_executor + .execute_match(executeable_match, order_reason) + .await + { + tracing::error!(%trader, %order_id, "Failed to execute match. Error: {e:#}"); + + trader_sender.send(TraderMessage { + trader_id: trader, + message: TradeError { + order_id, + error: e.into(), + }, + notification: None, + }); + } + } + Ok(None) => { + tracing::debug!(%trader, "No pending matches found."); + } + Err(e) + if e.to_string() + .contains("Please upgrade to the latest version") => + { + tracing::info!(%trader, "User is not on the latest version. Skipping check if user needs to be informed about pending matches."); + } + Err(e) => { + tracing::error!(%trader, "Failed to process pending match. Error: {e:#}"); + } + } + } + MatchExecutorEvent::NodeEvent(_) => {} // ignore other node events. + } + } + } + }); + + sender +} + +#[derive(Clone)] +struct MatchExecutor { + pool: Pool>, + order_matching_fee_rate: OrderMatchingFeeRate, + trade_executor: TradeExecutor, +} + +impl MatchExecutor { + async fn process_matched_order( + &self, + matched_order: MatchedOrder, + ) -> Result> { + let matches = spawn_blocking({ + let matched_orders = matched_order.matched.clone(); + let fee_percent = self.order_matching_fee_rate.taker; + let order = matched_order.order; + let pool = self.pool.clone(); + let trader = matched_order.order.trader_id; + move || { + let mut conn = pool.clone().get()?; + let mut matches: HashMap = HashMap::new(); + conn.transaction(|conn| { + let status = + referrals::get_referral_status(trader, conn).map_err(|_| RollbackTransaction)?; + let fee_discount = status.referral_fee_bonus; + let fee_percent = fee_percent - (fee_percent * fee_discount); + + tracing::debug!(%trader, %fee_discount, total_fee_percent = %fee_percent, "Fee discount calculated"); + + for matched_order in matched_orders { + let matching_fee = matched_order.quantity / matched_order.price * fee_percent; + let matching_fee = matching_fee.round_dp_with_strategy(8, RoundingStrategy::MidpointAwayFromZero); + let matching_fee = match Amount::from_btc(matching_fee.to_f64().expect("to fit")) { + Ok(fee) => fee, + Err(err) => { + tracing::error!( + trader_pubkey = matched_order.trader_id.to_string(), + order_id = matched_order.id.to_string(), + "Failed calculating order matching fee for order {err:?}. Falling back to 0" + ); + Amount::ZERO + } + }; + + let taker_match = matches::insert(conn, &order, &matched_order, matching_fee, MatchState::Pending, matched_order.quantity)?; + if let Some(taker_matches) = matches.get_mut(&trader) { + taker_matches.matches.push(taker_match); + } else { + matches.insert(trader, ExecutableMatch { + order, + matches: vec![taker_match], + }); + } + + // TODO(holzeis): For now we don't execute the limit order with the maker as the our maker does not + // have a dlc channel with the coordinator, hence we set the match directly to filled. Once we + // introduce actual makers these matches need to execute them. + let _maker_match = matches::insert(conn, &matched_order, &order, matching_fee, MatchState::Filled, matched_order.quantity)?; + + // TODO(holzeis): Add executable match once we support actual limit orders. + // if let Some(maker_matches) = matches.get_mut(&matched_order.trader_id) { + // maker_matches.matches.push(taker_match); + // } else { + // matches.insert(order.trader_id, ExecutableMatch{ + // order, + // matches: vec![taker_match] + // }); + // } + } + + diesel::result::QueryResult::Ok(()) + })?; + + anyhow::Ok(matches) + } + }).await??; + + Ok(matches.into_values().collect::>()) + } + + async fn execute_match( + &self, + executable_match: ExecutableMatch, + order_reason: OrderReason, + ) -> Result<()> { + let trader = executable_match.order.trader_id; + let order_id = executable_match.order.id; + if self.trade_executor.is_connected(trader) { + tracing::info!(%trader, %order_id, "Executing pending match"); + match self.trade_executor.execute(executable_match).await { + Ok(()) => { + tracing::info!(%trader, %order_id, ?order_reason, "Successfully proposed trade."); + + // TODO(holzeis): We should only set the match to filled once the dlc + // protocol has finished. + if let Err(e) = spawn_blocking({ + let pool = self.pool.clone(); + move || { + let mut conn = pool.get()?; + matches::set_match_state(&mut conn, order_id, MatchState::Filled)?; + anyhow::Ok(()) + } + }) + .await? + { + tracing::error!(%trader, %order_id, "Failed to set matches to filled. Error: {e:#}"); + } + } + Err(e) => { + // TODO(holzeis): If the order failed to execute, the matched limit order + // should also fail. + tracing::error!(%trader, %order_id, ?order_reason, "Failed to propose trade. Error: {e:#}"); + + if let Err(e) = spawn_blocking({ + let pool = self.pool.clone(); + move || { + let mut conn = pool.get()?; + matches::set_match_state(&mut conn, order_id, MatchState::Failed)?; + anyhow::Ok(()) + } + }) + .await? + { + tracing::error!(%trader, %order_id, "Failed to set matches to failed. Error: {e:#}"); + } + + bail!(e) + } + } + } else { + match order_reason { + OrderReason::Manual => { + tracing::warn!(%trader, %order_id, ?order_reason, "Skipping trade execution as trader is not connected") + } + OrderReason::Expired + | OrderReason::TraderLiquidated + | OrderReason::CoordinatorLiquidated => { + tracing::info!(%trader, %order_id, ?order_reason, "Skipping trade execution as trader is not connected") + } + } + } + + Ok(()) + } + + /// Checks if there are any pending matches + async fn process_pending_match(&self, trader: PublicKey) -> Result> { + let matches = spawn_blocking({ + let pool = self.pool.clone(); + move || { + let mut conn = pool.get().context("no connection")?; + check_version(&mut conn, &trader)?; + + let matches = matches::get_pending_matches_by_trader(&mut conn, trader)?; + anyhow::Ok(matches) + } + }) + .await??; + + let executable_match = if !matches.is_empty() { + // we can assume that all matches belong to the same order id since a user + // can only have one active order at the time. Meaning there can't + // be multiple pending matches for different orders. + let order_id = matches.first().expect("not empty list").order_id; + let order = spawn_blocking({ + let pool = self.pool.clone(); + move || { + let mut conn = pool.get().context("no connection")?; + let order = + orders::get_with_id(&mut conn, order_id)?.context("Missing order")?; + anyhow::Ok(order) + } + }) + .await??; + + Some(ExecutableMatch { order, matches }) + } else { + None + }; + + Ok(executable_match) + } +} + +pub struct MatchedOrder { + pub order: Order, + pub matched: Vec, +} + +pub struct MatchExecutorSender { + pub sender: mpsc::Sender, +} + +impl MatchExecutorSender { + pub fn send(&self, matched_order: MatchedOrder) { + tokio::spawn({ + let sender = self.sender.clone(); + async move { + let trader = matched_order.order.trader_id; + let order_id = matched_order.order.id; + if let Err(e) = sender.send(matched_order).await { + tracing::error!(%trader, %order_id, "Failed to send trader message. Error: {e:#}"); + } + } + }); + } +} diff --git a/coordinator/src/orderbook/mod.rs b/coordinator/src/orderbook/mod.rs index 1450ab5c9..b41f6fd7f 100644 --- a/coordinator/src/orderbook/mod.rs +++ b/coordinator/src/orderbook/mod.rs @@ -1,8 +1,741 @@ -pub mod async_match; +use crate::message::TraderMessage; +use crate::message::TraderSender; +use crate::notifications::Notification; +use crate::notifications::NotificationKind; +use crate::orderbook::db::matches; +use crate::orderbook::db::orders; +use crate::orderbook::match_order::MatchExecutorSender; +use crate::orderbook::match_order::MatchedOrder; +use anyhow::anyhow; +use anyhow::Context; +use anyhow::Result; +use bitcoin::secp256k1::PublicKey; +use diesel::r2d2::ConnectionManager; +use diesel::r2d2::Pool; +use diesel::PgConnection; +use rust_decimal::Decimal; +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::vec; +use tokio::sync::mpsc; +use tokio::task::spawn_blocking; +use uuid::Uuid; +use xxi_node::commons; +use xxi_node::commons::Message; +use xxi_node::commons::Message::DeleteOrder; +use xxi_node::commons::NewLimitOrder; +use xxi_node::commons::NewMarketOrder; +use xxi_node::commons::Order; +use xxi_node::commons::OrderReason; +use xxi_node::commons::OrderState; +use xxi_node::commons::OrderType; +use xxi_node::commons::TradingError; + pub mod collaborative_revert; pub mod db; +pub mod match_order; pub mod trading; pub mod websocket; #[cfg(test)] mod tests; + +struct OrderbookActionSender { + // implementing a sync sender to ensure the orderbook action has been sent before continuing. + orderbook_executor: std::sync::mpsc::Sender, +} + +impl OrderbookActionSender { + pub fn send(&self, action: OrderbookAction) { + if let Err(e) = self.orderbook_executor.send(action) { + tracing::error!("Failed to send orderbook action. Error: {e:#}"); + } + } +} + +#[derive(Debug)] +pub enum OrderbookAction { + AddLimitOrder(NewLimitOrder), + AddMarketOrder { + new_order: NewMarketOrder, + order_reason: OrderReason, + }, + FailOrder { + order_id: Uuid, + order_reason: OrderReason, + }, + FillOrder { + order_id: Uuid, + quantity: Decimal, + }, + RemoveOrder(Uuid), +} + +fn spawn_orderbook_executor( + pool: Pool>, +) -> std::sync::mpsc::Sender { + let (sender, receiver) = std::sync::mpsc::channel::(); + + tokio::spawn({ + async move { + while let Ok(action) = receiver.recv() { + tracing::trace!(?action, "Processing orderbook action."); + if let Err(e) = match action { + OrderbookAction::AddLimitOrder(new_order) => spawn_blocking({ + let pool = pool.clone(); + move || { + let mut conn = pool.clone().get()?; + orders::insert_limit_order(&mut conn, new_order, OrderReason::Manual) + .map_err(|e| anyhow!(e)) + .context("Failed to insert new order into DB")?; + anyhow::Ok(()) + } + }), + OrderbookAction::RemoveOrder(order_id) => { + spawn_blocking({ + let pool = pool.clone(); + move || { + let mut conn = pool.clone().get()?; + let matches = + matches::get_matches_by_order_id(&mut conn, order_id)?; + + if !matches.is_empty() { + // order has been at least partially matched. + let matched_quantity = matches.iter().map(|m| m.quantity).sum(); + + orders::set_order_state_partially_taken( + &mut conn, + order_id, + matched_quantity, + )?; + } else { + orders::delete(&mut conn, order_id)?; + } + + anyhow::Ok(()) + } + }) + } + OrderbookAction::AddMarketOrder { + new_order, + order_reason, + } => spawn_blocking({ + let pool = pool.clone(); + move || { + let mut conn = pool.clone().get()?; + orders::insert_market_order(&mut conn, new_order, order_reason) + .map_err(|e| anyhow!(e)) + .context("Failed to insert new order into DB")?; + + anyhow::Ok(()) + } + }), + OrderbookAction::FailOrder { + order_id, + order_reason, + } => spawn_blocking({ + let pool = pool.clone(); + move || { + let mut conn = pool.get()?; + let order_state = match order_reason { + OrderReason::CoordinatorLiquidated + | OrderReason::TraderLiquidated + | OrderReason::Manual => OrderState::Failed, + OrderReason::Expired => OrderState::Expired, + }; + orders::set_order_state(&mut conn, order_id, order_state) + .context("Failed to set order state")?; + + anyhow::Ok(()) + } + }), + OrderbookAction::FillOrder { order_id, quantity } => spawn_blocking({ + let pool = pool.clone(); + move || { + let mut conn = pool.get()?; + let order = orders::get_with_id(&mut conn, order_id)? + .context("Missing order.")?; + + match order.quantity.cmp(&quantity) { + // The order quantity is greater than the consumed quantity. The + // order remains open, but we reduce the quantity. + Ordering::Greater => { + orders::update_quantity( + &mut conn, + order_id, + order.quantity - quantity, + )?; + } + // The order has been matched to its full quantity. The order is set + // to taken. + Ordering::Equal => { + orders::set_order_state( + &mut conn, + order_id, + OrderState::Taken, + )?; + } + Ordering::Less => debug_assert!( + false, + "Can't take more quantity than the volume of the order." + ), + } + + anyhow::Ok(()) + } + }), + } + .await + { + tracing::error!(?action, "Failed to process orderbook action. Error: {e:#}"); + } + } + + tracing::warn!("Sender closed the channel. Stop listening to orderbook actions."); + } + }); + + sender +} + +struct Orderbook { + shorts: OrderbookSide, + longs: OrderbookSide, + orderbook_action_sender: OrderbookActionSender, + trader_sender: TraderSender, + match_executor: MatchExecutorSender, + notifier: mpsc::Sender, +} + +struct OrderbookSide { + orders: BTreeMap>, + direction: commons::Direction, +} + +impl OrderbookSide { + pub fn new(orders: Vec, direction: commons::Direction) -> Self { + let mut orders_map: BTreeMap> = BTreeMap::new(); + for order in orders { + orders_map.entry(order.price).or_default().push(order); + } + for vec in orders_map.values_mut() { + vec.sort_by_key(|order| order.timestamp); + } + OrderbookSide { + orders: orders_map, + direction, + } + } + /// adds the given order to the orderbook. + pub fn add_order(&mut self, order: Order) { + let entry = self.orders.entry(order.price).or_default(); + entry.push(order); + } + + /// removes the order by the given id from the orderbook. Returns true if the order was found, + /// returns false otherwise. + fn remove_order(&mut self, order_id: Uuid) -> bool { + for (_price, orders) in self.orders.iter_mut() { + if let Some(pos) = orders.iter().position(|order| order.id == order_id) { + orders.remove(pos); + if orders.is_empty() { + self.orders.retain(|_price, orders| !orders.is_empty()); + } + return true; + } + } + false + } + + /// returns the a sorted vec orders of the orderbook side with the best price at first. + fn get_orders(&self) -> Vec { + let mut sorted_orders: Vec = self + .orders + .values() + .flat_map(|orders| orders.iter().copied()) + .collect(); + + if commons::Direction::Short == self.direction { + sorted_orders.reverse(); + } + + sorted_orders + } + + /// matches orders from the orderbook for the given quantity. + pub fn match_order(&self, quantity: Decimal) -> Vec { + let mut matched_orders = vec![]; + + let mut quantity = quantity; + for order in self.get_orders().iter() { + if order.is_expired() { + // ignore expired orders. + continue; + } + + match order.quantity.cmp(&quantity) { + Ordering::Less => { + // if the found order has less quantity we subtract the full quantity from the + // searched quantity and add the limit order to the matched orders. + quantity -= order.quantity; + matched_orders.push(*order); + } + Ordering::Greater => { + // we found enough liquidity in the order book to match the order. + matched_orders.push(Order { quantity, ..*order }); + break; + } + Ordering::Equal => { + // we found a perfect match for the searched for quantity. + matched_orders.push(*order); + break; + } + } + } + + matched_orders + } + + /// commits the given matched orders to the in memory orderbook, by removing the volume from the + /// orderbook. If an order is partially matched the quantity of the matched order is reduced.s + pub fn commit_matched_orders(&mut self, matched_orders: &Vec) { + for order in matched_orders { + if let Some(vec) = self.orders.get_mut(&order.price) { + if let Some(existing_order) = vec.iter_mut().find(|o| o.id == order.id) { + match existing_order.quantity.cmp(&order.quantity) { + Ordering::Equal => { + // If the quantities are equal, remove the order + vec.retain(|o| o.id != order.id); + } + Ordering::Greater => { + // If the existing order quantity is greater, update the quantity + existing_order.quantity -= order.quantity; + } + Ordering::Less => debug_assert!( + false, + "matched quantity is bigger than the existing order" + ), + } + } + if vec.is_empty() { + self.orders.remove(&order.price); + } + } + } + } +} + +#[derive(Clone, Copy)] +pub struct OrderMatchingFeeRate { + pub maker: Decimal, + pub taker: Decimal, +} + +impl Orderbook { + /// Initializes the orderbook with non expired open limit orders. + async fn new( + pool: Pool>, + notifier: mpsc::Sender, + match_executor: mpsc::Sender, + trader_sender: mpsc::Sender, + ) -> Result { + let all_orders = spawn_blocking({ + let mut conn = pool.clone().get()?; + move || { + // TODO(holzeis): join with trades to get partially matched orders. + let all_orders = + orders::get_all_orders(&mut conn, OrderType::Limit, OrderState::Open, true)?; + anyhow::Ok(all_orders) + } + }) + .await??; + + let shorts = all_orders + .clone() + .into_iter() + .filter(|o| o.direction == commons::Direction::Short) + .collect::>(); + + let longs = all_orders + .into_iter() + .filter(|o| o.direction == commons::Direction::Long) + .collect::>(); + + let orderbook_executor = spawn_orderbook_executor(pool); + + let orderbook = Self { + shorts: OrderbookSide::new(shorts, commons::Direction::Short), + longs: OrderbookSide::new(longs, commons::Direction::Long), + orderbook_action_sender: OrderbookActionSender { orderbook_executor }, + trader_sender: TraderSender { + sender: trader_sender, + }, + match_executor: MatchExecutorSender { + sender: match_executor, + }, + notifier, + }; + + Ok(orderbook) + } + + /// Adds a limit order to the orderbook. + fn add_limit_order(&mut self, new_order: NewLimitOrder) -> Message { + self.orderbook_action_sender + .send(OrderbookAction::AddLimitOrder(new_order)); + + let order: Order = new_order.into(); + match order.direction { + commons::Direction::Short => self.shorts.add_order(order), + commons::Direction::Long => self.longs.add_order(order), + } + Message::NewOrder(order) + } + + /// Matches a market order against the orderbook. Will fail if the market order can't be fully + /// matched. + fn match_market_order(&mut self, new_order: NewMarketOrder, order_reason: OrderReason) { + self.orderbook_action_sender + .send(OrderbookAction::AddMarketOrder { + new_order, + order_reason, + }); + + let order = Order { + order_reason, + ..new_order.into() + }; + + // find a match for the market order. + let matched_orders = match order.direction.opposite() { + commons::Direction::Short => self.shorts.match_order(order.quantity), + commons::Direction::Long => self.longs.match_order(order.quantity), + }; + + let order_id = order.id; + let trader_pubkey = order.trader_id; + + let matched_quantity: Decimal = matched_orders.iter().map(|o| o.quantity).sum(); + if matched_quantity != order.quantity { + // not enough liquidity in the orderbook. + tracing::warn!( + trader_pubkey = %order.trader_id, + order_id = %order.id, + wanted = %order.quantity, + got = %matched_quantity, + "Couldn't match order due to insufficient liquidity in the orderbook." + ); + + self.orderbook_action_sender + .send(OrderbookAction::FailOrder { + order_id: order.id, + order_reason, + }); + + let message = TraderMessage { + trader_id: trader_pubkey, + message: Message::TradeError { + order_id, + error: TradingError::NoMatchFound(order.id.to_string()), + }, + notification: None, + }; + self.trader_sender.send(message); + } else { + // apply changes to the in memory orderbook. + match order.direction.opposite() { + commons::Direction::Short => self.shorts.commit_matched_orders(&matched_orders), + commons::Direction::Long => self.longs.commit_matched_orders(&matched_orders), + } + + let order = Order { + order_state: OrderState::Taken, + ..order + }; + + self.orderbook_action_sender + .send(OrderbookAction::FillOrder { + order_id, + quantity: order.quantity, + }); + + for order in &matched_orders { + let order = Order { + order_state: OrderState::Taken, + ..*order + }; + self.orderbook_action_sender + .send(OrderbookAction::FillOrder { + order_id: order.id, + quantity: order.quantity, + }); + } + + // execute matched order + self.match_executor.send(MatchedOrder { + order, + matched: matched_orders, + }); + + notify_user(self.notifier.clone(), trader_pubkey, order_reason); + } + } + + /// Updates a limit order in the orderbook by removing it and adding it anew. + fn update_limit_order(&mut self, order: Order) -> Message { + self.remove_order(order.id); + let new_order = NewLimitOrder { + id: order.id, + contract_symbol: order.contract_symbol, + price: order.price, + quantity: order.quantity, + trader_id: order.trader_id, + direction: order.direction, + leverage: Decimal::try_from(order.leverage).expect("to fit"), + expiry: order.expiry, + stable: false, + }; + self.add_limit_order(new_order); + + Message::Update(order) + } + + /// Removes a limit order from the orderbook. Ignores removing market orders, since they aren't + /// stored into the orderbook. + fn remove_order(&mut self, order_id: Uuid) -> Option { + self.orderbook_action_sender + .send(OrderbookAction::RemoveOrder(order_id)); + + if self.shorts.remove_order(order_id) { + return Some(DeleteOrder(order_id)); + } + + if self.longs.remove_order(order_id) { + return Some(DeleteOrder(order_id)); + } + + None + } +} + +/// Sends a push notification to the user in case of an expiry or liquidation. +fn notify_user( + notifier: mpsc::Sender, + trader_pubkey: PublicKey, + order_reason: OrderReason, +) { + tokio::spawn({ + async move { + let notification = match order_reason { + OrderReason::Expired => Some(NotificationKind::PositionExpired), + OrderReason::TraderLiquidated => Some(NotificationKind::Custom { + title: "Oops, you got liquidated 💸".to_string(), + message: "Open your app to execute the liquidation".to_string(), + }), + OrderReason::CoordinatorLiquidated => Some(NotificationKind::Custom { + title: "Your counterparty got liquidated 💸".to_string(), + message: "Open your app to execute the liquidation".to_string(), + }), + OrderReason::Manual => None, + }; + + if let Some(notification) = notification { + tracing::info!(%trader_pubkey, ?order_reason, "Notifying trader about match"); + // send user a push notification + if let Err(e) = notifier + .send(Notification::new(trader_pubkey, notification)) + .await + { + tracing::error!("Failed to notify trader about match. Error: {e:#}") + } + } + } + }); +} + +#[cfg(test)] +mod orderbook_tests { + use crate::orderbook::OrderbookSide; + use bitcoin::secp256k1::PublicKey; + use rust_decimal::Decimal; + use rust_decimal::RoundingStrategy; + use rust_decimal_macros::dec; + use std::str::FromStr; + use time::Duration; + use time::OffsetDateTime; + use uuid::Uuid; + use xxi_node::commons::ContractSymbol; + use xxi_node::commons::Direction; + use xxi_node::commons::Order; + use xxi_node::commons::OrderReason; + use xxi_node::commons::OrderState; + use xxi_node::commons::OrderType; + + #[test] + pub fn test_add_order_to_orderbook_side() { + let mut longs = OrderbookSide::new(vec![], Direction::Long); + + let long_order = dummy_order(dec!(100), dec!(50000), Direction::Long); + longs.add_order(long_order); + + let order = longs.get_orders().first().cloned(); + + assert_eq!(Some(long_order), order); + } + + #[test] + pub fn test_remove_order_from_orderbook_side() { + let mut longs = OrderbookSide::new(vec![], Direction::Long); + + let long_order = dummy_order(dec!(100), dec!(50000), Direction::Long); + longs.add_order(long_order); + + longs.remove_order(long_order.id); + + let order = longs.get_orders().first().cloned(); + assert_eq!(None, order); + } + + #[test] + pub fn test_remove_invalid_order_id_from_orderbook_side() { + let mut longs = OrderbookSide::new(vec![], Direction::Long); + + let long_order = dummy_order(dec!(100), dec!(50000), Direction::Long); + longs.add_order(long_order); + + longs.remove_order(Uuid::new_v4()); + + let order = longs.get_orders().first().cloned(); + assert_eq!(Some(long_order), order); + } + + #[test] + pub fn test_match_order_exact_match_single_order() { + let mut longs = OrderbookSide::new(vec![], Direction::Long); + + let long_order = dummy_order(dec!(100), dec!(50000), Direction::Long); + longs.add_order(long_order); + + let matched_orders = longs.match_order(dec!(100)); + assert_eq!(1, matched_orders.len()); + assert_eq!(dec!(100), matched_orders.iter().map(|m| m.quantity).sum()); + + longs.commit_matched_orders(&matched_orders); + + assert_eq!(None, longs.get_orders().first()); + } + + #[test] + pub fn test_match_order_partial_limit_order_match_single_order() { + let mut longs = OrderbookSide::new(vec![], Direction::Long); + + let long_order = dummy_order(dec!(100), dec!(50000), Direction::Long); + longs.add_order(long_order); + + let matched_orders = longs.match_order(dec!(25)); + assert_eq!(1, matched_orders.len()); + assert_eq!(dec!(25), matched_orders.iter().map(|m| m.quantity).sum()); + + longs.commit_matched_orders(&matched_orders); + + assert_eq!( + Some(Order { + quantity: dec!(75), + ..long_order + }), + longs.get_orders().first().cloned() + ); + } + + #[test] + pub fn test_match_order_partial_market_order_match_single_order() { + let mut longs = OrderbookSide::new(vec![], Direction::Long); + + let long_order = dummy_order(dec!(100), dec!(50000), Direction::Long); + longs.add_order(long_order); + + let matched_orders = longs.match_order(dec!(125)); + assert_eq!(1, matched_orders.len()); + assert_eq!(dec!(100), matched_orders.iter().map(|m| m.quantity).sum()); + + assert_eq!(Some(long_order), longs.get_orders().first().cloned()); + } + + #[test] + pub fn test_match_order_partial_match_multiple_orders() { + let mut longs = OrderbookSide::new(vec![], Direction::Long); + + let long_order_1 = dummy_order(dec!(25), dec!(50100), Direction::Long); + longs.add_order(long_order_1); + + let long_order_2 = dummy_order(dec!(40), dec!(50200), Direction::Long); + longs.add_order(long_order_2); + + let long_order_3 = dummy_order(dec!(35), dec!(50300), Direction::Long); + longs.add_order(long_order_3); + + let matched_orders = longs.match_order(dec!(50)); + assert_eq!(2, matched_orders.len()); + assert_eq!(dec!(50), matched_orders.iter().map(|m| m.quantity).sum()); + + assert_eq!(dec!(50149.95), average_entry_price(&matched_orders)); + + longs.commit_matched_orders(&matched_orders); + + assert_eq!( + Some(Order { + quantity: dec!(15), + ..long_order_2 + }), + longs.get_orders().first().cloned() + ); + + assert_eq!( + Some(Order { + quantity: dec!(35), + ..long_order_3 + }), + longs.get_orders().get(1).cloned() + ); + } + + fn dummy_order(quantity: Decimal, price: Decimal, direction: Direction) -> Order { + Order { + id: Uuid::new_v4(), + price, + trader_id: dummy_public_key(), + direction, + leverage: 1.0, + contract_symbol: ContractSymbol::BtcUsd, + quantity, + order_type: OrderType::Market, + timestamp: OffsetDateTime::now_utc(), + expiry: OffsetDateTime::now_utc() + Duration::minutes(1), + order_state: OrderState::Open, + order_reason: OrderReason::Manual, + stable: false, + } + } + + fn dummy_public_key() -> PublicKey { + PublicKey::from_str("02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655") + .unwrap() + } + + fn average_entry_price(orders: &[Order]) -> Decimal { + if orders.is_empty() { + return Decimal::ZERO; + } + if orders.len() == 1 { + return orders.first().expect("to be exactly one").price; + } + let sum_quantity = orders.iter().fold(dec!(0), |acc, m| acc + m.quantity); + + let nominal_prices = orders + .iter() + .fold(dec!(0), |acc, m| acc + (m.quantity / m.price)); + + (sum_quantity / nominal_prices) + .round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero) + } +} diff --git a/coordinator/src/orderbook/trading.rs b/coordinator/src/orderbook/trading.rs index 7dfbf4997..5bf8a04a9 100644 --- a/coordinator/src/orderbook/trading.rs +++ b/coordinator/src/orderbook/trading.rs @@ -1,836 +1,110 @@ -use crate::db; use crate::message::TraderMessage; -use crate::node::Node; use crate::notifications::Notification; -use crate::notifications::NotificationKind; -use crate::orderbook::db::matches; -use crate::orderbook::db::orders; -use crate::referrals; -use crate::trade::TradeExecutor; -use crate::ChannelOpeningParams; -use anyhow::anyhow; -use anyhow::bail; -use anyhow::Context; -use anyhow::Result; -use bitcoin::secp256k1::PublicKey; -use bitcoin::secp256k1::XOnlyPublicKey; -use bitcoin::Amount; -use bitcoin::Network; -use futures::future::RemoteHandle; -use futures::FutureExt; -use rust_decimal::prelude::ToPrimitive; -use rust_decimal::Decimal; -use rust_decimal::RoundingStrategy; -use std::cmp::Ordering; -use time::OffsetDateTime; +use crate::orderbook::match_order::MatchedOrder; +use crate::orderbook::Orderbook; +use diesel::r2d2::ConnectionManager; +use diesel::r2d2::Pool; +use diesel::PgConnection; use tokio::sync::broadcast; use tokio::sync::mpsc; -use tokio::task::spawn_blocking; use uuid::Uuid; use xxi_node::commons; -use xxi_node::commons::ContractSymbol; -use xxi_node::commons::Direction; -use xxi_node::commons::FilledWith; -use xxi_node::commons::Match; use xxi_node::commons::Message; -use xxi_node::commons::Message::TradeError; +use xxi_node::commons::NewOrder; use xxi_node::commons::Order; use xxi_node::commons::OrderReason; -use xxi_node::commons::OrderState; -use xxi_node::commons::OrderType; -use xxi_node::commons::TradeAndChannelParams; -use xxi_node::commons::TradeParams; -use xxi_node::commons::TradingError; /// This value is arbitrarily set to 100 and defines the number of new order messages buffered in /// the channel. -const NEW_ORDERS_BUFFER_SIZE: usize = 100; - -pub struct NewOrderMessage { - pub order: Order, - pub order_reason: OrderReason, - pub channel_opening_params: Option, -} - -#[derive(Clone)] -pub struct MatchParams { - pub taker_match: TraderMatchParams, - pub makers_matches: Vec, +const ORDERBOOK_BUFFER_SIZE: usize = 100; + +#[derive(Debug)] +pub enum OrderbookMessage { + NewOrder { + new_order: NewOrder, + order_reason: OrderReason, + }, + DeleteOrder(Uuid), + Update(Order), } -#[derive(Clone)] -pub struct TraderMatchParams { - pub trader_id: PublicKey, - pub filled_with: FilledWith, -} - -/// Spawn a task that processes [`NewOrderMessage`]s. -/// -/// To feed messages to this task, the caller can use the corresponding -/// [`mpsc::Sender`] returned. -pub fn start( - node: Node, - tx_orderbook_feed: broadcast::Sender, - trade_notifier: mpsc::Sender, +pub fn spawn_orderbook( + pool: Pool>, notifier: mpsc::Sender, - network: Network, - oracle_pk: XOnlyPublicKey, -) -> (RemoteHandle<()>, mpsc::Sender) { - let (sender, mut receiver) = mpsc::channel::(NEW_ORDERS_BUFFER_SIZE); - - let (fut, remote_handle) = async move { - while let Some(new_order_msg) = receiver.recv().await { - tokio::spawn({ - let tx_orderbook_feed = tx_orderbook_feed.clone(); - let notifier = notifier.clone(); - let trade_notifier = trade_notifier.clone(); - let node = node.clone(); - async move { - let new_order = new_order_msg.order; - let trader_id = new_order.trader_id; - let order_id = new_order.id; - let channel_opening_params = new_order_msg.channel_opening_params; - - tracing::trace!( - %trader_id, - %order_id, - order_type = ?new_order.order_type, - "Processing new order", - ); - - if let Err(error) = match &new_order.order_type { - OrderType::Market => { - process_new_market_order( - node, - notifier.clone(), - trade_notifier.clone(), - &new_order, - network, - oracle_pk, - channel_opening_params - ) - .await - } - OrderType::Limit => { - process_new_limit_order( - tx_orderbook_feed, - new_order.clone(), - ) - .await - } - } { - - if new_order.order_reason == OrderReason::Manual { - // TODO(holzeis): the maker is currently not subscribed to the websocket - // api, hence it wouldn't receive the error message. - if let Err(e) = trade_notifier - .send(TraderMessage { - trader_id, - message: TradeError { order_id, error }, - notification: None, - }) - .await - { - tracing::error!(%trader_id, %order_id, "Failed to send trade error. Error: {e:#}"); - } - } - } - } - }); - } - - tracing::error!("Channel closed"); - } - .remote_handle(); - - tokio::spawn(fut); - - (remote_handle, sender) -} - -pub async fn process_new_limit_order( + match_executor: mpsc::Sender, tx_orderbook_feed: broadcast::Sender, - order: Order, -) -> Result<(), TradingError> { - tx_orderbook_feed - .send(Message::NewOrder(order)) - .map_err(|e| anyhow!(e)) - .context("Could not update price feed")?; - - Ok(()) -} - -// TODO(holzeis): This functions runs multiple inserts in separate db transactions. This should only -// happen in a single transaction to ensure either all data or nothing is stored to the database. -pub async fn process_new_market_order( - node: Node, - notifier: mpsc::Sender, - trade_notifier: mpsc::Sender, - order: &Order, - network: Network, - oracle_pk: XOnlyPublicKey, - channel_opening_params: Option, -) -> Result<(), TradingError> { - let mut conn = spawn_blocking({ - let node = node.clone(); - move || node.pool.get() - }) - .await - .expect("task to complete") - .map_err(|e| anyhow!("{e:#}"))?; - - // Reject new order if there is already a matched order waiting for execution. - if let Some(order) = - orders::get_by_trader_id_and_state(&mut conn, order.trader_id, OrderState::Matched) - .map_err(|e| anyhow!("{e:#}"))? - { - return Err(TradingError::InvalidOrder(format!( - "trader_id={}, order_id={}. Order is currently in execution. \ - Can't accept new orders until the order execution is finished", - order.trader_id, order.id - ))); - } - - let opposite_direction_limit_orders = orders::all_by_direction_and_type( - &mut conn, - order.direction.opposite(), - OrderType::Limit, - true, - ) - .map_err(|e| anyhow!("{e:#}"))?; - - let fee_percent = { node.settings.read().await.order_matching_fee_rate }; - let fee_percent = Decimal::try_from(fee_percent).expect("to fit into decimal"); - - let trader_pubkey_string = order.trader_id.to_string(); - let status = referrals::get_referral_status(order.trader_id, &mut conn)?; - let fee_discount = status.referral_fee_bonus; - let fee_percent = fee_percent - (fee_percent * fee_discount); - - tracing::debug!( - trader_pubkey = trader_pubkey_string, - %fee_discount, total_fee_percent = %fee_percent, "Fee discount calculated"); - - let matched_orders = match match_order( - order, - opposite_direction_limit_orders, - network, - oracle_pk, - fee_percent, - ) { - Ok(Some(matched_orders)) => matched_orders, - Ok(None) => { - // TODO(holzeis): Currently we still respond to the user immediately if there - // has been a match or not, that's the reason why we also have to set the order - // to failed here. But actually we could keep the order until either expired or - // a match has been found and then update the state accordingly. - - orders::set_order_state(&mut conn, order.id, OrderState::Failed) - .map_err(|e| anyhow!("{e:#}"))?; - return Err(TradingError::NoMatchFound(format!( - "Could not match order {}", - order.id - ))); - } - Err(e) => { - orders::set_order_state(&mut conn, order.id, OrderState::Failed) - .map_err(|e| anyhow!("{e:#}"))?; - return Err(TradingError::Other(format!("Failed to match order: {e:#}"))); - } - }; - - tracing::info!( - trader_id=%order.trader_id, - order_id=%order.id, - "Found a match with {} makers for new order", - matched_orders.taker_match.filled_with.matches.len() - ); - - for match_param in matched_orders.matches() { - matches::insert(&mut conn, match_param)?; - - let trader_id = match_param.trader_id; - let order_id = match_param.filled_with.order_id.to_string(); - - tracing::info!(%trader_id, order_id, "Notifying trader about match"); - - let notification = match &order.order_reason { - OrderReason::Expired => Some(NotificationKind::PositionExpired), - OrderReason::TraderLiquidated => Some(NotificationKind::Custom { - title: "Oops, you got liquidated 💸".to_string(), - message: "Open your app to execute the liquidation".to_string(), - }), - OrderReason::CoordinatorLiquidated => Some(NotificationKind::Custom { - title: "Your counterparty got liquidated 💸".to_string(), - message: "Open your app to execute the liquidation".to_string(), - }), - OrderReason::Manual => None, - }; - - if let Some(notification) = notification { - // send user a push notification - notifier - .send(Notification::new(order.trader_id, notification)) - .await - .with_context(|| { - format!( - "Failed to send push notification. trader_id = {}", - order.trader_id - ) - })?; - } - - let order_state = if order.order_type == OrderType::Limit { - // FIXME: The maker is currently not connected to the WebSocket so we can't - // notify him about a trade. However, trades are always accepted by the - // maker at the moment so in order to not have all limit orders in order - // state `Match` we are setting the order to `Taken` even if we couldn't - // notify the maker. - OrderState::Taken - } else { - OrderState::Matched - }; - - tracing::debug!(%trader_id, order_id, "Updating the order state to {order_state:?}"); - - orders::set_order_state(&mut conn, match_param.filled_with.order_id, order_state) - .map_err(|e| anyhow!("{e:#}"))?; - } - - if let Some(channel_opening_params) = channel_opening_params { - db::channel_opening_params::insert(&mut conn, order.id, channel_opening_params) - .map_err(|e| anyhow!("{e:#}"))?; - } - - if node.inner.is_connected(order.trader_id) { - tracing::info!(trader_id = %order.trader_id, order_id = %order.id, order_reason = ?order.order_reason, "Executing trade for match"); - let trade_executor = TradeExecutor::new(node.clone(), trade_notifier); - - trade_executor - .execute(&TradeAndChannelParams { - trade_params: TradeParams { - pubkey: order.trader_id, - contract_symbol: ContractSymbol::BtcUsd, - leverage: order.leverage, - quantity: order.quantity.to_f32().expect("to fit into f32"), - direction: order.direction, - filled_with: matched_orders.taker_match.filled_with, - }, - trader_reserve: channel_opening_params.map(|p| p.trader_reserve), - coordinator_reserve: channel_opening_params.map(|p| p.coordinator_reserve), - external_funding: channel_opening_params.and_then(|c| c.external_funding), - }) - .await; - } else { - match order.order_reason { - OrderReason::Manual => { - tracing::warn!(trader_id = %order.trader_id, order_id = %order.id, order_reason = ?order.order_reason, "Skipping trade execution as trader is not connected") - } - OrderReason::Expired - | OrderReason::TraderLiquidated - | OrderReason::CoordinatorLiquidated => { - tracing::info!(trader_id = %order.trader_id, order_id = %order.id, order_reason = ?order.order_reason, "Skipping trade execution as trader is not connected") - } - } - } - - Ok(()) -} - -/// Matches an [`Order`] of [`OrderType::Market`] with a list of [`Order`]s of [`OrderType::Limit`]. -/// -/// The caller is expected to provide a list of `opposite_direction_orders` of [`OrderType::Limit`] -/// and opposite [`Direction`] to the `market_order`. We nevertheless ensure that this is the case -/// to be on the safe side. -fn match_order( - market_order: &Order, - opposite_direction_orders: Vec, - network: Network, - oracle_pk: XOnlyPublicKey, - fee_percent: Decimal, -) -> Result> { - if market_order.order_type == OrderType::Limit { - // We don't match limit orders with other limit orders at the moment. - return Ok(None); - } - - let opposite_direction_orders = opposite_direction_orders - .into_iter() - .filter(|o| !o.direction.eq(&market_order.direction)) - .collect(); - - let mut orders = sort_orders(opposite_direction_orders, market_order.direction); - - let mut remaining_quantity = market_order.quantity; - let mut matched_orders = vec![]; - while !orders.is_empty() { - let matched_order = orders.remove(0); - remaining_quantity -= matched_order.quantity; - matched_orders.push(matched_order); - - if remaining_quantity <= Decimal::ZERO { - break; - } - } - - // For the time being we do not want to support multi-matches. - if matched_orders.len() > 1 { - bail!("More than one matched order, please reduce order quantity"); - } - - if matched_orders.is_empty() { - return Ok(None); - } - - let expiry_timestamp = commons::calculate_next_expiry(OffsetDateTime::now_utc(), network); - - let matches = matched_orders - .iter() - .map(|maker_order| { - let matching_fee = market_order.quantity / maker_order.price * fee_percent; - let matching_fee = matching_fee.round_dp_with_strategy(8, RoundingStrategy::MidpointAwayFromZero); - let matching_fee = match Amount::from_btc(matching_fee.to_f64().expect("to fit")) { - Ok(fee) => {fee} - Err(err) => { - tracing::error!( - trader_pubkey = maker_order.trader_id.to_string(), - order_id = maker_order.id.to_string(), - "Failed calculating order matching fee for order {err:?}. Falling back to 0"); - Amount::ZERO + trader_sender: mpsc::Sender, +) -> mpsc::Sender { + let (sender, mut receiver) = mpsc::channel::(ORDERBOOK_BUFFER_SIZE); + + tokio::spawn({ + let notifier = notifier.clone(); + let trader_sender = trader_sender.clone(); + let tx_orderbook_feed = tx_orderbook_feed.clone(); + + async move { + let mut orderbook = match Orderbook::new( + pool.clone(), + notifier, + match_executor, + trader_sender.clone(), + ) + .await + { + Ok(orderbook) => orderbook, + Err(e) => { + tracing::error!("Failed to initialize orderbook. Error: {e:#}"); + return; } }; - ( - TraderMatchParams { - trader_id: maker_order.trader_id, - filled_with: FilledWith { - order_id: maker_order.id, - expiry_timestamp, - oracle_pk, - matches: vec![Match { - id: Uuid::new_v4(), - order_id: market_order.id, - quantity: market_order.quantity, - pubkey: market_order.trader_id, - execution_price: maker_order.price, - matching_fee, - }], - }, - }, - Match { - id: Uuid::new_v4(), - order_id: maker_order.id, - quantity: market_order.quantity, - pubkey: maker_order.trader_id, - execution_price: maker_order.price, - matching_fee, - }, - ) - }) - .collect::>(); - let mut maker_matches = vec![]; - let mut taker_matches = vec![]; - - for (mm, taker_match) in matches { - maker_matches.push(mm); - taker_matches.push(taker_match); - } - - Ok(Some(MatchParams { - taker_match: TraderMatchParams { - trader_id: market_order.trader_id, - filled_with: FilledWith { - order_id: market_order.id, - expiry_timestamp, - oracle_pk, - matches: taker_matches, - }, - }, - makers_matches: maker_matches, - })) -} + while let Some(message) = receiver.recv().await { + let msg = match message { + OrderbookMessage::NewOrder { + new_order: NewOrder::Market(new_order), + order_reason, + } => { + orderbook.match_market_order(new_order, order_reason); + // TODO(holzeis): Send orderbook updates about updated or removed limit + // orders due to matching. + None + } + OrderbookMessage::NewOrder { + new_order: NewOrder::Limit(new_order), + .. + } => { + let message = orderbook.add_limit_order(new_order); + Some(message) + } + OrderbookMessage::DeleteOrder(order_id) => orderbook.remove_order(order_id), + OrderbookMessage::Update( + order @ Order { + order_type: commons::OrderType::Limit, + .. + }, + ) => { + let message = orderbook.update_limit_order(order); + Some(message) + } + OrderbookMessage::Update(Order { + order_type: commons::OrderType::Market, + .. + }) => { + tracing::debug!("Ignoring market order update."); + None + } + }; -/// Sort the provided list of limit [`Order`]s based on the [`Direction`] of the market order to be -/// matched. -/// -/// For matching a market order and limit orders we have to -/// -/// - take the highest rate if the market order is short; and -/// -/// - take the lowest rate if the market order is long. -/// -/// Hence, the orders are sorted accordingly: -/// -/// - If the market order is short, the limit orders are sorted in descending order of -/// price. -/// -/// - If the market order is long, the limit orders are sorted in ascending order of price. -/// -/// Additionally, if two orders have the same price, the one with the earlier `timestamp` takes -/// precedence. -fn sort_orders(mut limit_orders: Vec, market_order_direction: Direction) -> Vec { - limit_orders.sort_by(|a, b| { - if a.price.cmp(&b.price) == Ordering::Equal { - return a.timestamp.cmp(&b.timestamp); - } + if let Some(msg) = msg { + if let Err(e) = tx_orderbook_feed.send(msg) { + tracing::error!("Failed to send message. Error: {e:#}"); + } + } + } - match market_order_direction { - // Ascending order. - Direction::Long => a.price.cmp(&b.price), - // Descending order. - Direction::Short => b.price.cmp(&a.price), + tracing::warn!("Orderbook channel has been closed."); } }); - limit_orders -} - -impl MatchParams { - fn matches(&self) -> Vec<&TraderMatchParams> { - std::iter::once(&self.taker_match) - .chain(self.makers_matches.iter()) - .collect() - } -} - -impl From<&TradeParams> for TraderMatchParams { - fn from(value: &TradeParams) -> Self { - TraderMatchParams { - trader_id: value.pubkey, - filled_with: value.filled_with.clone(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rust_decimal_macros::dec; - use std::str::FromStr; - use time::Duration; - use xxi_node::commons::ContractSymbol; - - #[test] - fn when_short_then_sort_desc() { - let order1 = dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - Default::default(), - Duration::seconds(0), - ); - let order2 = dummy_long_order( - dec!(21_000), - Uuid::new_v4(), - Default::default(), - Duration::seconds(0), - ); - let order3 = dummy_long_order( - dec!(20_500), - Uuid::new_v4(), - Default::default(), - Duration::seconds(0), - ); - - let orders = vec![order3.clone(), order1.clone(), order2.clone()]; - - let orders = sort_orders(orders, Direction::Short); - assert_eq!(orders[0], order2); - assert_eq!(orders[1], order3); - assert_eq!(orders[2], order1); - } - - #[test] - fn when_long_then_sort_asc() { - let order1 = dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - Default::default(), - Duration::seconds(0), - ); - let order2 = dummy_long_order( - dec!(21_000), - Uuid::new_v4(), - Default::default(), - Duration::seconds(0), - ); - let order3 = dummy_long_order( - dec!(20_500), - Uuid::new_v4(), - Default::default(), - Duration::seconds(0), - ); - - let orders = vec![order3.clone(), order1.clone(), order2.clone()]; - - let orders = sort_orders(orders, Direction::Long); - assert_eq!(orders[0], order1); - assert_eq!(orders[1], order3); - assert_eq!(orders[2], order2); - } - - #[test] - fn when_all_same_price_sort_by_id() { - let order1 = dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - Default::default(), - Duration::seconds(0), - ); - let order2 = dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - Default::default(), - Duration::seconds(1), - ); - let order3 = dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - Default::default(), - Duration::seconds(2), - ); - - let orders = vec![order3.clone(), order1.clone(), order2.clone()]; - - let orders = sort_orders(orders, Direction::Long); - assert_eq!(orders[0], order1); - assert_eq!(orders[1], order2); - assert_eq!(orders[2], order3); - - let orders = sort_orders(orders, Direction::Short); - assert_eq!(orders[0], order1); - assert_eq!(orders[1], order2); - assert_eq!(orders[2], order3); - } - - #[test] - fn given_limit_and_market_with_same_amount_then_match() { - let all_orders = vec![ - dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - dec!(100), - Duration::seconds(0), - ), - dummy_long_order( - dec!(21_000), - Uuid::new_v4(), - dec!(200), - Duration::seconds(0), - ), - dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - dec!(300), - Duration::seconds(0), - ), - dummy_long_order( - dec!(22_000), - Uuid::new_v4(), - dec!(400), - Duration::seconds(0), - ), - ]; - - let order = Order { - id: Uuid::new_v4(), - price: Default::default(), - trader_id: PublicKey::from_str( - "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", - ) - .unwrap(), - direction: Direction::Short, - leverage: 1.0, - contract_symbol: ContractSymbol::BtcUsd, - quantity: dec!(100), - order_type: OrderType::Market, - timestamp: OffsetDateTime::now_utc(), - expiry: OffsetDateTime::now_utc() + Duration::minutes(1), - order_state: OrderState::Open, - order_reason: OrderReason::Manual, - stable: false, - }; - - let matched_orders = match_order( - &order, - all_orders, - Network::Bitcoin, - get_oracle_public_key(), - Decimal::ZERO, - ) - .unwrap() - .unwrap(); - - assert_eq!(matched_orders.makers_matches.len(), 1); - let maker_matches = matched_orders - .makers_matches - .first() - .unwrap() - .filled_with - .matches - .clone(); - assert_eq!(maker_matches.len(), 1); - assert_eq!(maker_matches.first().unwrap().quantity, dec!(100)); - - assert_eq!(matched_orders.taker_match.filled_with.order_id, order.id); - assert_eq!(matched_orders.taker_match.filled_with.matches.len(), 1); - assert_eq!( - matched_orders - .taker_match - .filled_with - .matches - .first() - .unwrap() - .quantity, - order.quantity - ); - } - - /// This test is for safety reasons only. Once we want multiple matches we should update it - #[test] - fn given_limit_and_market_with_smaller_amount_then_error() { - let order1 = dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - dec!(400), - Duration::seconds(0), - ); - let order2 = dummy_long_order( - dec!(21_000), - Uuid::new_v4(), - dec!(200), - Duration::seconds(0), - ); - let order3 = dummy_long_order( - dec!(22_000), - Uuid::new_v4(), - dec!(100), - Duration::seconds(0), - ); - let order4 = dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - dec!(300), - Duration::seconds(0), - ); - let all_orders = vec![order1, order2, order3, order4]; - - let order = Order { - id: Uuid::new_v4(), - price: Default::default(), - trader_id: PublicKey::from_str( - "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", - ) - .unwrap(), - direction: Direction::Short, - leverage: 1.0, - contract_symbol: ContractSymbol::BtcUsd, - quantity: dec!(200), - order_type: OrderType::Market, - timestamp: OffsetDateTime::now_utc(), - expiry: OffsetDateTime::now_utc() + Duration::minutes(1), - order_state: OrderState::Open, - order_reason: OrderReason::Manual, - stable: false, - }; - - assert!(match_order( - &order, - all_orders, - Network::Bitcoin, - get_oracle_public_key(), - Decimal::ZERO, - ) - .is_err()); - } - - #[test] - fn given_long_when_needed_short_direction_then_no_match() { - let all_orders = vec![ - dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - dec!(100), - Duration::seconds(0), - ), - dummy_long_order( - dec!(21_000), - Uuid::new_v4(), - dec!(200), - Duration::seconds(0), - ), - dummy_long_order( - dec!(22_000), - Uuid::new_v4(), - dec!(400), - Duration::seconds(0), - ), - dummy_long_order( - dec!(20_000), - Uuid::new_v4(), - dec!(300), - Duration::seconds(0), - ), - ]; - - let order = Order { - id: Uuid::new_v4(), - price: Default::default(), - trader_id: PublicKey::from_str( - "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", - ) - .unwrap(), - direction: Direction::Long, - leverage: 1.0, - contract_symbol: ContractSymbol::BtcUsd, - quantity: dec!(200), - order_type: OrderType::Market, - timestamp: OffsetDateTime::now_utc(), - expiry: OffsetDateTime::now_utc() + Duration::minutes(1), - order_state: OrderState::Open, - order_reason: OrderReason::Manual, - stable: false, - }; - - let matched_orders = match_order( - &order, - all_orders, - Network::Bitcoin, - get_oracle_public_key(), - Decimal::ZERO, - ) - .unwrap(); - - assert!(matched_orders.is_none()); - } - - fn dummy_long_order( - price: Decimal, - id: Uuid, - quantity: Decimal, - timestamp_delay: Duration, - ) -> Order { - Order { - id, - price, - trader_id: PublicKey::from_str( - "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", - ) - .unwrap(), - direction: Direction::Long, - leverage: 1.0, - contract_symbol: ContractSymbol::BtcUsd, - quantity, - order_type: OrderType::Limit, - timestamp: OffsetDateTime::now_utc() + timestamp_delay, - expiry: OffsetDateTime::now_utc() + Duration::minutes(1), - order_state: OrderState::Open, - order_reason: OrderReason::Manual, - stable: false, - } - } - - fn get_oracle_public_key() -> XOnlyPublicKey { - XOnlyPublicKey::from_str("16f88cf7d21e6c0f46bcbc983a4e3b19726c6c98858cc31c83551a88fde171c0") - .unwrap() - } + sender } diff --git a/coordinator/src/orderbook/websocket.rs b/coordinator/src/orderbook/websocket.rs index fee22e7c4..38fe81576 100644 --- a/coordinator/src/orderbook/websocket.rs +++ b/coordinator/src/orderbook/websocket.rs @@ -4,7 +4,7 @@ use crate::funding_fee::get_funding_fee_events_for_active_trader_positions; use crate::funding_fee::get_next_funding_rate; use crate::message::NewUserMessage; use crate::orderbook::db::orders; -use crate::orderbook::trading::NewOrderMessage; +use crate::orderbook::trading::OrderbookMessage; use crate::referrals; use crate::routes::AppState; use anyhow::bail; @@ -18,11 +18,11 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::broadcast::error::RecvError; use tokio::sync::mpsc; -use tokio::task::spawn_blocking; use uuid::Uuid; use xxi_node::commons::create_sign_message; use xxi_node::commons::Message; use xxi_node::commons::NewLimitOrder; +use xxi_node::commons::NewOrder; use xxi_node::commons::OrderReason; use xxi_node::commons::OrderbookRequest; use xxi_node::commons::ReferralStatus; @@ -40,23 +40,10 @@ async fn handle_insert_order( bail!("Maker {trader_id} tried to trade on behalf of someone else: {order:?}"); } - tracing::trace!(?order, "Inserting order"); - - let order = spawn_blocking({ - let mut conn = state.pool.clone().get()?; - move || { - let order = orders::insert_limit_order(&mut conn, order, OrderReason::Manual)?; - - anyhow::Ok(order) - } - }) - .await??; - let _ = state - .trading_sender - .send(NewOrderMessage { - order, - channel_opening_params: None, + .orderbook_sender + .send(OrderbookMessage::NewOrder { + new_order: NewOrder::Limit(order), order_reason: OrderReason::Manual, }) .await; @@ -66,22 +53,15 @@ async fn handle_insert_order( async fn handle_delete_order( state: Arc, - trader_id: PublicKey, + trader_pubkey: PublicKey, order_id: Uuid, ) -> Result<()> { - tracing::trace!(%order_id, "Deleting order"); - - spawn_blocking({ - let mut conn = state.pool.clone().get()?; - move || { - orders::delete_trader_order(&mut conn, order_id, trader_id)?; - - anyhow::Ok(()) - } - }) - .await??; + tracing::trace!(%trader_pubkey, %order_id, "Deleting order"); - let _ = state.tx_orderbook_feed.send(Message::DeleteOrder(order_id)); + state + .orderbook_sender + .send(OrderbookMessage::DeleteOrder(order_id)) + .await?; Ok(()) } diff --git a/coordinator/src/routes.rs b/coordinator/src/routes.rs index f24ea287a..e1010c4ca 100644 --- a/coordinator/src/routes.rs +++ b/coordinator/src/routes.rs @@ -13,7 +13,7 @@ use crate::message::TraderMessage; use crate::node::invoice; use crate::node::Node; use crate::notifications::Notification; -use crate::orderbook::trading::NewOrderMessage; +use crate::orderbook::trading::OrderbookMessage; use crate::parse_dlc_channel_id; use crate::routes::admin::post_funding_rates; use crate::settings::Settings; @@ -99,12 +99,12 @@ mod orderbook; pub struct AppState { pub node: Node, + pub orderbook_sender: mpsc::Sender, // Channel used to send messages to all connected clients. pub tx_orderbook_feed: broadcast::Sender, /// A channel used to send messages about position updates pub tx_position_feed: broadcast::Sender, pub tx_user_feed: broadcast::Sender, - pub trading_sender: mpsc::Sender, pub pool: Pool>, pub settings: RwLock, pub node_alias: String, @@ -121,7 +121,7 @@ pub fn router( pool: Pool>, settings: Settings, node_alias: &str, - trading_sender: mpsc::Sender, + orderbook_sender: mpsc::Sender, tx_orderbook_feed: broadcast::Sender, tx_position_feed: broadcast::Sender, tx_user_feed: broadcast::Sender, @@ -139,7 +139,7 @@ pub fn router( tx_orderbook_feed, tx_position_feed, tx_user_feed, - trading_sender, + orderbook_sender, node_alias: node_alias.to_string(), auth_users_notifier, notification_sender, diff --git a/coordinator/src/routes/orderbook.rs b/coordinator/src/routes/orderbook.rs index bb8433980..52b3db557 100644 --- a/coordinator/src/routes/orderbook.rs +++ b/coordinator/src/routes/orderbook.rs @@ -1,12 +1,10 @@ use crate::check_version::check_version; use crate::db; use crate::orderbook; -use crate::orderbook::db::orders; -use crate::orderbook::trading::NewOrderMessage; +use crate::orderbook::trading::OrderbookMessage; use crate::orderbook::websocket::websocket_connection; use crate::routes::AppState; use crate::AppError; -use anyhow::anyhow; use anyhow::Context; use anyhow::Result; use axum::extract::ws::WebSocketUpgrade; @@ -19,12 +17,10 @@ use diesel::r2d2::PooledConnection; use diesel::PgConnection; use rust_decimal::Decimal; use std::sync::Arc; -use tokio::sync::broadcast::Sender; use tokio::task::spawn_blocking; use tracing::instrument; use uuid::Uuid; use xxi_node::commons; -use xxi_node::commons::Message; use xxi_node::commons::NewOrder; use xxi_node::commons::NewOrderRequest; use xxi_node::commons::Order; @@ -79,32 +75,44 @@ pub async fn post_order( let new_order = new_order_request.value; let order_id = new_order.id(); - // TODO(holzeis): We should add a similar check eventually for limit orders (makers). - if let NewOrder::Market(new_order) = &new_order { - let mut conn = state - .pool - .get() - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - check_version(&mut conn, &new_order.trader_id) + match new_order { + NewOrder::Market(new_order) => { + spawn_blocking({ + let pool = state.pool.clone(); + move || { + let mut conn = pool + .get() + .context("Could not acquire database connection")?; + // TODO(holzeis): We should add a similar check eventually for limit orders + // (makers). + check_version(&mut conn, &new_order.trader_id) + } + }) + .await + .expect("task to finish") .map_err(|e| AppError::BadRequest(e.to_string()))?; - } - - let settings = state.settings.read().await; - - if let NewOrder::Limit(new_order) = &new_order { - if settings.whitelist_enabled && !settings.whitelisted_makers.contains(&new_order.trader_id) - { - tracing::warn!( - trader_id = %new_order.trader_id, - "Trader tried to post limit order but was not whitelisted" - ); - return Err(AppError::Unauthorized); } + NewOrder::Limit(new_order) => { + if new_order.price == Decimal::ZERO { + return Err(AppError::BadRequest( + "Limit orders with zero price are not allowed".to_string(), + )); + } - if new_order.price == Decimal::ZERO { - return Err(AppError::BadRequest( - "Limit orders with zero price are not allowed".to_string(), - )); + let (whitelist_enabled, whitelisted_makers) = { + let settings = state.settings.read().await; + ( + settings.whitelist_enabled, + settings.whitelisted_makers.clone(), + ) + }; + if whitelist_enabled && !whitelisted_makers.contains(&new_order.trader_id) { + tracing::warn!( + trader_id = %new_order.trader_id, + "Trader tried to post limit order but was not whitelisted" + ); + return Err(AppError::Unauthorized); + } } } @@ -149,70 +157,59 @@ pub async fn post_order( None => None, }; - let pool = state.pool.clone(); - let new_order = new_order.clone(); - let order = spawn_blocking(move || { - let mut conn = pool.get()?; - - let order = match new_order { - NewOrder::Market(o) => { - orders::insert_market_order(&mut conn, o.clone(), OrderReason::Manual) - } - NewOrder::Limit(o) => orders::insert_limit_order(&mut conn, o, OrderReason::Manual), - } - .map_err(|e| anyhow!(e)) - .context("Failed to insert new order into DB")?; - - anyhow::Ok(order) - }) - .await - .expect("task to complete") - .map_err(|e| AppError::InternalServerError(e.to_string()))?; - - // FIXME(holzeis): We shouldn't blindly trust the user about the coordinator reserve. Note, we - // already ignore the trader reserve parameter when the channel is externally funded. - let message = NewOrderMessage { - order, - channel_opening_params: new_order_request.channel_opening_params.map(|params| { - crate::ChannelOpeningParams { - trader_reserve: params.trader_reserve, - coordinator_reserve: params.coordinator_reserve, - external_funding, + // FIXME(holzeis): We shouldn't blindly trust the user about the coordinator reserve. Note, + // we already ignore the trader reserve parameter when the channel is externally + // funded. + if let Some(channel_opening_params) = new_order_request.channel_opening_params { + spawn_blocking({ + let pool = state.pool.clone(); + move || { + let mut conn = pool.get()?; + db::channel_opening_params::insert( + &mut conn, + order_id, + crate::ChannelOpeningParams { + trader_reserve: channel_opening_params.trader_reserve, + coordinator_reserve: channel_opening_params.coordinator_reserve, + external_funding, + }, + )?; + anyhow::Ok(()) } - }), + }) + .await + .expect("task to complete") + .map_err(|e| { + AppError::InternalServerError(format!("Failed to store channel opening params: {e:#}")) + })?; + } + + let message = OrderbookMessage::NewOrder { + new_order, order_reason: OrderReason::Manual, }; - state.trading_sender.send(message).await.map_err(|e| { + state.orderbook_sender.send(message).await.map_err(|e| { AppError::InternalServerError(format!("Failed to send new order message: {e:#}")) })?; Ok(()) } -fn update_pricefeed(pricefeed_msg: Message, sender: Sender) { - match sender.send(pricefeed_msg) { - Ok(_) => { - tracing::trace!("Pricefeed updated") - } - Err(error) => { - tracing::warn!("Could not update pricefeed due to '{error}'") - } - } -} - #[instrument(skip_all, err(Debug))] pub async fn delete_order( Path(order_id): Path, State(state): State>, -) -> Result, AppError> { - let mut conn = get_db_connection(&state)?; - let order = orderbook::db::orders::delete(&mut conn, order_id) - .map_err(|e| AppError::InternalServerError(format!("Failed to delete order: {e:#}")))?; - let sender = state.tx_orderbook_feed.clone(); - update_pricefeed(Message::DeleteOrder(order_id), sender); +) -> Result<(), AppError> { + state + .orderbook_sender + .send(OrderbookMessage::DeleteOrder(order_id)) + .await + .map_err(|e| { + AppError::InternalServerError(format!("Failed to send delete order message: {e:#}")) + })?; - Ok(Json(order)) + Ok(()) } pub async fn websocket_handler( diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index 8d2d8755a..33994e36d 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -4,15 +4,11 @@ use crate::decimal_from_f32; use crate::dlc_protocol; use crate::funding_fee::funding_fee_from_funding_fee_events; use crate::funding_fee::get_outstanding_funding_fee_events; -use crate::message::TraderMessage; use crate::node::Node; -use crate::orderbook::db::matches; -use crate::orderbook::db::orders; use crate::payout_curve; use crate::position::models::NewPosition; use crate::position::models::Position; use crate::position::models::PositionState; -use anyhow::anyhow; use anyhow::bail; use anyhow::ensure; use anyhow::Context; @@ -20,8 +16,6 @@ use anyhow::Result; use bitcoin::secp256k1::PublicKey; use bitcoin::Amount; use bitcoin::SignedAmount; -use diesel::Connection; -use diesel::PgConnection; use dlc_manager::channel::signed_channel::SignedChannel; use dlc_manager::channel::signed_channel::SignedChannelState; use dlc_manager::channel::Channel; @@ -36,7 +30,6 @@ use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use time::OffsetDateTime; -use tokio::sync::mpsc; use tokio::task::spawn_blocking; use uuid::Uuid; use xxi_node::bitcoin_conversion::to_secp_pk_29; @@ -46,11 +39,13 @@ use xxi_node::cfd::calculate_margin; use xxi_node::cfd::calculate_pnl; use xxi_node::cfd::calculate_short_liquidation_price; use xxi_node::commons; +use xxi_node::commons::ContractSymbol; use xxi_node::commons::Direction; -use xxi_node::commons::MatchState; -use xxi_node::commons::Message; +use xxi_node::commons::FilledWith; +use xxi_node::commons::Match; +use xxi_node::commons::Matches; +use xxi_node::commons::Order; use xxi_node::commons::OrderState; -use xxi_node::commons::TradeAndChannelParams; use xxi_node::commons::TradeParams; use xxi_node::message_handler::TenTenOneMessage; use xxi_node::message_handler::TenTenOneReject; @@ -65,9 +60,6 @@ pub mod websocket; enum TradeAction { OpenDlcChannel, - OpenSingleFundedChannel { - external_funding: Amount, - }, OpenPosition { channel_id: DlcChannelId, own_payout: Amount, @@ -103,9 +95,28 @@ enum ResizeAction { }, } +pub struct ExecutableMatch { + pub order: Order, + pub matches: Vec, +} + +impl ExecutableMatch { + pub fn build_matches(&self) -> Vec { + self.matches + .clone() + .into_iter() + .map(|m| m.into()) + .collect::>() + } + + pub fn sum_quantity(&self) -> Decimal { + self.matches.iter().map(|m| m.quantity).sum() + } +} + +#[derive(Clone)] pub struct TradeExecutor { node: Node, - notifier: mpsc::Sender, } /// The funds the trader will need to provide to open a DLC channel with the coordinator. @@ -121,33 +132,36 @@ enum TraderRequiredLiquidity { } impl TradeExecutor { - pub fn new(node: Node, notifier: mpsc::Sender) -> Self { - Self { node, notifier } + pub fn new(node: Node) -> Self { + Self { node } } - pub async fn execute(&self, params: &TradeAndChannelParams) { - let trader_id = params.trade_params.pubkey; - let order_id = params.trade_params.filled_with.order_id; + pub fn is_connected(&self, trader: PublicKey) -> bool { + self.node.is_connected(trader) + } - match self.execute_internal(params).await { - Ok(()) => { - tracing::info!( - %trader_id, - %order_id, - "Successfully processed match, setting match to Filled" - ); + pub async fn execute(&self, executable_match: ExecutableMatch) -> Result<()> { + let trader_pubkey = executable_match.order.trader_id; + let order_id = executable_match.order.id; + let trader_id = executable_match.order.trader_id; - if let Err(e) = - self.update_order_and_match(order_id, MatchState::Filled, OrderState::Taken) - { - tracing::error!( - %trader_id, - %order_id, - "Failed to update order and match state. Error: {e:#}" - ); - } + let external_funding = spawn_blocking({ + let pool = self.node.pool.clone(); + move || { + let mut conn = pool.get()?; + let params = db::channel_opening_params::get_by_order_id(&mut conn, order_id)?; + anyhow::Ok(params.and_then(|p| p.external_funding)) + } + }) + .await + .expect("to finish task") + .unwrap_or_default(); - if params.external_funding.is_some() { + match self.execute_internal(executable_match).await { + Ok(()) => { + tracing::info!(%trader_pubkey, %order_id, "Successfully processed match"); + + if external_funding.is_some() { // The channel was funded externally. We need to post process the dlc channel // offer. if let Err(e) = self.settle_invoice(trader_id, order_id).await { @@ -161,19 +175,7 @@ impl TradeExecutor { tracing::error!(%trader_id, %order_id, "Failed to cancel hodl invoice. Error: {e:#}"); } - let message = TraderMessage { - trader_id, - message: Message::TradeError { - order_id, - error: e.into(), - }, - notification: None, - }; - if let Err(e) = self.notifier.send(message).await { - tracing::debug!("Failed to notify trader. Error: {e:#}"); - } - - return; + return Err(e); } } @@ -182,12 +184,16 @@ impl TradeExecutor { self.node .inner .event_handler - .publish(NodeEvent::SendLastDlcMessage { peer: trader_id }); + .publish(NodeEvent::SendLastDlcMessage { + peer: trader_pubkey, + }); + + Ok(()) } Err(e) => { - tracing::error!(%trader_id, %order_id,"Failed to execute trade. Error: {e:#}"); + tracing::error!(%trader_pubkey, %order_id, "Failed to execute trade. Error: {e:#}"); - if params.external_funding.is_some() { + if external_funding.is_some() { // TODO(holzeis): It might make sense to do this for any failed offer to // unreserve potentially reserved utxos. if let Err(e) = self.cancel_offer(trader_id).await { @@ -199,25 +205,9 @@ impl TradeExecutor { } } - if let Err(e) = - self.update_order_and_match(order_id, MatchState::Failed, OrderState::Failed) - { - tracing::error!(%trader_id, %order_id, "Failed to update order and match: {e}"); - }; - - let message = TraderMessage { - trader_id, - message: Message::TradeError { - order_id, - error: e.into(), - }, - notification: None, - }; - if let Err(e) = self.notifier.send(message).await { - tracing::debug!("Failed to notify trader. Error: {e:#}"); - } + Err(e) } - }; + } } /// Settles the accepted invoice for the given trader @@ -302,13 +292,32 @@ impl TradeExecutor { /// 2. If no position is found, we open a position. /// /// 3. If a position of differing quantity is found, we resize the position. - async fn execute_internal(&self, params: &TradeAndChannelParams) -> Result<()> { - let mut connection = self.node.pool.get()?; + async fn execute_internal(&self, executable_match: ExecutableMatch) -> Result<()> { + let trader_pubkey = executable_match.order.trader_id; + let order_id = executable_match.order.id; + + let expiry_timestamp = + commons::calculate_next_expiry(OffsetDateTime::now_utc(), self.node.inner.network); + + let order = executable_match.order; + + let filled_with = FilledWith { + order_id, + expiry_timestamp, + oracle_pk: self.node.inner.oracle_pubkey, + matches: executable_match.build_matches(), + }; - let order_id = params.trade_params.filled_with.order_id; - let trader_id = params.trade_params.pubkey; - let order = - orders::get_with_id(&mut connection, order_id)?.context("Could not find order")?; + let trade_params = TradeParams { + pubkey: trader_pubkey, + contract_symbol: ContractSymbol::BtcUsd, + leverage: order.leverage, + quantity: executable_match.sum_quantity().to_f32().expect("to fit"), + direction: order.direction, + filled_with, + }; + + let trader_pubkey = trade_params.pubkey; let is_stable_order = order.stable; ensure!( @@ -316,14 +325,14 @@ impl TradeExecutor { "Can't execute a trade on an expired order" ); ensure!( - order.order_state == OrderState::Matched, - "Can't execute trade with in invalid state {:?}", + order.order_state == OrderState::Taken, + "Can't execute trade with an invalid state {:?}", order.order_state ); - tracing::info!(%trader_id, %order_id, "Executing match"); + tracing::info!(%trader_pubkey, %order_id, "Executing match"); - let trade_action = self.determine_trade_action(&mut connection, params).await?; + let trade_action = self.determine_trade_action(&trade_params).await?; ensure!( matches!(trade_action, TradeAction::ClosePosition { .. }) @@ -333,70 +342,78 @@ impl TradeExecutor { match trade_action { TradeAction::OpenDlcChannel => { - let collateral_reserve_coordinator = params - .coordinator_reserve - .context("Missing coordinator collateral reserve")?; - let collateral_reserve_trader = params - .trader_reserve - .context("Missing trader collateral reserve")?; + let channel_opening_params = spawn_blocking({ + let pool = self.node.pool.clone(); + move || { + let mut conn = pool.get()?; + let params = + db::channel_opening_params::get_by_order_id(&mut conn, order_id)?; + anyhow::Ok(params) + } + }) + .await?? + .context("Missing channel opening params")?; - self.open_dlc_channel( - &mut connection, - ¶ms.trade_params, + let ( collateral_reserve_coordinator, collateral_reserve_trader, - is_stable_order, - TraderRequiredLiquidity::ForTradeCostAndTxFees, - ) - .await - .context("Failed to open DLC channel")?; - } - TradeAction::OpenSingleFundedChannel { external_funding } => { - let collateral_reserve_coordinator = params - .coordinator_reserve - .context("Missing coordinator collateral reserve")?; - let order_matching_fee = params.trade_params.order_matching_fee(); - let margin_trader = margin_trader(¶ms.trade_params); - - let fee_rate = self - .node - .inner - .fee_rate_estimator - .get(ConfirmationTarget::Normal); - - // The on chain fees are split evenly between the two parties. - let funding_transaction_fee = - estimated_funding_transaction_fee(fee_rate.as_sat_per_vb() as f64) / 2; - - let channel_fee_reserve = - estimated_dlc_channel_fee_reserve(fee_rate.as_sat_per_vb() as f64) / 2; - - // If the user funded the channel externally we derive the collateral reserve - // trader from the difference of the trader margin and the - // externally received funds. - // - // TODO(holzeis): Introduce margin orders to directly use the - // external_funding_sats for the position instead of failing here. We need - // to do this though as a malicious actor could otherwise drain us. - // - // Note, we add a min trader reserve to the external funding to ensure that - // minor price movements are covered. - let collateral_reserve_trader = external_funding - .checked_sub( - margin_trader - + order_matching_fee - + funding_transaction_fee - + channel_fee_reserve, - ) - .context("Not enough external funds to open position")?; + trader_required_utxos, + ) = match channel_opening_params.external_funding { + Some(external_funding) => { + let order_matching_fee = trade_params.order_matching_fee(); + let margin_trader = margin_trader(&trade_params); + + let fee_rate = self + .node + .inner + .fee_rate_estimator + .get(ConfirmationTarget::Normal); + + // The on chain fees are split evenly between the two parties. + let funding_transaction_fee = + estimated_funding_transaction_fee(fee_rate.as_sat_per_vb() as f64) / 2; + + let channel_fee_reserve = + estimated_dlc_channel_fee_reserve(fee_rate.as_sat_per_vb() as f64) / 2; + + // If the user funded the channel externally we derive the collateral + // reserve trader from the difference of the trader + // margin and the externally received funds. + // + // TODO(holzeis): Introduce margin orders to directly use the + // external_funding_sats for the position instead of failing here. We need + // to do this though as a malicious actor could otherwise drain us. + // + // Note, we add a min trader reserve to the external funding to ensure that + // minor price movements are covered. + let collateral_reserve_trader = external_funding + .checked_sub( + margin_trader + + order_matching_fee + + funding_transaction_fee + + channel_fee_reserve, + ) + .context("Not enough external funds to open position")?; + + ( + channel_opening_params.coordinator_reserve, + collateral_reserve_trader, + TraderRequiredLiquidity::None, + ) + } + None => ( + channel_opening_params.coordinator_reserve, + channel_opening_params.trader_reserve, + TraderRequiredLiquidity::ForTradeCostAndTxFees, + ), + }; self.open_dlc_channel( - &mut connection, - ¶ms.trade_params, + &trade_params, collateral_reserve_coordinator, collateral_reserve_trader, is_stable_order, - TraderRequiredLiquidity::None, + trader_required_utxos, ) .await .context("Failed to open DLC channel")?; @@ -407,9 +424,8 @@ impl TradeExecutor { counter_payout, } => self .open_position( - &mut connection, channel_id, - ¶ms.trade_params, + &trade_params, own_payout, counter_payout, is_stable_order, @@ -420,13 +436,7 @@ impl TradeExecutor { channel_id, position, } => self - .start_closing_position( - &mut connection, - order, - &position, - ¶ms.trade_params, - channel_id, - ) + .start_closing_position(order, &position, &trade_params, channel_id) .await .with_context(|| format!("Failed to close position {}", position.id))?, TradeAction::ResizePosition { @@ -434,13 +444,7 @@ impl TradeExecutor { position, resize_action, } => self - .resize_position( - &mut connection, - channel_id, - &position, - ¶ms.trade_params, - resize_action, - ) + .resize_position(channel_id, &position, &trade_params, resize_action) .await .with_context(|| format!("Failed to resize position {}", position.id))?, }; @@ -450,7 +454,6 @@ impl TradeExecutor { async fn open_dlc_channel( &self, - conn: &mut PgConnection, trade_params: &TradeParams, collateral_reserve_coordinator: Amount, collateral_reserve_trader: Amount, @@ -600,7 +603,6 @@ impl TradeExecutor { // TODO(holzeis): The position should only get created after the dlc protocol has finished // successfully. self.persist_position( - conn, trade_params, temporary_contract_id, leverage_coordinator, @@ -612,7 +614,6 @@ impl TradeExecutor { async fn open_position( &self, - conn: &mut PgConnection, dlc_channel_id: DlcChannelId, trade_params: &TradeParams, coordinator_dlc_channel_collateral: Amount, @@ -768,7 +769,6 @@ impl TradeExecutor { // TODO(holzeis): The position should only get created after the dlc protocol has finished // successfully. self.persist_position( - conn, trade_params, temporary_contract_id, leverage_coordinator, @@ -780,7 +780,6 @@ impl TradeExecutor { async fn resize_position( &self, - conn: &mut PgConnection, dlc_channel_id: DlcChannelId, position: &Position, trade_params: &TradeParams, @@ -798,8 +797,19 @@ impl TradeExecutor { let peer_id = trade_params.pubkey; // Update position based on the outstanding funding fee events _before_ applying resize. - let funding_fee_events = - get_outstanding_funding_fee_events(conn, position.trader, position.id)?; + let funding_fee_events = spawn_blocking({ + let pool = self.node.pool.clone(); + let trader = position.trader; + let position_id = position.id; + move || { + let mut conn = pool.get()?; + let funding_fee_events = + get_outstanding_funding_fee_events(&mut conn, trader, position_id)?; + + anyhow::Ok(funding_fee_events) + } + }) + .await??; let funding_fee = funding_fee_from_funding_fee_events(&funding_fee_events); @@ -963,28 +973,36 @@ impl TradeExecutor { funding_fee_event_ids, )?; - db::positions::Position::set_position_to_resizing( - conn, - peer_id, - temporary_contract_id, - contracts, - coordinator_direction.opposite(), - margin_trader, - margin_coordinator, - average_execution_price, - expiry_timestamp, - coordinator_liquidation_price, - trader_liquidation_price, - realized_pnl, - order_matching_fee, - )?; + spawn_blocking({ + let pool = self.node.pool.clone(); + move || { + let mut conn = pool.get()?; + db::positions::Position::set_position_to_resizing( + &mut conn, + peer_id, + temporary_contract_id, + contracts, + coordinator_direction.opposite(), + margin_trader, + margin_coordinator, + average_execution_price, + expiry_timestamp, + coordinator_liquidation_price, + trader_liquidation_price, + realized_pnl, + order_matching_fee, + )?; + + anyhow::Ok(()) + } + }) + .await??; Ok(()) } async fn persist_position( &self, - connection: &mut PgConnection, trade_params: &TradeParams, temporary_contract_id: ContractId, coordinator_leverage: f32, @@ -1039,15 +1057,20 @@ impl TradeExecutor { // TODO(holzeis): We should only create the position once the dlc protocol finished // successfully. - db::positions::Position::insert(connection, new_position.clone())?; - - Ok(()) + spawn_blocking({ + let pool = self.node.pool.clone(); + move || { + let mut conn = pool.get()?; + db::positions::Position::insert(&mut conn, new_position.clone())?; + anyhow::Ok(()) + } + }) + .await? } pub async fn start_closing_position( &self, - conn: &mut PgConnection, - order: commons::Order, + order: Order, position: &Position, trade_params: &TradeParams, channel_id: DlcChannelId, @@ -1063,8 +1086,19 @@ impl TradeExecutor { // Update position based on the outstanding funding fee events _before_ calculating // `position_settlement_amount_coordinator`. - let funding_fee_events = - get_outstanding_funding_fee_events(conn, position.trader, position.id)?; + let funding_fee_events = spawn_blocking({ + let pool = self.node.pool.clone(); + let trader = position.trader; + let position_id = position.id; + move || { + let mut conn = pool.get()?; + let funding_fee_events = + get_outstanding_funding_fee_events(&mut conn, trader, position_id)?; + + anyhow::Ok(funding_fee_events) + } + }) + .await??; let funding_fee = funding_fee_from_funding_fee_events(&funding_fee_events); @@ -1150,44 +1184,31 @@ impl TradeExecutor { funding_fee_event_ids, )?; - db::positions::Position::set_open_position_to_closing( - conn, - &position.trader, - Some(closing_price), - )?; + spawn_blocking({ + let pool = self.node.pool.clone(); + let trader_pubkey = position.trader; + move || { + let mut conn = pool.get()?; + db::positions::Position::set_open_position_to_closing( + &mut conn, + &trader_pubkey, + Some(closing_price), + )?; + anyhow::Ok(()) + } + }) + .await??; Ok(()) } - fn update_order_and_match( - &self, - order_id: Uuid, - match_state: MatchState, - order_state: OrderState, - ) -> Result<()> { - let mut connection = self.node.pool.get()?; - connection - .transaction(|connection| { - matches::set_match_state(connection, order_id, match_state)?; - - orders::set_order_state(connection, order_id, order_state)?; - - diesel::result::QueryResult::Ok(()) - }) - .map_err(|e| anyhow!("Failed to update order and match. Error: {e:#}")) - } - - async fn determine_trade_action( - &self, - connection: &mut PgConnection, - params: &TradeAndChannelParams, - ) -> Result { - let trader_id = params.trade_params.pubkey; + async fn determine_trade_action(&self, trade_params: &TradeParams) -> Result { + let trader_pubkey = trade_params.pubkey; let trade_action = match self .node .inner - .get_signed_dlc_channel_by_counterparty(&trader_id)? + .get_signed_dlc_channel_by_counterparty(&trader_pubkey)? { None => { ensure!( @@ -1196,17 +1217,12 @@ impl TradeExecutor { .inner .list_dlc_channels()? .iter() - .filter(|c| c.get_counter_party_id() == to_secp_pk_29(trader_id)) + .filter(|c| c.get_counter_party_id() == to_secp_pk_29(trader_pubkey)) .any(|c| matches!(c, Channel::Offered(_) | Channel::Accepted(_))), "Previous DLC Channel offer still pending." ); - match params.external_funding { - Some(external_funding) => { - TradeAction::OpenSingleFundedChannel { external_funding } - } - None => TradeAction::OpenDlcChannel, - } + TradeAction::OpenDlcChannel } Some(SignedChannel { channel_id, @@ -1227,14 +1243,21 @@ impl TradeExecutor { channel_id, .. }) => { - let trade_params = ¶ms.trade_params; - - let position = db::positions::Position::get_position_by_trader( - connection, - trader_id, - vec![PositionState::Open], - )? - .context("Failed to find open position")?; + let position = spawn_blocking({ + let pool = self.node.pool.clone(); + move || { + let mut conn = pool.get()?; + let position = db::positions::Position::get_position_by_trader( + &mut conn, + trader_pubkey, + vec![PositionState::Open], + )? + .context("Failed to find open position")?; + + anyhow::Ok(position) + } + }) + .await??; let position_contracts = { let contracts = decimal_from_f32(position.quantity); diff --git a/crates/dev-maker/src/main.rs b/crates/dev-maker/src/main.rs index 321b2674e..f62ec9f68 100644 --- a/crates/dev-maker/src/main.rs +++ b/crates/dev-maker/src/main.rs @@ -113,7 +113,7 @@ async fn post_order( id: uuid, contract_symbol: ContractSymbol::BtcUsd, price, - quantity: Decimal::from(5000), + quantity: Decimal::from(1000), trader_id: public_key, direction, leverage: Decimal::from(2), diff --git a/crates/xxi-node/src/commons/order.rs b/crates/xxi-node/src/commons/order.rs index 266ad35a2..5f3a19654 100644 --- a/crates/xxi-node/src/commons/order.rs +++ b/crates/xxi-node/src/commons/order.rs @@ -4,6 +4,7 @@ use anyhow::Result; use bitcoin::hashes::sha256; use bitcoin::secp256k1::PublicKey; use bitcoin::Amount; +use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use secp256k1::ecdsa::Signature; use secp256k1::Message; @@ -31,7 +32,7 @@ impl NewOrderRequest { } } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, Copy)] pub enum NewOrder { Market(NewMarketOrder), Limit(NewLimitOrder), @@ -82,7 +83,7 @@ impl NewOrder { } } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] pub struct NewMarketOrder { pub id: Uuid, pub contract_symbol: ContractSymbol, @@ -114,6 +115,26 @@ pub struct NewLimitOrder { pub stable: bool, } +impl From for Order { + fn from(value: NewLimitOrder) -> Self { + Self { + id: value.id, + price: value.price, + leverage: value.leverage.to_f32().expect("to fit"), + contract_symbol: value.contract_symbol, + trader_id: value.trader_id, + direction: value.direction, + quantity: value.quantity, + order_type: OrderType::Limit, + timestamp: OffsetDateTime::now_utc(), + expiry: value.expiry, + order_state: OrderState::Open, + order_reason: OrderReason::Manual, + stable: false, + } + } +} + impl NewLimitOrder { pub fn message(&self) -> Message { let mut vec: Vec = vec![]; @@ -144,6 +165,26 @@ impl NewLimitOrder { } } +impl From for Order { + fn from(value: NewMarketOrder) -> Self { + Self { + id: value.id, + price: Decimal::ZERO, // market orders don't have a price. + leverage: value.leverage.to_f32().expect("to fit"), + contract_symbol: value.contract_symbol, + trader_id: value.trader_id, + direction: value.direction, + quantity: value.quantity, + order_type: OrderType::Market, + timestamp: OffsetDateTime::now_utc(), + expiry: value.expiry, + order_state: OrderState::Open, + order_reason: OrderReason::Manual, + stable: false, + } + } +} + impl NewMarketOrder { pub fn message(&self) -> Message { let mut vec: Vec = vec![]; @@ -188,7 +229,7 @@ impl OrderType { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] pub enum OrderState { Open, Matched, @@ -198,7 +239,7 @@ pub enum OrderState { Deleted, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] pub enum OrderReason { Manual, Expired, @@ -206,7 +247,7 @@ pub enum OrderReason { TraderLiquidated, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Copy)] pub struct Order { pub id: Uuid, #[serde(with = "rust_decimal::serde::float")] @@ -227,6 +268,13 @@ pub struct Order { pub stable: bool, } +impl Order { + /// Returns true if the order is expired. + pub fn is_expired(&self) -> bool { + OffsetDateTime::now_utc() > self.expiry + } +} + /// Extra information required to open a DLC channel, independent of the [`TradeParams`] associated /// with the filled order. /// diff --git a/crates/xxi-node/src/commons/trade.rs b/crates/xxi-node/src/commons/trade.rs index 722c8ef2f..6fa6aa15d 100644 --- a/crates/xxi-node/src/commons/trade.rs +++ b/crates/xxi-node/src/commons/trade.rs @@ -9,17 +9,6 @@ use serde::Serialize; use time::OffsetDateTime; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct TradeAndChannelParams { - pub trade_params: TradeParams, - #[serde(with = "bitcoin::amount::serde::as_sat::opt")] - pub trader_reserve: Option, - #[serde(with = "bitcoin::amount::serde::as_sat::opt")] - pub coordinator_reserve: Option, - #[serde(with = "bitcoin::amount::serde::as_sat::opt")] - pub external_funding: Option, -} - /// The trade parameters defining the trade execution. /// /// Emitted by the orderbook when a match is found. @@ -54,7 +43,7 @@ pub struct TradeParams { /// The filling information from the orderbook /// /// This is used by the coordinator to be able to make sure both trading parties are acting. - /// The `quantity` has to match the cummed up quantities of the matches in `filled_with`. + /// The `quantity` has to match the summed up quantities of the matches in `filled_with`. pub filled_with: FilledWith, } @@ -187,12 +176,14 @@ pub fn average_execution_price(matches: Vec) -> Decimal { sum_quantity / nominal_prices } +#[derive(Clone, Copy)] pub enum MatchState { Pending, Filled, Failed, } +#[derive(Clone, Copy)] pub struct Matches { pub id: Uuid, pub match_state: MatchState, diff --git a/crates/xxi-node/src/message_handler.rs b/crates/xxi-node/src/message_handler.rs index bfd5d7c67..23bbff911 100644 --- a/crates/xxi-node/src/message_handler.rs +++ b/crates/xxi-node/src/message_handler.rs @@ -652,7 +652,7 @@ impl TenTenOneMessage { | TenTenOneMessage::SettleAccept(TenTenOneSettleAccept { order_reason, .. }) | TenTenOneMessage::SettleConfirm(TenTenOneSettleConfirm { order_reason, .. }) | TenTenOneMessage::SettleFinalize(TenTenOneSettleFinalize { order_reason, .. }) => { - Some(order_reason.clone()) + Some(*order_reason) } TenTenOneMessage::Offer(_) | TenTenOneMessage::Accept(_) diff --git a/crates/xxi-node/src/tests/dlc_channel.rs b/crates/xxi-node/src/tests/dlc_channel.rs index 883d89ce4..960a3455d 100644 --- a/crates/xxi-node/src/tests/dlc_channel.rs +++ b/crates/xxi-node/src/tests/dlc_channel.rs @@ -587,7 +587,7 @@ async fn open_channel_and_position_and_settle_position( let order = dummy_order(); coordinator .propose_dlc_channel_collaborative_settlement( - order.clone(), + order, filled_with.clone(), &coordinator_signed_channel.channel_id, coordinator_dlc_collateral.to_sat() / 2, diff --git a/mobile/native/src/dlc/node.rs b/mobile/native/src/dlc/node.rs index 9a4836464..baebf6c6a 100644 --- a/mobile/native/src/dlc/node.rs +++ b/mobile/native/src/dlc/node.rs @@ -607,7 +607,7 @@ impl Node { #[instrument(fields(channel_id = hex::encode(offer.settle_offer.channel_id)),skip_all, err(Debug))] pub fn process_settle_offer(&self, offer: &TenTenOneSettleOffer) -> Result<()> { // TODO(holzeis): We should check if the offered amounts are expected. - let order_reason = offer.order.clone().order_reason; + let order_reason = offer.order.order_reason; let order_id = offer.order.id; match order_reason { diff --git a/mobile/native/src/trade/order/handler.rs b/mobile/native/src/trade/order/handler.rs index ea4a808df..1f8603f83 100644 --- a/mobile/native/src/trade/order/handler.rs +++ b/mobile/native/src/trade/order/handler.rs @@ -226,7 +226,7 @@ pub(crate) fn async_order_filling( }, creation_timestamp: order.timestamp, order_expiry_timestamp: order.expiry, - reason: order.order_reason.clone().into(), + reason: order.order_reason.into(), stable: order.stable, failure_reason: None, }; From 37a7a73f9458b0cd9502baf4daa54e2879bae970 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Mon, 3 Jun 2024 15:36:51 +0200 Subject: [PATCH 5/6] chore: Remove unused db functions --- coordinator/src/orderbook/db/orders.rs | 68 ------------------- .../src/orderbook/tests/sample_test.rs | 3 +- 2 files changed, 1 insertion(+), 70 deletions(-) diff --git a/coordinator/src/orderbook/db/orders.rs b/coordinator/src/orderbook/db/orders.rs index 6ff9e692b..7f1b5b325 100644 --- a/coordinator/src/orderbook/db/orders.rs +++ b/coordinator/src/orderbook/db/orders.rs @@ -19,7 +19,6 @@ use time::OffsetDateTime; use uuid::Uuid; use xxi_node::commons; use xxi_node::commons::BestPrice; -use xxi_node::commons::Direction as OrderbookDirection; use xxi_node::commons::NewLimitOrder; use xxi_node::commons::NewMarketOrder; use xxi_node::commons::Order as OrderbookOrder; @@ -233,29 +232,6 @@ pub fn all_limit_orders(conn: &mut PgConnection) -> QueryResult QueryResult> { - let filters = orders::table - .filter(orders::direction.eq(Direction::from(direction))) - .filter(orders::order_type.eq(OrderType::from(order_type))) - .filter(orders::order_state.eq(OrderState::Open)); - - let orders: Vec = if filter_expired { - filters - .filter(orders::expiry.gt(OffsetDateTime::now_utc())) - .load::(conn)? - } else { - filters.load::(conn)? - }; - - Ok(orders.into_iter().map(OrderbookOrder::from).collect()) -} - pub fn get_best_price( conn: &mut PgConnection, contract_symbol: commons::ContractSymbol, @@ -377,34 +353,6 @@ pub fn insert_market_order( Ok(OrderbookOrder::from(order)) } -/// Returns the number of affected rows: 1. -pub fn set_is_taken( - conn: &mut PgConnection, - id: Uuid, - is_taken: bool, -) -> QueryResult { - if is_taken { - set_order_state(conn, id, commons::OrderState::Taken) - } else { - set_order_state(conn, id, commons::OrderState::Open) - } -} - -/// Mark an order as [`OrderState::Deleted`], if it belongs to the given `trader_id`. -pub fn delete_trader_order( - conn: &mut PgConnection, - id: Uuid, - trader_pubkey: PublicKey, -) -> QueryResult { - let order: Order = diesel::update(orders::table) - .filter(orders::order_id.eq(id)) - .filter(orders::trader_pubkey.eq(trader_pubkey.to_string())) - .set(orders::order_state.eq(OrderState::Deleted)) - .get_result(conn)?; - - Ok(OrderbookOrder::from(order)) -} - /// Mark an order as [`OrderState::Deleted`]. pub fn delete(conn: &mut PgConnection, id: Uuid) -> QueryResult { set_order_state(conn, id, commons::OrderState::Deleted) @@ -451,22 +399,6 @@ pub fn update_quantity( .execute(conn) } -pub fn set_expired_limit_orders_to_expired( - conn: &mut PgConnection, -) -> QueryResult> { - let expired_limit_orders: Vec = diesel::update(orders::table) - .filter(orders::order_state.eq(OrderState::Open)) - .filter(orders::order_type.eq(OrderType::Limit)) - .filter(orders::expiry.lt(OffsetDateTime::now_utc())) - .set(orders::order_state.eq(OrderState::Expired)) - .get_results(conn)?; - - Ok(expired_limit_orders - .into_iter() - .map(OrderbookOrder::from) - .collect()) -} - /// Returns the order by id pub fn get_with_id(conn: &mut PgConnection, uid: Uuid) -> QueryResult> { let x = orders::table diff --git a/coordinator/src/orderbook/tests/sample_test.rs b/coordinator/src/orderbook/tests/sample_test.rs index b111f531c..b0e9a10ab 100644 --- a/coordinator/src/orderbook/tests/sample_test.rs +++ b/coordinator/src/orderbook/tests/sample_test.rs @@ -32,8 +32,7 @@ async fn crud_test() { ) .unwrap(); - let order = orders::set_is_taken(&mut conn, order.id, true).unwrap(); - assert_eq!(order.order_state, OrderState::Taken); + assert_eq!(order.order_state, OrderState::Open); } #[tokio::test] From 51e49ec178faba71f66de6e22e0c1655f1cf4ed1 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Fri, 7 Jun 2024 11:08:06 +0200 Subject: [PATCH 6/6] fix: Drop foreign key constraint on order id The order and the matches are persisted by async actors. It can happen that the market order has not yet been persisted when the match is processed. Thus it could happen that the matches table is persisted before the order has been persisted, which resulted in a foreign key violation. By dropping the foreign key we don't care if the order is already persisted and can continue with eventual consistency. I think this is the better option than to synchronize the two inserts as we are anyways aiming for decoupling the orderbook from the coordinator. --- .../down.sql | 9 +++++ .../up.sql | 2 ++ coordinator/src/schema.rs | 34 +++++++++---------- 3 files changed, 28 insertions(+), 17 deletions(-) create mode 100644 coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/down.sql create mode 100644 coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/up.sql diff --git a/coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/down.sql b/coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/down.sql new file mode 100644 index 000000000..beb16f583 --- /dev/null +++ b/coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/down.sql @@ -0,0 +1,9 @@ +ALTER TABLE "matches" + ADD CONSTRAINT matches_order_id_fkey + FOREIGN KEY (order_id) + REFERENCES orders (order_id); + +ALTER TABLE "matches" + ADD CONSTRAINT matches_match_order_id_fkey + FOREIGN KEY (match_order_id) + REFERENCES orders (order_id); diff --git a/coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/up.sql b/coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/up.sql new file mode 100644 index 000000000..dc5ad5e1d --- /dev/null +++ b/coordinator/migrations/2024-06-07-105918_drop_foreign_key_constraint_to_order_id_on_matches/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE "matches" DROP CONSTRAINT matches_order_id_fkey; +ALTER TABLE "matches" DROP CONSTRAINT matches_match_order_id_fkey; diff --git a/coordinator/src/schema.rs b/coordinator/src/schema.rs index a0d1ebb95..ac0424001 100644 --- a/coordinator/src/schema.rs +++ b/coordinator/src/schema.rs @@ -216,23 +216,6 @@ diesel::table! { } } -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::InvoiceStateType; - - hodl_invoices (id) { - id -> Int4, - trader_pubkey -> Text, - r_hash -> Text, - amount_sats -> Int8, - pre_image -> Nullable, - created_at -> Timestamptz, - updated_at -> Nullable, - invoice_state -> InvoiceStateType, - order_id -> Nullable, - } -} - diesel::table! { funding_fee_events (id) { id -> Int4, @@ -257,6 +240,23 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::InvoiceStateType; + + hodl_invoices (id) { + id -> Int4, + trader_pubkey -> Text, + r_hash -> Text, + amount_sats -> Int8, + pre_image -> Nullable, + created_at -> Timestamptz, + updated_at -> Nullable, + invoice_state -> InvoiceStateType, + order_id -> Nullable, + } +} + diesel::table! { last_outbound_dlc_messages (peer_id) { peer_id -> Text,