ndaxrs 0.1.0

Rust client library for the NDAX cryptocurrency exchange API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
// Copyright (C) 2026 ndaxrs Art Morozov
// SPDX-License-Identifier: GPL-3.0-only

//! Integration tests for the ndaxrs crate.
//!
//! These tests require network connectivity and connect to the real NDAX
//! WebSocket API. They are marked with `#[ignore]` to prevent running during
//! normal CI.
//!
//! Run manually with: `cargo test -- --ignored`
//! Run specific test: `cargo test -- --ignored test_websocket_connect`

use std::{thread, time::Duration};

use ndaxrs::{
  ws::{NdaxWsAPI, NdaxWsConfig, PrivateConfig},
  NdaxCredentials,
};

// ============================================================================
// WebSocket Connection Tests
// ============================================================================

/// Tests that we can successfully connect to the NDAX WebSocket API.
///
/// This test verifies:
/// - Connection to wss://api.ndax.io:8443/WSGateway/ succeeds
/// - The WebSocket stream stays open
/// - Graceful disconnection works
#[test]
#[ignore]
fn test_websocket_connect() {
  let config = NdaxWsConfig::builder().build();

  let api =
    NdaxWsAPI::new(config).expect("Failed to connect to NDAX WebSocket");

  // Give the connection a moment to stabilize
  thread::sleep(Duration::from_secs(2));

  // Verify connection is still alive
  assert!(
    !api.is_closed(),
    "WebSocket connection should still be open"
  );

  // Clean up
  api.close();
}

/// Tests that we can retrieve the list of instruments from NDAX.
///
/// This test verifies:
/// - GetInstruments request works
/// - Response contains valid instrument data
/// - BTC/CAD instrument exists (ID=1)
#[test]
#[ignore]
fn test_get_instruments() {
  let config = NdaxWsConfig::builder().build();

  let api = NdaxWsAPI::new(config).expect("Failed to connect to NDAX");

  // Request instruments
  let instruments = api.get_instruments().expect("Failed to get instruments");

  // Verify we got instruments
  assert!(
    !instruments.is_empty(),
    "Should receive at least one instrument"
  );

  // Find BTC/CAD (usually instrument ID 1)
  let btc_cad = instruments.iter().find(|i| i.symbol.contains("BTCCAD"));

  assert!(btc_cad.is_some(), "BTC/CAD instrument should exist on NDAX");

  let btc_cad = btc_cad.unwrap();
  println!(
    "Found BTC/CAD: instrument_id={}, symbol={}",
    btc_cad.instrument_id, btc_cad.symbol
  );
  println!(
    "  Base: {} (ID {})",
    btc_cad.product1_symbol, btc_cad.product1
  );
  println!(
    "  Quote: {} (ID {})",
    btc_cad.product2_symbol, btc_cad.product2
  );
  println!("  Session: {}", btc_cad.session_status);

  // Verify helper methods
  assert_eq!(btc_cad.trading_pair(), "BTC/CAD");
  assert_eq!(btc_cad.base_symbol(), "BTC");
  assert_eq!(btc_cad.quote_symbol(), "CAD");

  api.close();
}

/// Tests that we can subscribe to Level2 (order book) data.
///
/// This test verifies:
/// - Level2 subscription works
/// - We receive order book data within a reasonable time
/// - Book data contains valid bids and/or asks
#[test]
#[ignore]
fn test_subscribe_level2() {
  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");

  // Wait for some data to arrive
  let max_wait = Duration::from_secs(10);
  let start = std::time::Instant::now();
  let mut received_data = false;

  while start.elapsed() < max_wait {
    if let Some(book) = api.get_book(btc_cad_instrument_id) {
      if !book.bids.is_empty() || !book.asks.is_empty() {
        received_data = true;

        println!(
          "Received Level2 data for instrument {}",
          btc_cad_instrument_id
        );
        println!("  Bids: {} levels", book.bids.len());
        println!("  Asks: {} levels", book.asks.len());

        if let Some((price, qty)) = book.best_bid() {
          println!("  Best bid: {} @ {}", qty, price);
        }
        if let Some((price, qty)) = book.best_ask() {
          println!("  Best ask: {} @ {}", qty, price);
        }
        if let Some(spread) = book.spread() {
          println!("  Spread: {}", spread);
        }
        if let Some(mid) = book.mid_price() {
          println!("  Mid price: {}", mid);
        }

        break;
      }
    }
    thread::sleep(Duration::from_millis(500));
  }

  assert!(
    received_data,
    "Should have received Level2 data within {} seconds",
    max_wait.as_secs()
  );

  api.close();
}

/// Tests that we can subscribe to Level1 (ticker) data.
///
/// This test verifies:
/// - Level1 subscription works
/// - We receive ticker data within a reasonable time
/// - Ticker data contains valid best bid/ask
#[test]
#[ignore]
fn test_subscribe_level1() {
  let btc_cad_instrument_id = 1;

  let config = NdaxWsConfig::builder()
    .subscribe_level1(vec![btc_cad_instrument_id])
    .build();

  let api = NdaxWsAPI::new(config).expect("Failed to connect to NDAX");

  // Wait for some data to arrive
  let max_wait = Duration::from_secs(10);
  let start = std::time::Instant::now();
  let mut received_data = false;

  while start.elapsed() < max_wait {
    if let Some(ticker) = api.get_level1(btc_cad_instrument_id) {
      // Check if we have any meaningful data
      if ticker.best_bid > rust_decimal::Decimal::ZERO
        || ticker.best_ask > rust_decimal::Decimal::ZERO
      {
        received_data = true;

        println!(
          "Received Level1 data for instrument {}",
          btc_cad_instrument_id
        );
        println!("  Best Bid: {}", ticker.best_bid);
        println!("  Best Ask: {}", ticker.best_ask);
        println!("  Last Price: {}", ticker.last_price);
        println!("  Volume: {}", ticker.volume);
        println!("  Mid Price: {}", ticker.mid_price());
        println!("  Spread: {}", ticker.spread());

        break;
      }
    }
    thread::sleep(Duration::from_millis(500));
  }

  assert!(
    received_data,
    "Should have received Level1 data within {} seconds",
    max_wait.as_secs()
  );

  api.close();
}

// ============================================================================
// Authenticated Tests
// ============================================================================

/// Tests authentication with NDAX.
///
/// This test requires credentials to be set via environment variables:
/// - NDAX_API_KEY
/// - NDAX_API_SECRET
/// - NDAX_USER_ID
///
/// The test gracefully skips if credentials are not available.
#[test]
#[ignore]
fn test_authenticate() {
  let credentials = match NdaxCredentials::from_env() {
    Ok(c) => c,
    Err(_) => {
      eprintln!("Skipping auth test: no credentials available");
      eprintln!(
        "Set NDAX_API_KEY, NDAX_API_SECRET, NDAX_USER_ID to run this test"
      );
      return;
    }
  };

  println!(
    "Testing authentication with user_id: {}",
    credentials.user_id
  );

  let config = NdaxWsConfig::builder().credentials(credentials).build();

  let api = match NdaxWsAPI::new(config) {
    Ok(api) => api,
    Err(e) => {
      panic!("Authentication failed: {}", e);
    }
  };

  // Verify authentication status
  assert!(
    api.is_authenticated(),
    "Should be authenticated after successful connection"
  );

  println!("Authentication successful!");

  api.close();
}

/// Tests retrieving account positions (balances).
///
/// This test requires credentials and will skip if not available.
#[test]
#[ignore]
fn test_get_account_positions() {
  let credentials = match NdaxCredentials::from_env() {
    Ok(c) => c,
    Err(_) => {
      eprintln!("Skipping test: no credentials available");
      return;
    }
  };

  let account_id: u64 = match credentials.user_id.parse() {
    Ok(id) => id,
    Err(_) => {
      eprintln!("Skipping test: invalid user_id format");
      return;
    }
  };

  let config = NdaxWsConfig::builder()
    .credentials(credentials)
    .private(PrivateConfig::new(account_id).with_account_events())
    .build();

  let api = NdaxWsAPI::new(config).expect("Failed to connect to NDAX");

  // Get positions
  let positions = api
    .get_account_positions(account_id)
    .expect("Failed to get account positions");

  println!(
    "Account {} has {} position(s):",
    account_id,
    positions.len()
  );
  for pos in &positions {
    let available = pos.amount - pos.hold;
    println!(
      "  {}: Total={}, Available={}, Hold={}",
      pos.product_symbol, pos.amount, available, pos.hold
    );
  }

  // We can't assert specific values, but we can verify the structure
  for pos in &positions {
    assert!(!pos.product_symbol.is_empty());
    assert_eq!(pos.account_id, account_id);
  }

  api.close();
}

/// Tests the full trading cycle: place order -> cancel order.
///
/// This test requires credentials and will skip if not available.
/// It places a limit order far from market ($1 for BTC) that won't execute,
/// then immediately cancels it.
///
/// WARNING: This test places a real order. Use with caution.
#[test]
#[ignore]
fn test_place_and_cancel_order() {
  use ndaxrs::{
    messages::orders::{create_limit_order, CancelOrderRequest},
    Side,
  };
  use rust_decimal_macros::dec;

  let credentials = match NdaxCredentials::from_env() {
    Ok(c) => c,
    Err(_) => {
      eprintln!("Skipping test: no credentials available");
      return;
    }
  };

  let account_id: u64 = match credentials.user_id.parse() {
    Ok(id) => id,
    Err(_) => {
      eprintln!("Skipping test: invalid user_id format");
      return;
    }
  };

  let config = NdaxWsConfig::builder()
    .credentials(credentials)
    .private(PrivateConfig::new(account_id).with_account_events())
    .build();

  let api = NdaxWsAPI::new(config).expect("Failed to connect to NDAX");

  // Create a limit order far from market (won't execute)
  let btc_cad_instrument_id = 1;
  let order = create_limit_order(
    btc_cad_instrument_id,
    account_id,
    Side::Buy,
    dec!(0.0001), // Minimum quantity
    dec!(1.00),   // Far below market price
  );

  println!("Placing test order: Buy 0.0001 BTC @ $1 CAD");

  let response = api.send_order(&order).expect("Failed to send order");
  println!("Order response: status={}", response.status);

  let order_id = match response.order_id {
    Some(id) => {
      println!("Order placed successfully, order_id={}", id);
      id
    }
    None => {
      if response.status == "Rejected" {
        println!("Order rejected: {}", response.errormsg.unwrap_or_default());
        api.close();
        return;
      }
      panic!("No order ID returned for accepted order");
    }
  };

  // Wait a moment for the order to be processed
  thread::sleep(Duration::from_secs(1));

  // Cancel the order
  println!("Canceling order {}", order_id);
  let cancel_request = CancelOrderRequest {
    oms_id: 1,
    account_id,
    order_id: Some(order_id),
    client_order_id: None,
  };

  let cancel_response = api
    .cancel_order(&cancel_request)
    .expect("Failed to cancel order");
  println!("Cancel response: result={}", cancel_response.result);

  // Wait for cancellation to propagate
  thread::sleep(Duration::from_secs(1));

  // Verify order is no longer in open orders
  let open_orders = api
    .get_open_orders(account_id)
    .expect("Failed to get open orders");

  let order_still_exists = open_orders.iter().any(|o| o.order_id == order_id);
  assert!(
    !order_still_exists,
    "Order {} should have been canceled",
    order_id
  );

  println!("Test completed successfully - order placed and canceled");

  api.close();
}

// ============================================================================
// Error Handling Tests
// ============================================================================

/// Tests connection to invalid URL fails gracefully.
#[test]
#[ignore]
fn test_invalid_url_fails() {
  let config = NdaxWsConfig::builder()
    .ws_url("wss://invalid.example.com:12345/WSGateway/")
    .build();

  // This should fail to connect
  let result = NdaxWsAPI::new(config);
  assert!(result.is_err(), "Connection to invalid URL should fail");
}

/// Tests that subscribing to an invalid instrument ID doesn't crash.
#[test]
#[ignore]
fn test_invalid_instrument_subscription() {
  let invalid_instrument_id = 999999;

  let config = NdaxWsConfig::builder()
    .subscribe_level2(vec![invalid_instrument_id])
    .build();

  // Should still connect successfully
  let api = NdaxWsAPI::new(config).expect("Connection should succeed");

  // Wait a moment
  thread::sleep(Duration::from_secs(2));

  // Get book for invalid instrument - should return None or empty book
  let book = api.get_book(invalid_instrument_id);

  // It might return None or an empty book, both are acceptable
  if let Some(book) = book {
    println!(
      "Invalid instrument returned book with {} bids, {} asks",
      book.bids.len(),
      book.asks.len()
    );
  } else {
    println!("Invalid instrument returned None as expected");
  }

  api.close();
}

// ============================================================================
// Multiple Subscription Tests
// ============================================================================

/// Tests subscribing to multiple instruments simultaneously.
#[test]
#[ignore]
fn test_multiple_instrument_subscriptions() {
  let instruments = vec![1, 4, 8]; // BTC/CAD, ETH/CAD, LTC/CAD

  let config = NdaxWsConfig::builder()
    .subscribe_level2(instruments.clone())
    .book_depth(5)
    .build();

  let api = NdaxWsAPI::new(config).expect("Failed to connect to NDAX");

  // Wait for data to arrive
  thread::sleep(Duration::from_secs(5));

  // Check we have data for at least one instrument
  let mut found_data = false;
  for &id in &instruments {
    if let Some(book) = api.get_book(id) {
      if !book.bids.is_empty() || !book.asks.is_empty() {
        println!(
          "Instrument {}: {} bids, {} asks",
          id,
          book.bids.len(),
          book.asks.len()
        );
        found_data = true;
      }
    }
  }

  assert!(
    found_data,
    "Should have received data for at least one instrument"
  );

  api.close();
}