mod config;
mod conn;
mod subscriptions;
mod types;
use std::{
sync::{atomic::Ordering, Arc},
thread,
time::Duration,
};
pub use config::{NdaxWsConfig, NdaxWsConfigBuilder, PrivateConfig};
pub use conn::{NdaxWsClient, WsAPIResults, WsError};
use futures::StreamExt;
use serde::{de::DeserializeOwned, Serialize};
pub use subscriptions::*;
use tokio::{
runtime,
sync::{mpsc, oneshot},
time,
};
pub use types::*;
use crate::{
messages::{
AccountPosition,
CancelOrderRequest,
GenericResponse,
GetAccountPositionsRequest,
GetInstrumentsRequest,
GetOpenOrdersRequest,
Instrument,
NdaxFrame,
OrderInfo,
SendOrderRequest,
SendOrderResponse,
},
Error,
Result,
};
pub struct NdaxWsAPI {
worker_thread: Option<thread::JoinHandle<()>>,
sender: mpsc::UnboundedSender<WorkerCommand>,
output: Arc<WsAPIResults>,
}
impl NdaxWsAPI {
pub fn new(config: NdaxWsConfig) -> Result<Self> {
let rt = runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(Error::Io)?;
let (mut client, mut stream, output) =
rt.block_on(NdaxWsClient::new(config))?;
let (sender, mut receiver) = mpsc::unbounded_channel();
let worker_thread = Some(
thread::Builder::new()
.name("ndax-ws-worker".into())
.spawn(move || {
rt.block_on(async move {
let mut interval = time::interval(Duration::from_secs(30));
loop {
tokio::select! {
stream_result = stream.next() => {
match stream_result {
Some(result) => {
if let Err(err) = client.update(result) {
log::error!("NDAX WebSocket error: {}", err);
drop(client.close().await);
return;
}
}
None => {
log::warn!("NDAX WebSocket stream closed");
drop(client.close().await);
return;
}
}
}
cmd = receiver.recv() => {
match cmd {
None | Some(WorkerCommand::Stop) => {
drop(client.close().await);
return;
}
Some(WorkerCommand::Request { name, payload, response_tx }) => {
if let Err(e) = client.request_raw(&name, &payload, response_tx).await {
log::error!("Failed to send request '{}': {}", name, e);
}
}
}
}
_ = interval.tick() => {
}
}
}
})
})
.map_err(Error::Io)?,
);
Ok(Self {
worker_thread,
sender,
output,
})
}
pub fn get_book(&self, instrument_id: u64) -> Option<BookData> {
self
.output
.books
.get(&instrument_id)
.map(|lock| lock.lock().expect("mutex poisoned").clone())
}
pub fn get_level1(&self, instrument_id: u64) -> Option<Level1Snapshot> {
self
.output
.level1
.get(&instrument_id)
.map(|lock| lock.lock().expect("mutex poisoned").clone())
}
pub fn get_trades(&self, instrument_id: u64) -> Option<Vec<TradeData>> {
self
.output
.trades
.get(&instrument_id)
.map(|lock| lock.lock().expect("mutex poisoned").clone())
}
pub fn is_closed(&self) -> bool {
self.output.stream_closed.load(Ordering::SeqCst)
}
pub fn is_authenticated(&self) -> bool {
self.output.authenticated.load(Ordering::SeqCst)
}
pub fn close(mut self) {
let _ = self.sender.send(WorkerCommand::Stop);
if let Some(thread) = self.worker_thread.take() {
let _ = thread.join();
}
}
pub fn request<T: Serialize, R: DeserializeOwned>(
&self,
endpoint: &str,
payload: &T,
) -> Result<R> {
let payload_json = serde_json::to_string(payload)?;
let (response_tx, response_rx) = oneshot::channel();
self
.sender
.send(WorkerCommand::Request {
name: endpoint.to_string(),
payload: payload_json,
response_tx,
})
.map_err(|_| Error::ConnectionClosed)?;
let frame = response_rx
.blocking_recv()
.map_err(|_| Error::ConnectionClosed)??;
frame.parse_payload()
}
pub fn get_instruments(&self) -> Result<Vec<Instrument>> {
let request = GetInstrumentsRequest {
oms_id: 1
};
self.request("GetInstruments", &request)
}
pub fn get_account_positions(
&self,
account_id: u64,
) -> Result<Vec<AccountPosition>> {
let request = GetAccountPositionsRequest {
account_id,
oms_id: 1,
};
self.request("GetAccountPositions", &request)
}
pub fn send_order(
&self,
order: &SendOrderRequest,
) -> Result<SendOrderResponse> {
self.request("SendOrder", order)
}
pub fn cancel_order(
&self,
request: &CancelOrderRequest,
) -> Result<GenericResponse> {
self.request("CancelOrder", request)
}
pub fn get_open_orders(&self, account_id: u64) -> Result<Vec<OrderInfo>> {
let request = GetOpenOrdersRequest {
account_id,
oms_id: 1,
};
self.request("GetOpenOrders", &request)
}
}
impl Drop for NdaxWsAPI {
fn drop(&mut self) {
if let Some(thread) = self.worker_thread.take() {
let _ = self.sender.send(WorkerCommand::Stop);
let _ = thread.join();
}
}
}
enum WorkerCommand {
Stop,
Request {
name: String,
payload: String,
response_tx: oneshot::Sender<Result<NdaxFrame>>,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder() {
let config = NdaxWsConfig::builder()
.subscribe_level1(vec![1, 2])
.subscribe_level2(vec![3, 4])
.book_depth(20)
.build();
assert_eq!(config.subscribe_level1, vec![1, 2]);
assert_eq!(config.subscribe_level2, vec![3, 4]);
assert_eq!(config.book_depth, 20);
}
#[test]
#[ignore]
fn test_connect_to_ndax() {
let config = NdaxWsConfig::builder()
.subscribe_level2(vec![4])
.book_depth(10)
.build();
let api = NdaxWsAPI::new(config).expect("Failed to connect to NDAX");
std::thread::sleep(Duration::from_secs(2));
assert!(!api.is_closed());
api.close();
}
#[test]
#[ignore]
fn test_send_receive_frame() {
let btc_cad_instrument_id = 1;
let config = NdaxWsConfig::builder()
.subscribe_level2(vec![btc_cad_instrument_id])
.book_depth(10)
.build();
let api = NdaxWsAPI::new(config).expect("Failed to connect to NDAX");
let mut found_data = false;
let mut book_exists = false;
for i in 1..=10 {
std::thread::sleep(Duration::from_secs(1));
if let Some(book) = api.get_book(btc_cad_instrument_id) {
book_exists = true;
if !book.bids.is_empty() || !book.asks.is_empty() {
println!(
"Book data received after {}s: {} bids, {} asks",
i,
book.bids.len(),
book.asks.len()
);
found_data = true;
break;
}
}
}
api.close();
if !found_data {
if book_exists {
println!(
"Warning: Book exists but is empty after 10s - market may be \
inactive"
);
} else {
panic!("Book not found for instrument {}", btc_cad_instrument_id);
}
}
}
}