From cfae4e573b19c8e4ee36500afbc954246fe6658d Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Tue, 14 Sep 2021 13:53:03 +1000 Subject: [PATCH] Margin calculation The buy margin is calculated from `initial_price`, `quantity` and `leverage`. The sell margin is calculated from `initial_price` and `quantity` (no leverage trading for the seller at the moment). Includes several refactors: - Separate API interface for `Cfd` and `CfdOffer` when mapping `toSseEvent`. This allows internal modelling to be different than the exposed API. - Remove the profit calculation internally, it is only relevant on the UI feed API level. - Move code that is specific to taker/maker http API from `model` to the respective `routes` module. - Some simplification of the calculation model of `Usd` (could be further improved by deriving calculation traits) - Introduces `OfferOrigin` used to save the offer's origin as `mine` and `others` in the database. This is used to properly derive the CFD position from the offer. --- ...0903050345_create_cfd_and_offer_tables.sql | 9 +- daemon/sqlx-data.json | 42 ++-- daemon/src/db.rs | 61 +++-- daemon/src/maker_cfd_actor.rs | 33 ++- daemon/src/model.rs | 47 +++- daemon/src/model/cfd.rs | 221 +++++++++++------- daemon/src/routes_maker.rs | 15 +- daemon/src/routes_taker.rs | 41 +++- daemon/src/taker.rs | 3 +- daemon/src/taker_cfd_actor.rs | 31 +-- daemon/src/to_sse_event.rs | 106 ++++++++- frontend/src/Taker.tsx | 63 ++++- frontend/src/components/CfdTile.tsx | 14 +- frontend/src/components/Types.tsx | 34 +-- 14 files changed, 529 insertions(+), 191 deletions(-) diff --git a/daemon/migrations/20210903050345_create_cfd_and_offer_tables.sql b/daemon/migrations/20210903050345_create_cfd_and_offer_tables.sql index 5304eb6..3d0cdbd 100644 --- a/daemon/migrations/20210903050345_create_cfd_and_offer_tables.sql +++ b/daemon/migrations/20210903050345_create_cfd_and_offer_tables.sql @@ -11,7 +11,8 @@ create table if not exists offers leverage integer not null, liquidation_price text not null, creation_timestamp text not null, - term text not null + term text not null, + origin text not null ); create unique index if not exists offers_uuid @@ -32,8 +33,8 @@ create unique index if not exists cfd_offer_uuid create table if not exists cfd_states ( - id integer primary key autoincrement, - cfd_id integer not null, - state text not null, + id integer primary key autoincrement, + cfd_id integer not null, + state text not null, foreign key (cfd_id) references cfds (id) ); diff --git a/daemon/sqlx-data.json b/daemon/sqlx-data.json index 8699d97..0ae0200 100644 --- a/daemon/sqlx-data.json +++ b/daemon/sqlx-data.json @@ -18,18 +18,8 @@ ] } }, - "29bc1b2bd17146eb36e2c61acc1bed3c9b5b3014e35874c752cbd50016e99b74": { - "query": "\n insert into offers (\n uuid,\n trading_pair,\n position,\n initial_price,\n min_quantity,\n max_quantity,\n leverage,\n liquidation_price,\n creation_timestamp,\n term\n ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - } - }, - "4896e2d2e2c6cc03f9ae2b7de85279f295fb7e70e083e0a1a3faf3e7551650f3": { - "query": "\n select\n cfds.id as cfd_id,\n offers.uuid as offer_id,\n offers.initial_price as initial_price,\n offers.leverage as leverage,\n offers.trading_pair as trading_pair,\n offers.position as position,\n offers.liquidation_price as liquidation_price,\n cfds.quantity_usd as quantity_usd,\n cfd_states.state as state\n from cfds as cfds\n inner join offers as offers on cfds.offer_id = offers.id\n inner join cfd_states as cfd_states on cfd_states.cfd_id = cfds.id\n where cfd_states.state in (\n select\n state\n from cfd_states\n where cfd_id = cfds.id\n order by id desc\n limit 1\n )\n ", + "2bfe23378b852e9c98f1db3e7c7694e1a7037183eb6fa8d81916cdb46c760547": { + "query": "\n select\n cfds.id as cfd_id,\n offers.uuid as offer_id,\n offers.initial_price as initial_price,\n offers.leverage as leverage,\n offers.trading_pair as trading_pair,\n offers.position as position,\n offers.origin as origin,\n offers.liquidation_price as liquidation_price,\n cfds.quantity_usd as quantity_usd,\n cfd_states.state as state\n from cfds as cfds\n inner join offers as offers on cfds.offer_id = offers.id\n inner join cfd_states as cfd_states on cfd_states.cfd_id = cfds.id\n where cfd_states.state in (\n select\n state\n from cfd_states\n where cfd_id = cfds.id\n order by id desc\n limit 1\n )\n ", "describe": { "columns": [ { @@ -63,19 +53,24 @@ "type_info": "Text" }, { - "name": "liquidation_price", + "name": "origin", "ordinal": 6, "type_info": "Text" }, { - "name": "quantity_usd", + "name": "liquidation_price", "ordinal": 7, "type_info": "Text" }, { - "name": "state", + "name": "quantity_usd", "ordinal": 8, "type_info": "Text" + }, + { + "name": "state", + "ordinal": 9, + "type_info": "Text" } ], "parameters": { @@ -90,10 +85,21 @@ false, false, false, + false, false ] } }, + "4a8db91aaef56a804d0151460297175355c9adee100b87fa9052245cdd18d7e9": { + "query": "\n insert into offers (\n uuid,\n trading_pair,\n position,\n initial_price,\n min_quantity,\n max_quantity,\n leverage,\n liquidation_price,\n creation_timestamp,\n term,\n origin\n ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + } + }, "50abbb297394739ec9d85917f8c32aa8bcfa0bfe140b24e9eeda4ce8d30d4f8d": { "query": "\n select\n state\n from cfd_states\n where cfd_id = ?\n order by id desc\n limit 1;\n ", "describe": { @@ -190,6 +196,11 @@ "name": "term", "ordinal": 10, "type_info": "Text" + }, + { + "name": "origin", + "ordinal": 11, + "type_info": "Text" } ], "parameters": { @@ -206,6 +217,7 @@ false, false, false, + false, false ] } diff --git a/daemon/src/db.rs b/daemon/src/db.rs index d7b17cb..da6ab21 100644 --- a/daemon/src/db.rs +++ b/daemon/src/db.rs @@ -1,13 +1,19 @@ use crate::model::cfd::{Cfd, CfdOffer, CfdOfferId, CfdState}; -use crate::model::{Leverage, Usd}; +use crate::model::{Leverage, Position}; use anyhow::Context; -use bdk::bitcoin::Amount; use rocket_db_pools::sqlx; +use serde::{Deserialize, Serialize}; use sqlx::pool::PoolConnection; use sqlx::{Acquire, Sqlite, SqlitePool}; use std::convert::TryInto; use std::mem; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum OfferOrigin { + Mine, + Others, +} + pub async fn run_migrations(pool: &SqlitePool) -> anyhow::Result<()> { sqlx::migrate!("./migrations").run(pool).await?; Ok(()) @@ -16,6 +22,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> anyhow::Result<()> { pub async fn insert_cfd_offer( cfd_offer: &CfdOffer, conn: &mut PoolConnection, + origin: OfferOrigin, ) -> anyhow::Result<()> { let uuid = serde_json::to_string(&cfd_offer.id).unwrap(); let trading_pair = serde_json::to_string(&cfd_offer.trading_pair).unwrap(); @@ -27,6 +34,7 @@ pub async fn insert_cfd_offer( let liquidation_price = serde_json::to_string(&cfd_offer.liquidation_price).unwrap(); let creation_timestamp = serde_json::to_string(&cfd_offer.creation_timestamp).unwrap(); let term = serde_json::to_string(&cfd_offer.term).unwrap(); + let origin = serde_json::to_string(&origin).unwrap(); sqlx::query!( r#" @@ -40,8 +48,9 @@ pub async fn insert_cfd_offer( leverage, liquidation_price, creation_timestamp, - term - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + term, + origin + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); "#, uuid, trading_pair, @@ -52,7 +61,8 @@ pub async fn insert_cfd_offer( leverage, liquidation_price, creation_timestamp, - term + term, + origin ) .execute(conn) .await?; @@ -155,7 +165,7 @@ pub async fn insert_cfd(cfd: Cfd, conn: &mut PoolConnection) -> anyhow:: Ok(()) } -#[allow(dead_code)] // This is only used by one binary. +#[allow(dead_code)] pub async fn insert_new_cfd_state_by_offer_id( offer_id: CfdOfferId, new_state: CfdState, @@ -190,6 +200,7 @@ pub async fn insert_new_cfd_state_by_offer_id( Ok(()) } +#[allow(dead_code)] async fn load_cfd_id_by_offer_uuid( offer_uuid: CfdOfferId, conn: &mut PoolConnection, @@ -213,6 +224,7 @@ async fn load_cfd_id_by_offer_uuid( Ok(cfd_id) } +#[allow(dead_code)] async fn load_latest_cfd_state( cfd_id: i64, conn: &mut PoolConnection, @@ -250,6 +262,7 @@ pub async fn load_all_cfds(conn: &mut PoolConnection) -> anyhow::Result< offers.leverage as leverage, offers.trading_pair as trading_pair, offers.position as position, + offers.origin as origin, offers.liquidation_price as liquidation_price, cfds.quantity_usd as quantity_usd, cfd_states.state as state @@ -269,9 +282,6 @@ pub async fn load_all_cfds(conn: &mut PoolConnection) -> anyhow::Result< .fetch_all(conn) .await?; - // TODO: We might want to separate the database model from the http model and properly map - // between them - let cfds = rows .iter() .map(|row| { @@ -279,11 +289,18 @@ pub async fn load_all_cfds(conn: &mut PoolConnection) -> anyhow::Result< let initial_price = serde_json::from_str(row.initial_price.as_str()).unwrap(); let leverage = Leverage(row.leverage.try_into().unwrap()); let trading_pair = serde_json::from_str(row.trading_pair.as_str()).unwrap(); - let position = serde_json::from_str(row.position.as_str()).unwrap(); let liquidation_price = serde_json::from_str(row.liquidation_price.as_str()).unwrap(); let quantity = serde_json::from_str(row.quantity_usd.as_str()).unwrap(); let latest_state = serde_json::from_str(row.state.as_str()).unwrap(); + let origin: OfferOrigin = serde_json::from_str(row.origin.as_str()).unwrap(); + let position: Position = serde_json::from_str(row.position.as_str()).unwrap(); + + let position = match origin { + OfferOrigin::Mine => position, + OfferOrigin::Others => position.counter_position(), + }; + Cfd { offer_id, initial_price, @@ -292,8 +309,6 @@ pub async fn load_all_cfds(conn: &mut PoolConnection) -> anyhow::Result< position, liquidation_price, quantity_usd: quantity, - profit_btc: Amount::ZERO, - profit_usd: Usd::ZERO, state: latest_state, } }) @@ -323,7 +338,9 @@ mod tests { let mut conn = pool.acquire().await.unwrap(); let cfd_offer = CfdOffer::from_default_with_price(Usd(dec!(10000))).unwrap(); - insert_cfd_offer(&cfd_offer, &mut conn).await.unwrap(); + insert_cfd_offer(&cfd_offer, &mut conn, OfferOrigin::Others) + .await + .unwrap(); let cfd_offer_loaded = load_offer_by_id(cfd_offer.id, &mut conn).await.unwrap(); @@ -344,12 +361,13 @@ mod tests { transition_timestamp: SystemTime::now(), }, }, - Usd(dec!(10001)), - ) - .unwrap(); + Position::Buy, + ); // the order ahs to exist in the db in order to be able to insert the cfd - insert_cfd_offer(&cfd_offer, &mut conn).await.unwrap(); + insert_cfd_offer(&cfd_offer, &mut conn, OfferOrigin::Others) + .await + .unwrap(); insert_cfd(cfd.clone(), &mut conn).await.unwrap(); let cfds_from_db = load_all_cfds(&mut conn).await.unwrap(); @@ -371,12 +389,13 @@ mod tests { transition_timestamp: SystemTime::now(), }, }, - Usd(dec!(10001)), - ) - .unwrap(); + Position::Buy, + ); // the order ahs to exist in the db in order to be able to insert the cfd - insert_cfd_offer(&cfd_offer, &mut conn).await.unwrap(); + insert_cfd_offer(&cfd_offer, &mut conn, OfferOrigin::Others) + .await + .unwrap(); insert_cfd(cfd.clone(), &mut conn).await.unwrap(); cfd.state = CfdState::Accepted { diff --git a/daemon/src/maker_cfd_actor.rs b/daemon/src/maker_cfd_actor.rs index 61cdfc5..6af064d 100644 --- a/daemon/src/maker_cfd_actor.rs +++ b/daemon/src/maker_cfd_actor.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; use std::time::SystemTime; +use crate::db::{insert_cfd, insert_cfd_offer, load_all_cfds, load_offer_by_id, OfferOrigin}; use crate::model::cfd::{Cfd, CfdOffer, CfdOfferId, CfdState, CfdStateCommon, FinalizedCfd}; use crate::model::{TakerId, Usd}; use crate::wire::{Msg0, Msg1, SetupMsg}; -use crate::{db, maker_cfd_actor, maker_inc_connections_actor}; +use crate::{maker_cfd_actor, maker_inc_connections_actor}; use bdk::bitcoin::secp256k1::{schnorrsig, SecretKey}; use bdk::bitcoin::{self, Amount}; use bdk::database::BatchDatabase; @@ -13,7 +14,6 @@ use cfd_protocol::{ WalletExt, }; use futures::Future; -use rust_decimal_macros::dec; use tokio::sync::{mpsc, watch}; #[allow(clippy::large_enum_variant)] @@ -62,7 +62,7 @@ where // populate the CFD feed with existing CFDs let mut conn = db.acquire().await.unwrap(); cfd_feed_actor_inbox - .send(db::load_all_cfds(&mut conn).await.unwrap()) + .send(load_all_cfds(&mut conn).await.unwrap()) .unwrap(); while let Some(message) = receiver.recv().await { @@ -82,9 +82,7 @@ where // 1. Validate if offer is still valid let current_offer = match current_offer_id { Some(current_offer_id) if current_offer_id == offer_id => { - db::load_offer_by_id(current_offer_id, &mut conn) - .await - .unwrap() + load_offer_by_id(current_offer_id, &mut conn).await.unwrap() } _ => { takers @@ -100,17 +98,16 @@ where // 2. Insert CFD in DB // TODO: Don't auto-accept, present to user in UI instead let cfd = Cfd::new( - current_offer, + current_offer.clone(), quantity, CfdState::Accepted { common: CfdStateCommon { transition_timestamp: SystemTime::now(), }, }, - Usd(dec!(10001)), - ) - .unwrap(); - db::insert_cfd(cfd, &mut conn).await.unwrap(); + current_offer.position, + ); + insert_cfd(cfd, &mut conn).await.unwrap(); takers .send(maker_inc_connections_actor::Command::NotifyOfferAccepted { @@ -119,7 +116,7 @@ where }) .unwrap(); cfd_feed_actor_inbox - .send(db::load_all_cfds(&mut conn).await.unwrap()) + .send(load_all_cfds(&mut conn).await.unwrap()) .unwrap(); // 3. Remove current offer @@ -134,7 +131,9 @@ where maker_cfd_actor::Command::NewOffer(offer) => { // 1. Save to DB let mut conn = db.acquire().await.unwrap(); - db::insert_cfd_offer(&offer, &mut conn).await.unwrap(); + insert_cfd_offer(&offer, &mut conn, OfferOrigin::Mine) + .await + .unwrap(); // 2. Update actor state to current offer current_offer_id.replace(offer.id); @@ -153,11 +152,9 @@ where let mut conn = db.acquire().await.unwrap(); let current_offer = match current_offer_id { - Some(current_offer_id) => Some( - db::load_offer_by_id(current_offer_id, &mut conn) - .await - .unwrap(), - ), + Some(current_offer_id) => { + Some(load_offer_by_id(current_offer_id, &mut conn).await.unwrap()) + } None => None, }; diff --git a/daemon/src/model.rs b/daemon/src/model.rs index b6ff7e4..ea5da1f 100644 --- a/daemon/src/model.rs +++ b/daemon/src/model.rs @@ -1,5 +1,7 @@ use std::fmt::{Display, Formatter}; +use anyhow::{Context, Result}; +use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -12,6 +14,33 @@ pub struct Usd(pub Decimal); impl Usd { pub const ZERO: Self = Self(Decimal::ZERO); + + pub fn checked_add(&self, other: Usd) -> Result { + let result = self.0.checked_add(other.0).context("addition error")?; + Ok(Usd(result)) + } + + pub fn checked_sub(&self, other: Usd) -> Result { + let result = self.0.checked_sub(other.0).context("subtraction error")?; + Ok(Usd(result)) + } + + pub fn checked_mul(&self, other: Usd) -> Result { + let result = self + .0 + .checked_mul(other.0) + .context("multiplication error")?; + Ok(Usd(result)) + } + + pub fn checked_div(&self, other: Usd) -> Result { + let result = self.0.checked_div(other.0).context("division error")?; + Ok(Usd(result)) + } + + pub fn try_into_u64(&self) -> Result { + self.0.to_u64().context("could not fit decimal into u64") + } } impl Display for Usd { @@ -20,7 +49,13 @@ impl Display for Usd { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +impl From for Usd { + fn from(decimal: Decimal) -> Self { + Usd(decimal) + } +} + +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)] pub struct Leverage(pub u8); #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -34,6 +69,16 @@ pub enum Position { Sell, } +impl Position { + #[allow(dead_code)] + pub fn counter_position(&self) -> Self { + match self { + Position::Buy => Position::Sell, + Position::Sell => Position::Buy, + } + } +} + #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct TakerId(Uuid); diff --git a/daemon/src/model/cfd.rs b/daemon/src/model/cfd.rs index cb4d9c7..848f214 100644 --- a/daemon/src/model/cfd.rs +++ b/daemon/src/model/cfd.rs @@ -1,5 +1,5 @@ use crate::model::{Leverage, Position, TradingPair, Usd}; -use anyhow::{Context, Result}; +use anyhow::Result; use bdk::bitcoin::secp256k1::{SecretKey, Signature}; use bdk::bitcoin::util::psbt::PartiallySignedTransaction; use bdk::bitcoin::{Amount, Transaction}; @@ -88,46 +88,18 @@ fn calculate_liquidation_price( price: &Usd, maintenance_margin_rate: &Decimal, ) -> Result { - let leverage = Decimal::from(leverage.0); - let price = price.0; + let leverage = Decimal::from(leverage.0).into(); + let maintenance_margin_rate: Usd = (*maintenance_margin_rate).into(); // liquidation price calc in isolated margin mode // currently based on: https://help.bybit.com/hc/en-us/articles/360039261334-How-to-calculate-Liquidation-Price-Inverse-Contract- - let liquidation_price = price - .checked_mul(leverage) - .context("multiplication error")? - .checked_div( - leverage - .checked_add(Decimal::ONE) - .context("addition error")? - .checked_sub( - maintenance_margin_rate - .checked_mul(leverage) - .context("multiplication error")?, - ) - .context("subtraction error")?, - ) - .context("division error")?; - - Ok(Usd(liquidation_price)) -} - -/// The taker POSTs this to create a Cfd -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfdTakeRequest { - pub offer_id: CfdOfferId, - pub quantity: Usd, -} + let liquidation_price = price.checked_mul(leverage)?.checked_div( + leverage + .checked_add(Decimal::ONE.into())? + .checked_sub(maintenance_margin_rate.checked_mul(leverage)?)?, + )?; -/// The maker POSTs this to create a new CfdOffer -// TODO: Use Rocket form? -#[derive(Debug, Clone, Deserialize)] -pub struct CfdNewOfferRequest { - pub price: Usd, - // TODO: [post-MVP] Representation of the contract size; at the moment the contract size is - // always 1 USD - pub min_quantity: Usd, - pub max_quantity: Usd, + Ok(liquidation_price) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -182,6 +154,8 @@ pub enum CfdState { Open { common: CfdStateCommon, settlement_timestamp: SystemTime, + #[serde(with = "::bdk::bitcoin::util::amount::serde::as_sat")] + margin: Amount, }, /// Requested close the position, but we have not passed that on to the blockchain yet. @@ -204,26 +178,26 @@ pub enum CfdState { } impl CfdState { - // fn get_common(&self) -> CfdStateCommon { - // let common = match self { - // CfdState::TakeRequested { common } => common, - // CfdState::PendingTakeRequest { common } => common, - // CfdState::Accepted { common } => common, - // CfdState::Rejected { common } => common, - // CfdState::ContractSetup { common } => common, - // CfdState::Open { common, .. } => common, - // CfdState::CloseRequested { common } => common, - // CfdState::PendingClose { common } => common, - // CfdState::Closed { common } => common, - // CfdState::Error { common } => common, - // }; - - // *common - // } - - // pub fn get_transition_timestamp(&self) -> SystemTime { - // self.get_common().transition_timestamp - // } + fn get_common(&self) -> CfdStateCommon { + let common = match self { + CfdState::TakeRequested { common } => common, + CfdState::PendingTakeRequest { common } => common, + CfdState::Accepted { common } => common, + CfdState::Rejected { common } => common, + CfdState::ContractSetup { common } => common, + CfdState::Open { common, .. } => common, + CfdState::CloseRequested { common } => common, + CfdState::PendingClose { common } => common, + CfdState::Closed { common } => common, + CfdState::Error { common } => common, + }; + + *common + } + + pub fn get_transition_timestamp(&self) -> SystemTime { + self.get_common().transition_timestamp + } } impl Display for CfdState { @@ -276,36 +250,42 @@ pub struct Cfd { pub quantity_usd: Usd, - #[serde(with = "::bdk::bitcoin::util::amount::serde::as_btc")] - pub profit_btc: Amount, - pub profit_usd: Usd, - pub state: CfdState, } impl Cfd { - pub fn new( - cfd_offer: CfdOffer, - quantity: Usd, - state: CfdState, - current_price: Usd, - ) -> Result { - let (profit_btc, profit_usd) = - calculate_profit(cfd_offer.price, current_price, dec!(0.005), Usd(dec!(0.1)))?; - - Ok(Cfd { + pub fn new(cfd_offer: CfdOffer, quantity: Usd, state: CfdState, position: Position) -> Self { + Cfd { offer_id: cfd_offer.id, initial_price: cfd_offer.price, leverage: cfd_offer.leverage, trading_pair: cfd_offer.trading_pair, - position: cfd_offer.position, + position, liquidation_price: cfd_offer.liquidation_price, quantity_usd: quantity, - // initially the profit is zero - profit_btc, - profit_usd, state, - }) + } + } + + pub fn calc_margin(&self) -> Result { + let margin = match self.position { + Position::Buy => { + calculate_buy_margin(self.initial_price, self.quantity_usd, self.leverage)? + } + Position::Sell => calculate_sell_margin(self.initial_price, self.quantity_usd)?, + }; + + Ok(margin) + } + + pub fn calc_profit(&self, current_price: Usd) -> Result<(Amount, Usd)> { + let profit = calculate_profit( + self.initial_price, + current_price, + dec!(0.005), + Usd(dec!(0.1)), + )?; + Ok(profit) } } @@ -319,6 +299,38 @@ fn calculate_profit( Ok((Amount::ZERO, Usd::ZERO)) } +/// Calculates the buyer's margin in BTC +/// +/// The margin is the initial margin and represents the collateral the buyer has to come up with to +/// satisfy the contract. Here we calculate the initial buy margin as: quantity / (initial_price * +/// leverage) +pub fn calculate_buy_margin(price: Usd, quantity: Usd, leverage: Leverage) -> Result { + let leverage = Decimal::from(leverage.0).into(); + + let margin = quantity.checked_div(price.checked_mul(leverage)?)?; + + let sat_adjust = Decimal::from(Amount::ONE_BTC.as_sat()).into(); + let margin = margin.checked_mul(sat_adjust)?; + let margin = Amount::from_sat(margin.try_into_u64()?); + + Ok(margin) +} + +/// Calculates the seller's margin in BTC +/// +/// The seller margin is represented as the quantity of the contract given the initial price. +/// The seller can currently not leverage the position but always has to cover the complete +/// quantity. +fn calculate_sell_margin(price: Usd, quantity: Usd) -> Result { + let margin = quantity.checked_div(price)?; + + let sat_adjust = Decimal::from(Amount::ONE_BTC.as_sat()).into(); + let margin = margin.checked_mul(sat_adjust)?; + let margin = Amount::from_sat(margin.try_into_u64()?); + + Ok(margin) +} + #[cfg(test)] mod tests { use super::*; @@ -337,9 +349,61 @@ mod tests { assert_eq!(liquidation_price, Usd(dec!(41004.184100418410041841004184))); } + #[test] + fn given_leverage_of_one_and_equal_price_and_quantity_then_buy_margin_is_one_btc() { + let price = Usd(dec!(40000)); + let quantity = Usd(dec![40000]); + let leverage = Leverage(1); + + let buy_margin = calculate_buy_margin(price, quantity, leverage).unwrap(); + + assert_eq!(buy_margin, Amount::ONE_BTC); + } + + #[test] + fn given_leverage_of_one_and_leverage_of_ten_then_buy_margin_is_lower_factor_ten() { + let price = Usd(dec!(40000)); + let quantity = Usd(dec![40000]); + let leverage = Leverage(10); + + let buy_margin = calculate_buy_margin(price, quantity, leverage).unwrap(); + + assert_eq!(buy_margin, Amount::from_btc(0.1).unwrap()); + } + + #[test] + fn given_quantity_equals_price_then_sell_margin_is_one_btc() { + let price = Usd(dec!(40000)); + let quantity = Usd(dec![40000]); + + let sell_margin = calculate_sell_margin(price, quantity).unwrap(); + + assert_eq!(sell_margin, Amount::ONE_BTC); + } + + #[test] + fn given_quantity_half_of_price_then_sell_margin_is_half_btc() { + let price = Usd(dec!(40000)); + let quantity = Usd(dec![20000]); + + let sell_margin = calculate_sell_margin(price, quantity).unwrap(); + + assert_eq!(sell_margin, Amount::from_btc(0.5).unwrap()); + } + + #[test] + fn given_quantity_double_of_price_then_sell_margin_is_two_btc() { + let price = Usd(dec!(40000)); + let quantity = Usd(dec![80000]); + + let sell_margin = calculate_sell_margin(price, quantity).unwrap(); + + assert_eq!(sell_margin, Amount::from_btc(2.0).unwrap()); + } + #[test] fn serialize_cfd_state_snapshot() { - // This test is to prevent us from breaking the cfd_state API used by the UI and database! + // This test is to prevent us from breaking the CfdState API against the database. // We serialize the state into the database, so changes to the enum result in breaking // program version changes. @@ -394,11 +458,12 @@ mod tests { transition_timestamp: fixed_timestamp, }, settlement_timestamp: fixed_timestamp, + margin: Amount::from_btc(0.5).unwrap(), }; let json = serde_json::to_string(&cfd_state).unwrap(); assert_eq!( json, - r#"{"type":"Open","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}},"settlement_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}"# + r#"{"type":"Open","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}},"settlement_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0},"margin":50000000}}"# ); let cfd_state = CfdState::CloseRequested { diff --git a/daemon/src/routes_maker.rs b/daemon/src/routes_maker.rs index 9cb316a..cc38314 100644 --- a/daemon/src/routes_maker.rs +++ b/daemon/src/routes_maker.rs @@ -1,5 +1,6 @@ use crate::maker_cfd_actor; -use crate::model::cfd::{Cfd, CfdNewOfferRequest, CfdOffer}; +use crate::model::cfd::{Cfd, CfdOffer}; +use crate::model::Usd; use crate::to_sse_event::ToSseEvent; use anyhow::Result; use bdk::bitcoin::Amount; @@ -7,6 +8,7 @@ use rocket::response::status; use rocket::response::stream::EventStream; use rocket::serde::json::Json; use rocket::State; +use serde::Deserialize; use tokio::select; use tokio::sync::{mpsc, watch}; @@ -49,6 +51,17 @@ pub async fn maker_feed( } } +/// The maker POSTs this to create a new CfdOffer +// TODO: Use Rocket form? +#[derive(Debug, Clone, Deserialize)] +pub struct CfdNewOfferRequest { + pub price: Usd, + // TODO: [post-MVP] Representation of the contract size; at the moment the contract size is + // always 1 USD + pub min_quantity: Usd, + pub max_quantity: Usd, +} + #[rocket::post("/offer/sell", data = "")] pub async fn post_sell_offer( offer: Json, diff --git a/daemon/src/routes_taker.rs b/daemon/src/routes_taker.rs index 2142692..1bffee7 100644 --- a/daemon/src/routes_taker.rs +++ b/daemon/src/routes_taker.rs @@ -1,10 +1,13 @@ -use crate::model::cfd::{Cfd, CfdOffer, CfdTakeRequest}; +use crate::model::cfd::{calculate_buy_margin, Cfd, CfdOffer, CfdOfferId}; +use crate::model::{Leverage, Usd}; use crate::taker_cfd_actor; use crate::to_sse_event::ToSseEvent; use bdk::bitcoin::Amount; +use rocket::response::status; use rocket::response::stream::EventStream; use rocket::serde::json::Json; use rocket::State; +use serde::{Deserialize, Serialize}; use tokio::select; use tokio::sync::{mpsc, watch}; @@ -47,6 +50,12 @@ pub async fn feed( } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CfdTakeRequest { + pub offer_id: CfdOfferId, + pub quantity: Usd, +} + #[rocket::post("/cfd", data = "")] pub async fn post_cfd( cfd_take_request: Json, @@ -62,3 +71,33 @@ pub async fn post_cfd( #[rocket::get("/alive")] pub fn get_health_check() {} + +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct MarginRequest { + pub price: Usd, + pub quantity: Usd, + pub leverage: Leverage, +} + +/// Represents the collateral that has to be put up +#[derive(Debug, Clone, Copy, Serialize)] +pub struct MarginResponse { + #[serde(with = "::bdk::bitcoin::util::amount::serde::as_btc")] + pub margin: Amount, +} + +// TODO: Consider moving this into wasm and load it into the UI instead of triggering this endpoint +// upon every quantity keystroke +#[rocket::post("/calculate/margin", data = "")] +pub fn margin_calc( + margin_request: Json, +) -> Result>, status::BadRequest> { + let margin = calculate_buy_margin( + margin_request.price, + margin_request.quantity, + margin_request.leverage, + ) + .map_err(|e| status::BadRequest(Some(e.to_string())))?; + + Ok(status::Accepted(Some(Json(MarginResponse { margin })))) +} diff --git a/daemon/src/taker.rs b/daemon/src/taker.rs index 0afaab7..cc61230 100644 --- a/daemon/src/taker.rs +++ b/daemon/src/taker.rs @@ -112,7 +112,8 @@ async fn main() -> Result<()> { rocket::routes![ routes_taker::feed, routes_taker::post_cfd, - routes_taker::get_health_check + routes_taker::get_health_check, + routes_taker::margin_calc, ], ) .launch() diff --git a/daemon/src/taker_cfd_actor.rs b/daemon/src/taker_cfd_actor.rs index ebb560e..fc07de1 100644 --- a/daemon/src/taker_cfd_actor.rs +++ b/daemon/src/taker_cfd_actor.rs @@ -1,7 +1,11 @@ +use crate::db::{ + insert_cfd, insert_cfd_offer, insert_new_cfd_state_by_offer_id, load_all_cfds, + load_offer_by_id, OfferOrigin, +}; use crate::model::cfd::{Cfd, CfdOffer, CfdOfferId, CfdState, CfdStateCommon, FinalizedCfd}; use crate::model::Usd; +use crate::wire; use crate::wire::{Msg0, Msg1, SetupMsg}; -use crate::{db, wire}; use bdk::bitcoin::secp256k1::{schnorrsig, SecretKey}; use bdk::bitcoin::{self, Amount}; @@ -47,7 +51,7 @@ where // populate the CFD feed with existing CFDs let mut conn = db.acquire().await.unwrap(); cfd_feed_actor_inbox - .send(db::load_all_cfds(&mut conn).await.unwrap()) + .send(load_all_cfds(&mut conn).await.unwrap()) .unwrap(); while let Some(message) = receiver.recv().await { @@ -55,27 +59,25 @@ where Command::TakeOffer { offer_id, quantity } => { let mut conn = db.acquire().await.unwrap(); - let current_offer = - db::load_offer_by_id(offer_id, &mut conn).await.unwrap(); + let current_offer = load_offer_by_id(offer_id, &mut conn).await.unwrap(); println!("Accepting current offer: {:?}", ¤t_offer); let cfd = Cfd::new( - current_offer, + current_offer.clone(), quantity, CfdState::PendingTakeRequest { common: CfdStateCommon { transition_timestamp: SystemTime::now(), }, }, - Usd::ZERO, - ) - .unwrap(); + current_offer.position.counter_position(), + ); - db::insert_cfd(cfd, &mut conn).await.unwrap(); + insert_cfd(cfd, &mut conn).await.unwrap(); cfd_feed_actor_inbox - .send(db::load_all_cfds(&mut conn).await.unwrap()) + .send(load_all_cfds(&mut conn).await.unwrap()) .unwrap(); out_msg_maker_inbox .send(wire::TakerToMaker::TakeOffer { offer_id, quantity }) @@ -83,15 +85,18 @@ where } Command::NewOffer(Some(offer)) => { let mut conn = db.acquire().await.unwrap(); - db::insert_cfd_offer(&offer, &mut conn).await.unwrap(); + insert_cfd_offer(&offer, &mut conn, OfferOrigin::Others) + .await + .unwrap(); offer_feed_actor_inbox.send(Some(offer)).unwrap(); } + Command::NewOffer(None) => { offer_feed_actor_inbox.send(None).unwrap(); } Command::OfferAccepted(offer_id) => { let mut conn = db.acquire().await.unwrap(); - db::insert_new_cfd_state_by_offer_id( + insert_new_cfd_state_by_offer_id( offer_id, CfdState::ContractSetup { common: CfdStateCommon { @@ -104,7 +109,7 @@ where .unwrap(); cfd_feed_actor_inbox - .send(db::load_all_cfds(&mut conn).await.unwrap()) + .send(load_all_cfds(&mut conn).await.unwrap()) .unwrap(); let (sk, pk) = crate::keypair::new(&mut rand::thread_rng()); diff --git a/daemon/src/to_sse_event.rs b/daemon/src/to_sse_event.rs index 4d56442..76843ac 100644 --- a/daemon/src/to_sse_event.rs +++ b/daemon/src/to_sse_event.rs @@ -1,20 +1,116 @@ -use crate::model::cfd::{Cfd, CfdOffer}; +use crate::model; +use crate::model::cfd::CfdOfferId; +use crate::model::{Leverage, Position, TradingPair, Usd}; use bdk::bitcoin::Amount; use rocket::response::stream::Event; +use serde::Serialize; +use std::time::UNIX_EPOCH; + +#[derive(Debug, Clone, Serialize)] +pub struct Cfd { + pub offer_id: CfdOfferId, + pub initial_price: Usd, + + pub leverage: Leverage, + pub trading_pair: TradingPair, + pub position: Position, + pub liquidation_price: Usd, + + pub quantity_usd: Usd, + + #[serde(with = "::bdk::bitcoin::util::amount::serde::as_btc")] + pub margin: Amount, + + #[serde(with = "::bdk::bitcoin::util::amount::serde::as_btc")] + pub profit_btc: Amount, + pub profit_usd: Usd, + + pub state: String, + pub state_transition_unix_timestamp: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CfdOffer { + pub id: CfdOfferId, + + pub trading_pair: TradingPair, + pub position: Position, + + pub price: Usd, + + pub min_quantity: Usd, + pub max_quantity: Usd, + + pub leverage: Leverage, + pub liquidation_price: Usd, + + pub creation_unix_timestamp: u64, + pub term_in_secs: u64, +} pub trait ToSseEvent { fn to_sse_event(&self) -> Event; } -impl ToSseEvent for Vec { +impl ToSseEvent for Vec { + // TODO: This conversion can fail, we might want to change the API fn to_sse_event(&self) -> Event { - Event::json(self).event("cfds") + let cfds = self + .iter() + .map(|cfd| { + // TODO: Get the actual current price here + let current_price = Usd::ZERO; + let (profit_btc, profit_usd) = cfd.calc_profit(current_price).unwrap(); + + Cfd { + offer_id: cfd.offer_id, + initial_price: cfd.initial_price, + leverage: cfd.leverage, + trading_pair: cfd.trading_pair.clone(), + position: cfd.position.clone(), + liquidation_price: cfd.liquidation_price, + quantity_usd: cfd.quantity_usd, + profit_btc, + profit_usd, + state: cfd.state.to_string(), + state_transition_unix_timestamp: cfd + .state + .get_transition_timestamp() + .duration_since(UNIX_EPOCH) + .expect("timestamp to be convertable to duration since epoch") + .as_secs(), + + // TODO: Depending on the state the margin might be set (i.e. in Open we save it + // in the DB internally) and does not have to be calculated + margin: cfd.calc_margin().unwrap(), + } + }) + .collect::>(); + + Event::json(&cfds).event("cfds") } } -impl ToSseEvent for Option { +impl ToSseEvent for Option { fn to_sse_event(&self) -> Event { - Event::json(self).event("offer") + let offer = self.clone().map(|offer| CfdOffer { + id: offer.id, + trading_pair: offer.trading_pair, + position: offer.position, + price: offer.price, + min_quantity: offer.min_quantity, + max_quantity: offer.max_quantity, + leverage: offer.leverage, + liquidation_price: offer.liquidation_price, + creation_unix_timestamp: offer + .creation_timestamp + .duration_since(UNIX_EPOCH) + .expect("timestamp to be convertiblae to dureation since epoch") + .as_secs(), + term_in_secs: offer.term.as_secs(), + }); + + Event::json(&offer).event("offer") } } diff --git a/frontend/src/Taker.tsx b/frontend/src/Taker.tsx index 5576480..a218eff 100644 --- a/frontend/src/Taker.tsx +++ b/frontend/src/Taker.tsx @@ -19,6 +19,16 @@ interface CfdTakeRequestPayload { quantity: number; } +interface MarginRequestPayload { + price: number; + quantity: number; + leverage: number; +} + +interface MarginResponse { + margin: number; +} + async function postCfdTakeRequest(payload: CfdTakeRequestPayload) { let res = await axios.post(BASE_URL + `/cfd`, JSON.stringify(payload)); @@ -27,6 +37,16 @@ async function postCfdTakeRequest(payload: CfdTakeRequestPayload) { } } +async function getMargin(payload: MarginRequestPayload): Promise { + let res = await axios.post(BASE_URL + `/calculate/margin`, JSON.stringify(payload)); + + if (!res.status.toString().startsWith("2")) { + throw new Error("failed to create new CFD take request: " + res.status + ", " + res.statusText); + } + + return res.data; +} + export default function App() { let source = useEventSource({ source: BASE_URL + "/feed" }); @@ -35,7 +55,28 @@ export default function App() { const balance = useLatestEvent(source, "balance"); const toast = useToast(); - let [quantity, setQuantity] = useState("10000"); + let [quantity, setQuantity] = useState("0"); + let [margin, setMargin] = useState("0"); + + let { run: calculateMargin } = useAsync({ + deferFn: async ([payload]: any[]) => { + try { + let res = await getMargin(payload as MarginRequestPayload); + setMargin(res.margin.toString()); + } catch (e) { + const description = typeof e === "string" ? e : JSON.stringify(e); + + toast({ + title: "Error", + description, + status: "error", + duration: 9000, + isClosable: true, + }); + } + }, + }); + const format = (val: any) => `$` + val; const parse = (val: any) => val.replace(/^\$/, ""); @@ -110,10 +151,28 @@ export default function App() { Quantity: setQuantity(parse(valueString))} + onChange={(valueString: string) => { + setQuantity(parse(valueString)) + + if (!offer) { + return; + } + + let quantity = valueString ? Number.parseFloat(valueString) : 0; + let payload: MarginRequestPayload = { + leverage: offer.leverage, + price: offer.price, + quantity + } + calculateMargin(payload); + }} value={format(quantity)} /> + + Margin in BTC: + {margin} + Leverage: {/* TODO: consider button group */} diff --git a/frontend/src/components/CfdTile.tsx b/frontend/src/components/CfdTile.tsx index 442d1ba..747897d 100644 --- a/frontend/src/components/CfdTile.tsx +++ b/frontend/src/components/CfdTile.tsx @@ -24,8 +24,14 @@ export default function CfdTile( {cfd.trading_pair} Position {cfd.position} - Amount + CFD Price + {cfd.initial_price} + Leverage + {cfd.leverage} + Quantity {cfd.quantity_usd} + Margin + {cfd.margin} Liquidation Price Open since {/* TODO: Format date in a more compact way */} - {(new Date(cfd.state.payload.common.transition_timestamp.secs_since_epoch * 1000).toString())} + {(new Date(cfd.state_transition_unix_timestamp * 1000).toString())} Status - {cfd.state.type} + {cfd.state} - {cfd.state.type === "Open" + {cfd.state === "Open" && } diff --git a/frontend/src/components/Types.tsx b/frontend/src/components/Types.tsx index 71833c5..1de3703 100644 --- a/frontend/src/components/Types.tsx +++ b/frontend/src/components/Types.tsx @@ -1,13 +1,3 @@ -export interface RustDuration { - secs: number; - nanos: number; -} - -export interface RustTimestamp { - secs_since_epoch: number; - nanos_since_epoch: number; -} - export interface Offer { id: string; trading_pair: string; @@ -17,22 +7,8 @@ export interface Offer { max_quantity: number; leverage: number; liquidation_price: number; - creation_timestamp: RustTimestamp; - term: RustDuration; -} - -export interface CfdStateCommon { - transition_timestamp: RustTimestamp; -} - -export interface CfdStatePayload { - common: CfdStateCommon; - settlement_timestamp?: RustTimestamp; // only in state Open -} - -export interface CfdState { - type: string; - payload: CfdStatePayload; + creation_unix_timestamp: number; + term_in_secs: number; } export interface Cfd { @@ -45,8 +21,12 @@ export interface Cfd { liquidation_price: number; quantity_usd: number; + + margin: number; + profit_btc: number; profit_usd: number; - state: CfdState; + state: string; + state_transition_unix_timestamp: number; }