mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for LX Trading SDK."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user