|
|
|
use crate::model::cfd::{OrderId, Role, SettlementKind, SettlementProposals};
|
|
|
|
use crate::model::{Leverage, Position, TradingPair, Usd};
|
|
|
|
use crate::{bitmex_price_feed, model};
|
|
|
|
use bdk::bitcoin::{Amount, SignedAmount};
|
|
|
|
use rocket::request::FromParam;
|
|
|
|
use rocket::response::stream::Event;
|
|
|
|
use rust_decimal::Decimal;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use tokio::sync::watch;
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
pub struct Cfd {
|
|
|
|
pub order_id: OrderId,
|
|
|
|
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: SignedAmount,
|
|
|
|
pub profit_in_percent: String,
|
|
|
|
|
|
|
|
pub state: CfdState,
|
|
|
|
pub actions: Vec<CfdAction>,
|
|
|
|
pub state_transition_timestamp: u64,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
pub enum CfdAction {
|
|
|
|
AcceptOrder,
|
|
|
|
RejectOrder,
|
|
|
|
Commit,
|
|
|
|
Settle,
|
|
|
|
AcceptSettlement,
|
|
|
|
RejectSettlement,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl<'v> FromParam<'v> for CfdAction {
|
|
|
|
type Error = serde_plain::Error;
|
|
|
|
|
|
|
|
fn from_param(param: &'v str) -> Result<Self, Self::Error> {
|
|
|
|
let action = serde_plain::from_str(param)?;
|
|
|
|
Ok(action)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
pub enum CfdState {
|
|
|
|
OutgoingOrderRequest,
|
|
|
|
IncomingOrderRequest,
|
|
|
|
Accepted,
|
|
|
|
Rejected,
|
|
|
|
ContractSetup,
|
|
|
|
PendingOpen,
|
|
|
|
Open,
|
|
|
|
PendingCommit,
|
|
|
|
OpenCommitted,
|
|
|
|
IncomingSettlementProposal,
|
|
|
|
OutgoingSettlementProposal,
|
|
|
|
MustRefund,
|
|
|
|
Refunded,
|
|
|
|
SetupFailed,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
pub struct CfdOrder {
|
|
|
|
pub id: OrderId,
|
|
|
|
|
|
|
|
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_timestamp: u64,
|
|
|
|
pub term_in_secs: u64,
|
|
|
|
}
|
|
|
|
|
|
|
|
pub trait ToSseEvent {
|
|
|
|
fn to_sse_event(&self) -> Event;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Intermediate struct to able to piggy-back additional information along with
|
|
|
|
/// cfds, so we can avoid a 1:1 mapping between the states in the model and seen
|
|
|
|
/// by UI
|
|
|
|
pub struct CfdsWithAuxData {
|
|
|
|
pub cfds: Vec<model::cfd::Cfd>,
|
|
|
|
pub current_price: Usd,
|
|
|
|
pub settlement_proposals: SettlementProposals,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl CfdsWithAuxData {
|
|
|
|
pub fn new(
|
|
|
|
rx_cfds: &watch::Receiver<Vec<model::cfd::Cfd>>,
|
|
|
|
rx_quote: &watch::Receiver<bitmex_price_feed::Quote>,
|
|
|
|
rx_settlement: &watch::Receiver<SettlementProposals>,
|
|
|
|
role: Role,
|
|
|
|
) -> Self {
|
|
|
|
let quote = rx_quote.borrow().clone();
|
|
|
|
let current_price = match role {
|
|
|
|
Role::Maker => quote.for_maker(),
|
|
|
|
Role::Taker => quote.for_taker(),
|
|
|
|
};
|
|
|
|
|
|
|
|
let settlement_proposals = rx_settlement.borrow().clone();
|
|
|
|
|
|
|
|
CfdsWithAuxData {
|
|
|
|
cfds: rx_cfds.borrow().clone(),
|
|
|
|
current_price,
|
|
|
|
settlement_proposals,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Check whether given CFD has any active settlement proposals
|
|
|
|
fn settlement_proposal_status(&self, cfd: &model::cfd::Cfd) -> Option<SettlementKind> {
|
|
|
|
self.settlement_proposals
|
|
|
|
.get(&cfd.order.id)
|
|
|
|
.map(|(_, kind)| kind.clone())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl ToSseEvent for CfdsWithAuxData {
|
|
|
|
// TODO: This conversion can fail, we might want to change the API
|
|
|
|
fn to_sse_event(&self) -> Event {
|
|
|
|
let current_price = self.current_price;
|
|
|
|
|
|
|
|
let cfds = self
|
|
|
|
.cfds
|
|
|
|
.iter()
|
|
|
|
.map(|cfd| {
|
|
|
|
let (profit_btc, profit_in_percent) =
|
|
|
|
cfd.profit(current_price).unwrap_or_else(|error| {
|
|
|
|
tracing::warn!(
|
|
|
|
"Calculating profit/loss failed. Falling back to 0. {:#}",
|
|
|
|
error
|
|
|
|
);
|
|
|
|
(SignedAmount::ZERO, Decimal::ZERO.into())
|
|
|
|
});
|
|
|
|
|
|
|
|
let state = to_cfd_state(&cfd.state, self.settlement_proposal_status(cfd));
|
|
|
|
|
|
|
|
Cfd {
|
|
|
|
order_id: cfd.order.id,
|
|
|
|
initial_price: cfd.order.price,
|
|
|
|
leverage: cfd.order.leverage,
|
|
|
|
trading_pair: cfd.order.trading_pair.clone(),
|
|
|
|
position: cfd.position(),
|
|
|
|
liquidation_price: cfd.order.liquidation_price,
|
|
|
|
quantity_usd: cfd.quantity_usd,
|
|
|
|
profit_btc,
|
|
|
|
profit_in_percent: profit_in_percent.to_string(),
|
|
|
|
state: state.clone(),
|
|
|
|
actions: available_actions(state, cfd.role()),
|
|
|
|
state_transition_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.margin().unwrap(),
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.collect::<Vec<Cfd>>();
|
|
|
|
|
|
|
|
Event::json(&cfds).event("cfds")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl ToSseEvent for Option<model::cfd::Order> {
|
|
|
|
fn to_sse_event(&self) -> Event {
|
|
|
|
let order = self.clone().map(|order| CfdOrder {
|
|
|
|
id: order.id,
|
|
|
|
trading_pair: order.trading_pair,
|
|
|
|
position: order.position,
|
|
|
|
price: order.price,
|
|
|
|
min_quantity: order.min_quantity,
|
|
|
|
max_quantity: order.max_quantity,
|
|
|
|
leverage: order.leverage,
|
|
|
|
liquidation_price: order.liquidation_price,
|
|
|
|
creation_timestamp: order
|
|
|
|
.creation_timestamp
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
.expect("timestamp to be convertible to duration since epoch")
|
|
|
|
.as_secs(),
|
|
|
|
term_in_secs: order.term.as_secs(),
|
|
|
|
});
|
|
|
|
|
|
|
|
Event::json(&order).event("order")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
pub struct WalletInfo {
|
|
|
|
#[serde(with = "::bdk::bitcoin::util::amount::serde::as_btc")]
|
|
|
|
balance: Amount,
|
|
|
|
address: String,
|
|
|
|
last_updated_at: u64,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl ToSseEvent for model::WalletInfo {
|
|
|
|
fn to_sse_event(&self) -> Event {
|
|
|
|
let wallet_info = WalletInfo {
|
|
|
|
balance: self.balance,
|
|
|
|
address: self.address.to_string(),
|
|
|
|
last_updated_at: into_unix_secs(self.last_updated_at),
|
|
|
|
};
|
|
|
|
|
|
|
|
Event::json(&wallet_info).event("wallet")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn to_cfd_state(
|
|
|
|
cfd_state: &model::cfd::CfdState,
|
|
|
|
proposal_status: Option<SettlementKind>,
|
|
|
|
) -> CfdState {
|
|
|
|
match proposal_status {
|
|
|
|
Some(SettlementKind::Incoming) => CfdState::IncomingSettlementProposal,
|
|
|
|
Some(SettlementKind::Outgoing) => CfdState::OutgoingSettlementProposal,
|
|
|
|
None => match cfd_state {
|
|
|
|
model::cfd::CfdState::OutgoingOrderRequest { .. } => CfdState::OutgoingOrderRequest,
|
|
|
|
model::cfd::CfdState::IncomingOrderRequest { .. } => CfdState::IncomingOrderRequest,
|
|
|
|
model::cfd::CfdState::Accepted { .. } => CfdState::Accepted,
|
|
|
|
model::cfd::CfdState::Rejected { .. } => CfdState::Rejected,
|
|
|
|
model::cfd::CfdState::ContractSetup { .. } => CfdState::ContractSetup,
|
|
|
|
model::cfd::CfdState::PendingOpen { .. } => CfdState::PendingOpen,
|
|
|
|
model::cfd::CfdState::Open { .. } => CfdState::Open,
|
|
|
|
model::cfd::CfdState::OpenCommitted { .. } => CfdState::OpenCommitted,
|
|
|
|
model::cfd::CfdState::MustRefund { .. } => CfdState::MustRefund,
|
|
|
|
model::cfd::CfdState::Refunded { .. } => CfdState::Refunded,
|
|
|
|
model::cfd::CfdState::SetupFailed { .. } => CfdState::SetupFailed,
|
|
|
|
model::cfd::CfdState::PendingCommit { .. } => CfdState::PendingCommit,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
pub struct Quote {
|
|
|
|
bid: Usd,
|
|
|
|
ask: Usd,
|
|
|
|
last_updated_at: u64,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl ToSseEvent for bitmex_price_feed::Quote {
|
|
|
|
fn to_sse_event(&self) -> Event {
|
|
|
|
let quote = Quote {
|
|
|
|
bid: self.bid,
|
|
|
|
ask: self.ask,
|
|
|
|
last_updated_at: into_unix_secs(self.timestamp),
|
|
|
|
};
|
|
|
|
Event::json("e).event("quote")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Convert to the format expected by the frontend
|
|
|
|
fn into_unix_secs(time: SystemTime) -> u64 {
|
|
|
|
time.duration_since(UNIX_EPOCH)
|
|
|
|
.expect("timestamp to be convertible to duration since epoch")
|
|
|
|
.as_secs()
|
|
|
|
}
|
|
|
|
|
|
|
|
fn available_actions(state: CfdState, role: Role) -> Vec<CfdAction> {
|
|
|
|
match (state, role) {
|
|
|
|
(CfdState::IncomingOrderRequest { .. }, Role::Maker) => {
|
|
|
|
vec![CfdAction::AcceptOrder, CfdAction::RejectOrder]
|
|
|
|
}
|
|
|
|
(CfdState::IncomingSettlementProposal { .. }, Role::Maker) => {
|
|
|
|
vec![CfdAction::AcceptSettlement, CfdAction::RejectSettlement]
|
|
|
|
}
|
|
|
|
// If there is an outgoing settlement proposal already, user can't
|
|
|
|
// initiate new one
|
|
|
|
(CfdState::OutgoingSettlementProposal { .. }, Role::Maker) => {
|
|
|
|
vec![CfdAction::Commit]
|
|
|
|
}
|
|
|
|
(CfdState::Open { .. }, Role::Taker) => {
|
|
|
|
vec![CfdAction::Commit, CfdAction::Settle]
|
|
|
|
}
|
|
|
|
(CfdState::Open { .. }, Role::Maker) => vec![CfdAction::Commit],
|
|
|
|
_ => vec![],
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn state_snapshot_test() {
|
|
|
|
// Make sure to update the UI after changing this test!
|
|
|
|
|
|
|
|
let json = serde_json::to_string(&CfdState::OutgoingOrderRequest).unwrap();
|
|
|
|
assert_eq!(json, "\"OutgoingOrderRequest\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::IncomingOrderRequest).unwrap();
|
|
|
|
assert_eq!(json, "\"IncomingOrderRequest\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::Accepted).unwrap();
|
|
|
|
assert_eq!(json, "\"Accepted\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::Rejected).unwrap();
|
|
|
|
assert_eq!(json, "\"Rejected\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::ContractSetup).unwrap();
|
|
|
|
assert_eq!(json, "\"ContractSetup\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::PendingOpen).unwrap();
|
|
|
|
assert_eq!(json, "\"PendingOpen\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::Open).unwrap();
|
|
|
|
assert_eq!(json, "\"Open\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::OpenCommitted).unwrap();
|
|
|
|
assert_eq!(json, "\"OpenCommitted\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::MustRefund).unwrap();
|
|
|
|
assert_eq!(json, "\"MustRefund\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::Refunded).unwrap();
|
|
|
|
assert_eq!(json, "\"Refunded\"");
|
|
|
|
let json = serde_json::to_string(&CfdState::SetupFailed).unwrap();
|
|
|
|
assert_eq!(json, "\"SetupFailed\"");
|
|
|
|
}
|
|
|
|
}
|