Browse Source

Merge pull request #50 from comit-network/margin

Margin calculation & refactoring
verify-transactions
Daniel Karzel 3 years ago
committed by GitHub
parent
commit
05a6482d67
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      daemon/migrations/20210903050345_create_cfd_and_offer_tables.sql
  2. 42
      daemon/sqlx-data.json
  3. 61
      daemon/src/db.rs
  4. 33
      daemon/src/maker_cfd_actor.rs
  5. 47
      daemon/src/model.rs
  6. 221
      daemon/src/model/cfd.rs
  7. 15
      daemon/src/routes_maker.rs
  8. 41
      daemon/src/routes_taker.rs
  9. 3
      daemon/src/taker.rs
  10. 31
      daemon/src/taker_cfd_actor.rs
  11. 106
      daemon/src/to_sse_event.rs
  12. 63
      frontend/src/Taker.tsx
  13. 14
      frontend/src/components/CfdTile.tsx
  14. 34
      frontend/src/components/Types.tsx

9
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)
);

42
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
]
}

61
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<Sqlite>,
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<Sqlite>) -> 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<Sqlite>,
@ -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<Sqlite>,
@ -250,6 +262,7 @@ pub async fn load_all_cfds(conn: &mut PoolConnection<Sqlite>) -> 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<Sqlite>) -> 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<Sqlite>) -> 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<Sqlite>) -> 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 {

33
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,
};

47
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<Usd> {
let result = self.0.checked_add(other.0).context("addition error")?;
Ok(Usd(result))
}
pub fn checked_sub(&self, other: Usd) -> Result<Usd> {
let result = self.0.checked_sub(other.0).context("subtraction error")?;
Ok(Usd(result))
}
pub fn checked_mul(&self, other: Usd) -> Result<Usd> {
let result = self
.0
.checked_mul(other.0)
.context("multiplication error")?;
Ok(Usd(result))
}
pub fn checked_div(&self, other: Usd) -> Result<Usd> {
let result = self.0.checked_div(other.0).context("division error")?;
Ok(Usd(result))
}
pub fn try_into_u64(&self) -> Result<u64> {
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<Decimal> 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);

221
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<Usd> {
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<Self> {
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<Amount> {
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<Amount> {
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<Amount> {
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 {

15
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 = "<offer>")]
pub async fn post_sell_offer(
offer: Json<CfdNewOfferRequest>,

41
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 = "<cfd_take_request>")]
pub async fn post_cfd(
cfd_take_request: Json<CfdTakeRequest>,
@ -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 = "<margin_request>")]
pub fn margin_calc(
margin_request: Json<MarginRequest>,
) -> Result<status::Accepted<Json<MarginResponse>>, status::BadRequest<String>> {
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 }))))
}

3
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()

31
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: {:?}", &current_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());

106
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<Cfd> {
impl ToSseEvent for Vec<model::cfd::Cfd> {
// 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::<Vec<Cfd>>();
Event::json(&cfds).event("cfds")
}
}
impl ToSseEvent for Option<CfdOffer> {
impl ToSseEvent for Option<model::cfd::CfdOffer> {
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")
}
}

63
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<MarginResponse> {
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<number>(source, "balance");
const toast = useToast();
let [quantity, setQuantity] = useState<string>("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() {
<HStack>
<Text>Quantity:</Text>
<CurrencyInputField
onChange={(valueString: string) => 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)}
/>
</HStack>
<HStack>
<Text>Margin in BTC:</Text>
<Text>{margin}</Text>
</HStack>
<Text>Leverage:</Text>
{/* TODO: consider button group */}
<Flex justifyContent={"space-between"}>

14
frontend/src/components/CfdTile.tsx

@ -24,8 +24,14 @@ export default function CfdTile(
<Text>{cfd.trading_pair}</Text>
<Text>Position</Text>
<Text>{cfd.position}</Text>
<Text>Amount</Text>
<Text>CFD Price</Text>
<Text>{cfd.initial_price}</Text>
<Text>Leverage</Text>
<Text>{cfd.leverage}</Text>
<Text>Quantity</Text>
<Text>{cfd.quantity_usd}</Text>
<Text>Margin</Text>
<Text>{cfd.margin}</Text>
<Text>Liquidation Price</Text>
<Text
overflow="hidden"
@ -40,12 +46,12 @@ export default function CfdTile(
<Text>Open since</Text>
{/* TODO: Format date in a more compact way */}
<Text>
{(new Date(cfd.state.payload.common.transition_timestamp.secs_since_epoch * 1000).toString())}
{(new Date(cfd.state_transition_unix_timestamp * 1000).toString())}
</Text>
<Text>Status</Text>
<Text>{cfd.state.type}</Text>
<Text>{cfd.state}</Text>
</SimpleGrid>
{cfd.state.type === "Open"
{cfd.state === "Open"
&& <Box paddingBottom={5}><Button colorScheme="blue" variant="solid">Close</Button></Box>}
</VStack>
</Box>

34
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;
}

Loading…
Cancel
Save