From 6d15d742028559e82404bcc4241cd41245c86fb3 Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Thu, 20 Jul 2023 18:30:21 +1000 Subject: [PATCH 1/4] Periodically update unrealized pnl for position in database --- .../down.sql | 3 + .../up.sql | 5 ++ coordinator/src/bin/coordinator.rs | 14 ++++ coordinator/src/db/positions.rs | 17 +++++ coordinator/src/node.rs | 1 + coordinator/src/node/unrealized_pnl.rs | 68 +++++++++++++++++++ coordinator/src/schema.rs | 1 + crates/trade/src/bitmex_client.rs | 2 +- 8 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql create mode 100644 coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql create mode 100644 coordinator/src/node/unrealized_pnl.rs diff --git a/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql new file mode 100644 index 000000000..ed85cdd99 --- /dev/null +++ b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE + "positions" DROP COLUMN "unrealized_pnl"; diff --git a/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql new file mode 100644 index 000000000..785cde29d --- /dev/null +++ b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +ALTER TABLE + positions +ADD + COLUMN "unrealized_pnl" BIGINT; diff --git a/coordinator/src/bin/coordinator.rs b/coordinator/src/bin/coordinator.rs index e4d16905d..477fdee03 100644 --- a/coordinator/src/bin/coordinator.rs +++ b/coordinator/src/bin/coordinator.rs @@ -9,6 +9,7 @@ use coordinator::node::closed_positions; use coordinator::node::connection; use coordinator::node::expired_positions; use coordinator::node::storage::NodeStorage; +use coordinator::node::unrealized_pnl; use coordinator::node::Node; use coordinator::routes::router; use coordinator::run_migration; @@ -34,6 +35,7 @@ const PROCESS_PROMETHEUS_METRICS: Duration = Duration::from_secs(10); const PROCESS_INCOMING_DLC_MESSAGES_INTERVAL: Duration = Duration::from_secs(5); const EXPIRED_POSITION_SYNC_INTERVAL: Duration = Duration::from_secs(300); const CLOSED_POSITION_SYNC_INTERVAL: Duration = Duration::from_secs(30); +const UNREALIZED_PNL_SYNC_INTERVAL: Duration = Duration::from_secs(600); const CONNECTION_CHECK_INTERVAL: Duration = Duration::from_secs(30); #[tokio::main] @@ -162,6 +164,18 @@ async fn main() -> Result<()> { } }); + tokio::spawn({ + let node = node.clone(); + async move { + loop { + tokio::time::sleep(UNREALIZED_PNL_SYNC_INTERVAL).await; + if let Err(e) = unrealized_pnl::sync(node.clone()).await { + tracing::error!("Failed to sync closed DLCs with positions in database: {e:#}"); + } + } + } + }); + tokio::spawn({ let node = node.clone(); async move { diff --git a/coordinator/src/db/positions.rs b/coordinator/src/db/positions.rs index 9be50235e..a999e856d 100644 --- a/coordinator/src/db/positions.rs +++ b/coordinator/src/db/positions.rs @@ -33,6 +33,7 @@ pub struct Position { pub trader_pubkey: String, pub temporary_contract_id: Option, pub realized_pnl: Option, + pub unrealized_pnl: Option, } impl Position { @@ -125,6 +126,22 @@ impl Position { Ok(()) } + pub fn update_unrealized_pnl(conn: &mut PgConnection, id: i32, pnl: i64) -> Result<()> { + let effected_rows = diesel::update(positions::table) + .filter(positions::id.eq(id)) + .set(( + positions::unrealized_pnl.eq(Some(pnl)), + positions::update_timestamp.eq(OffsetDateTime::now_utc()), + )) + .execute(conn)?; + + if effected_rows == 0 { + bail!("Could not update unrealized pnl {pnl} for position {id}") + } + + Ok(()) + } + /// inserts the given position into the db. Returns the position if successful #[autometrics] pub fn insert( diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index 1f575b00c..61a96cab2 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -54,6 +54,7 @@ pub mod expired_positions; pub mod order_matching_fee; pub mod routing_fees; pub mod storage; +pub mod unrealized_pnl; /// The leverage used by the coordinator for all trades. const COORDINATOR_LEVERAGE: f32 = 1.0; diff --git a/coordinator/src/node/unrealized_pnl.rs b/coordinator/src/node/unrealized_pnl.rs new file mode 100644 index 000000000..e3eec81f8 --- /dev/null +++ b/coordinator/src/node/unrealized_pnl.rs @@ -0,0 +1,68 @@ +use crate::db; +use crate::node::Node; +use crate::position::models::Position; +use anyhow::Context; +use anyhow::Result; +use diesel::r2d2::ConnectionManager; +use diesel::r2d2::PooledConnection; +use diesel::PgConnection; +use rust_decimal::Decimal; +use time::OffsetDateTime; +use trade::bitmex_client::BitmexClient; +use trade::bitmex_client::Quote; +use trade::cfd::calculate_pnl; +use trade::Direction; + +pub async fn sync(node: Node) -> Result<()> { + let mut conn = node.pool.get()?; + + let positions = db::positions::Position::get_all_open_or_closing_positions(&mut conn)?; + let current_quote = BitmexClient::get_quote(&OffsetDateTime::now_utc()) + .await + .context("Failed to fetch quote from BitMEX")?; + + for position in positions.iter() { + if let Err(e) = sync_position(&mut conn, position, current_quote.clone()) { + tracing::error!(position_id=%position.id, ?current_quote, "Failed to update position's unrealized pnl in database: {e:#}") + } + } + + Ok(()) +} + +fn sync_position( + conn: &mut PooledConnection>, + position: &Position, + quote: Quote, +) -> Result<()> { + let current_price = match position.direction { + trade::Direction::Long => quote.bid_price, + trade::Direction::Short => quote.ask_price, + }; + + let average_entry_price = Decimal::try_from(position.average_entry_price) + .context("Failed to convert average entry price to Decimal")?; + + let (long_leverage, short_leverage) = match position.direction { + Direction::Long => (position.leverage, 1.0_f32), + Direction::Short => (1.0_f32, position.leverage), + }; + + // the position in the database is the trader's position, our direction is opposite + let direction = position.direction.opposite(); + + let pnl = calculate_pnl( + average_entry_price, + current_price, + position.quantity, + long_leverage, + short_leverage, + direction, + ) + .context("Failed to calculate pnl for position")?; + + db::positions::Position::update_unrealized_pnl(conn, position.id, pnl) + .context("Failed to update unrealized pnl in db")?; + + Ok(()) +} diff --git a/coordinator/src/schema.rs b/coordinator/src/schema.rs index 13c80d345..190313577 100644 --- a/coordinator/src/schema.rs +++ b/coordinator/src/schema.rs @@ -109,6 +109,7 @@ diesel::table! { trader_pubkey -> Text, temporary_contract_id -> Nullable, realized_pnl -> Nullable, + unrealized_pnl -> Nullable, } } diff --git a/crates/trade/src/bitmex_client.rs b/crates/trade/src/bitmex_client.rs index bc19ae940..480124acb 100644 --- a/crates/trade/src/bitmex_client.rs +++ b/crates/trade/src/bitmex_client.rs @@ -35,7 +35,7 @@ impl BitmexClient { } } -#[derive(Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Quote { pub bid_size: u64, From e46a2be0c55a7c31ebeb8ca0826f5d9c96d5738c Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Fri, 21 Jul 2023 15:32:43 +1000 Subject: [PATCH 2/4] Update `unrealized_pnl` based on `closing_price` if known If we know the `closing_price` then it is used for updating the unrealized pnl. Note that the realized pnl is *only* set once we update it based on the closed contract. The realized pnl is not updated based on the execution price, because there is no execution guarantee. --- .../2023-07-19-055143_closed_positions/down.sql | 2 +- .../2023-07-19-055143_closed_positions/up.sql | 2 +- .../down.sql | 2 +- .../up.sql | 2 +- .../down.sql | 3 +++ .../up.sql | 5 +++++ coordinator/src/bin/coordinator.rs | 4 +++- coordinator/src/db/positions.rs | 14 +++++++++----- coordinator/src/node.rs | 3 +++ coordinator/src/node/unrealized_pnl.rs | 13 +++++++++---- coordinator/src/position/models.rs | 1 + coordinator/src/schema.rs | 5 +++-- 12 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 coordinator/migrations/2023-07-21-050135_closing_price_positions/down.sql create mode 100644 coordinator/migrations/2023-07-21-050135_closing_price_positions/up.sql diff --git a/coordinator/migrations/2023-07-19-055143_closed_positions/down.sql b/coordinator/migrations/2023-07-19-055143_closed_positions/down.sql index 6d8b5393d..f18439738 100644 --- a/coordinator/migrations/2023-07-19-055143_closed_positions/down.sql +++ b/coordinator/migrations/2023-07-19-055143_closed_positions/down.sql @@ -4,4 +4,4 @@ -- However, there is no proper way to replace the values to be removed where they are used (i.e. referenced in `positions` table) -- We opt to NOT remove enum values that were added at a later point. ALTER TABLE - "positions" DROP COLUMN "realized_pnl"; + "positions" DROP COLUMN "realized_pnl_sat"; diff --git a/coordinator/migrations/2023-07-19-055143_closed_positions/up.sql b/coordinator/migrations/2023-07-19-055143_closed_positions/up.sql index 1d7a36066..6bd2c69e4 100644 --- a/coordinator/migrations/2023-07-19-055143_closed_positions/up.sql +++ b/coordinator/migrations/2023-07-19-055143_closed_positions/up.sql @@ -7,4 +7,4 @@ ADD ALTER TABLE positions ADD - COLUMN "realized_pnl" BIGINT; + COLUMN "realized_pnl_sat" BIGINT; diff --git a/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql index ed85cdd99..6d5879a9f 100644 --- a/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql +++ b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/down.sql @@ -1,3 +1,3 @@ -- This file should undo anything in `up.sql` ALTER TABLE - "positions" DROP COLUMN "unrealized_pnl"; + "positions" DROP COLUMN "unrealized_pnl_sat"; diff --git a/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql index 785cde29d..99d6bdf95 100644 --- a/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql +++ b/coordinator/migrations/2023-07-20-055142_unrealized_pnl_positions/up.sql @@ -2,4 +2,4 @@ ALTER TABLE positions ADD - COLUMN "unrealized_pnl" BIGINT; + COLUMN "unrealized_pnl_sat" BIGINT; diff --git a/coordinator/migrations/2023-07-21-050135_closing_price_positions/down.sql b/coordinator/migrations/2023-07-21-050135_closing_price_positions/down.sql new file mode 100644 index 000000000..dac0262fe --- /dev/null +++ b/coordinator/migrations/2023-07-21-050135_closing_price_positions/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE + "positions" DROP COLUMN "closing_price"; diff --git a/coordinator/migrations/2023-07-21-050135_closing_price_positions/up.sql b/coordinator/migrations/2023-07-21-050135_closing_price_positions/up.sql new file mode 100644 index 000000000..cbcd14b5f --- /dev/null +++ b/coordinator/migrations/2023-07-21-050135_closing_price_positions/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +ALTER TABLE + positions +ADD + COLUMN "closing_price" REAL; diff --git a/coordinator/src/bin/coordinator.rs b/coordinator/src/bin/coordinator.rs index 477fdee03..eea084dfe 100644 --- a/coordinator/src/bin/coordinator.rs +++ b/coordinator/src/bin/coordinator.rs @@ -170,7 +170,9 @@ async fn main() -> Result<()> { loop { tokio::time::sleep(UNREALIZED_PNL_SYNC_INTERVAL).await; if let Err(e) = unrealized_pnl::sync(node.clone()).await { - tracing::error!("Failed to sync closed DLCs with positions in database: {e:#}"); + tracing::error!( + "Failed to sync unrealized PnL with positions in database: {e:#}" + ); } } } diff --git a/coordinator/src/db/positions.rs b/coordinator/src/db/positions.rs index a999e856d..bc0bb1fee 100644 --- a/coordinator/src/db/positions.rs +++ b/coordinator/src/db/positions.rs @@ -32,8 +32,9 @@ pub struct Position { pub update_timestamp: OffsetDateTime, pub trader_pubkey: String, pub temporary_contract_id: Option, - pub realized_pnl: Option, - pub unrealized_pnl: Option, + pub realized_pnl_sat: Option, + pub unrealized_pnl_sat: Option, + pub closing_price: Option, } impl Position { @@ -92,12 +93,14 @@ impl Position { pub fn set_open_position_to_closing( conn: &mut PgConnection, trader_pubkey: String, + closing_price: f32, ) -> Result<()> { let effected_rows = diesel::update(positions::table) .filter(positions::trader_pubkey.eq(trader_pubkey.clone())) .filter(positions::position_state.eq(PositionState::Open)) .set(( positions::position_state.eq(PositionState::Closing), + positions::closing_price.eq(Some(closing_price)), positions::update_timestamp.eq(OffsetDateTime::now_utc()), )) .execute(conn)?; @@ -114,7 +117,7 @@ impl Position { .filter(positions::id.eq(id)) .set(( positions::position_state.eq(PositionState::Closed), - positions::realized_pnl.eq(Some(pnl)), + positions::realized_pnl_sat.eq(Some(pnl)), positions::update_timestamp.eq(OffsetDateTime::now_utc()), )) .execute(conn)?; @@ -130,7 +133,7 @@ impl Position { let effected_rows = diesel::update(positions::table) .filter(positions::id.eq(id)) .set(( - positions::unrealized_pnl.eq(Some(pnl)), + positions::unrealized_pnl_sat.eq(Some(pnl)), positions::update_timestamp.eq(OffsetDateTime::now_utc()), )) .execute(conn)?; @@ -168,7 +171,7 @@ impl From for crate::position::models::Position { liquidation_price: value.liquidation_price, position_state: crate::position::models::PositionState::from(( value.position_state, - value.realized_pnl, + value.realized_pnl_sat, )), collateral: value.collateral, creation_timestamp: value.creation_timestamp, @@ -178,6 +181,7 @@ impl From for crate::position::models::Position { temporary_contract_id: value.temporary_contract_id.map(|contract_id| { ContractId::from_hex(contract_id.as_str()).expect("contract id to decode") }), + closing_price: value.closing_price, } } } diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index 61a96cab2..5f26275e5 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -333,6 +333,9 @@ impl Node { db::positions::Position::set_open_position_to_closing( &mut connection, position.trader.to_string(), + closing_price + .to_f32() + .expect("Closing price to fit into f32"), ) } diff --git a/coordinator/src/node/unrealized_pnl.rs b/coordinator/src/node/unrealized_pnl.rs index e3eec81f8..9325726a2 100644 --- a/coordinator/src/node/unrealized_pnl.rs +++ b/coordinator/src/node/unrealized_pnl.rs @@ -35,9 +35,14 @@ fn sync_position( position: &Position, quote: Quote, ) -> Result<()> { - let current_price = match position.direction { - trade::Direction::Long => quote.bid_price, - trade::Direction::Short => quote.ask_price, + let closing_price = match position.closing_price { + None => match position.direction { + trade::Direction::Long => quote.bid_price, + trade::Direction::Short => quote.ask_price, + }, + Some(closing_price) => { + Decimal::try_from(closing_price).expect("f32 closing price to fit into decimal") + } }; let average_entry_price = Decimal::try_from(position.average_entry_price) @@ -53,7 +58,7 @@ fn sync_position( let pnl = calculate_pnl( average_entry_price, - current_price, + closing_price, position.quantity, long_leverage, short_leverage, diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index 167305ca7..7a81dddfd 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -49,4 +49,5 @@ pub struct Position { /// This field is optional for backwards compatibility because we cannot deterministically /// associate already existing contracts with positions. pub temporary_contract_id: Option, + pub closing_price: Option, } diff --git a/coordinator/src/schema.rs b/coordinator/src/schema.rs index 190313577..e7e2959c8 100644 --- a/coordinator/src/schema.rs +++ b/coordinator/src/schema.rs @@ -108,8 +108,9 @@ diesel::table! { update_timestamp -> Timestamptz, trader_pubkey -> Text, temporary_contract_id -> Nullable, - realized_pnl -> Nullable, - unrealized_pnl -> Nullable, + realized_pnl_sat -> Nullable, + unrealized_pnl_sat -> Nullable, + closing_price -> Nullable, } } From 0ba98ebb1ea7f62e3357cfbb05464a13fca0fe14 Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Mon, 24 Jul 2023 13:46:40 +1000 Subject: [PATCH 3/4] Extract `closing_price` into a function We use the same logic in three different places already, better extract it into a function to make this clearer. --- coordinator/src/node/expired_positions.rs | 5 +- coordinator/src/node/unrealized_pnl.rs | 5 +- crates/trade/src/bitmex_client.rs | 13 +++ crates/trade/src/cfd.rs | 88 +++++++++++++++++++++ mobile/native/src/trade/position/handler.rs | 5 +- 5 files changed, 104 insertions(+), 12 deletions(-) diff --git a/coordinator/src/node/expired_positions.rs b/coordinator/src/node/expired_positions.rs index c4cb483d8..752a39379 100644 --- a/coordinator/src/node/expired_positions.rs +++ b/coordinator/src/node/expired_positions.rs @@ -57,10 +57,7 @@ pub async fn close(node: Node) { }; let closing_price = match BitmexClient::get_quote(&position.expiry_timestamp).await { - Ok(quote) => match position.direction { - trade::Direction::Long => quote.bid_price, - trade::Direction::Short => quote.ask_price, - }, + Ok(quote) => quote.get_price_for_direction(position.direction.opposite()), Err(e) => { tracing::warn!( "Failed to get quote from bitmex for {} at {}. Error: {e:?}", diff --git a/coordinator/src/node/unrealized_pnl.rs b/coordinator/src/node/unrealized_pnl.rs index 9325726a2..ed9a3b1d3 100644 --- a/coordinator/src/node/unrealized_pnl.rs +++ b/coordinator/src/node/unrealized_pnl.rs @@ -36,10 +36,7 @@ fn sync_position( quote: Quote, ) -> Result<()> { let closing_price = match position.closing_price { - None => match position.direction { - trade::Direction::Long => quote.bid_price, - trade::Direction::Short => quote.ask_price, - }, + None => quote.get_price_for_direction(position.direction.opposite()), Some(closing_price) => { Decimal::try_from(closing_price).expect("f32 closing price to fit into decimal") } diff --git a/crates/trade/src/bitmex_client.rs b/crates/trade/src/bitmex_client.rs index 480124acb..799266104 100644 --- a/crates/trade/src/bitmex_client.rs +++ b/crates/trade/src/bitmex_client.rs @@ -1,3 +1,4 @@ +use crate::Direction; use anyhow::bail; use anyhow::Context; use anyhow::Result; @@ -48,3 +49,15 @@ pub struct Quote { #[serde(with = "time::serde::rfc3339")] pub timestamp: OffsetDateTime, } + +impl Quote { + /// Get the price for the direction + /// + /// For going long we get the best ask price, for going short we get the best bid price. + pub fn get_price_for_direction(&self, direction: Direction) -> Decimal { + match direction { + Direction::Long => self.ask_price, + Direction::Short => self.bid_price, + } + } +} diff --git a/crates/trade/src/cfd.rs b/crates/trade/src/cfd.rs index 967951b84..b6fbc0f81 100644 --- a/crates/trade/src/cfd.rs +++ b/crates/trade/src/cfd.rs @@ -224,4 +224,92 @@ pub mod tests { // This is a liquidation, our margin is consumed by the loss assert_eq!(pnl_long, 500000); } + + #[test] + fn given_long_position_when_price_10_pc_up_then_18pc_profit() { + let opening_price = Decimal::from(20000); + let closing_price = Decimal::from(22000); + let quantity = 20000.0; + let long_leverage = 2.0; + let short_leverage = 1.0; + + let pnl_long = calculate_pnl( + opening_price, + closing_price, + quantity, + long_leverage, + short_leverage, + Direction::Long, + ) + .unwrap(); + + // Value taken from our CFD hedging model sheet + assert_eq!(pnl_long, 9_090_909); + } + + #[test] + fn given_short_position_when_price_10_pc_up_then_18pc_loss() { + let opening_price = Decimal::from(20000); + let closing_price = Decimal::from(22000); + let quantity = 20000.0; + let long_leverage = 2.0; + let short_leverage = 1.0; + + let pnl_long = calculate_pnl( + opening_price, + closing_price, + quantity, + long_leverage, + short_leverage, + Direction::Short, + ) + .unwrap(); + + // Value taken from our CFD hedging model sheet + assert_eq!(pnl_long, -9_090_909); + } + + #[test] + fn given_long_position_when_price_10_pc_down_then_22pc_loss() { + let opening_price = Decimal::from(20000); + let closing_price = Decimal::from(18000); + let quantity = 20000.0; + let long_leverage = 2.0; + let short_leverage = 1.0; + + let pnl_long = calculate_pnl( + opening_price, + closing_price, + quantity, + long_leverage, + short_leverage, + Direction::Long, + ) + .unwrap(); + + // Value taken from our CFD hedging model sheet + assert_eq!(pnl_long, -11_111_111); + } + + #[test] + fn given_short_position_when_price_10_pc_down_then_22pc_profit() { + let opening_price = Decimal::from(20000); + let closing_price = Decimal::from(18000); + let quantity = 20000.0; + let long_leverage = 2.0; + let short_leverage = 1.0; + + let pnl_long = calculate_pnl( + opening_price, + closing_price, + quantity, + long_leverage, + short_leverage, + Direction::Short, + ) + .unwrap(); + + // Value taken from our CFD hedging model sheet + assert_eq!(pnl_long, 11_111_111); + } } diff --git a/mobile/native/src/trade/position/handler.rs b/mobile/native/src/trade/position/handler.rs index 8f7585c04..a12a921f5 100644 --- a/mobile/native/src/trade/position/handler.rs +++ b/mobile/native/src/trade/position/handler.rs @@ -186,10 +186,7 @@ pub async fn close_position() -> Result<()> { tracing::debug!("Adding order for the expired closed position"); let quote = BitmexClient::get_quote(&position.expiry).await?; - let closing_price = match position.direction { - trade::Direction::Long => quote.bid_price, - trade::Direction::Short => quote.ask_price, - }; + let closing_price = quote.get_price_for_direction(position.direction.opposite()); let order = Order { id: Uuid::new_v4(), From 2479fcf7b213a428261a8fb17f1138c008a2b495 Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Mon, 24 Jul 2023 15:45:11 +1000 Subject: [PATCH 4/4] Add tests for coordinator `pnl` calculation Additionally adds more tests to `trade::cfd` that reflect the pnl calculation on the trader side. This reflects that the traders losses are the coordinator's profits and vice versa. --- coordinator/src/node.rs | 2 +- coordinator/src/node/unrealized_pnl.rs | 32 +--- coordinator/src/position/models.rs | 199 +++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 32 deletions(-) diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index 5f26275e5..abf21504b 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -57,7 +57,7 @@ pub mod storage; pub mod unrealized_pnl; /// The leverage used by the coordinator for all trades. -const COORDINATOR_LEVERAGE: f32 = 1.0; +pub const COORDINATOR_LEVERAGE: f32 = 1.0; #[derive(Debug, Clone)] pub struct NodeSettings { diff --git a/coordinator/src/node/unrealized_pnl.rs b/coordinator/src/node/unrealized_pnl.rs index ed9a3b1d3..bd0e93934 100644 --- a/coordinator/src/node/unrealized_pnl.rs +++ b/coordinator/src/node/unrealized_pnl.rs @@ -6,12 +6,9 @@ use anyhow::Result; use diesel::r2d2::ConnectionManager; use diesel::r2d2::PooledConnection; use diesel::PgConnection; -use rust_decimal::Decimal; use time::OffsetDateTime; use trade::bitmex_client::BitmexClient; use trade::bitmex_client::Quote; -use trade::cfd::calculate_pnl; -use trade::Direction; pub async fn sync(node: Node) -> Result<()> { let mut conn = node.pool.get()?; @@ -35,34 +32,7 @@ fn sync_position( position: &Position, quote: Quote, ) -> Result<()> { - let closing_price = match position.closing_price { - None => quote.get_price_for_direction(position.direction.opposite()), - Some(closing_price) => { - Decimal::try_from(closing_price).expect("f32 closing price to fit into decimal") - } - }; - - let average_entry_price = Decimal::try_from(position.average_entry_price) - .context("Failed to convert average entry price to Decimal")?; - - let (long_leverage, short_leverage) = match position.direction { - Direction::Long => (position.leverage, 1.0_f32), - Direction::Short => (1.0_f32, position.leverage), - }; - - // the position in the database is the trader's position, our direction is opposite - let direction = position.direction.opposite(); - - let pnl = calculate_pnl( - average_entry_price, - closing_price, - position.quantity, - long_leverage, - short_leverage, - direction, - ) - .context("Failed to calculate pnl for position")?; - + let pnl = position.calculate_coordinator_pnl(quote)?; db::positions::Position::update_unrealized_pnl(conn, position.id, pnl) .context("Failed to update unrealized pnl in db")?; diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index 7a81dddfd..f6318864e 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -1,6 +1,12 @@ +use crate::node::COORDINATOR_LEVERAGE; +use anyhow::Context; +use anyhow::Result; use bitcoin::secp256k1::PublicKey; use dlc_manager::ContractId; +use rust_decimal::Decimal; use time::OffsetDateTime; +use trade::bitmex_client::Quote; +use trade::cfd::calculate_pnl; use trade::ContractSymbol; use trade::Direction; @@ -51,3 +57,196 @@ pub struct Position { pub temporary_contract_id: Option, pub closing_price: Option, } + +impl Position { + /// Calculates the profit and loss for the coordinator in satoshis + /// + /// The position stored represents the values of the trader. + pub fn calculate_coordinator_pnl(&self, quote: Quote) -> Result { + let closing_price = match self.closing_price { + None => quote.get_price_for_direction(self.direction.opposite()), + Some(closing_price) => { + Decimal::try_from(closing_price).expect("f32 closing price to fit into decimal") + } + }; + + let average_entry_price = Decimal::try_from(self.average_entry_price) + .context("Failed to convert average entry price to Decimal")?; + + let (long_leverage, short_leverage) = match self.direction { + Direction::Long => (self.leverage, COORDINATOR_LEVERAGE), + Direction::Short => (COORDINATOR_LEVERAGE, self.leverage), + }; + + // the position in the database is the trader's position, our direction is opposite + let direction = self.direction.opposite(); + + let pnl = calculate_pnl( + average_entry_price, + closing_price, + self.quantity, + long_leverage, + short_leverage, + direction, + ) + .context("Failed to calculate pnl for position")?; + + Ok(pnl) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn given_trader_long_position_when_no_bid_price_change_then_zero_pnl() { + let position = Position::dummy() + .with_leverage(2.0) + .with_quantity(1.0) + .with_average_entry_price(1000.0) + .with_direction(Direction::Long); + + let quote = dummy_quote(1000, 0); + + let coordinator_pnl = position.calculate_coordinator_pnl(quote).unwrap(); + + assert_eq!(coordinator_pnl, 0); + } + + #[test] + fn given_trader_short_position_when_no_ask_price_change_then_zero_pnl() { + let position = Position::dummy() + .with_leverage(2.0) + .with_quantity(1.0) + .with_average_entry_price(1000.0) + .with_direction(Direction::Short); + + let quote = dummy_quote(0, 1000); + + let coordinator_pnl = position.calculate_coordinator_pnl(quote).unwrap(); + + assert_eq!(coordinator_pnl, 0); + } + + /// See also: `given_long_position_when_price_10_pc_up_then_18pc_profit` test in `trade::cfd` + #[test] + fn given_trader_long_position_when_bid_price_10pc_up_then_coordinator_18pc_loss() { + let position = Position::dummy() + .with_leverage(2.0) + .with_quantity(20000.0) + .with_average_entry_price(20000.0) + .with_direction(Direction::Long); + + let quote = dummy_quote(22000, 0); + + let coordinator_pnl = position.calculate_coordinator_pnl(quote).unwrap(); + + assert_eq!(coordinator_pnl, -9_090_909); + } + + /// See also: `given_short_position_when_price_10_pc_up_then_18pc_loss` test in `trade::cfd` + #[test] + fn given_trader_short_position_when_bid_price_10pc_up_then_coordinator_18pc_profit() { + let position = Position::dummy() + .with_leverage(2.0) + .with_quantity(20000.0) + .with_average_entry_price(20000.0) + .with_direction(Direction::Short); + + let quote = dummy_quote(0, 22000); + + let coordinator_pnl = position.calculate_coordinator_pnl(quote).unwrap(); + + assert_eq!(coordinator_pnl, 9_090_909); + } + + /// See also: `given_long_position_when_price_10_pc_down_then_22pc_loss` test in `trade::cfd` + #[test] + fn given_trader_long_position_when_bid_price_10pc_down_then_coordinator_22pc_profit() { + let position = Position::dummy() + .with_leverage(2.0) + .with_quantity(20000.0) + .with_average_entry_price(20000.0) + .with_direction(Direction::Long); + + let quote = dummy_quote(18000, 0); + + let coordinator_pnl = position.calculate_coordinator_pnl(quote).unwrap(); + + assert_eq!(coordinator_pnl, 11_111_111); + } + + /// See also: `given_short_position_when_price_10_pc_down_then_22pc_profit` test in `trade::cfd` + #[test] + fn given_trader_short_position_when_bid_price_10pc_down_then_coordinator_22pc_loss() { + let position = Position::dummy() + .with_leverage(2.0) + .with_quantity(20000.0) + .with_average_entry_price(20000.0) + .with_direction(Direction::Short); + + let quote = dummy_quote(0, 18000); + + let coordinator_pnl = position.calculate_coordinator_pnl(quote).unwrap(); + + assert_eq!(coordinator_pnl, -11_111_111); + } + + fn dummy_quote(bid: u64, ask: u64) -> Quote { + Quote { + bid_size: 0, + ask_size: 0, + bid_price: Decimal::from(bid), + ask_price: Decimal::from(ask), + symbol: "".to_string(), + timestamp: OffsetDateTime::now_utc(), + } + } + + impl Position { + fn dummy() -> Self { + Position { + id: 0, + contract_symbol: ContractSymbol::BtcUsd, + leverage: 2.0, + quantity: 100.0, + direction: Direction::Long, + average_entry_price: 10000.0, + liquidation_price: 0.0, + position_state: PositionState::Open, + collateral: 1000, + creation_timestamp: OffsetDateTime::now_utc(), + expiry_timestamp: OffsetDateTime::now_utc(), + update_timestamp: OffsetDateTime::now_utc(), + trader: PublicKey::from_str( + "02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655", + ) + .unwrap(), + temporary_contract_id: None, + closing_price: None, + } + } + + fn with_quantity(mut self, quantity: f32) -> Self { + self.quantity = quantity; + self + } + + fn with_average_entry_price(mut self, average_entry_price: f32) -> Self { + self.average_entry_price = average_entry_price; + self + } + + fn with_leverage(mut self, leverage: f32) -> Self { + self.leverage = leverage; + self + } + + fn with_direction(mut self, direction: Direction) -> Self { + self.direction = direction; + self + } + } +}