test(sdk): add comprehensive test suites for TypeScript and Python SDKs

Add 265+ tests across TypeScript and Python SDKs covering:
- Types: TradingPair parsing, Balance, Order, Trade, Ticker
- Math: Black-Scholes, Greeks, IV, AMM pricing, risk metrics
- Orderbook: bid/ask sorting, spread, liquidity, VWAP, venue routing
- Risk: kill switch, position limits, order limits, PnL tracking

TypeScript: 133 tests (97% coverage)
Python: 132 tests (all passing)
This commit is contained in:
2025-12-11 22:38:22 -08:00
parent 0386a09566
commit be975c5dc1
13 changed files with 2608 additions and 65 deletions
+26 -25
View File
@@ -1,33 +1,34 @@
// LX Trading SDK - Math Tests
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/catch_approx.hpp>
#include <lx/trading/math.hpp>
using namespace lx::trading::math;
using Catch::Approx;
TEST_CASE("Black-Scholes pricing", "[math]") {
SECTION("At-the-money call") {
double price = black_scholes(100, 100, 1.0, 0.05, 0.2, true);
REQUIRE(price == Catch::Approx(10.45).margin(0.1));
REQUIRE(price == Approx(10.45).margin(0.1));
}
SECTION("In-the-money call") {
double price = black_scholes(110, 100, 1.0, 0.05, 0.2, true);
REQUIRE(price == Catch::Approx(17.68).margin(0.1));
REQUIRE(price == Approx(17.68).margin(0.1));
}
SECTION("Out-of-the-money put") {
double price = black_scholes(110, 100, 1.0, 0.05, 0.2, false);
REQUIRE(price == Catch::Approx(2.80).margin(0.1));
REQUIRE(price == Approx(2.80).margin(0.1));
}
SECTION("Zero time to expiry") {
double call_price = black_scholes(110, 100, 0, 0.05, 0.2, true);
REQUIRE(call_price == Catch::Approx(10.0));
REQUIRE(call_price == Approx(10.0));
double put_price = black_scholes(90, 100, 0, 0.05, 0.2, false);
REQUIRE(put_price == Catch::Approx(10.0));
REQUIRE(put_price == Approx(10.0));
}
}
@@ -35,15 +36,15 @@ TEST_CASE("Greeks calculation", "[math]") {
Greeks g = greeks(100, 100, 1.0, 0.05, 0.2, true);
SECTION("Delta") {
REQUIRE(g.delta == Catch::Approx(0.64).margin(0.02));
REQUIRE(g.delta == Approx(0.64).margin(0.02));
}
SECTION("Gamma") {
REQUIRE(g.gamma == Catch::Approx(0.019).margin(0.002));
REQUIRE(g.gamma == Approx(0.019).margin(0.002));
}
SECTION("Vega") {
REQUIRE(g.vega == Catch::Approx(0.38).margin(0.02));
REQUIRE(g.vega == Approx(0.38).margin(0.02));
}
SECTION("Theta (daily)") {
@@ -56,14 +57,14 @@ TEST_CASE("Implied volatility", "[math]") {
double price = black_scholes(100, 100, 0.5, 0.05, true_vol, true);
double iv = implied_volatility(price, 100, 100, 0.5, 0.05, true);
REQUIRE(iv == Catch::Approx(true_vol).margin(0.01));
REQUIRE(iv == Approx(true_vol).margin(0.01));
}
TEST_CASE("Constant product AMM", "[math]") {
SECTION("Basic swap") {
auto [out, price] = constant_product_price(1000, 1000, 10, 0.003, true);
REQUIRE(out == Catch::Approx(9.88).margin(0.02));
REQUIRE(price == Catch::Approx(0.988).margin(0.002));
REQUIRE(out == Approx(9.88).margin(0.02));
REQUIRE(price == Approx(0.988).margin(0.002));
}
SECTION("Large swap with slippage") {
@@ -76,7 +77,7 @@ TEST_CASE("Constant product AMM", "[math]") {
SECTION("Symmetric reserves") {
auto [out1, p1] = constant_product_price(1000, 1000, 50, 0.003, true);
auto [out2, p2] = constant_product_price(1000, 1000, 50, 0.003, false);
REQUIRE(out1 == Catch::Approx(out2).margin(0.01));
REQUIRE(out1 == Approx(out2).margin(0.01));
}
}
@@ -109,7 +110,7 @@ TEST_CASE("Volatility calculation", "[math]") {
SECTION("Annualized") {
double vol_daily = volatility(returns, false);
double vol_annual = volatility(returns, true, 252);
REQUIRE(vol_annual == Catch::Approx(vol_daily * std::sqrt(252)).margin(0.001));
REQUIRE(vol_annual == Approx(vol_daily * std::sqrt(252)).margin(0.001));
}
}
@@ -135,7 +136,7 @@ TEST_CASE("Maximum drawdown", "[math]") {
auto [dd, peak_idx, trough_idx] = max_drawdown(prices);
// Max DD from 110 to 85 = 22.7%
REQUIRE(dd == Catch::Approx(0.227).margin(0.01));
REQUIRE(dd == Approx(0.227).margin(0.01));
REQUIRE(peak_idx == 1); // 110
REQUIRE(trough_idx == 6); // 85
}
@@ -177,9 +178,9 @@ TEST_CASE("Statistical utilities", "[math]") {
auto returns = calculate_returns(prices);
REQUIRE(returns.size() == 3);
REQUIRE(returns[0] == Catch::Approx(0.05)); // 5%
REQUIRE(returns[1] == Catch::Approx(-0.0286).margin(0.001)); // -2.86%
REQUIRE(returns[2] == Catch::Approx(0.0784).margin(0.001)); // 7.84%
REQUIRE(returns[0] == Approx(0.05)); // 5%
REQUIRE(returns[1] == Approx(-0.0286).margin(0.001)); // -2.86%
REQUIRE(returns[2] == Approx(0.0784).margin(0.001)); // 7.84%
}
SECTION("Rolling mean") {
@@ -187,8 +188,8 @@ TEST_CASE("Statistical utilities", "[math]") {
auto rm = rolling_mean(data, 3);
REQUIRE(rm.size() == 5);
REQUIRE(rm[0] == Catch::Approx(2.0)); // (1+2+3)/3
REQUIRE(rm[1] == Catch::Approx(3.0)); // (2+3+4)/3
REQUIRE(rm[0] == Approx(2.0)); // (1+2+3)/3
REQUIRE(rm[1] == Approx(3.0)); // (2+3+4)/3
}
SECTION("EMA") {
@@ -196,7 +197,7 @@ TEST_CASE("Statistical utilities", "[math]") {
auto e = ema(data, 0.3);
REQUIRE(e.size() == 6);
REQUIRE(e[0] == Catch::Approx(10.0));
REQUIRE(e[0] == Approx(10.0));
// Each subsequent value is weighted average
}
@@ -205,7 +206,7 @@ TEST_CASE("Statistical utilities", "[math]") {
std::vector<double> y = {2, 4, 6, 8, 10};
double corr = correlation(x, y);
REQUIRE(corr == Catch::Approx(1.0)); // Perfect positive
REQUIRE(corr == Approx(1.0)); // Perfect positive
}
SECTION("Beta") {
@@ -220,18 +221,18 @@ TEST_CASE("Statistical utilities", "[math]") {
TEST_CASE("Price conversions", "[math]") {
SECTION("Price to sqrt price") {
double sqrt_p = price_to_sqrt_price(100);
REQUIRE(sqrt_p == Catch::Approx(10.0));
REQUIRE(sqrt_p == Approx(10.0));
}
SECTION("Sqrt price to price") {
double p = sqrt_price_to_price(10.0);
REQUIRE(p == Catch::Approx(100.0));
REQUIRE(p == Approx(100.0));
}
SECTION("Round trip") {
double original = 12345.67;
double sqrt_p = price_to_sqrt_price(original);
double back = sqrt_price_to_price(sqrt_p);
REQUIRE(back == Catch::Approx(original));
REQUIRE(back == Approx(original));
}
}
+24 -23
View File
@@ -1,10 +1,11 @@
// LX Trading SDK - Orderbook Tests
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/catch_approx.hpp>
#include <lx/trading/orderbook.hpp>
using namespace lx::trading;
using Catch::Approx;
TEST_CASE("Orderbook basic operations", "[orderbook]") {
Orderbook book("BTC-USDC", "test_venue");
@@ -23,12 +24,12 @@ TEST_CASE("Orderbook basic operations", "[orderbook]") {
REQUIRE(asks.size() == 2);
// Bids sorted descending
REQUIRE(bids[0].price.to_double() == Catch::Approx(100.0));
REQUIRE(bids[1].price.to_double() == Catch::Approx(99.0));
REQUIRE(bids[0].price.to_double() == Approx(100.0));
REQUIRE(bids[1].price.to_double() == Approx(99.0));
// Asks sorted ascending
REQUIRE(asks[0].price.to_double() == Catch::Approx(101.0));
REQUIRE(asks[1].price.to_double() == Catch::Approx(102.0));
REQUIRE(asks[0].price.to_double() == Approx(101.0));
REQUIRE(asks[1].price.to_double() == Approx(102.0));
}
SECTION("Best bid/ask") {
@@ -36,8 +37,8 @@ TEST_CASE("Orderbook basic operations", "[orderbook]") {
book.add_ask(Decimal::from_double(101.0), Decimal::from_double(1.0));
book.sort();
REQUIRE(book.best_bid().value().to_double() == Catch::Approx(100.0));
REQUIRE(book.best_ask().value().to_double() == Catch::Approx(101.0));
REQUIRE(book.best_bid().value().to_double() == Approx(100.0));
REQUIRE(book.best_ask().value().to_double() == Approx(101.0));
}
SECTION("Mid price and spread") {
@@ -45,9 +46,9 @@ TEST_CASE("Orderbook basic operations", "[orderbook]") {
book.add_ask(Decimal::from_double(102.0), Decimal::from_double(1.0));
book.sort();
REQUIRE(book.mid_price().value().to_double() == Catch::Approx(101.0));
REQUIRE(book.spread().value().to_double() == Catch::Approx(2.0));
REQUIRE(book.spread_percent().value().to_double() == Catch::Approx(1.98).margin(0.01));
REQUIRE(book.mid_price().value().to_double() == Approx(101.0));
REQUIRE(book.spread().value().to_double() == Approx(2.0));
REQUIRE(book.spread_percent().value().to_double() == Approx(1.98).margin(0.01));
}
}
@@ -62,21 +63,21 @@ TEST_CASE("Orderbook VWAP", "[orderbook]") {
SECTION("VWAP for small amount") {
auto vwap = book.vwap_buy(Decimal::from_double(0.5));
REQUIRE(vwap.has_value());
REQUIRE(vwap->to_double() == Catch::Approx(100.0));
REQUIRE(vwap->to_double() == Approx(100.0));
}
SECTION("VWAP across multiple levels") {
auto vwap = book.vwap_buy(Decimal::from_double(2.5));
REQUIRE(vwap.has_value());
// (1.0 * 100 + 1.5 * 101) / 2.5 = 100.6
REQUIRE(vwap->to_double() == Catch::Approx(100.6));
REQUIRE(vwap->to_double() == Approx(100.6));
}
SECTION("VWAP for full book") {
auto vwap = book.vwap_buy(Decimal::from_double(6.0));
REQUIRE(vwap.has_value());
// (1*100 + 2*101 + 3*102) / 6 = 101.333...
REQUIRE(vwap->to_double() == Catch::Approx(101.333).margin(0.01));
REQUIRE(vwap->to_double() == Approx(101.333).margin(0.01));
}
}
@@ -91,17 +92,17 @@ TEST_CASE("Orderbook liquidity", "[orderbook]") {
SECTION("Bid liquidity") {
// 1.0 * 100 + 2.0 * 99 = 298
REQUIRE(book.bid_liquidity().to_double() == Catch::Approx(298.0));
REQUIRE(book.bid_liquidity().to_double() == Approx(298.0));
}
SECTION("Ask liquidity") {
// 1.5 * 101 + 2.5 * 102 = 406.5
REQUIRE(book.ask_liquidity().to_double() == Catch::Approx(406.5));
REQUIRE(book.ask_liquidity().to_double() == Approx(406.5));
}
SECTION("Depth for specific levels") {
REQUIRE(book.bid_depth(1).to_double() == Catch::Approx(100.0));
REQUIRE(book.ask_depth(1).to_double() == Catch::Approx(151.5));
REQUIRE(book.bid_depth(1).to_double() == Approx(100.0));
REQUIRE(book.ask_depth(1).to_double() == Approx(151.5));
}
SECTION("Has liquidity") {
@@ -132,7 +133,7 @@ TEST_CASE("AggregatedOrderbook", "[orderbook]") {
auto best = agg.best_bid();
REQUIRE(best.has_value());
auto [price, venue, qty] = *best;
REQUIRE(price.to_double() == Catch::Approx(100.0));
REQUIRE(price.to_double() == Approx(100.0));
REQUIRE(venue == "venue1");
}
@@ -140,7 +141,7 @@ TEST_CASE("AggregatedOrderbook", "[orderbook]") {
auto best = agg.best_ask();
REQUIRE(best.has_value());
auto [price, venue, qty] = *best;
REQUIRE(price.to_double() == Catch::Approx(101.0));
REQUIRE(price.to_double() == Approx(101.0));
REQUIRE(venue == "venue2");
}
@@ -148,8 +149,8 @@ TEST_CASE("AggregatedOrderbook", "[orderbook]") {
auto asks = agg.aggregated_asks();
REQUIRE(asks.size() == 2);
// Sorted by price ascending
REQUIRE(asks[0].price.to_double() == Catch::Approx(101.0));
REQUIRE(asks[1].price.to_double() == Catch::Approx(102.0));
REQUIRE(asks[0].price.to_double() == Approx(101.0));
REQUIRE(asks[1].price.to_double() == Approx(102.0));
}
SECTION("Best venue for buying") {
@@ -157,7 +158,7 @@ TEST_CASE("AggregatedOrderbook", "[orderbook]") {
REQUIRE(best.has_value());
auto [venue, price] = *best;
REQUIRE(venue == "venue2"); // Lower ask price
REQUIRE(price.to_double() == Catch::Approx(101.0));
REQUIRE(price.to_double() == Approx(101.0));
}
SECTION("Best venue for selling") {
@@ -165,6 +166,6 @@ TEST_CASE("AggregatedOrderbook", "[orderbook]") {
REQUIRE(best.has_value());
auto [venue, price] = *best;
REQUIRE(venue == "venue1"); // Higher bid price
REQUIRE(price.to_double() == Catch::Approx(100.0));
REQUIRE(price.to_double() == Approx(100.0));
}
}
+4 -3
View File
@@ -1,10 +1,11 @@
// LX Trading SDK - Risk Manager Tests
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/catch_approx.hpp>
#include <lx/trading/risk.hpp>
using namespace lx::trading;
using Catch::Approx;
TEST_CASE("RiskManager basic validation", "[risk]") {
RiskConfig config;
@@ -40,7 +41,7 @@ TEST_CASE("RiskManager position limits", "[risk]") {
SECTION("Within global limit") {
rm.update_position("BTC", Decimal::from_double(30.0), Side::Buy);
REQUIRE(rm.position("BTC").to_double() == Catch::Approx(30.0));
REQUIRE(rm.position("BTC").to_double() == Approx(30.0));
OrderRequest req = OrderRequest::market("BTC-USDC", Side::Buy,
Decimal::from_double(10.0));
@@ -75,7 +76,7 @@ TEST_CASE("RiskManager PnL tracking", "[risk]") {
SECTION("Track PnL") {
rm.update_pnl(Decimal::from_double(100.0));
rm.update_pnl(Decimal::from_double(-50.0));
REQUIRE(rm.daily_pnl().to_double() == Catch::Approx(50.0));
REQUIRE(rm.daily_pnl().to_double() == Approx(50.0));
}
SECTION("Daily loss limit triggers rejection") {
+15 -14
View File
@@ -1,28 +1,29 @@
// LX Trading SDK - Types Tests
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/catch_approx.hpp>
#include <lx/trading/types.hpp>
using namespace lx::trading;
using Catch::Approx;
TEST_CASE("Decimal arithmetic", "[types]") {
SECTION("Basic operations") {
Decimal a = Decimal::from_double(100.5);
Decimal b = Decimal::from_double(50.25);
REQUIRE((a + b).to_double() == Catch::Approx(150.75));
REQUIRE((a - b).to_double() == Catch::Approx(50.25));
REQUIRE((a * Decimal::from_double(2.0)).to_double() == Catch::Approx(201.0));
REQUIRE((a / Decimal::from_double(2.0)).to_double() == Catch::Approx(50.25));
REQUIRE((a + b).to_double() == Approx(150.75));
REQUIRE((a - b).to_double() == Approx(50.25));
REQUIRE((a * Decimal::from_double(2.0)).to_double() == Approx(201.0));
REQUIRE((a / Decimal::from_double(2.0)).to_double() == Approx(50.25));
}
SECTION("String conversion") {
auto d = Decimal::from_string("123.456");
REQUIRE(d.to_double() == Catch::Approx(123.456));
REQUIRE(d.to_double() == Approx(123.456));
auto d2 = Decimal::from_string("-99.99");
REQUIRE(d2.to_double() == Catch::Approx(-99.99));
REQUIRE(d2.to_double() == Approx(-99.99));
REQUIRE(d2.is_negative());
}
@@ -39,7 +40,7 @@ TEST_CASE("Decimal arithmetic", "[types]") {
SECTION("Zero and one") {
REQUIRE(Decimal::zero().is_zero());
REQUIRE(Decimal::one().to_double() == Catch::Approx(1.0));
REQUIRE(Decimal::one().to_double() == Approx(1.0));
}
}
@@ -83,7 +84,7 @@ TEST_CASE("OrderRequest factory methods", "[types]") {
REQUIRE(req.symbol == "BTC-USDC");
REQUIRE(req.side == Side::Buy);
REQUIRE(req.order_type == OrderType::Market);
REQUIRE(req.quantity.to_double() == Catch::Approx(1.5));
REQUIRE(req.quantity.to_double() == Approx(1.5));
REQUIRE(req.time_in_force == TimeInForce::IOC);
}
@@ -93,7 +94,7 @@ TEST_CASE("OrderRequest factory methods", "[types]") {
REQUIRE(req.symbol == "ETH-USDC");
REQUIRE(req.side == Side::Sell);
REQUIRE(req.order_type == OrderType::Limit);
REQUIRE(req.price.value().to_double() == Catch::Approx(2000.0));
REQUIRE(req.price.value().to_double() == Approx(2000.0));
REQUIRE(req.time_in_force == TimeInForce::GTC);
}
@@ -120,7 +121,7 @@ TEST_CASE("Order status checks", "[types]") {
order.status = OrderStatus::PartiallyFilled;
REQUIRE(order.is_open());
REQUIRE_FALSE(order.is_done());
REQUIRE(order.fill_percent().to_double() == Catch::Approx(50.0));
REQUIRE(order.fill_percent().to_double() == Approx(50.0));
}
SECTION("Filled") {
@@ -145,19 +146,19 @@ TEST_CASE("Ticker calculations", "[types]") {
SECTION("Mid price") {
auto mid = ticker.mid_price();
REQUIRE(mid.has_value());
REQUIRE(mid->to_double() == Catch::Approx(100.5));
REQUIRE(mid->to_double() == Approx(100.5));
}
SECTION("Spread") {
auto spread = ticker.spread();
REQUIRE(spread.has_value());
REQUIRE(spread->to_double() == Catch::Approx(1.0));
REQUIRE(spread->to_double() == Approx(1.0));
}
SECTION("Spread percent") {
auto spread_pct = ticker.spread_percent();
REQUIRE(spread_pct.has_value());
REQUIRE(spread_pct->to_double() == Catch::Approx(1.0));
REQUIRE(spread_pct->to_double() == Approx(1.0));
}
}
+1
View File
@@ -0,0 +1 @@
"""Tests for LX Trading SDK."""
+317
View File
@@ -0,0 +1,317 @@
"""Tests for LX Trading SDK math module."""
import pytest
import math
from decimal import Decimal
from lx_trading.math import (
black_scholes,
implied_volatility,
greeks,
constant_product_price,
concentrated_liquidity_price,
calculate_liquidity,
volatility,
sharpe_ratio,
sortino_ratio,
max_drawdown,
var,
cvar,
price_to_sqrt_price,
sqrt_price_to_price,
tick_to_sqrt_price,
sqrt_price_to_tick,
_norm_cdf,
_norm_pdf,
)
class TestBlackScholes:
def test_atm_call_price(self):
"""ATM call: S=100, K=100, T=1yr, r=5%, vol=20%"""
price = black_scholes(100, 100, 1, 0.05, 0.2, "call")
# Expected ~10.45 based on standard B-S
assert 10 < price < 11
def test_atm_put_price(self):
price = black_scholes(100, 100, 1, 0.05, 0.2, "put")
# Put-call parity: Put ≈ 5.57
assert 5 < price < 6
def test_intrinsic_value_at_expiry(self):
# At expiry (T=0), call = max(S-K, 0)
call_itm = black_scholes(110, 100, 0, 0.05, 0.2, "call")
assert call_itm == 10
call_otm = black_scholes(90, 100, 0, 0.05, 0.2, "call")
assert call_otm == 0
put_itm = black_scholes(90, 100, 0, 0.05, 0.2, "put")
assert put_itm == 10
def test_deep_itm_call(self):
price = black_scholes(150, 100, 0.1, 0.05, 0.2, "call")
assert price > 49 # At least intrinsic
assert price < 52 # Not too much time value
def test_deep_otm_call(self):
price = black_scholes(50, 100, 0.1, 0.05, 0.2, "call")
assert price < 0.01
class TestImpliedVolatility:
def test_recover_volatility(self):
true_vol = 0.25
price = black_scholes(100, 100, 1, 0.05, true_vol, "call")
iv = implied_volatility(price, 100, 100, 1, 0.05, "call")
assert abs(iv - true_vol) < 0.02
def test_works_for_puts(self):
true_vol = 0.30
price = black_scholes(100, 105, 0.5, 0.03, true_vol, "put")
iv = implied_volatility(price, 100, 105, 0.5, 0.03, "put")
assert abs(iv - true_vol) < 0.02
class TestGreeks:
def test_delta_atm_call(self):
g = greeks(100, 100, 1, 0.05, 0.2, "call")
# ATM call delta should be around 0.5-0.6
assert 0.5 < g["delta"] < 0.7
def test_delta_atm_put(self):
g = greeks(100, 100, 1, 0.05, 0.2, "put")
# Put delta is negative
assert -0.6 < g["delta"] < -0.3
def test_gamma_positive(self):
g = greeks(100, 100, 1, 0.05, 0.2, "call")
assert g["gamma"] > 0
def test_theta_negative_for_long_call(self):
g = greeks(100, 100, 1, 0.05, 0.2, "call")
assert g["theta"] < 0 # Time decay
def test_vega_positive(self):
g = greeks(100, 100, 1, 0.05, 0.2, "call")
assert g["vega"] > 0
def test_zeros_at_expiry(self):
g = greeks(100, 100, 0, 0.05, 0.2, "call")
assert g["delta"] == 0
assert g["gamma"] == 0
assert g["vega"] == 0
class TestConstantProductAMM:
def test_balanced_pool(self):
out, price = constant_product_price(1000, 1000, 10, 0.003, True)
# Without fee: dy = 1000 * 10 / (1000 + 10) ≈ 9.9
# With fee: dy ≈ 9.87
assert 9.8 < out < 10
def test_effective_price(self):
out, price = constant_product_price(1000, 2000, 10, 0.003, True)
assert price > 0
assert price < 2 # Less than spot due to slippage
def test_symmetric(self):
x_to_y, _ = constant_product_price(1000, 1000, 10, 0.003, True)
y_to_x, _ = constant_product_price(1000, 1000, 10, 0.003, False)
assert abs(x_to_y - y_to_x) < 0.1
def test_zero_input(self):
out, price = constant_product_price(1000, 1000, 0, 0.003, True)
assert out == 0
assert price == 0
def test_fee_impact(self):
no_fee, _ = constant_product_price(1000, 1000, 100, 0, True)
with_fee, _ = constant_product_price(1000, 1000, 100, 0.01, True)
assert with_fee < no_fee
class TestConcentratedLiquidity:
def test_output_within_range(self):
out, new_sqrt_p, impact = concentrated_liquidity_price(
1000, 10, 9, 11, 10, 0.003, True
)
assert out > 0
assert new_sqrt_p > 10 # Price goes up when buying Y
assert new_sqrt_p <= 11 # Capped at upper bound
def test_price_impact(self):
_, _, impact = concentrated_liquidity_price(
1000, 10, 9, 11, 100, 0.003, True
)
assert impact > 0
assert impact < 1
def test_swap_y_for_x(self):
out, new_sqrt_p, _ = concentrated_liquidity_price(
1000, 10, 9, 11, 10, 0.003, False
)
assert out > 0
assert new_sqrt_p < 10 # Price goes down
assert new_sqrt_p >= 9 # Capped at lower bound
class TestCalculateLiquidity:
def test_in_range_position(self):
L = calculate_liquidity(100, 1000, 10, 9, 11)
assert L > 0
def test_below_range_only_x(self):
L = calculate_liquidity(100, 0, 8, 9, 11)
assert L > 0
def test_above_range_only_y(self):
L = calculate_liquidity(0, 100, 12, 9, 11)
assert L > 0
class TestVolatility:
def test_calculate_volatility(self):
returns = [0.01, -0.02, 0.015, -0.005, 0.02, -0.01, 0.005]
vol = volatility(returns, annualize=False)
assert vol > 0
assert vol < 1
def test_annualize(self):
returns = [0.01, -0.02, 0.015, -0.005, 0.02, -0.01, 0.005]
daily_vol = volatility(returns, annualize=False)
annual_vol = volatility(returns, annualize=True, periods_per_year=252)
assert abs(annual_vol - daily_vol * math.sqrt(252)) < 0.01
def test_insufficient_data(self):
assert volatility([0.01]) == 0
assert volatility([]) == 0
class TestSharpeRatio:
def test_positive_sharpe(self):
returns = [0.01, 0.02, 0.015, 0.005, 0.02, 0.01, 0.015]
sharpe = sharpe_ratio(returns, 0, 252)
assert sharpe > 0
def test_negative_sharpe(self):
returns = [-0.01, -0.02, -0.015, -0.005, -0.02, -0.01, -0.015]
sharpe = sharpe_ratio(returns, 0, 252)
assert sharpe < 0
def test_insufficient_data(self):
assert sharpe_ratio([0.01]) == 0
def test_risk_free_rate_impact(self):
returns = [0.0001, 0.0002, 0.0001, 0.0002, 0.0001]
sharpe_no_rf = sharpe_ratio(returns, 0, 252)
sharpe_with_rf = sharpe_ratio(returns, 0.05, 252)
assert sharpe_with_rf < sharpe_no_rf
class TestSortinoRatio:
def test_calculate_sortino(self):
returns = [0.01, -0.02, 0.015, -0.005, 0.02, -0.01, 0.015]
sortino = sortino_ratio(returns, 0, 0, 252)
assert not math.isnan(sortino)
def test_higher_than_sharpe_when_downside_limited(self):
returns = [0.02, 0.01, 0.015, -0.002, 0.02, 0.01, 0.015]
sharpe = sharpe_ratio(returns, 0, 252)
sortino = sortino_ratio(returns, 0, 0, 252)
assert sortino >= sharpe
class TestMaxDrawdown:
def test_find_max_drawdown(self):
prices = [100, 110, 105, 120, 90, 95, 100]
dd, peak_idx, trough_idx = max_drawdown(prices)
# Peak at 120, trough at 90: (120-90)/120 = 0.25
assert abs(dd - 0.25) < 0.01
def test_track_indices(self):
prices = [100, 110, 105, 120, 90, 95, 100]
_, peak_idx, trough_idx = max_drawdown(prices)
assert peak_idx == 3 # Index of 120
assert trough_idx == 4 # Index of 90
def test_monotonic_increase(self):
prices = [100, 110, 120, 130, 140]
dd, _, _ = max_drawdown(prices)
assert dd == 0
def test_insufficient_data(self):
dd, _, _ = max_drawdown([100])
assert dd == 0
class TestValueAtRisk:
def test_historical_var(self):
returns = [(i - 50) / 1000 for i in range(100)]
var_95 = var(returns, 0.95, "historical")
assert var_95 > 0
def test_parametric_var(self):
returns = [-0.03, -0.02, -0.01, 0, 0.01, 0.02, 0.03, -0.015, 0.015, -0.025]
var_95 = var(returns, 0.95, "parametric")
assert var_95 > 0
def test_insufficient_data(self):
var_95 = var([0.01, 0.02], 0.95)
assert var_95 == 0
def test_higher_confidence_higher_var(self):
import random
returns = [(random.random() - 0.5) * 0.1 for _ in range(100)]
var_95 = var(returns, 0.95)
var_99 = var(returns, 0.99)
assert var_99 >= var_95
class TestConditionalVaR:
def test_cvar_greater_than_var(self):
import random
returns = [(random.random() - 0.5) * 0.1 for _ in range(100)]
var_95 = var(returns, 0.95)
cvar_95 = cvar(returns, 0.95)
assert cvar_95 >= var_95 * 0.99 # Allow tolerance
def test_insufficient_data(self):
assert cvar([0.01, 0.02], 0.95) == 0
class TestNormalDistribution:
def test_cdf(self):
assert abs(_norm_cdf(0) - 0.5) < 0.001
assert abs(_norm_cdf(-1.96) - 0.025) < 0.01
assert abs(_norm_cdf(1.96) - 0.975) < 0.01
def test_pdf(self):
# PDF at 0 = 1/sqrt(2*pi) ≈ 0.3989
assert abs(_norm_pdf(0) - 0.3989) < 0.001
# Symmetric
assert abs(_norm_pdf(-1) - _norm_pdf(1)) < 0.0001
class TestPriceTickConversions:
def test_price_to_sqrt_price(self):
assert price_to_sqrt_price(100) == 10
assert price_to_sqrt_price(1) == 1
def test_sqrt_price_to_price(self):
assert sqrt_price_to_price(10) == 100
assert sqrt_price_to_price(1) == 1
def test_inverse_operations(self):
price = 150
recovered = sqrt_price_to_price(price_to_sqrt_price(price))
assert abs(recovered - price) < 0.0001
def test_tick_to_sqrt_price(self):
# tick = 0 => sqrt(1.0001^0) = 1
assert abs(tick_to_sqrt_price(0) - 1) < 0.0001
def test_sqrt_price_to_tick(self):
sqrt_p = tick_to_sqrt_price(1000)
tick = sqrt_price_to_tick(sqrt_p, tick_spacing=1)
assert abs(tick - 1000) < 1
+318
View File
@@ -0,0 +1,318 @@
"""Tests for LX Trading SDK orderbook module."""
import pytest
from decimal import Decimal
from lx_trading.orderbook import Orderbook, AggregatedOrderbook
from lx_trading.types import Side
class TestOrderbook:
def test_create_orderbook(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
assert book.symbol == "BTC-USDC"
assert book.venue == "lx"
assert book.timestamp > 0
def test_add_and_retrieve_bids(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1"))
book.add_bid(Decimal("99"), Decimal("2"))
book.sort()
assert len(book.bids) == 2
assert book.bids[0].price == Decimal("100") # Highest first
assert book.bids[1].price == Decimal("99")
def test_add_and_retrieve_asks(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_ask(Decimal("102"), Decimal("1.5"))
book.add_ask(Decimal("101"), Decimal("2.5"))
book.sort()
assert len(book.asks) == 2
assert book.asks[0].price == Decimal("101") # Lowest first
assert book.asks[1].price == Decimal("102")
def test_sort_order(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("98"), Decimal("1"))
book.add_bid(Decimal("100"), Decimal("1"))
book.add_bid(Decimal("99"), Decimal("1"))
book.add_ask(Decimal("103"), Decimal("1"))
book.add_ask(Decimal("101"), Decimal("1"))
book.add_ask(Decimal("102"), Decimal("1"))
book.sort()
assert book.bids[0].price == Decimal("100")
assert book.bids[1].price == Decimal("99")
assert book.bids[2].price == Decimal("98")
assert book.asks[0].price == Decimal("101")
assert book.asks[1].price == Decimal("102")
assert book.asks[2].price == Decimal("103")
class TestOrderbookPrices:
def test_best_bid(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1"))
book.add_bid(Decimal("99"), Decimal("2"))
book.sort()
assert book.best_bid == Decimal("100")
def test_best_ask(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_ask(Decimal("101"), Decimal("1"))
book.add_ask(Decimal("102"), Decimal("2"))
book.sort()
assert book.best_ask == Decimal("101")
def test_empty_book(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
assert book.best_bid is None
assert book.best_ask is None
def test_mid_price(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1"))
book.add_ask(Decimal("102"), Decimal("1"))
book.sort()
assert book.mid_price == Decimal("101")
def test_spread(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1"))
book.add_ask(Decimal("102"), Decimal("1"))
book.sort()
assert book.spread == Decimal("2")
def test_spread_percent(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1"))
book.add_ask(Decimal("102"), Decimal("1"))
book.sort()
# spread = 2, mid = 101, spread% = 2/101*100 ≈ 1.98
assert book.spread_percent is not None
assert abs(float(book.spread_percent) - 1.98) < 0.01
class TestOrderbookLiquidity:
def test_bid_liquidity(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1")) # 100 value
book.add_bid(Decimal("99"), Decimal("2")) # 198 value
book.sort()
assert book.bid_liquidity == Decimal("298")
def test_ask_liquidity(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_ask(Decimal("101"), Decimal("1.5")) # 151.5 value
book.add_ask(Decimal("102"), Decimal("2.5")) # 255 value
book.sort()
assert book.ask_liquidity == Decimal("406.5")
def test_bid_depth(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1")) # 100 value
book.add_bid(Decimal("99"), Decimal("2")) # 198 value
book.add_bid(Decimal("98"), Decimal("3")) # 294 value
book.sort()
assert book.bid_depth(1) == Decimal("100")
assert book.bid_depth(2) == Decimal("298")
def test_ask_depth(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_ask(Decimal("101"), Decimal("1.5")) # 151.5
book.add_ask(Decimal("102"), Decimal("2.5")) # 255
book.sort()
assert book.ask_depth(1) == Decimal("151.5")
class TestOrderbookVWAP:
def test_vwap_small_buy(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_ask(Decimal("100"), Decimal("1"))
book.add_ask(Decimal("101"), Decimal("2"))
book.add_ask(Decimal("102"), Decimal("3"))
book.sort()
vwap = book.vwap_buy(Decimal("0.5"))
assert vwap == Decimal("100")
def test_vwap_across_levels(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_ask(Decimal("100"), Decimal("1"))
book.add_ask(Decimal("101"), Decimal("2"))
book.add_ask(Decimal("102"), Decimal("3"))
book.sort()
# Buying 2.5: 1@100 + 1.5@101 = 100 + 151.5 = 251.5 / 2.5 = 100.6
vwap = book.vwap_buy(Decimal("2.5"))
assert vwap is not None
assert abs(float(vwap) - 100.6) < 0.01
def test_vwap_sell(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("1"))
book.add_bid(Decimal("99"), Decimal("2"))
book.sort()
vwap = book.vwap_sell(Decimal("0.5"))
assert vwap == Decimal("100")
def test_vwap_empty_book(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
assert book.vwap_buy(Decimal("1")) is None
assert book.vwap_sell(Decimal("1")) is None
class TestOrderbookHasLiquidity:
def test_sufficient_liquidity_buy(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_ask(Decimal("100"), Decimal("5"))
book.sort()
assert book.has_liquidity(Side.BUY, Decimal("3")) is True
assert book.has_liquidity(Side.BUY, Decimal("10")) is False
def test_sufficient_liquidity_sell(self):
book = Orderbook(symbol="BTC-USDC", venue="lx")
book.add_bid(Decimal("100"), Decimal("5"))
book.sort()
assert book.has_liquidity(Side.SELL, Decimal("3")) is True
assert book.has_liquidity(Side.SELL, Decimal("10")) is False
class TestAggregatedOrderbook:
@pytest.fixture
def test_books(self):
book1 = Orderbook(symbol="BTC-USDC", venue="venue1")
book1.add_bid(Decimal("100"), Decimal("1"))
book1.add_ask(Decimal("102"), Decimal("1"))
book1.sort()
book2 = Orderbook(symbol="BTC-USDC", venue="venue2")
book2.add_bid(Decimal("99"), Decimal("2"))
book2.add_ask(Decimal("101"), Decimal("1.5"))
book2.sort()
return book1, book2
def test_aggregate_orderbooks(self, test_books):
book1, book2 = test_books
agg = AggregatedOrderbook("BTC-USDC")
agg.add_orderbook(book1)
agg.add_orderbook(book2)
assert len(agg.bids) == 2
assert len(agg.asks) == 2
def test_best_bid_across_venues(self, test_books):
book1, book2 = test_books
agg = AggregatedOrderbook("BTC-USDC")
agg.add_orderbook(book1)
agg.add_orderbook(book2)
best = agg.best_bid()
assert best is not None
price, venue, qty = best
assert price == Decimal("100")
assert venue == "venue1"
def test_best_ask_across_venues(self, test_books):
book1, book2 = test_books
agg = AggregatedOrderbook("BTC-USDC")
agg.add_orderbook(book1)
agg.add_orderbook(book2)
best = agg.best_ask()
assert best is not None
price, venue, qty = best
assert price == Decimal("101")
assert venue == "venue2"
def test_empty_aggregated_book(self):
agg = AggregatedOrderbook("BTC-USDC")
assert agg.best_bid() is None
assert agg.best_ask() is None
def test_aggregated_bids(self, test_books):
book1, book2 = test_books
agg = AggregatedOrderbook("BTC-USDC")
agg.add_orderbook(book1)
agg.add_orderbook(book2)
bids = agg.aggregated_bids()
assert len(bids) == 2
assert bids[0].price == Decimal("100") # Highest first
assert bids[1].price == Decimal("99")
def test_aggregated_asks(self, test_books):
book1, book2 = test_books
agg = AggregatedOrderbook("BTC-USDC")
agg.add_orderbook(book1)
agg.add_orderbook(book2)
asks = agg.aggregated_asks()
assert len(asks) == 2
assert asks[0].price == Decimal("101") # Lowest first
assert asks[1].price == Decimal("102")
def test_sum_quantities_same_price(self):
agg = AggregatedOrderbook("BTC-USDC")
book1 = Orderbook(symbol="BTC-USDC", venue="venue1")
book1.add_bid(Decimal("100"), Decimal("1"))
book1.sort()
book2 = Orderbook(symbol="BTC-USDC", venue="venue2")
book2.add_bid(Decimal("100"), Decimal("2"))
book2.sort()
agg.add_orderbook(book1)
agg.add_orderbook(book2)
bids = agg.aggregated_bids()
assert len(bids) == 1
assert bids[0].quantity == Decimal("3") # 1 + 2
def test_best_venue_buy(self, test_books):
book1, book2 = test_books
agg = AggregatedOrderbook("BTC-USDC")
agg.add_orderbook(book1)
agg.add_orderbook(book2)
best = agg.best_venue_buy(Decimal("1"))
assert best is not None
venue, price = best
assert venue == "venue2" # Lower ask at 101
assert price == Decimal("101")
def test_best_venue_sell(self, test_books):
book1, book2 = test_books
agg = AggregatedOrderbook("BTC-USDC")
agg.add_orderbook(book1)
agg.add_orderbook(book2)
best = agg.best_venue_sell(Decimal("0.5"))
assert best is not None
venue, price = best
assert venue == "venue1" # Higher bid at 100
assert price == Decimal("100")
def test_best_venue_empty_book(self):
agg = AggregatedOrderbook("BTC-USDC")
assert agg.best_venue_buy(Decimal("1")) is None
assert agg.best_venue_sell(Decimal("1")) is None
+297
View File
@@ -0,0 +1,297 @@
"""Tests for LX Trading SDK risk module."""
import pytest
from decimal import Decimal
from lx_trading.risk import RiskManager, RiskError
from lx_trading.config import RiskConfig
from lx_trading.types import OrderRequest, Side, OrderType, TimeInForce
def create_risk_config(**kwargs) -> RiskConfig:
defaults = {
"enabled": True,
"max_position_size": Decimal("100"),
"max_order_size": Decimal("10"),
"max_daily_loss": Decimal("1000"),
"max_open_orders": 5,
"kill_switch_enabled": False,
"position_limits": {},
}
defaults.update(kwargs)
return RiskConfig(**defaults)
def create_order_request(**kwargs) -> OrderRequest:
defaults = {
"symbol": "BTC-USDC",
"side": Side.BUY,
"order_type": OrderType.LIMIT,
"quantity": Decimal("1"),
"price": Decimal("50000"),
"time_in_force": TimeInForce.GTC,
}
defaults.update(kwargs)
return OrderRequest(**defaults)
class TestRiskManagerConfiguration:
def test_enabled_flag(self):
enabled_manager = RiskManager(create_risk_config(enabled=True))
assert enabled_manager.is_enabled is True
disabled_manager = RiskManager(create_risk_config(enabled=False))
assert disabled_manager.is_enabled is False
def test_skip_validation_when_disabled(self):
manager = RiskManager(create_risk_config(enabled=False))
# Oversized order should pass when disabled
order = create_order_request(quantity=Decimal("1000"))
manager.validate_order(order) # Should not raise
class TestKillSwitch:
def test_starts_inactive(self):
manager = RiskManager(create_risk_config())
assert manager.is_killed is False
def test_activate_kill(self):
manager = RiskManager(create_risk_config())
manager.kill()
assert manager.is_killed is True
def test_deactivate_reset(self):
manager = RiskManager(create_risk_config())
manager.kill()
manager.reset()
assert manager.is_killed is False
def test_blocks_all_orders_when_active(self):
manager = RiskManager(create_risk_config())
manager.kill()
order = create_order_request()
with pytest.raises(RiskError, match="Kill switch"):
manager.validate_order(order)
class TestOrderSizeLimits:
def test_reject_oversized_order(self):
manager = RiskManager(create_risk_config(max_order_size=Decimal("5")))
order = create_order_request(quantity=Decimal("10"))
with pytest.raises(RiskError, match="Order size"):
manager.validate_order(order)
def test_allow_order_within_limit(self):
manager = RiskManager(create_risk_config(max_order_size=Decimal("10")))
order = create_order_request(quantity=Decimal("5"))
manager.validate_order(order) # Should not raise
def test_allow_any_size_when_no_limit(self):
manager = RiskManager(create_risk_config(
max_order_size=Decimal("0"),
max_position_size=Decimal("0"),
))
order = create_order_request(quantity=Decimal("1000"))
manager.validate_order(order) # Should not raise
class TestPositionLimits:
def test_reject_order_exceeding_max_position(self):
manager = RiskManager(create_risk_config(max_position_size=Decimal("10")))
# Simulate existing position
manager.update_position("BTC", Decimal("8"), Side.BUY)
# Trying to buy 5 more would exceed limit (8 + 5 = 13 > 10)
order = create_order_request(quantity=Decimal("5"))
with pytest.raises(RiskError, match="position"):
manager.validate_order(order)
def test_allow_order_within_position_limit(self):
manager = RiskManager(create_risk_config(max_position_size=Decimal("10")))
manager.update_position("BTC", Decimal("5"), Side.BUY)
order = create_order_request(quantity=Decimal("3"))
manager.validate_order(order) # Should not raise
def test_asset_specific_limits(self):
manager = RiskManager(create_risk_config(
position_limits={"BTC": Decimal("5")},
))
manager.update_position("BTC", Decimal("4"), Side.BUY)
# Trying to buy 2 more exceeds BTC-specific limit
order = create_order_request(quantity=Decimal("2"))
with pytest.raises(RiskError, match="Position limit"):
manager.validate_order(order)
class TestOpenOrdersLimit:
def test_reject_when_max_reached(self):
manager = RiskManager(create_risk_config(max_open_orders=3))
manager.order_opened("BTC-USDC")
manager.order_opened("BTC-USDC")
manager.order_opened("BTC-USDC")
order = create_order_request()
with pytest.raises(RiskError, match="open orders"):
manager.validate_order(order)
def test_allow_when_under_limit(self):
manager = RiskManager(create_risk_config(max_open_orders=5))
manager.order_opened("BTC-USDC")
manager.order_opened("BTC-USDC")
order = create_order_request()
manager.validate_order(order) # Should not raise
def test_track_per_symbol(self):
manager = RiskManager(create_risk_config(max_open_orders=2))
manager.order_opened("BTC-USDC")
manager.order_opened("BTC-USDC")
# Different symbol should still be allowed
order = create_order_request(symbol="ETH-USDC")
manager.validate_order(order) # Should not raise
def test_decrement_on_close(self):
manager = RiskManager(create_risk_config(max_open_orders=2))
manager.order_opened("BTC-USDC")
manager.order_opened("BTC-USDC")
manager.order_closed("BTC-USDC")
order = create_order_request()
manager.validate_order(order) # Should not raise
class TestDailyLossLimit:
def test_reject_when_exceeded(self):
manager = RiskManager(create_risk_config(max_daily_loss=Decimal("100")))
manager.update_pnl(Decimal("-150"))
order = create_order_request()
with pytest.raises(RiskError, match="Daily loss"):
manager.validate_order(order)
def test_allow_when_within_limit(self):
manager = RiskManager(create_risk_config(max_daily_loss=Decimal("100")))
manager.update_pnl(Decimal("-50"))
order = create_order_request()
manager.validate_order(order) # Should not raise
def test_auto_trigger_kill_switch(self):
manager = RiskManager(create_risk_config(
max_daily_loss=Decimal("100"),
kill_switch_enabled=True,
))
manager.update_pnl(Decimal("-150"))
assert manager.is_killed is True
def test_no_auto_trigger_when_disabled(self):
manager = RiskManager(create_risk_config(
max_daily_loss=Decimal("100"),
kill_switch_enabled=False,
))
manager.update_pnl(Decimal("-150"))
assert manager.is_killed is False
class TestPositionTracking:
def test_track_buys(self):
manager = RiskManager(create_risk_config())
manager.update_position("BTC", Decimal("5"), Side.BUY)
assert manager.position("BTC") == Decimal("5")
manager.update_position("BTC", Decimal("3"), Side.BUY)
assert manager.position("BTC") == Decimal("8")
def test_track_sells(self):
manager = RiskManager(create_risk_config())
manager.update_position("BTC", Decimal("10"), Side.BUY)
manager.update_position("BTC", Decimal("3"), Side.SELL)
assert manager.position("BTC") == Decimal("7")
def test_allow_negative_positions(self):
manager = RiskManager(create_risk_config())
manager.update_position("BTC", Decimal("5"), Side.SELL)
assert manager.position("BTC") == Decimal("-5")
def test_unknown_asset_returns_zero(self):
manager = RiskManager(create_risk_config())
assert manager.position("UNKNOWN") == Decimal("0")
def test_all_positions(self):
manager = RiskManager(create_risk_config())
manager.update_position("BTC", Decimal("5"), Side.BUY)
manager.update_position("ETH", Decimal("10"), Side.BUY)
positions = manager.positions()
assert len(positions) == 2
assert positions["BTC"] == Decimal("5")
assert positions["ETH"] == Decimal("10")
class TestPnlTracking:
def test_track_daily_pnl(self):
manager = RiskManager(create_risk_config())
manager.update_pnl(Decimal("50"))
assert manager.daily_pnl == Decimal("50")
manager.update_pnl(Decimal("-20"))
assert manager.daily_pnl == Decimal("30")
def test_reset_daily_pnl(self):
manager = RiskManager(create_risk_config())
manager.update_pnl(Decimal("100"))
manager.reset_daily_pnl()
assert manager.daily_pnl == Decimal("0")
class TestOpenOrdersTracking:
def test_track_count(self):
manager = RiskManager(create_risk_config())
assert manager.open_orders("BTC-USDC") == 0
manager.order_opened("BTC-USDC")
manager.order_opened("BTC-USDC")
assert manager.open_orders("BTC-USDC") == 2
manager.order_closed("BTC-USDC")
assert manager.open_orders("BTC-USDC") == 1
def test_not_go_below_zero(self):
manager = RiskManager(create_risk_config())
manager.order_closed("BTC-USDC")
manager.order_closed("BTC-USDC")
assert manager.open_orders("BTC-USDC") == 0
class TestRiskError:
def test_error_message(self):
error = RiskError("test message")
assert str(error) == "test message"
+257
View File
@@ -0,0 +1,257 @@
"""Tests for LX Trading SDK types module."""
import pytest
from decimal import Decimal
from lx_trading.types import (
TradingPair,
Balance,
AggregatedBalance,
OrderRequest,
Order,
Trade,
Ticker,
PriceLevel,
Fee,
Side,
OrderType,
OrderStatus,
TimeInForce,
)
class TestTradingPair:
def test_parse_dash_separator(self):
pair = TradingPair.from_symbol("BTC-USDC")
assert pair is not None
assert pair.base == "BTC"
assert pair.quote == "USDC"
def test_parse_slash_separator(self):
pair = TradingPair.from_symbol("ETH/USD")
assert pair is not None
assert pair.base == "ETH"
assert pair.quote == "USD"
def test_parse_underscore_separator(self):
pair = TradingPair.from_symbol("SOL_USDT")
assert pair is not None
assert pair.base == "SOL"
assert pair.quote == "USDT"
def test_parse_invalid_symbol(self):
pair = TradingPair.from_symbol("BTCUSDC")
assert pair is None
def test_to_hummingbot(self):
pair = TradingPair(base="BTC", quote="USDC")
assert pair.to_hummingbot() == "BTC-USDC"
def test_to_ccxt(self):
pair = TradingPair(base="ETH", quote="USD")
assert pair.to_ccxt() == "ETH/USD"
def test_str(self):
pair = TradingPair(base="BTC", quote="USDC")
assert str(pair) == "BTC-USDC"
class TestBalance:
def test_total(self):
balance = Balance(
asset="BTC",
venue="lx",
free=Decimal("1.5"),
locked=Decimal("0.5"),
)
assert balance.total == Decimal("2")
class TestAggregatedBalance:
def test_total(self):
balance = AggregatedBalance(
asset="ETH",
total_free=Decimal("10"),
total_locked=Decimal("5"),
by_venue=[],
)
assert balance.total == Decimal("15")
class TestOrderRequest:
def test_market_order(self):
order = OrderRequest.market("BTC-USDC", Side.BUY, Decimal("0.5"))
assert order.symbol == "BTC-USDC"
assert order.side == Side.BUY
assert order.order_type == OrderType.MARKET
assert order.quantity == Decimal("0.5")
assert order.time_in_force == TimeInForce.IOC
assert order.client_order_id # Should have UUID
def test_limit_order(self):
order = OrderRequest.limit("ETH-USD", Side.SELL, Decimal("1"), Decimal("2000"))
assert order.symbol == "ETH-USD"
assert order.side == Side.SELL
assert order.order_type == OrderType.LIMIT
assert order.quantity == Decimal("1")
assert order.price == Decimal("2000")
assert order.time_in_force == TimeInForce.GTC
def test_with_venue(self):
order = OrderRequest.market("BTC-USDC", Side.BUY, Decimal("1"))
order.with_venue("binance")
assert order.venue == "binance"
def test_with_post_only(self):
order = OrderRequest.limit("BTC-USDC", Side.BUY, Decimal("1"), Decimal("50000"))
order.with_post_only()
assert order.post_only is True
assert order.time_in_force == TimeInForce.POST_ONLY
class TestOrder:
def test_is_open(self):
order = Order(
order_id="1",
client_order_id="c1",
symbol="BTC-USDC",
venue="lx",
side=Side.BUY,
order_type=OrderType.LIMIT,
status=OrderStatus.OPEN,
quantity=Decimal("1"),
filled_quantity=Decimal("0"),
remaining_quantity=Decimal("1"),
price=Decimal("50000"),
average_price=None,
created_at=1000,
updated_at=1000,
)
assert order.is_open is True
assert order.is_done is False
def test_is_done(self):
order = Order(
order_id="2",
client_order_id="c2",
symbol="ETH-USD",
venue="lx",
side=Side.SELL,
order_type=OrderType.MARKET,
status=OrderStatus.FILLED,
quantity=Decimal("1"),
filled_quantity=Decimal("1"),
remaining_quantity=Decimal("0"),
price=None,
average_price=Decimal("2000"),
created_at=1000,
updated_at=1000,
)
assert order.is_open is False
assert order.is_done is True
def test_fill_percent(self):
order = Order(
order_id="3",
client_order_id="c3",
symbol="BTC-USDC",
venue="lx",
side=Side.BUY,
order_type=OrderType.LIMIT,
status=OrderStatus.PARTIALLY_FILLED,
quantity=Decimal("10"),
filled_quantity=Decimal("3"),
remaining_quantity=Decimal("7"),
price=Decimal("50000"),
average_price=Decimal("50000"),
created_at=1000,
updated_at=1000,
)
assert order.fill_percent == Decimal("30")
class TestTrade:
def test_value(self):
trade = Trade(
trade_id="t1",
order_id="o1",
symbol="BTC-USDC",
venue="lx",
side=Side.BUY,
price=Decimal("50000"),
quantity=Decimal("0.1"),
fee=Fee(asset="USDC", amount=Decimal("5")),
timestamp=1000,
is_maker=False,
)
assert trade.value == Decimal("5000")
class TestTicker:
def test_mid_price_with_bid_ask(self):
ticker = Ticker(
symbol="BTC-USDC",
venue="lx",
bid=Decimal("49900"),
ask=Decimal("50100"),
last=Decimal("50000"),
volume_24h=None,
high_24h=None,
low_24h=None,
change_24h=None,
timestamp=1000,
)
assert ticker.mid_price == Decimal("50000")
def test_mid_price_without_bid_ask(self):
ticker = Ticker(
symbol="BTC-USDC",
venue="lx",
bid=None,
ask=None,
last=Decimal("50000"),
volume_24h=None,
high_24h=None,
low_24h=None,
change_24h=None,
timestamp=1000,
)
assert ticker.mid_price == Decimal("50000")
def test_spread(self):
ticker = Ticker(
symbol="BTC-USDC",
venue="lx",
bid=Decimal("49900"),
ask=Decimal("50100"),
last=Decimal("50000"),
volume_24h=None,
high_24h=None,
low_24h=None,
change_24h=None,
timestamp=1000,
)
assert ticker.spread == Decimal("200")
def test_spread_percent(self):
ticker = Ticker(
symbol="BTC-USDC",
venue="lx",
bid=Decimal("49900"),
ask=Decimal("50100"),
last=Decimal("50000"),
volume_24h=None,
high_24h=None,
low_24h=None,
change_24h=None,
timestamp=1000,
)
# spread_percent = (200/49900) * 100 ≈ 0.401
assert ticker.spread_percent is not None
assert 0.4 < float(ticker.spread_percent) < 0.41
class TestPriceLevel:
def test_value(self):
level = PriceLevel(price=Decimal("100"), quantity=Decimal("5"))
assert level.value == Decimal("500")
+406
View File
@@ -0,0 +1,406 @@
/**
* Tests for LX Trading SDK math module
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import {
blackScholes,
impliedVolatility,
greeks,
constantProductPrice,
concentratedLiquidityPrice,
calculateLiquidity,
volatility,
sharpeRatio,
sortinoRatio,
maxDrawdown,
valueAtRisk,
conditionalVaR,
normCdf,
normPdf,
priceToSqrtPrice,
sqrtPriceToPrice,
tickToSqrtPrice,
sqrtPriceToTick,
calculateReturns,
calculateLogReturns,
} from './math.js';
// Helper for floating point comparison
function approxEqual(actual: number, expected: number, epsilon = 0.01): boolean {
return Math.abs(actual - expected) < epsilon;
}
describe('Black-Scholes Options Pricing', () => {
it('should price ATM call option correctly', () => {
// At-the-money call: S=100, K=100, T=1yr, r=5%, vol=20%
const price = blackScholes(100, 100, 1, 0.05, 0.2, 'call');
// Expected ~10.45 based on standard B-S
assert.ok(approxEqual(price, 10.45, 0.5));
});
it('should price ATM put option correctly', () => {
const price = blackScholes(100, 100, 1, 0.05, 0.2, 'put');
// Put-call parity: Put = Call - S + K*exp(-rT)
// Put ≈ 10.45 - 100 + 100*0.951 = 5.58
assert.ok(approxEqual(price, 5.57, 0.5));
});
it('should return intrinsic value at expiry', () => {
// At expiry (T=0), call = max(S-K, 0)
const callITM = blackScholes(110, 100, 0, 0.05, 0.2, 'call');
assert.strictEqual(callITM, 10);
const callOTM = blackScholes(90, 100, 0, 0.05, 0.2, 'call');
assert.strictEqual(callOTM, 0);
const putITM = blackScholes(90, 100, 0, 0.05, 0.2, 'put');
assert.strictEqual(putITM, 10);
});
it('should price deep ITM call close to intrinsic', () => {
// Deep in-the-money call
const price = blackScholes(150, 100, 0.1, 0.05, 0.2, 'call');
assert.ok(price > 49); // At least intrinsic value
assert.ok(price < 52); // Not too much time value left
});
it('should price deep OTM call close to zero', () => {
const price = blackScholes(50, 100, 0.1, 0.05, 0.2, 'call');
assert.ok(price < 0.01);
});
});
describe('Implied Volatility', () => {
it('should recover volatility from option price', () => {
const trueVol = 0.25;
const price = blackScholes(100, 100, 1, 0.05, trueVol, 'call');
const iv = impliedVolatility(price, 100, 100, 1, 0.05, 'call');
assert.ok(approxEqual(iv, trueVol, 0.01));
});
it('should work for puts', () => {
const trueVol = 0.30;
const price = blackScholes(100, 105, 0.5, 0.03, trueVol, 'put');
const iv = impliedVolatility(price, 100, 105, 0.5, 0.03, 'put');
assert.ok(approxEqual(iv, trueVol, 0.01));
});
});
describe('Greeks', () => {
it('should calculate delta for ATM call', () => {
const g = greeks(100, 100, 1, 0.05, 0.2, 'call');
// ATM call delta should be around 0.5-0.6
assert.ok(g.delta > 0.5 && g.delta < 0.7);
});
it('should calculate delta for ATM put', () => {
const g = greeks(100, 100, 1, 0.05, 0.2, 'put');
// Put delta is negative
assert.ok(g.delta > -0.6 && g.delta < -0.3);
});
it('should have positive gamma', () => {
const g = greeks(100, 100, 1, 0.05, 0.2, 'call');
assert.ok(g.gamma > 0);
});
it('should have negative theta for long call', () => {
const g = greeks(100, 100, 1, 0.05, 0.2, 'call');
assert.ok(g.theta < 0); // Time decay hurts long positions
});
it('should have positive vega', () => {
const g = greeks(100, 100, 1, 0.05, 0.2, 'call');
assert.ok(g.vega > 0); // Higher vol helps long options
});
it('should return zeros at expiry', () => {
const g = greeks(100, 100, 0, 0.05, 0.2, 'call');
assert.strictEqual(g.delta, 0);
assert.strictEqual(g.gamma, 0);
assert.strictEqual(g.vega, 0);
});
});
describe('Constant Product AMM', () => {
it('should calculate output for balanced pool', () => {
// Equal reserves, swap 10 X for Y with 0.3% fee
const result = constantProductPrice(1000, 1000, 10, 0.003, true);
// Without fee: dy = 1000 * 10 / (1000 + 10) ≈ 9.9
// With fee: dy = 1000 * 9.97 / (1000 + 9.97) ≈ 9.87
assert.ok(approxEqual(result.outputAmount, 9.87, 0.1));
});
it('should calculate effective price', () => {
const result = constantProductPrice(1000, 2000, 10, 0.003, true);
// Price = output/input
assert.ok(result.effectivePrice > 0);
assert.ok(result.effectivePrice < 2); // Less than spot rate due to slippage
});
it('should work in both directions', () => {
const xToY = constantProductPrice(1000, 1000, 10, 0.003, true);
const yToX = constantProductPrice(1000, 1000, 10, 0.003, false);
// Symmetric pool, same input, same output
assert.ok(approxEqual(xToY.outputAmount, yToX.outputAmount, 0.1));
});
it('should handle zero input', () => {
const result = constantProductPrice(1000, 1000, 0, 0.003, true);
assert.strictEqual(result.outputAmount, 0);
assert.strictEqual(result.effectivePrice, 0);
});
it('should account for fees', () => {
const noFee = constantProductPrice(1000, 1000, 100, 0, true);
const withFee = constantProductPrice(1000, 1000, 100, 0.01, true);
assert.ok(withFee.outputAmount < noFee.outputAmount);
});
});
describe('Concentrated Liquidity', () => {
it('should calculate output within range', () => {
// L=1000, sqrt price = 10 (price=100), range [9, 11]
const result = concentratedLiquidityPrice(1000, 10, 9, 11, 10, 0.003, true);
assert.ok(result.outputAmount > 0);
assert.ok(result.newSqrtPrice > 10); // Price goes up when buying Y
assert.ok(result.newSqrtPrice <= 11); // Capped at upper bound
});
it('should calculate price impact', () => {
const result = concentratedLiquidityPrice(1000, 10, 9, 11, 100, 0.003, true);
assert.ok(result.priceImpact > 0);
assert.ok(result.priceImpact < 1); // Less than 100%
});
it('should handle swapping Y for X', () => {
const result = concentratedLiquidityPrice(1000, 10, 9, 11, 10, 0.003, false);
assert.ok(result.outputAmount > 0);
assert.ok(result.newSqrtPrice < 10); // Price goes down when selling Y
assert.ok(result.newSqrtPrice >= 9); // Capped at lower bound
});
});
describe('Calculate Liquidity', () => {
it('should calculate liquidity for in-range position', () => {
const L = calculateLiquidity(100, 1000, 10, 9, 11);
assert.ok(L > 0);
});
it('should calculate liquidity below range (only X)', () => {
const L = calculateLiquidity(100, 0, 8, 9, 11);
assert.ok(L > 0);
});
it('should calculate liquidity above range (only Y)', () => {
const L = calculateLiquidity(0, 100, 12, 9, 11);
assert.ok(L > 0);
});
});
describe('Volatility', () => {
it('should calculate volatility of returns', () => {
const returns = [0.01, -0.02, 0.015, -0.005, 0.02, -0.01, 0.005];
const vol = volatility(returns, false);
assert.ok(vol > 0);
assert.ok(vol < 1); // Should be reasonable for small returns
});
it('should annualize volatility', () => {
const returns = [0.01, -0.02, 0.015, -0.005, 0.02, -0.01, 0.005];
const dailyVol = volatility(returns, false);
const annualVol = volatility(returns, true, 252);
// Annual vol ≈ daily vol * sqrt(252)
assert.ok(approxEqual(annualVol, dailyVol * Math.sqrt(252), 0.01));
});
it('should return 0 for insufficient data', () => {
assert.strictEqual(volatility([0.01]), 0);
assert.strictEqual(volatility([]), 0);
});
});
describe('Sharpe Ratio', () => {
it('should calculate positive Sharpe for good returns', () => {
// Positive average returns with moderate vol
const returns = [0.01, 0.02, 0.015, 0.005, 0.02, 0.01, 0.015];
const sharpe = sharpeRatio(returns, 0, 252);
assert.ok(sharpe > 0);
});
it('should calculate negative Sharpe for poor returns', () => {
const returns = [-0.01, -0.02, -0.015, -0.005, -0.02, -0.01, -0.015];
const sharpe = sharpeRatio(returns, 0, 252);
assert.ok(sharpe < 0);
});
it('should return 0 for insufficient data', () => {
assert.strictEqual(sharpeRatio([0.01]), 0);
});
it('should account for risk-free rate', () => {
const returns = [0.0001, 0.0002, 0.0001, 0.0002, 0.0001]; // ~2.5% annual
const sharpeNoRf = sharpeRatio(returns, 0, 252);
const sharpeWithRf = sharpeRatio(returns, 0.05, 252); // 5% risk-free
assert.ok(sharpeWithRf < sharpeNoRf);
});
});
describe('Sortino Ratio', () => {
it('should calculate Sortino ratio', () => {
const returns = [0.01, -0.02, 0.015, -0.005, 0.02, -0.01, 0.015];
const sortino = sortinoRatio(returns, 0, 0, 252);
// Sortino should be defined for this data
assert.ok(!isNaN(sortino));
});
it('should be higher than Sharpe when downside limited', () => {
// Returns with small downside
const returns = [0.02, 0.01, 0.015, -0.002, 0.02, 0.01, 0.015];
const sharpe = sharpeRatio(returns, 0, 252);
const sortino = sortinoRatio(returns, 0, 0, 252);
// With limited downside, Sortino typically > Sharpe
assert.ok(sortino >= sharpe);
});
});
describe('Maximum Drawdown', () => {
it('should find maximum drawdown', () => {
const prices = [100, 110, 105, 120, 90, 95, 100];
const result = maxDrawdown(prices);
// Peak at 120, trough at 90: drawdown = (120-90)/120 = 0.25
assert.ok(approxEqual(result.maxDrawdown, 0.25, 0.01));
});
it('should track peak and trough indices', () => {
const prices = [100, 110, 105, 120, 90, 95, 100];
const result = maxDrawdown(prices);
assert.strictEqual(result.peakIndex, 3); // Index of 120
assert.strictEqual(result.troughIndex, 4); // Index of 90
});
it('should return 0 for monotonic increase', () => {
const prices = [100, 110, 120, 130, 140];
const result = maxDrawdown(prices);
assert.strictEqual(result.maxDrawdown, 0);
});
it('should handle insufficient data', () => {
const result = maxDrawdown([100]);
assert.strictEqual(result.maxDrawdown, 0);
});
});
describe('Value at Risk', () => {
it('should calculate historical VaR', () => {
// Generate some returns with known distribution
const returns = Array.from({ length: 100 }, (_, i) => (i - 50) / 1000);
const var95 = valueAtRisk(returns, 0.95, 'historical');
assert.ok(var95 > 0);
});
it('should calculate parametric VaR', () => {
const returns = [-0.03, -0.02, -0.01, 0, 0.01, 0.02, 0.03, -0.015, 0.015, -0.025];
const var95 = valueAtRisk(returns, 0.95, 'parametric');
assert.ok(var95 > 0);
});
it('should return 0 for insufficient data', () => {
const var95 = valueAtRisk([0.01, 0.02], 0.95);
assert.strictEqual(var95, 0);
});
it('should be higher for 99% confidence than 95%', () => {
const returns = Array.from({ length: 100 }, () => (Math.random() - 0.5) * 0.1);
const var95 = valueAtRisk(returns, 0.95);
const var99 = valueAtRisk(returns, 0.99);
assert.ok(var99 >= var95);
});
});
describe('Conditional VaR (CVaR)', () => {
it('should be greater than or equal to VaR', () => {
const returns = Array.from({ length: 100 }, () => (Math.random() - 0.5) * 0.1);
const var95 = valueAtRisk(returns, 0.95);
const cvar95 = conditionalVaR(returns, 0.95);
assert.ok(cvar95 >= var95 * 0.99); // Allow small numerical tolerance
});
it('should return 0 for insufficient data', () => {
assert.strictEqual(conditionalVaR([0.01, 0.02], 0.95), 0);
});
});
describe('Normal Distribution Helpers', () => {
it('should calculate CDF correctly', () => {
assert.ok(approxEqual(normCdf(0), 0.5, 0.001));
assert.ok(approxEqual(normCdf(-1.96), 0.025, 0.01));
assert.ok(approxEqual(normCdf(1.96), 0.975, 0.01));
});
it('should calculate PDF correctly', () => {
// PDF at 0 = 1/sqrt(2*pi) ≈ 0.3989
assert.ok(approxEqual(normPdf(0), 0.3989, 0.001));
// PDF is symmetric
assert.ok(approxEqual(normPdf(-1), normPdf(1), 0.0001));
});
});
describe('Price/Tick Conversions', () => {
it('should convert price to sqrt price', () => {
assert.strictEqual(priceToSqrtPrice(100), 10);
assert.strictEqual(priceToSqrtPrice(1), 1);
});
it('should convert sqrt price to price', () => {
assert.strictEqual(sqrtPriceToPrice(10), 100);
assert.strictEqual(sqrtPriceToPrice(1), 1);
});
it('should be inverse operations', () => {
const price = 150;
const recovered = sqrtPriceToPrice(priceToSqrtPrice(price));
assert.ok(approxEqual(recovered, price, 0.0001));
});
it('should convert tick to sqrt price', () => {
// tick = 0 => sqrt(1.0001^0) = 1
assert.ok(approxEqual(tickToSqrtPrice(0), 1, 0.0001));
});
it('should convert sqrt price to tick', () => {
const sqrtP = tickToSqrtPrice(1000);
const tick = sqrtPriceToTick(sqrtP, 1);
assert.ok(approxEqual(tick, 1000, 1));
});
});
describe('Returns Calculation', () => {
it('should calculate simple returns', () => {
const prices = [100, 110, 105, 115];
const returns = calculateReturns(prices);
assert.strictEqual(returns.length, 3);
assert.ok(approxEqual(returns[0]!, 0.1, 0.0001)); // (110-100)/100
assert.ok(approxEqual(returns[1]!, -0.0455, 0.001)); // (105-110)/110
assert.ok(approxEqual(returns[2]!, 0.0952, 0.001)); // (115-105)/105
});
it('should calculate log returns', () => {
const prices = [100, 110, 105, 115];
const returns = calculateLogReturns(prices);
assert.strictEqual(returns.length, 3);
assert.ok(approxEqual(returns[0]!, Math.log(110 / 100), 0.0001));
});
it('should handle empty array', () => {
assert.strictEqual(calculateReturns([]).length, 0);
assert.strictEqual(calculateLogReturns([]).length, 0);
});
it('should handle single price', () => {
assert.strictEqual(calculateReturns([100]).length, 0);
assert.strictEqual(calculateLogReturns([100]).length, 0);
});
});
+370
View File
@@ -0,0 +1,370 @@
/**
* Tests for LX Trading SDK orderbook module
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Decimal } from 'decimal.js';
import { Orderbook, AggregatedOrderbook } from './orderbook.js';
import { Side } from './types.js';
// Helper for floating point comparison
function approxEqual(actual: number, expected: number, epsilon = 0.01): boolean {
return Math.abs(actual - expected) < epsilon;
}
describe('Orderbook', () => {
describe('Basic operations', () => {
it('should create with symbol and venue', () => {
const book = new Orderbook('BTC-USDC', 'lx');
assert.strictEqual(book.symbol, 'BTC-USDC');
assert.strictEqual(book.venue, 'lx');
assert.ok(book.timestamp > 0);
});
it('should add and retrieve bids', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1'));
book.addBid(new Decimal('99'), new Decimal('2'));
book.sort();
assert.strictEqual(book.bids.length, 2);
assert.ok(book.bids[0]?.price.eq(100)); // Highest bid first
assert.ok(book.bids[1]?.price.eq(99));
});
it('should add and retrieve asks', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addAsk(new Decimal('102'), new Decimal('1.5'));
book.addAsk(new Decimal('101'), new Decimal('2.5'));
book.sort();
assert.strictEqual(book.asks.length, 2);
assert.ok(book.asks[0]?.price.eq(101)); // Lowest ask first
assert.ok(book.asks[1]?.price.eq(102));
});
it('should sort bids descending and asks ascending', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('98'), new Decimal('1'));
book.addBid(new Decimal('100'), new Decimal('1'));
book.addBid(new Decimal('99'), new Decimal('1'));
book.addAsk(new Decimal('103'), new Decimal('1'));
book.addAsk(new Decimal('101'), new Decimal('1'));
book.addAsk(new Decimal('102'), new Decimal('1'));
book.sort();
assert.ok(book.bids[0]?.price.eq(100));
assert.ok(book.bids[1]?.price.eq(99));
assert.ok(book.bids[2]?.price.eq(98));
assert.ok(book.asks[0]?.price.eq(101));
assert.ok(book.asks[1]?.price.eq(102));
assert.ok(book.asks[2]?.price.eq(103));
});
});
describe('Price properties', () => {
it('should return best bid', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1'));
book.addBid(new Decimal('99'), new Decimal('2'));
book.sort();
assert.ok(book.bestBid?.eq(100));
});
it('should return best ask', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addAsk(new Decimal('101'), new Decimal('1'));
book.addAsk(new Decimal('102'), new Decimal('2'));
book.sort();
assert.ok(book.bestAsk?.eq(101));
});
it('should return undefined when empty', () => {
const book = new Orderbook('BTC-USDC', 'lx');
assert.strictEqual(book.bestBid, undefined);
assert.strictEqual(book.bestAsk, undefined);
});
it('should calculate mid price', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1'));
book.addAsk(new Decimal('102'), new Decimal('1'));
book.sort();
assert.ok(book.midPrice?.eq(101));
});
it('should calculate spread', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1'));
book.addAsk(new Decimal('102'), new Decimal('1'));
book.sort();
assert.ok(book.spread?.eq(2));
});
it('should calculate spread percent', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1'));
book.addAsk(new Decimal('102'), new Decimal('1'));
book.sort();
// spread = 2, mid = 101, spread% = 2/101*100 ≈ 1.98
const spreadPct = book.spreadPercent;
assert.ok(spreadPct);
assert.ok(approxEqual(spreadPct.toNumber(), 1.98, 0.01));
});
});
describe('Liquidity', () => {
it('should calculate bid liquidity', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1')); // 100 value
book.addBid(new Decimal('99'), new Decimal('2')); // 198 value
book.sort();
// Total = 100 + 198 = 298
assert.ok(book.bidLiquidity.eq(298));
});
it('should calculate ask liquidity', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addAsk(new Decimal('101'), new Decimal('1.5')); // 151.5 value
book.addAsk(new Decimal('102'), new Decimal('2.5')); // 255 value
book.sort();
// Total = 151.5 + 255 = 406.5
assert.ok(book.askLiquidity.eq(406.5));
});
it('should calculate bid depth for specific levels', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1')); // 100 value
book.addBid(new Decimal('99'), new Decimal('2')); // 198 value
book.addBid(new Decimal('98'), new Decimal('3')); // 294 value
book.sort();
assert.ok(book.bidDepth(1).eq(100)); // First level only
assert.ok(book.bidDepth(2).eq(298)); // First two levels
});
it('should calculate ask depth for specific levels', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addAsk(new Decimal('101'), new Decimal('1.5')); // 151.5
book.addAsk(new Decimal('102'), new Decimal('2.5')); // 255
book.sort();
assert.ok(book.askDepth(1).eq(151.5));
});
});
describe('VWAP', () => {
it('should calculate VWAP for small buy', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addAsk(new Decimal('100'), new Decimal('1'));
book.addAsk(new Decimal('101'), new Decimal('2'));
book.addAsk(new Decimal('102'), new Decimal('3'));
book.sort();
// Buying 0.5 at 100 = VWAP 100
const vwap = book.vwapBuy(new Decimal('0.5'));
assert.ok(vwap?.eq(100));
});
it('should calculate VWAP across multiple levels', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addAsk(new Decimal('100'), new Decimal('1'));
book.addAsk(new Decimal('101'), new Decimal('2'));
book.addAsk(new Decimal('102'), new Decimal('3'));
book.sort();
// Buying 2.5: 1@100 + 1.5@101 = 100 + 151.5 = 251.5 / 2.5 = 100.6
const vwap = book.vwapBuy(new Decimal('2.5'));
assert.ok(vwap);
assert.ok(approxEqual(vwap.toNumber(), 100.6, 0.01));
});
it('should calculate VWAP for sell', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('1'));
book.addBid(new Decimal('99'), new Decimal('2'));
book.sort();
// Selling 0.5 at 100 = VWAP 100
const vwap = book.vwapSell(new Decimal('0.5'));
assert.ok(vwap?.eq(100));
});
it('should return undefined for empty book', () => {
const book = new Orderbook('BTC-USDC', 'lx');
assert.strictEqual(book.vwapBuy(new Decimal('1')), undefined);
assert.strictEqual(book.vwapSell(new Decimal('1')), undefined);
});
});
describe('Liquidity check', () => {
it('should detect sufficient liquidity for buy', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addAsk(new Decimal('100'), new Decimal('5'));
book.sort();
assert.ok(book.hasLiquidity(Side.BUY, new Decimal('3')));
assert.ok(!book.hasLiquidity(Side.BUY, new Decimal('10')));
});
it('should detect sufficient liquidity for sell', () => {
const book = new Orderbook('BTC-USDC', 'lx');
book.addBid(new Decimal('100'), new Decimal('5'));
book.sort();
assert.ok(book.hasLiquidity(Side.SELL, new Decimal('3')));
assert.ok(!book.hasLiquidity(Side.SELL, new Decimal('10')));
});
});
});
describe('AggregatedOrderbook', () => {
function createTestBooks(): [Orderbook, Orderbook] {
const book1 = new Orderbook('BTC-USDC', 'venue1');
book1.addBid(new Decimal('100'), new Decimal('1'));
book1.addAsk(new Decimal('102'), new Decimal('1'));
book1.sort();
const book2 = new Orderbook('BTC-USDC', 'venue2');
book2.addBid(new Decimal('99'), new Decimal('2'));
book2.addAsk(new Decimal('101'), new Decimal('1.5'));
book2.sort();
return [book1, book2];
}
it('should aggregate orderbooks from multiple venues', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const [book1, book2] = createTestBooks();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
assert.strictEqual(agg.bids.size, 2);
assert.strictEqual(agg.asks.size, 2);
});
describe('Best bid/ask', () => {
it('should find best bid across venues', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const [book1, book2] = createTestBooks();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
const best = agg.bestBid();
assert.ok(best);
assert.ok(best.price.eq(100));
assert.strictEqual(best.venue, 'venue1');
});
it('should find best ask across venues', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const [book1, book2] = createTestBooks();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
const best = agg.bestAsk();
assert.ok(best);
assert.ok(best.price.eq(101));
assert.strictEqual(best.venue, 'venue2');
});
it('should return undefined for empty book', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
assert.strictEqual(agg.bestBid(), undefined);
assert.strictEqual(agg.bestAsk(), undefined);
});
});
describe('Aggregated levels', () => {
it('should aggregate bids sorted descending', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const [book1, book2] = createTestBooks();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
const bids = agg.aggregatedBids();
assert.strictEqual(bids.length, 2);
assert.ok(bids[0]?.price.eq(100)); // Highest first
assert.ok(bids[1]?.price.eq(99));
});
it('should aggregate asks sorted ascending', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const [book1, book2] = createTestBooks();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
const asks = agg.aggregatedAsks();
assert.strictEqual(asks.length, 2);
assert.ok(asks[0]?.price.eq(101)); // Lowest first
assert.ok(asks[1]?.price.eq(102));
});
it('should sum quantities at same price from different venues', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const book1 = new Orderbook('BTC-USDC', 'venue1');
book1.addBid(new Decimal('100'), new Decimal('1'));
book1.sort();
const book2 = new Orderbook('BTC-USDC', 'venue2');
book2.addBid(new Decimal('100'), new Decimal('2'));
book2.sort();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
const bids = agg.aggregatedBids();
assert.strictEqual(bids.length, 1);
assert.ok(bids[0]?.quantity.eq(3)); // 1 + 2
});
});
describe('Best venue routing', () => {
it('should find best venue for buying', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const [book1, book2] = createTestBooks();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
const best = agg.bestVenueBuy(new Decimal('1'));
assert.ok(best);
assert.strictEqual(best.venue, 'venue2'); // Lower ask at 101
assert.ok(best.price.eq(101));
});
it('should find best venue for selling', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
const [book1, book2] = createTestBooks();
agg.addOrderbook(book1);
agg.addOrderbook(book2);
const best = agg.bestVenueSell(new Decimal('0.5'));
assert.ok(best);
assert.strictEqual(best.venue, 'venue1'); // Higher bid at 100
assert.ok(best.price.eq(100));
});
it('should return undefined for empty book', () => {
const agg = new AggregatedOrderbook('BTC-USDC');
assert.strictEqual(agg.bestVenueBuy(new Decimal('1')), undefined);
assert.strictEqual(agg.bestVenueSell(new Decimal('1')), undefined);
});
});
});
+336
View File
@@ -0,0 +1,336 @@
/**
* Tests for LX Trading SDK risk module
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Decimal } from 'decimal.js';
import { RiskManager, RiskError } from './risk.js';
import { Side, OrderType, TimeInForce, type OrderRequest } from './types.js';
import type { RiskConfig } from './config.js';
function createRiskConfig(overrides?: Partial<RiskConfig>): RiskConfig {
return {
enabled: true,
maxPositionSize: new Decimal(100),
maxOrderSize: new Decimal(10),
maxDailyLoss: new Decimal(1000),
maxOpenOrders: 5,
killSwitchEnabled: false,
positionLimits: new Map(),
...overrides,
};
}
function createOrderRequest(overrides?: Partial<OrderRequest>): OrderRequest {
return {
symbol: 'BTC-USDC',
side: Side.BUY,
orderType: OrderType.LIMIT,
quantity: new Decimal(1),
price: new Decimal(50000),
timeInForce: TimeInForce.GTC,
reduceOnly: false,
postOnly: false,
clientOrderId: `test-${Date.now()}`,
...overrides,
};
}
describe('RiskManager', () => {
describe('Configuration', () => {
it('should respect enabled flag', () => {
const manager = new RiskManager(createRiskConfig({ enabled: true }));
assert.ok(manager.isEnabled);
const disabledManager = new RiskManager(createRiskConfig({ enabled: false }));
assert.ok(!disabledManager.isEnabled);
});
it('should skip validation when disabled', () => {
const manager = new RiskManager(createRiskConfig({ enabled: false }));
// Oversized order should pass when disabled
const order = createOrderRequest({ quantity: new Decimal(1000) });
assert.doesNotThrow(() => manager.validateOrder(order));
});
});
describe('Kill switch', () => {
it('should start inactive', () => {
const manager = new RiskManager(createRiskConfig());
assert.ok(!manager.isKilled);
});
it('should activate on kill()', () => {
const manager = new RiskManager(createRiskConfig());
manager.kill();
assert.ok(manager.isKilled);
});
it('should deactivate on reset()', () => {
const manager = new RiskManager(createRiskConfig());
manager.kill();
manager.reset();
assert.ok(!manager.isKilled);
});
it('should block all orders when active', () => {
const manager = new RiskManager(createRiskConfig());
manager.kill();
const order = createOrderRequest();
assert.throws(() => manager.validateOrder(order), RiskError);
});
});
describe('Order size limits', () => {
it('should reject orders exceeding max size', () => {
const manager = new RiskManager(createRiskConfig({ maxOrderSize: new Decimal(5) }));
const order = createOrderRequest({ quantity: new Decimal(10) });
assert.throws(() => manager.validateOrder(order), RiskError);
});
it('should allow orders within limit', () => {
const manager = new RiskManager(createRiskConfig({ maxOrderSize: new Decimal(10) }));
const order = createOrderRequest({ quantity: new Decimal(5) });
assert.doesNotThrow(() => manager.validateOrder(order));
});
it('should allow any size when max is zero (no limit)', () => {
const manager = new RiskManager(createRiskConfig({
maxOrderSize: new Decimal(0),
maxPositionSize: new Decimal(0), // Also disable position limit
}));
const order = createOrderRequest({ quantity: new Decimal(1000) });
assert.doesNotThrow(() => manager.validateOrder(order));
});
});
describe('Position limits', () => {
it('should reject orders exceeding max position', () => {
const manager = new RiskManager(createRiskConfig({ maxPositionSize: new Decimal(10) }));
// Simulate existing position
manager.updatePosition('BTC', new Decimal(8), Side.BUY);
// Trying to buy 5 more would exceed limit (8 + 5 = 13 > 10)
const order = createOrderRequest({ quantity: new Decimal(5) });
assert.throws(() => manager.validateOrder(order), RiskError);
});
it('should allow orders within position limit', () => {
const manager = new RiskManager(createRiskConfig({ maxPositionSize: new Decimal(10) }));
manager.updatePosition('BTC', new Decimal(5), Side.BUY);
const order = createOrderRequest({ quantity: new Decimal(3) });
assert.doesNotThrow(() => manager.validateOrder(order));
});
it('should respect asset-specific limits', () => {
const positionLimits = new Map([['BTC', new Decimal(5)]]);
const manager = new RiskManager(createRiskConfig({ positionLimits }));
// Existing position of 4
manager.updatePosition('BTC', new Decimal(4), Side.BUY);
// Trying to buy 2 more exceeds BTC-specific limit
const order = createOrderRequest({ quantity: new Decimal(2) });
assert.throws(() => manager.validateOrder(order), RiskError);
});
});
describe('Open orders limit', () => {
it('should reject orders when max open orders reached', () => {
const manager = new RiskManager(createRiskConfig({ maxOpenOrders: 3 }));
// Open 3 orders
manager.orderOpened('BTC-USDC');
manager.orderOpened('BTC-USDC');
manager.orderOpened('BTC-USDC');
const order = createOrderRequest();
assert.throws(() => manager.validateOrder(order), RiskError);
});
it('should allow orders when under limit', () => {
const manager = new RiskManager(createRiskConfig({ maxOpenOrders: 5 }));
manager.orderOpened('BTC-USDC');
manager.orderOpened('BTC-USDC');
const order = createOrderRequest();
assert.doesNotThrow(() => manager.validateOrder(order));
});
it('should track orders per symbol', () => {
const manager = new RiskManager(createRiskConfig({ maxOpenOrders: 2 }));
manager.orderOpened('BTC-USDC');
manager.orderOpened('BTC-USDC');
// Different symbol should still be allowed
const order = createOrderRequest({ symbol: 'ETH-USDC' });
assert.doesNotThrow(() => manager.validateOrder(order));
});
it('should decrement on order close', () => {
const manager = new RiskManager(createRiskConfig({ maxOpenOrders: 2 }));
manager.orderOpened('BTC-USDC');
manager.orderOpened('BTC-USDC');
manager.orderClosed('BTC-USDC');
const order = createOrderRequest();
assert.doesNotThrow(() => manager.validateOrder(order));
});
});
describe('Daily loss limit', () => {
it('should reject orders when daily loss exceeded', () => {
const manager = new RiskManager(createRiskConfig({ maxDailyLoss: new Decimal(100) }));
// Record loss exceeding daily limit
manager.updatePnl(new Decimal(-150));
const order = createOrderRequest();
assert.throws(() => manager.validateOrder(order), RiskError);
});
it('should allow orders when within daily loss limit', () => {
const manager = new RiskManager(createRiskConfig({ maxDailyLoss: new Decimal(100) }));
manager.updatePnl(new Decimal(-50));
const order = createOrderRequest();
assert.doesNotThrow(() => manager.validateOrder(order));
});
it('should auto-trigger kill switch when enabled', () => {
const manager = new RiskManager(
createRiskConfig({
maxDailyLoss: new Decimal(100),
killSwitchEnabled: true,
}),
);
manager.updatePnl(new Decimal(-150));
assert.ok(manager.isKilled);
});
it('should not trigger kill switch when disabled', () => {
const manager = new RiskManager(
createRiskConfig({
maxDailyLoss: new Decimal(100),
killSwitchEnabled: false,
}),
);
manager.updatePnl(new Decimal(-150));
assert.ok(!manager.isKilled);
});
});
describe('Position tracking', () => {
it('should track position after buys', () => {
const manager = new RiskManager(createRiskConfig());
manager.updatePosition('BTC', new Decimal(5), Side.BUY);
assert.ok(manager.position('BTC').eq(5));
manager.updatePosition('BTC', new Decimal(3), Side.BUY);
assert.ok(manager.position('BTC').eq(8));
});
it('should track position after sells', () => {
const manager = new RiskManager(createRiskConfig());
manager.updatePosition('BTC', new Decimal(10), Side.BUY);
manager.updatePosition('BTC', new Decimal(3), Side.SELL);
assert.ok(manager.position('BTC').eq(7));
});
it('should allow negative positions (shorts)', () => {
const manager = new RiskManager(createRiskConfig());
manager.updatePosition('BTC', new Decimal(5), Side.SELL);
assert.ok(manager.position('BTC').eq(-5));
});
it('should return zero for unknown assets', () => {
const manager = new RiskManager(createRiskConfig());
assert.ok(manager.position('UNKNOWN').eq(0));
});
it('should return all positions', () => {
const manager = new RiskManager(createRiskConfig());
manager.updatePosition('BTC', new Decimal(5), Side.BUY);
manager.updatePosition('ETH', new Decimal(10), Side.BUY);
const positions = manager.allPositions();
assert.strictEqual(positions.size, 2);
assert.ok(positions.get('BTC')?.eq(5));
assert.ok(positions.get('ETH')?.eq(10));
});
});
describe('PnL tracking', () => {
it('should track daily PnL', () => {
const manager = new RiskManager(createRiskConfig());
manager.updatePnl(new Decimal(50));
assert.ok(manager.getDailyPnl().eq(50));
manager.updatePnl(new Decimal(-20));
assert.ok(manager.getDailyPnl().eq(30));
});
it('should reset daily PnL', () => {
const manager = new RiskManager(createRiskConfig());
manager.updatePnl(new Decimal(100));
manager.resetDailyPnl();
assert.ok(manager.getDailyPnl().eq(0));
});
});
describe('Open orders tracking', () => {
it('should track open orders count', () => {
const manager = new RiskManager(createRiskConfig());
assert.strictEqual(manager.openOrders('BTC-USDC'), 0);
manager.orderOpened('BTC-USDC');
manager.orderOpened('BTC-USDC');
assert.strictEqual(manager.openOrders('BTC-USDC'), 2);
manager.orderClosed('BTC-USDC');
assert.strictEqual(manager.openOrders('BTC-USDC'), 1);
});
it('should not go below zero', () => {
const manager = new RiskManager(createRiskConfig());
manager.orderClosed('BTC-USDC');
manager.orderClosed('BTC-USDC');
assert.strictEqual(manager.openOrders('BTC-USDC'), 0);
});
});
describe('RiskError', () => {
it('should have correct name', () => {
const error = new RiskError('test message');
assert.strictEqual(error.name, 'RiskError');
assert.strictEqual(error.message, 'test message');
});
});
});
+237
View File
@@ -0,0 +1,237 @@
/**
* Tests for LX Trading SDK types
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Decimal } from 'decimal.js';
import {
TradingPair,
Side,
OrderType,
OrderStatus,
TimeInForce,
balanceTotal,
aggregatedBalanceTotal,
createMarketOrder,
createLimitOrder,
orderIsOpen,
orderIsDone,
orderFillPercent,
tradeValue,
tickerMidPrice,
tickerSpread,
tickerSpreadPercent,
priceLevelValue,
type Balance,
type AggregatedBalance,
type Order,
type Trade,
type Ticker,
type PriceLevel,
} from './types.js';
describe('TradingPair', () => {
it('should parse symbol with dash separator', () => {
const pair = TradingPair.fromSymbol('BTC-USDC');
assert.ok(pair);
assert.strictEqual(pair.base, 'BTC');
assert.strictEqual(pair.quote, 'USDC');
});
it('should parse symbol with slash separator', () => {
const pair = TradingPair.fromSymbol('ETH/USD');
assert.ok(pair);
assert.strictEqual(pair.base, 'ETH');
assert.strictEqual(pair.quote, 'USD');
});
it('should parse symbol with underscore separator', () => {
const pair = TradingPair.fromSymbol('SOL_USDT');
assert.ok(pair);
assert.strictEqual(pair.base, 'SOL');
assert.strictEqual(pair.quote, 'USDT');
});
it('should return null for invalid symbol', () => {
const pair = TradingPair.fromSymbol('BTCUSDC');
assert.strictEqual(pair, null);
});
it('should format to Hummingbot style', () => {
const pair = new TradingPair('BTC', 'USDC');
assert.strictEqual(pair.toHummingbot(), 'BTC-USDC');
});
it('should format to CCXT style', () => {
const pair = new TradingPair('ETH', 'USD');
assert.strictEqual(pair.toCcxt(), 'ETH/USD');
});
});
describe('Balance functions', () => {
it('should calculate total balance', () => {
const balance: Balance = {
asset: 'BTC',
venue: 'binance',
free: new Decimal('1.5'),
locked: new Decimal('0.5'),
};
assert.strictEqual(balanceTotal(balance).toString(), '2');
});
it('should calculate aggregated balance total', () => {
const aggBalance: AggregatedBalance = {
asset: 'ETH',
totalFree: new Decimal('10'),
totalLocked: new Decimal('5'),
byVenue: [],
};
assert.strictEqual(aggregatedBalanceTotal(aggBalance).toString(), '15');
});
});
describe('Order functions', () => {
it('should create market order', () => {
const order = createMarketOrder('BTC-USDC', Side.BUY, new Decimal('0.5'));
assert.strictEqual(order.symbol, 'BTC-USDC');
assert.strictEqual(order.side, Side.BUY);
assert.strictEqual(order.orderType, OrderType.MARKET);
assert.strictEqual(order.quantity.toString(), '0.5');
assert.strictEqual(order.timeInForce, TimeInForce.IOC);
assert.ok(order.clientOrderId);
});
it('should create limit order', () => {
const order = createLimitOrder('ETH-USD', Side.SELL, new Decimal('1'), new Decimal('2000'));
assert.strictEqual(order.symbol, 'ETH-USD');
assert.strictEqual(order.side, Side.SELL);
assert.strictEqual(order.orderType, OrderType.LIMIT);
assert.strictEqual(order.price?.toString(), '2000');
assert.strictEqual(order.timeInForce, TimeInForce.GTC);
});
it('should detect open orders', () => {
const openOrder: Order = {
orderId: '1',
clientOrderId: 'c1',
symbol: 'BTC-USDC',
venue: 'lx',
side: Side.BUY,
orderType: OrderType.LIMIT,
status: OrderStatus.OPEN,
quantity: new Decimal('1'),
filledQuantity: new Decimal('0'),
remainingQuantity: new Decimal('1'),
createdAt: Date.now(),
updatedAt: Date.now(),
fees: [],
};
assert.ok(orderIsOpen(openOrder));
assert.ok(!orderIsDone(openOrder));
});
it('should detect done orders', () => {
const filledOrder: Order = {
orderId: '2',
clientOrderId: 'c2',
symbol: 'ETH-USD',
venue: 'lx',
side: Side.SELL,
orderType: OrderType.MARKET,
status: OrderStatus.FILLED,
quantity: new Decimal('1'),
filledQuantity: new Decimal('1'),
remainingQuantity: new Decimal('0'),
createdAt: Date.now(),
updatedAt: Date.now(),
fees: [],
};
assert.ok(!orderIsOpen(filledOrder));
assert.ok(orderIsDone(filledOrder));
});
it('should calculate fill percent', () => {
const partialOrder: Order = {
orderId: '3',
clientOrderId: 'c3',
symbol: 'BTC-USDC',
venue: 'lx',
side: Side.BUY,
orderType: OrderType.LIMIT,
status: OrderStatus.PARTIALLY_FILLED,
quantity: new Decimal('10'),
filledQuantity: new Decimal('3'),
remainingQuantity: new Decimal('7'),
createdAt: Date.now(),
updatedAt: Date.now(),
fees: [],
};
assert.strictEqual(orderFillPercent(partialOrder).toString(), '30');
});
});
describe('Trade functions', () => {
it('should calculate trade value', () => {
const trade: Trade = {
tradeId: 't1',
orderId: 'o1',
symbol: 'BTC-USDC',
venue: 'lx',
side: Side.BUY,
price: new Decimal('50000'),
quantity: new Decimal('0.1'),
fee: { asset: 'USDC', amount: new Decimal('5') },
timestamp: Date.now(),
isMaker: false,
};
assert.strictEqual(tradeValue(trade).toString(), '5000');
});
});
describe('Ticker functions', () => {
const ticker: Ticker = {
symbol: 'BTC-USDC',
venue: 'lx',
bid: new Decimal('49900'),
ask: new Decimal('50100'),
last: new Decimal('50000'),
timestamp: Date.now(),
};
it('should calculate mid price', () => {
const mid = tickerMidPrice(ticker);
assert.strictEqual(mid?.toString(), '50000');
});
it('should calculate spread', () => {
const spread = tickerSpread(ticker);
assert.strictEqual(spread?.toString(), '200');
});
it('should calculate spread percent', () => {
const spreadPct = tickerSpreadPercent(ticker);
// (200 / 49900) * 100 ≈ 0.401
assert.ok(spreadPct && spreadPct.gt(0.4) && spreadPct.lt(0.41));
});
it('should return last as mid when bid/ask missing', () => {
const tickerNoSpread: Ticker = {
symbol: 'ETH-USD',
venue: 'lx',
last: new Decimal('2000'),
timestamp: Date.now(),
};
assert.strictEqual(tickerMidPrice(tickerNoSpread)?.toString(), '2000');
});
});
describe('PriceLevel functions', () => {
it('should calculate price level value', () => {
const level: PriceLevel = {
price: new Decimal('100'),
quantity: new Decimal('5'),
};
assert.strictEqual(priceLevelValue(level).toString(), '500');
});
});