diff --git a/crates/orderbook-commons/src/lib.rs b/crates/orderbook-commons/src/lib.rs index 6832fe126..890285d80 100644 --- a/crates/orderbook-commons/src/lib.rs +++ b/crates/orderbook-commons/src/lib.rs @@ -1,3 +1,9 @@ +mod price; + +pub use crate::price::best_current_price; +pub use crate::price::Price; +pub use crate::price::Prices; +use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use secp256k1::Message; use secp256k1::PublicKey; @@ -195,6 +201,11 @@ mod test { use std::str::FromStr; use time::OffsetDateTime; + fn dummy_public_key() -> PublicKey { + PublicKey::from_str("02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655") + .unwrap() + } + #[test] fn test_serialize_signature() { let secret_key = SecretKey::from_slice(&[ @@ -221,7 +232,7 @@ mod test { let serialized: Signature = serde_json::from_str(sig).unwrap(); let signature = Signature { - pubkey: PublicKey::from_str("02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655").unwrap(), + pubkey: dummy_public_key(), signature: "3045022100ddd8e15dea994a3dd98c481d901fb46b7f3624bb25b4210ea10f8a00779c6f0e0220222235da47b1ba293184fa4a91b39999911c08020e069c9f4afa2d81586b23e1".parse().unwrap(), }; @@ -245,19 +256,13 @@ mod test { Match { order_id: Default::default(), quantity: match_0_quantity, - pubkey: PublicKey::from_str( - "02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655", - ) - .unwrap(), + pubkey: dummy_public_key(), execution_price: match_0_price, }, Match { order_id: Default::default(), quantity: match_1_quantity, - pubkey: PublicKey::from_str( - "02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655", - ) - .unwrap(), + pubkey: dummy_public_key(), execution_price: match_1_price, }, ], diff --git a/crates/orderbook-commons/src/price.rs b/crates/orderbook-commons/src/price.rs new file mode 100644 index 000000000..43a008fcb --- /dev/null +++ b/crates/orderbook-commons/src/price.rs @@ -0,0 +1,137 @@ +use crate::Order; +use crate::ToPrimitive; +use rust_decimal::Decimal; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use trade::ContractSymbol; +use trade::Direction; + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +pub struct Price { + pub bid: Option, + pub ask: Option, +} + +pub type Prices = HashMap; + +/// Best prices across all current orders for given ContractSymbol in the orderbook +/// Taken orders are not included in the average +pub fn best_current_price(current_orders: &[Order]) -> Prices { + let mut prices = HashMap::new(); + let mut add_price_for_symbol = |symbol| { + prices.insert( + symbol, + Price { + bid: best_bid_price(current_orders, symbol), + ask: best_ask_price(current_orders, symbol), + }, + ); + }; + add_price_for_symbol(ContractSymbol::BtcUsd); + prices +} + +/// Best price (highest) of all long (buy) orders in the orderbook +fn best_bid_price(current_orders: &[Order], symbol: ContractSymbol) -> Option { + best_price_for(current_orders, Direction::Long, symbol) +} + +/// Best price (lowest) of all short (sell) orders in the orderbook +fn best_ask_price(current_orders: &[Order], symbol: ContractSymbol) -> Option { + best_price_for(current_orders, Direction::Short, symbol) +} + +fn best_price_for( + current_orders: &[Order], + direction: Direction, + symbol: ContractSymbol, +) -> Option { + assert_eq!( + symbol, + ContractSymbol::BtcUsd, + "only btcusd supported for now" + ); + let use_max = direction == Direction::Long; + current_orders + .iter() + .filter(|order| !order.taken && order.direction == direction) + .map(|order| order.price.to_f64().expect("to represent decimal as f64")) + // get the best price + .fold(None, |acc, x| match acc { + Some(y) => Some(if use_max { x.max(y) } else { x.min(y) }), + None => Some(x), + })? + .try_into() + .ok() +} + +#[cfg(test)] +mod test { + use crate::price::best_ask_price; + use crate::price::best_bid_price; + use crate::Order; + use crate::OrderType; + use rust_decimal::Decimal; + use rust_decimal_macros::dec; + use secp256k1::PublicKey; + use std::str::FromStr; + use time::OffsetDateTime; + use trade::ContractSymbol; + use trade::Direction; + use uuid::Uuid; + use ContractSymbol::BtcUsd; + + fn dummy_public_key() -> PublicKey { + PublicKey::from_str("02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655") + .unwrap() + } + + fn dummy_order(price: Decimal, direction: Direction, taken: bool) -> Order { + Order { + id: Uuid::new_v4(), + price, + trader_id: dummy_public_key(), + taken, + direction, + quantity: 100.into(), + order_type: OrderType::Market, + timestamp: OffsetDateTime::now_utc(), + } + } + + #[test] + fn test_best_bid_price() { + let current_orders = vec![ + dummy_order(dec!(10_000), Direction::Long, false), + dummy_order(dec!(30_000), Direction::Long, false), + dummy_order(dec!(500_000), Direction::Long, true), // taken + dummy_order(dec!(50_000), Direction::Short, false), // wrong direction + ]; + assert_eq!(best_bid_price(¤t_orders, BtcUsd), Some(dec!(30_000))); + } + + #[test] + fn test_best_ask_price() { + let current_orders = vec![ + dummy_order(dec!(10_000), Direction::Short, false), + dummy_order(dec!(30_000), Direction::Short, false), + // ignored in the calculations - this order is taken + dummy_order(dec!(5_000), Direction::Short, true), + // ignored in the calculations - it's the bid price + dummy_order(dec!(50_000), Direction::Long, false), + ]; + assert_eq!(best_ask_price(¤t_orders, BtcUsd), Some(dec!(10_000))); + } + + #[test] + fn test_no_price() { + let all_orders_taken = vec![ + dummy_order(dec!(10_000), Direction::Short, true), + dummy_order(dec!(30_000), Direction::Long, true), + ]; + + assert_eq!(best_ask_price(&all_orders_taken, BtcUsd), None); + assert_eq!(best_bid_price(&all_orders_taken, BtcUsd), None); + } +} diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs index 7d40469e7..067547341 100644 --- a/crates/trade/src/lib.rs +++ b/crates/trade/src/lib.rs @@ -8,7 +8,7 @@ use std::str::FromStr; pub mod cfd; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum ContractSymbol { BtcUsd, } diff --git a/mobile/native/src/event/api.rs b/mobile/native/src/event/api.rs index f15171d75..01d221fae 100644 --- a/mobile/native/src/event/api.rs +++ b/mobile/native/src/event/api.rs @@ -7,6 +7,7 @@ use crate::trade::position::api::Position; use core::convert::From; use flutter_rust_bridge::frb; use flutter_rust_bridge::StreamSink; +use rust_decimal::prelude::ToPrimitive; use trade::ContractSymbol; #[frb] @@ -18,6 +19,7 @@ pub enum Event { WalletInfoUpdateNotification(WalletInfo), PositionUpdateNotification(Position), PositionClosedNotification(PositionClosed), + PriceUpdateNotification(BestPrice), } impl From for Event { @@ -40,6 +42,14 @@ impl From for Event { EventInternal::PositionCloseNotification(contract_symbol) => { Event::PositionClosedNotification(PositionClosed { contract_symbol }) } + EventInternal::PriceUpdateNotification(prices) => { + let best_price = prices + .get(&ContractSymbol::BtcUsd) + .cloned() + .unwrap_or_default() + .into(); + Event::PriceUpdateNotification(best_price) + } } } } @@ -72,6 +82,7 @@ impl Subscriber for FlutterSubscriber { EventType::OrderUpdateNotification, EventType::PositionUpdateNotification, EventType::PositionClosedNotification, + EventType::PriceUpdateNotification, ] } } @@ -81,3 +92,27 @@ impl FlutterSubscriber { FlutterSubscriber { stream } } } + +/// The best bid and ask price for a contract. +/// +/// Best prices come from an orderbook. Contrary to the `Price` struct, we can have no price +/// available, due to no orders in the orderbook. +#[frb] +#[derive(Clone, Debug, Default)] +pub struct BestPrice { + pub bid: Option, + pub ask: Option, +} + +impl From for BestPrice { + fn from(value: orderbook_commons::Price) -> Self { + BestPrice { + bid: value + .bid + .map(|bid| bid.to_f64().expect("price bid to fit into f64")), + ask: value + .ask + .map(|ask| ask.to_f64().expect("price ask to fit into f64")), + } + } +} diff --git a/mobile/native/src/event/mod.rs b/mobile/native/src/event/mod.rs index fef7e1f4e..92d9b4572 100644 --- a/mobile/native/src/event/mod.rs +++ b/mobile/native/src/event/mod.rs @@ -4,6 +4,7 @@ pub mod subscriber; use crate::api::WalletInfo; use coordinator_commons::TradeParams; +use orderbook_commons::Prices; use std::hash::Hash; use trade::ContractSymbol; @@ -29,6 +30,7 @@ pub enum EventInternal { OrderFilledWith(Box), PositionUpdateNotification(Position), PositionCloseNotification(ContractSymbol), + PriceUpdateNotification(Prices), } impl From for EventType { @@ -43,6 +45,7 @@ impl From for EventType { EventInternal::OrderFilledWith(_) => EventType::OrderFilledWith, EventInternal::PositionUpdateNotification(_) => EventType::PositionUpdateNotification, EventInternal::PositionCloseNotification(_) => EventType::PositionClosedNotification, + EventInternal::PriceUpdateNotification(_) => EventType::PriceUpdateNotification, } } } @@ -56,4 +59,5 @@ pub enum EventType { OrderFilledWith, PositionUpdateNotification, PositionClosedNotification, + PriceUpdateNotification, } diff --git a/mobile/native/src/orderbook.rs b/mobile/native/src/orderbook.rs index 9e11fef20..b496d7604 100644 --- a/mobile/native/src/orderbook.rs +++ b/mobile/native/src/orderbook.rs @@ -4,6 +4,7 @@ use anyhow::Result; use bdk::bitcoin::secp256k1::SecretKey; use bdk::bitcoin::secp256k1::SECP256K1; use futures::TryStreamExt; +use orderbook_commons::best_current_price; use orderbook_commons::OrderbookMsg; use orderbook_commons::Signature; use state::Storage; @@ -38,6 +39,9 @@ pub fn subscribe(secret_key: SecretKey) -> Result<()> { Signature { pubkey, signature } }; + // Consider using a HashMap instead to optimize the lookup for removal/update + let mut orders = Vec::new(); + loop { let mut stream = orderbook_client::subscribe_with_authentication(url.clone(), &authenticate); @@ -65,6 +69,57 @@ pub fn subscribe(secret_key: SecretKey) -> Result<()> { tracing::error!("Trade request sent to coordinator failed. Error: {e:#}"); } }, + OrderbookMsg::AllOrders(initial_orders) => { + if !orders.is_empty() { + tracing::warn!("Received all orders from orderbook, but we already have some orders. This should not happen"); + } + else { + tracing::debug!(?orders, "Received all orders from orderbook"); + } + orders = initial_orders; + if let Err(e) = position::handler::price_update(best_current_price(&orders)) { + tracing::error!("Price update from the orderbook failed. Error: {e:#}"); + } + }, + OrderbookMsg::NewOrder(order) => { + orders.push(order); + if let Err(e) = position::handler::price_update(best_current_price(&orders)) { + tracing::error!("Price update from the orderbook failed. Error: {e:#}"); + } + } + OrderbookMsg::DeleteOrder(order_id) => { + let mut found = false; + for (index, element) in orders.iter().enumerate() { + if element.id == order_id { + found = true; + orders.remove(index); + break; + } + } + if !found { + tracing::warn!(%order_id, "Could not remove non-existing order"); + } + if let Err(e) = position::handler::price_update(best_current_price(&orders)) { + tracing::error!("Price update from the orderbook failed. Error: {e:#}"); + } + }, + OrderbookMsg::Update(updated_order) => { + let mut found = false; + for (index, element) in orders.iter().enumerate() { + if element.id == updated_order.id { + orders.remove(index); + found = true; + break; + } + } + if !found { + tracing::warn!(?updated_order, "Update without prior knowledge of order"); + } + orders.push(updated_order); + if let Err(e) = position::handler::price_update(best_current_price(&orders)) { + tracing::error!("Price update from the orderbook failed. Error: {e:#}"); + } + }, _ => tracing::debug!(?msg, "Skipping message from orderbook"), } } diff --git a/mobile/native/src/trade/position/handler.rs b/mobile/native/src/trade/position/handler.rs index 030f9c015..14b4b59cb 100644 --- a/mobile/native/src/trade/position/handler.rs +++ b/mobile/native/src/trade/position/handler.rs @@ -12,6 +12,7 @@ use anyhow::Context; use anyhow::Result; use coordinator_commons::TradeParams; use orderbook_commons::FilledWith; +use orderbook_commons::Prices; use rust_decimal::prelude::ToPrimitive; use trade::ContractSymbol; use trade::Direction; @@ -116,3 +117,8 @@ pub fn update_position_after_order_filled(filled_order: Order, collateral: u64) Ok(()) } + +pub fn price_update(prices: Prices) -> Result<()> { + event::publish(&EventInternal::PriceUpdateNotification(prices)); + Ok(()) +}