1
0
mirror of https://github.com/JKorf/CryptoExchange.Net.git synced 2026-08-12 17:03:10 +00:00

Compare commits

..

21 Commits

Author SHA1 Message Date
Jkorf 271503c426 Updated to version 11.2.1 2026-06-02 11:07:10 +02:00
Jkorf f0ece589f7 Updated some test logic 2026-06-01 16:05:50 +02:00
Jkorf 3a9382ca0f Fixed test rest request checking for no-response requests 2026-05-28 16:19:38 +02:00
Jkorf 1e1a02324a Potential fix for test splitting on newline 2026-05-28 16:13:13 +02:00
Jkorf 95dd050c73 Updated to version 11.2.0 2026-05-26 10:34:26 +02:00
Jkorf 504836924c Fixed test 2026-05-26 10:30:16 +02:00
Jkorf f176fb5db5 Fixed timing issue in SymbolOrderBook stopping 2026-05-26 09:27:04 +02:00
Jkorf 6b575be1ac Improved EnumConverter initialization performance 2026-05-26 09:26:45 +02:00
Jkorf b637d5cdc4 Added RateLimitGroup option 2026-05-26 09:25:53 +02:00
JKorf a46b018c50 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2026-05-25 12:55:23 +02:00
JKorf 562d1d76c1 Cleanup 2026-05-25 12:55:21 +02:00
Jkorf 7dcb2241c6 Ai docs 2026-05-09 21:56:08 +02:00
Jkorf 8c4cf62d9f Fixed some examples 2026-05-07 14:15:50 +02:00
Jkorf 6e4dbcf7b1 Added AI documentation 2026-05-07 13:28:20 +02:00
Jkorf 7853834286 Added request parameter checking in RestRequestValidator 2026-04-13 11:20:41 +02:00
Jkorf 9ae1263662 Added Weex to examples 2026-04-10 12:57:37 +02:00
Jkorf ee30a6716e Added Weex reference 2026-04-10 11:54:32 +02:00
Jkorf a4b7b273dc Updated to version 11.1.1 2026-04-10 10:05:14 +02:00
Jkorf c92eeb2ec8 Added Weex client reference 2026-04-10 10:03:42 +02:00
Jkorf 4d4b0576ee Fix for rate limiter resetting 2026-04-10 10:01:13 +02:00
Jkorf 9add5e0adc Added Reset functionality to rate limiter implementation, added reset of rate limit per connection when connection is disconnected 2026-04-10 09:39:19 +02:00
42 changed files with 1332 additions and 348 deletions
+71
View File
@@ -0,0 +1,71 @@
---
description: Conventions for cross-exchange code using CryptoExchange.Net SharedApis abstractions. Apply when generating C# code that interacts with multiple cryptocurrency exchanges through a unified interface.
globs:
- "**/*.cs"
- "**/*.csproj"
alwaysApply: false
---
# CryptoExchange.Net Conventions
This codebase uses **CryptoExchange.Net** abstractions for multi-exchange access. Each exchange has its own library (Binance.Net, Bybit.Net, OKX.Net, ...). Use `CryptoExchange.Net.SharedApis` for code that should work across exchanges.
## Multi-exchange pattern
```csharp
using Binance.Net.Clients;
using OKX.Net.Clients;
using CryptoExchange.Net.SharedApis;
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
// ticker.Data.LastPrice — same model regardless of exchange
```
## Symbol normalization
`SharedSymbol(TradingMode.Spot, "BTC", "USDT")` is portable. Each library translates to its native format internally. Don't pass raw strings like `"BTCUSDT"` to shared methods.
## Result pattern
Same `WebCallResult<T>` / `CallResult<T>` everywhere. Always check `.Success`. `.Exchange` property identifies which exchange responded — useful for logging.
## Available shared interfaces
- REST tickers/symbols/orderbook/klines/trades, orders (spot/futures, regular/trigger/TP-SL), balances, positions, fees, deposits/withdrawals, transfers
- WebSocket tickers, book tickers, order book, trades, klines, user data
Each exchange documents which it implements. Not every exchange supports every operation.
## Multi-exchange aggregation
Run requests across exchanges concurrently via `Task.WhenAll` — the library is async-safe and concurrent requests are the norm.
```csharp
var clients = new ISpotTickerRestClient[] { binance, okx, bybit };
var tasks = clients.Select(c => c.GetSpotTickerAsync(new GetTickerRequest(symbol)));
var results = await Task.WhenAll(tasks);
```
## Hard rules
- ❌ Never install `CryptoExchange.Net` alone and expect to call exchanges — it's a base library; you need exchange-specific packages
- ❌ Never mix exchange-specific models in cross-exchange code (use `SharedSymbol`, `SharedSpotTicker`, etc.)
- ❌ Never use `.Result` / `.Wait()` — async-only
- ❌ Never iterate sequentially when concurrency is fine — `Task.WhenAll` is your friend
- ❌ Never instantiate clients per-request — reuse via DI
- ✅ Always use `.SharedClient` for cross-exchange code
- ✅ Always check `.Success` before reading `.Data`
- ✅ Always log with `.Exchange` so multi-exchange logs are decipherable
- ✅ Always handle "not supported on this exchange" errors gracefully
## Reference
- `AGENTS.md` in repo root has fuller examples
- `llms.txt` for AI context
- `Examples/ai-friendly/` for compilable examples
- For single-exchange code, see that exchange's library (e.g., Binance.Net `AGENTS.md`)
+52
View File
@@ -0,0 +1,52 @@
# Copilot Instructions for CryptoExchange.Net
This repository is **CryptoExchange.Net** — the base library powering 28+ cryptocurrency exchange wrappers in C#/.NET (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.).
When generating code in this ecosystem, follow these conventions:
## You don't install CryptoExchange.Net directly
Install the exchange-specific library you need (`Binance.Net`, `JK.OKX.Net`, `Bybit.Net`, ...) or `CryptoClients.Net` for the bundle. CryptoExchange.Net is pulled in as a dependency.
## Multi-exchange code uses SharedApis
For code that must work against multiple exchanges, use `CryptoExchange.Net.SharedApis` interfaces accessed via `.SharedClient` properties on each exchange's API surface:
```csharp
using CryptoExchange.Net.SharedApis;
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
```
Same code works on every exchange that implements the interface. Use `Task.WhenAll` for concurrent multi-exchange calls.
## Single-exchange code uses the exchange's own client
For Binance-only code, use `BinanceRestClient` directly (see Binance.Net repo `AGENTS.md`). SharedApis is for portability — use it when you need that.
## Result pattern
Every method returns `WebCallResult<T>` or `CallResult<T>`. Check `.Success` before `.Data`. `.Error` has structured info. `.Exchange` on shared clients identifies which exchange responded.
## Available shared interfaces
REST: tickers, symbols, orderbook, klines, trades, orders (spot/futures, trigger, TP-SL), balances, positions, fees, deposits/withdrawals, transfers.
WebSocket: tickers, book tickers, orderbook, trades, klines, user data.
Each exchange library implements a subset. Check exchange docs for support matrix.
## Avoid
- Installing `CryptoExchange.Net` alone and trying to call exchange APIs (need exchange-specific packages)
- Mixing exchange-native models in cross-exchange code (use Shared* types)
- Synchronous `.Result` / `.Wait()` (use `await`)
- Instantiating clients per-request (use DI, reuse instances)
- Sequential per-exchange calls when parallel is fine (`Task.WhenAll`)
## Reference
For detailed patterns see `AGENTS.md` and `llms.txt` in repo root, `examples/ai-friendly/` for compilable examples.
+169
View File
@@ -0,0 +1,169 @@
---
name: cryptoexchange-net
description: Use CryptoExchange.Net abstractions when generating C#/.NET code that needs to work across MULTIPLE cryptocurrency exchanges (Binance + Bybit + OKX + Kraken + Coinbase + ...) — for arbitrage, best-execution routing, multi-exchange portfolio dashboards, exchange-agnostic trading bots, or comparison tools. Triggers on requests mentioning multi-exchange, cross-exchange, arbitrage, exchange-agnostic, or unified crypto API in C#. Also triggers when the user is implementing a new exchange library following the CryptoExchange.Net pattern.
---
# CryptoExchange.Net Skill
## When to use
CryptoExchange.Net is the **base library** powering 28+ exchange-specific libraries (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.). You don't install it directly — you install the exchange libraries, which depend on it.
**Three usage modes:**
1. **You target ONE exchange** → use that exchange's library directly (e.g., Binance.Net), see its CLAUDE.md.
2. **You target MULTIPLE exchanges** → install each library you need + use `CryptoExchange.Net.SharedApis` interfaces — write code once, runs against any exchange. **This is the main use case for this skill.**
3. **You want ALL exchanges in one package** → install `CryptoClients.Net`, get `ExchangeRestClient` and `ExchangeSocketClient` with everything bundled.
## Installation
For a multi-exchange project:
```bash
dotnet add package Binance.Net
dotnet add package JK.OKX.Net
dotnet add package Bybit.Net
# ... etc
```
Or the bundle:
```bash
dotnet add package CryptoClients.Net
```
## Core Pattern: Shared Interfaces
Every exchange library exposes `.SharedClient` properties on its API surfaces. These implement the same interfaces from `CryptoExchange.Net.SharedApis`.
```csharp
using Binance.Net.Clients;
using OKX.Net.Clients;
using Bybit.Net.Clients;
using CryptoExchange.Net.SharedApis;
// All three implement ISpotTickerRestClient
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
ISpotTickerRestClient bybit = new BybitRestClient().V5Api.SharedClient;
// Single agnostic call — works against any of them
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
// ticker.Data.LastPrice, ticker.Data.HighPrice, etc. — same model regardless of exchange
```
## Core Pattern: SharedSymbol
Different exchanges format symbols differently — Binance uses `BTCUSDT`, OKX uses `BTC-USDT`, others may have other formats. `SharedSymbol` normalizes this:
```csharp
var btcusdt = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// Each exchange library translates SharedSymbol → its native format internally.
// For futures:
var btcusdtPerp = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
```
For exchanges that use exotic asset names, see the AssetAliases configuration.
## Available Shared Interfaces
**REST:**
- Market data: `ISpotTickerRestClient`, `IBookTickerRestClient`, `ISpotSymbolRestClient`, `IFuturesSymbolRestClient`, `IOrderBookRestClient`, `IRecentTradeRestClient`, `IKlineRestClient`
- Orders: `ISpotOrderRestClient`, `IFuturesOrderRestClient`, `ISpotOrderClientIdRestClient`, `IFuturesOrderClientIdRestClient`, `ISpotTriggerOrderRestClient`, `IFuturesTriggerOrderRestClient`, `IFuturesTpSlRestClient`
- Account: `IBalanceRestClient`, `IPositionRestClient`, `IFeeRestClient`, `ITransferRestClient`, `IDepositRestClient`, `IWithdrawalRestClient`
**WebSocket:**
- `ITickerSocketClient`, `IBookTickerSocketClient`
- `IOrderBookSocketClient`, `ITradeSocketClient`, `IKlineSocketClient`
- `IUserTradeSocketClient`, `ISpotOrderSocketClient`, `IFuturesOrderSocketClient`, `IPositionSocketClient`, `IBalanceSocketClient`
Each exchange documents which interfaces it implements (some exchanges don't support every operation).
## Core Pattern: Result Handling
Same as exchange-specific libraries — `WebCallResult<T>` (REST) or `CallResult<T>` (WebSocket) with `.Success`, `.Data`, `.Error`. Always check `.Success` first.
```csharp
var result = await sharedClient.GetSpotTickerAsync(new GetTickerRequest(symbol));
if (!result.Success)
{
Console.WriteLine($"[{sharedClient.Exchange}] Error: {result.Error}");
return;
}
Console.WriteLine($"[{sharedClient.Exchange}] {result.Data.LastPrice}");
```
`.Exchange` property on every shared client tells you which exchange you're talking to — useful for logging.
## Core Pattern: Multi-Exchange Aggregation
```csharp
var clients = new ISpotTickerRestClient[]
{
new BinanceRestClient().SpotApi.SharedClient,
new OKXRestClient().UnifiedApi.SharedClient,
new BybitRestClient().V5Api.SharedClient,
};
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// Fetch concurrently from all exchanges
var tasks = clients.Select(c => c.GetSpotTickerAsync(new GetTickerRequest(symbol))).ToArray();
var results = await Task.WhenAll(tasks);
for (int i = 0; i < clients.Length; i++)
{
if (results[i].Success)
Console.WriteLine($"{clients[i].Exchange}: {results[i].Data!.LastPrice}");
}
```
## Per-Exchange Setup
Each exchange library has its own credentials class and options. See each library's CLAUDE.md for specifics. The pattern is consistent: `XxxRestClient(options => { options.ApiCredentials = new XxxCredentials(...); })`.
## Dependency Injection
Each exchange library has its own `services.AddXxx(...)` extension. They all share the same option-builder pattern. Register only the ones you use:
```csharp
services.AddBinance(restOpts => { /*...*/ }, socketOpts => { /*...*/ });
services.AddOKX(restOpts => { /*...*/ }, socketOpts => { /*...*/ });
// Inject IBinanceRestClient, IOKXRestClient, etc.
```
For one-package access: `services.AddCryptoClients(...)` from `CryptoClients.Net`.
## Common Pitfalls — AVOID
- **Do NOT install `CryptoExchange.Net` and try to call exchange APIs directly** — it's a base abstraction; you need an exchange library.
- **Do NOT try to use one exchange's models with another's client** — use the SharedApis types (`SharedSymbol`, `SharedSpotTicker`, `SharedSpotOrder`, etc.) for cross-exchange code.
- **Do NOT block on async operations** — use `await` throughout. `Task.WhenAll` for parallelism across exchanges.
- **Do NOT assume every exchange supports every operation** — check exchange docs or the library's implementation. Operations may return errors like "not supported on this exchange".
- **Do NOT instantiate clients per-request** — reuse via DI.
- **Do NOT iterate exchanges sequentially when concurrency is fine** — use `Task.WhenAll` for ~Nx speedup.
## Implementing a New Exchange Library
If you're building a NEW exchange wrapper following the CryptoExchange.Net pattern (rare but valuable):
- Inherit from `RestApiClient` and `SocketApiClient` base classes
- Define your own `XxxCredentials` extending `ApiCredentials` (or use `ApiCredentials` directly)
- Implement `AuthenticationProvider` for the exchange's signing scheme
- Implement the relevant `Shared*` interfaces on your API client classes for cross-exchange support
- Follow the same `XxxRestOptions` / `XxxSocketOptions` pattern
See existing libraries (Binance.Net, Bybit.Net) as reference implementations.
## Reference
- Source: https://github.com/JKorf/CryptoExchange.Net
- Documentation: https://cryptoexchange.jkorf.dev/
- SharedApis docs: https://cryptoexchange.jkorf.dev/CryptoExchange.Net/idocs_shared.html
- Bundle (all exchanges): https://github.com/JKorf/CryptoClients.Net
- Demo app: https://github.com/JKorf/CryptoManager.Net
- Discord: https://discord.gg/MSpeEtSY8t
@@ -147,231 +147,5 @@ namespace CryptoExchange.Net.UnitTests.ClientTests
Assert.That(result.RequestBody?.Contains("TestParam2") == true == (pos == HttpMethodParameterPosition.InBody));
Assert.That((result.RequestUrl?.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri));
}
[TestCase(1, 0.1)]
[TestCase(2, 0.1)]
[TestCase(5, 1)]
[TestCase(1, 2)]
public async Task PartialEndpointRateLimiterBasics(int requests, double perSeconds)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new PathStartFilter("/sapi/"), requests, TimeSpan.FromSeconds(perSeconds), RateLimitWindowType.Fixed));
var triggered = false;
rateLimiter.RateLimitTriggered += (x) => { triggered = true; };
var requestDefinition = new RequestDefinition("/sapi/v1/system/status", HttpMethod.Get);
for (var i = 0; i < requests + 1; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(i == requests ? triggered : !triggered);
}
triggered = false;
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(!triggered);
}
[TestCase("/sapi/test1", true)]
[TestCase("/sapi/test2", true)]
[TestCase("/api/test1", false)]
[TestCase("sapi/test1", true)]
[TestCase("/sapi/", true)]
public async Task PartialEndpointRateLimiterEndpoints(string endpoint, bool expectLimiting)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new PathStartFilter("/sapi/"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
for (var i = 0; i < 2; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
Assert.That(expected);
}
}
[TestCase("/sapi/", "/sapi/", true)]
[TestCase("/sapi/test", "/sapi/test", true)]
[TestCase("/sapi/test", "/sapi/test123", false)]
[TestCase("/sapi/test", "/sapi/", false)]
public async Task PartialEndpointRateLimiterEndpoints(string endpoint1, string endpoint2, bool expectLimiting)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new PathStartFilter("/sapi/"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimiting ? evnt != null : evnt == null);
}
[TestCase(1, 0.1)]
[TestCase(2, 0.1)]
[TestCase(5, 1)]
[TestCase(1, 2)]
public async Task EndpointRateLimiterBasics(int requests, double perSeconds)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new PathStartFilter("/sapi/test"), requests, TimeSpan.FromSeconds(perSeconds), RateLimitWindowType.Fixed));
bool triggered = false;
rateLimiter.RateLimitTriggered += (x) => { triggered = true; };
var requestDefinition = new RequestDefinition("/sapi/test", HttpMethod.Get);
for (var i = 0; i < requests + 1; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(i == requests ? triggered : !triggered);
}
triggered = false;
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(!triggered);
}
[TestCase("/", false)]
[TestCase("/sapi/test", true)]
[TestCase("/sapi/test/123", false)]
public async Task EndpointRateLimiterEndpoints(string endpoint, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathFilter("/sapi/test"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
for (var i = 0; i < 2; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
Assert.That(expected);
}
}
[TestCase("/", false)]
[TestCase("/sapi/test", true)]
[TestCase("/sapi/test2", true)]
[TestCase("/sapi/test23", false)]
public async Task EndpointRateLimiterMultipleEndpoints(string endpoint, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathsFilter(new[] { "/sapi/test", "/sapi/test2" }), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
for (var i = 0; i < 2; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
Assert.That(expected);
}
}
[TestCase("123", "123", "/sapi/test", "/sapi/test", true)]
[TestCase("123", "456", "/sapi/test", "/sapi/test", false)]
[TestCase("123", "123", "/sapi/test", "/sapi/test2", true)]
[TestCase("123", "123", "/sapi/test2", "/sapi/test", true)]
[TestCase(null, "123", "/sapi/test", "/sapi/test", false)]
[TestCase("123", null, "/sapi/test", "/sapi/test", false)]
[TestCase(null, null, "/sapi/test", "/sapi/test", false)]
public async Task ApiKeyRateLimiterBasics(string key1, string key2, string endpoint1, string endpoint2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerApiKey, new AuthenticatedEndpointFilter(true), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Sliding));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get) { Authenticated = key1 != null };
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = key2 != null };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", key1, 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "https://test.com", key2, 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[TestCase("/sapi/test", "/sapi/test", true)]
[TestCase("/sapi/test1", "/api/test2", true)]
[TestCase("/", "/sapi/test2", true)]
public async Task TotalRateLimiterBasics(string endpoint1, string endpoint2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, Array.Empty<IGuardFilter>(), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "https://test.com", null, 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[TestCase("https://test.com", "/sapi/test", "https://test.com", "/sapi/test", true)]
[TestCase("https://test2.com", "/sapi/test", "https://test.com", "/sapi/test", false)]
[TestCase("https://test.com", "/sapi/test", "https://test2.com", "/sapi/test", false)]
[TestCase("https://test.com", "/sapi/test", "https://test.com", "/sapi/test2", true)]
public async Task HostRateLimiterBasics(string host1, string endpoint1, string host2, string endpoint2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new HostFilter("https://test.com"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, host1, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, host2, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[TestCase("https://test.com", "https://test.com", true)]
[TestCase("https://test2.com", "https://test.com", false)]
[TestCase("https://test.com", "https://test2.com", false)]
public async Task ConnectionRateLimiterBasics(string host1, string host2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), host1, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), host2, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[Test]
public async Task ConnectionRateLimiterCancel()
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
Assert.That(result2.Error, Is.TypeOf<CancellationRequestedError>());
}
}
}
@@ -3,6 +3,7 @@ using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
using CryptoExchange.Net.Converters.SystemTextJson;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Testing.Implementations;
using Microsoft.Extensions.Logging;
@@ -47,11 +48,12 @@ namespace CryptoExchange.Net.UnitTests.Implementations
RequestFactory = factory;
}
internal async Task<WebCallResult<T>> GetResponseAsync<T>(HttpMethod? httpMethod = null, ParameterCollection? collection = null)
internal async Task<WebCallResult<T>> GetResponseAsync<T>(HttpMethod? httpMethod = null, ParameterCollection? collection = null, RateLimitGate? rateLimitGate = null)
{
var definition = new RequestDefinition("/path", httpMethod ?? HttpMethod.Get)
{
Weight = 0
Weight = rateLimitGate == null ? 0 : 1,
RateLimitGate = rateLimitGate
};
return await SendAsync<T>(BaseAddress, definition, collection ?? new ParameterCollection(), default);
}
@@ -0,0 +1,325 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting;
using CryptoExchange.Net.RateLimiting.Filters;
using CryptoExchange.Net.RateLimiting.Guards;
using CryptoExchange.Net.RateLimiting.Interfaces;
using CryptoExchange.Net.UnitTests.Implementations;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.UnitTests
{
[TestFixture()]
public class RateLimitTests
{
[TestCase(1, 0.1)]
[TestCase(2, 0.1)]
[TestCase(5, 1)]
[TestCase(1, 2)]
public async Task PartialEndpointRateLimiterBasics(int requests, double perSeconds)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new PathStartFilter("/sapi/"), requests, TimeSpan.FromSeconds(perSeconds), RateLimitWindowType.Fixed));
var triggered = false;
rateLimiter.RateLimitTriggered += (x) => { triggered = true; };
var requestDefinition = new RequestDefinition("/sapi/v1/system/status", HttpMethod.Get);
for (var i = 0; i < requests + 1; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(i == requests ? triggered : !triggered);
}
triggered = false;
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(!triggered);
}
[TestCase("/sapi/test1", true)]
[TestCase("/sapi/test2", true)]
[TestCase("/api/test1", false)]
[TestCase("sapi/test1", true)]
[TestCase("/sapi/", true)]
public async Task PartialEndpointRateLimiterEndpoints(string endpoint, bool expectLimiting)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new PathStartFilter("/sapi/"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
for (var i = 0; i < 2; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
Assert.That(expected);
}
}
[TestCase("/sapi/", "/sapi/", true)]
[TestCase("/sapi/test", "/sapi/test", true)]
[TestCase("/sapi/test", "/sapi/test123", false)]
[TestCase("/sapi/test", "/sapi/", false)]
public async Task PartialEndpointRateLimiterEndpoints(string endpoint1, string endpoint2, bool expectLimiting)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new PathStartFilter("/sapi/"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimiting ? evnt != null : evnt == null);
}
[TestCase(1, 0.1)]
[TestCase(2, 0.1)]
[TestCase(5, 1)]
[TestCase(1, 2)]
public async Task EndpointRateLimiterBasics(int requests, double perSeconds)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new PathStartFilter("/sapi/test"), requests, TimeSpan.FromSeconds(perSeconds), RateLimitWindowType.Fixed));
bool triggered = false;
rateLimiter.RateLimitTriggered += (x) => { triggered = true; };
var requestDefinition = new RequestDefinition("/sapi/test", HttpMethod.Get);
for (var i = 0; i < requests + 1; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(i == requests ? triggered : !triggered);
}
triggered = false;
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(!triggered);
}
[TestCase("/", false)]
[TestCase("/sapi/test", true)]
[TestCase("/sapi/test/123", false)]
public async Task EndpointRateLimiterEndpoints(string endpoint, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathFilter("/sapi/test"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
for (var i = 0; i < 2; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
Assert.That(expected);
}
}
[TestCase("/", false)]
[TestCase("/sapi/test", true)]
[TestCase("/sapi/test2", true)]
[TestCase("/sapi/test23", false)]
public async Task EndpointRateLimiterMultipleEndpoints(string endpoint, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathsFilter(new[] { "/sapi/test", "/sapi/test2" }), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
for (var i = 0; i < 2; i++)
{
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
Assert.That(expected);
}
}
[TestCase("123", "123", "/sapi/test", "/sapi/test", true)]
[TestCase("123", "456", "/sapi/test", "/sapi/test", false)]
[TestCase("123", "123", "/sapi/test", "/sapi/test2", true)]
[TestCase("123", "123", "/sapi/test2", "/sapi/test", true)]
[TestCase(null, "123", "/sapi/test", "/sapi/test", false)]
[TestCase("123", null, "/sapi/test", "/sapi/test", false)]
[TestCase(null, null, "/sapi/test", "/sapi/test", false)]
public async Task ApiKeyRateLimiterBasics(string key1, string key2, string endpoint1, string endpoint2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerApiKey, new AuthenticatedEndpointFilter(true), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Sliding));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get) { Authenticated = key1 != null };
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = key2 != null };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", key1, 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "https://test.com", key2, 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[TestCase("/sapi/test", "/sapi/test", true)]
[TestCase("/sapi/test1", "/api/test2", true)]
[TestCase("/", "/sapi/test2", true)]
public async Task TotalRateLimiterBasics(string endpoint1, string endpoint2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, Array.Empty<IGuardFilter>(), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "https://test.com", null, 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[TestCase("https://test.com", "/sapi/test", "https://test.com", "/sapi/test", true)]
[TestCase("https://test2.com", "/sapi/test", "https://test.com", "/sapi/test", false)]
[TestCase("https://test.com", "/sapi/test", "https://test2.com", "/sapi/test", false)]
[TestCase("https://test.com", "/sapi/test", "https://test.com", "/sapi/test2", true)]
public async Task HostRateLimiterBasics(string host1, string endpoint1, string host2, string endpoint2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new HostFilter("https://test.com"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, host1, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, host2, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[TestCase("https://test.com", "https://test.com", true)]
[TestCase("https://test2.com", "https://test.com", false)]
[TestCase("https://test.com", "https://test2.com", false)]
public async Task ConnectionRateLimiterBasics(string host1, string host2, bool expectLimited)
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), host1, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(evnt == null);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), host2, "123", 1, RateLimitingBehaviour.Wait, null, default);
Assert.That(expectLimited ? evnt != null : evnt == null);
}
[Test]
public async Task ConnectionRateLimiterCancel()
{
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
Assert.That(result2.Error, Is.TypeOf<CancellationRequestedError>());
}
[Test]
public async Task RateLimiterReset_Should_AllowNextRequestForSameDefinition()
{
// arrange
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerConnection, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
var definition = new RequestDefinition("1", HttpMethod.Get) { ConnectionId = 1 };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
// act
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition, "https://test.com", null, null, default);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
// assert
Assert.That(evnt, Is.Null);
}
[Test]
public async Task RateLimiterReset_Should_NotAllowNextRequestForDifferentDefinition()
{
// arrange
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerConnection, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
var definition1 = new RequestDefinition("1", HttpMethod.Get) { ConnectionId = 1 };
var definition2 = new RequestDefinition("2", HttpMethod.Get) { ConnectionId = 2 };
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
// act
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, default);
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, default);
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition1, "https://test.com", null, null, default);
var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, default);
// assert
Assert.That(evnt, Is.Not.Null);
}
[TestCase(null, null, true)]
[TestCase("Group1", null, false)]
[TestCase(null, "Group2", false)]
[TestCase("Group1", "Group2", false)]
[TestCase("Group3", "Group3", true)]
public async Task RateLimiterWithDifferentGroups_Should_LimitPerGroup(string? group1, string? group2, bool expectLimited)
{
// arrange
var data = JsonSerializer.Serialize(new TestObject { });
var client1 = new TestRestClient(x =>
{
x.RateLimitGroup = group1;
});
client1.ApiClient1.SetNextResponse(data, System.Net.HttpStatusCode.OK);
var client2 = new TestRestClient(x =>
{
x.RateLimitGroup = group2;
});
client2.ApiClient1.SetNextResponse(data, System.Net.HttpStatusCode.OK);
var rateLimiter = new RateLimitGate("Test");
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(2), RateLimitWindowType.Fixed));
RateLimitEvent? evnt = null;
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
// act
var result1 = await client1.ApiClient1.GetResponseAsync<TestObject>(rateLimitGate: rateLimiter);
var result2 = await client2.ApiClient1.GetResponseAsync<TestObject>(rateLimitGate: rateLimiter);
// assert
Assert.That(evnt != null, Is.EqualTo(expectLimited));
}
}
}
+1 -1
View File
@@ -342,7 +342,7 @@ namespace CryptoExchange.Net.Clients
GetAuthenticationProvider()?.Key,
requestWeight,
ClientOptions.RateLimitingBehaviour,
rateLimitKeySuffix,
rateLimitKeySuffix + ClientOptions.RateLimitGroup,
cancellationToken).ConfigureAwait(false);
if (!limitResult)
return limitResult.Error!;
@@ -383,8 +383,6 @@ namespace CryptoExchange.Net.Clients
released = true;
}
var needsConnecting = !socketConnection.Connected;
var connectResult = await ConnectIfNeededAsync(socketConnection, false, ct).ConfigureAwait(false);
if (!connectResult)
return new CallResult<HighPerfUpdateSubscription>(connectResult.Error!);
@@ -67,25 +67,14 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
#endif
: JsonConverter<T>, INullableConverterFactory where T : struct, Enum
{
class EnumMapping
{
public T Value { get; set; }
public string StringValue { get; set; }
public EnumMapping(T value, string stringValue)
{
Value = value;
StringValue = stringValue;
}
}
#if NET8_0_OR_GREATER
private static FrozenSet<EnumMapping>? _mappingToEnum = null;
private static FrozenDictionary<string, T>? _mappingToEnum = null;
private static FrozenDictionary<T, string>? _mappingToString = null;
private static bool RunOptimistic => true;
#else
private static List<EnumMapping>? _mappingToEnum = null;
private static Dictionary<string, T>? _mappingToEnum = null;
private static Dictionary<T, string>? _mappingToString = null;
// In NetStandard the `ValueTextEquals` method used is slower than just string comparing
@@ -205,7 +194,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
if (!_unknownValuesWarned.Contains(stringValue))
{
_unknownValuesWarned.Add(stringValue!);
LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should be added please open an issue on the Github repo");
LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.Key}: {m.Value}"))}]. If you think {stringValue} should be added please open an issue on the Github repo");
}
}
@@ -217,7 +206,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
if (!_notOptimalValuesWarned.Contains(stringValue))
{
_notOptimalValuesWarned.Add(stringValue!);
LibraryHelpers.StaticLogger?.LogTrace($"Enum mapping sub-optimal. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]");
LibraryHelpers.StaticLogger?.LogTrace($"Enum mapping sub-optimal. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.Key}: {m.Value}"))}]");
}
}
@@ -252,7 +241,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
optimisticCheckDone = true;
foreach (var item in _mappingToEnum!)
{
if (reader.ValueTextEquals(item.StringValue))
if (reader.ValueTextEquals(item.Key))
return item.Value;
}
@@ -261,43 +250,44 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
private static bool GetValue(string value, bool optimisticCheckDone, out T? result)
{
if (_mappingToEnum != null)
if (_mappingToEnum == null)
throw new InvalidOperationException("Enum mapping not initialized");
T? mapping = null;
// If we tried the optimistic path first we already know its not case match
if (!optimisticCheckDone)
{
EnumMapping? mapping = null;
// If we tried the optimistic path first we already know its not case match
if (!optimisticCheckDone)
// Try match on full equals
foreach (var item in _mappingToEnum)
{
// Try match on full equals
foreach (var item in _mappingToEnum)
if (item.Key.Equals(value, StringComparison.Ordinal))
{
if (item.StringValue.Equals(value, StringComparison.Ordinal))
{
mapping = item;
break;
}
mapping = item.Value;
break;
}
}
// If not found, try matching ignoring case
if (mapping == null)
{
foreach (var item in _mappingToEnum)
{
if (item.StringValue.Equals(value, StringComparison.OrdinalIgnoreCase))
{
mapping = item;
break;
}
}
}
if (mapping != null)
{
result = mapping.Value;
return true;
}
}
// If not found, try matching ignoring case
if (mapping == null)
{
foreach (var item in _mappingToEnum)
{
if (item.Key.Equals(value, StringComparison.OrdinalIgnoreCase))
{
mapping = item.Value;
break;
}
}
}
if (mapping != null)
{
result = mapping;
return true;
}
if (_hasFlagsAttribute)
{
var intValue = int.Parse(value);
@@ -345,26 +335,21 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
private static void CreateMapping()
{
var mappingStringToEnum = new List<EnumMapping>();
var mappingStringToEnum = new Dictionary<string, T>();
var mappingEnumToString = new Dictionary<T, string>();
#pragma warning disable IL2080
var enumMembers = _enumType.GetFields();
var enumMembers = _enumType.GetFields(BindingFlags.Public | BindingFlags.Static);
#pragma warning restore IL2080
foreach (var member in enumMembers)
{
var enumVal = (T)member.GetValue(null)!;
var maps = member.GetCustomAttributes(typeof(MapAttribute), false);
foreach (MapAttribute attribute in maps)
{
foreach (var value in attribute.Values)
{
#if NET8_0_OR_GREATER
var enumVal = Enum.Parse<T>(member.Name);
#else
var enumVal = (T)Enum.Parse(_enumType, member.Name);
#endif
mappingStringToEnum.Add(new EnumMapping(enumVal, value));
mappingStringToEnum.Add(value, enumVal);
if (!mappingEnumToString.ContainsKey(enumVal))
mappingEnumToString.Add(enumVal, value);
}
@@ -372,7 +357,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
}
#if NET8_0_OR_GREATER
_mappingToEnum = mappingStringToEnum.ToFrozenSet();
_mappingToEnum = mappingStringToEnum.ToFrozenDictionary();
_mappingToString = mappingEnumToString.ToFrozenDictionary();
#else
_mappingToEnum = mappingStringToEnum;
@@ -411,33 +396,20 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
if (_mappingToEnum == null)
CreateMapping();
EnumMapping? mapping = null;
// Try match on full equals
foreach(var item in _mappingToEnum!)
{
if (item.StringValue.Equals(value, StringComparison.Ordinal))
{
mapping = item;
break;
}
if (item.Key.Equals(value, StringComparison.Ordinal))
return item.Value;
}
// If not found, try matching ignoring case
if (mapping == null)
foreach (var item in _mappingToEnum)
{
foreach (var item in _mappingToEnum)
{
if (item.StringValue.Equals(value, StringComparison.OrdinalIgnoreCase))
{
mapping = item;
break;
}
}
if (item.Key.Equals(value, StringComparison.OrdinalIgnoreCase))
return item.Value;
}
if (mapping != null)
return mapping.Value;
try
{
#if NET8_0_OR_GREATER
+3 -3
View File
@@ -6,9 +6,9 @@
<PackageId>CryptoExchange.Net</PackageId>
<Authors>JKorf</Authors>
<Description>CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations.</Description>
<PackageVersion>11.1.0</PackageVersion>
<AssemblyVersion>11.1.0</AssemblyVersion>
<FileVersion>11.1.0</FileVersion>
<PackageVersion>11.2.1</PackageVersion>
<AssemblyVersion>11.2.1</AssemblyVersion>
<FileVersion>11.2.1</FileVersion>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageTags>OKX;OKX.Net;Mexc;Mexc.Net;Kucoin;Kucoin.Net;Kraken;Kraken.Net;Huobi;Huobi.Net;CoinEx;CoinEx.Net;Bybit;Bybit.Net;Bitget;Bitget.Net;Bitfinex;Bitfinex.Net;Binance;Binance.Net;CryptoCurrency;CryptoCurrency Exchange;CryptoExchange.Net</PackageTags>
<RepositoryType>git</RepositoryType>
+1
View File
@@ -55,6 +55,7 @@ namespace CryptoExchange.Net
{ "Kucoin.SpotKey", "f8ae62cb-2b3d-420c-8c98-e1c17dd4e30a" },
{ "Mexc", "EASYT" },
{ "OKX", "1425d83a94fbBCDE" },
{ "Weex", "b-WEEX111124-" },
{ "XT", "4XWeqN10M1fcoI5L" },
};
@@ -22,6 +22,10 @@ namespace CryptoExchange.Net.Objects.Options
/// Note that this comes at a performance cost
/// </summary>
public bool OutputOriginalData { get; set; } = false;
/// <summary>
/// A group name to use for client side rate limiting. Requests with the same group name will be counted together for rate limiting purposes. If null all requests will be counted together.
/// </summary>
public string? RateLimitGroup { get; set; }
/// <summary>
/// The max time a request is allowed to take
@@ -69,6 +69,7 @@ namespace CryptoExchange.Net.Objects.Options
item.RequestTimeout = RequestTimeout;
item.RateLimiterEnabled = RateLimiterEnabled;
item.RateLimitingBehaviour = RateLimitingBehaviour;
item.RateLimitGroup = RateLimitGroup;
item.CachingEnabled = CachingEnabled;
item.CachingMaxAge = CachingMaxAge;
item.HttpVersion = HttpVersion;
@@ -104,6 +104,7 @@ namespace CryptoExchange.Net.Objects.Options
item.RequestTimeout = RequestTimeout;
item.RateLimitingBehaviour = RateLimitingBehaviour;
item.RateLimiterEnabled = RateLimiterEnabled;
item.RateLimitGroup = RateLimitGroup;
item.ReceiveBufferSize = ReceiveBufferSize;
return item;
}
@@ -333,17 +333,19 @@ namespace CryptoExchange.Net.OrderBook
{
_logger.OrderBookStopping(Api, Symbol);
_cts?.Cancel();
_queueEvent.Set();
if (_processTask != null)
await _processTask.ConfigureAwait(false);
if (_subscription != null) {
if (_subscription != null)
{
await _subscription.CloseAsync().ConfigureAwait(false);
_subscription.ConnectionLost -= HandleConnectionLost;
_subscription.ConnectionClosed -= HandleConnectionClosed;
_subscription.ConnectionRestored -= HandleConnectionRestored;
}
_queueEvent.Set();
if (_processTask != null)
await _processTask.ConfigureAwait(false);
Status = OrderBookStatus.Disconnected;
_logger.OrderBookStopped(Api, Symbol);
}
@@ -3,6 +3,7 @@ using CryptoExchange.Net.RateLimiting.Interfaces;
using CryptoExchange.Net.RateLimiting.Trackers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace CryptoExchange.Net.RateLimiting.Guards
@@ -126,7 +127,6 @@ namespace CryptoExchange.Net.RateLimiting.Guards
_trackers.Add(key, tracker);
}
var delay = tracker.GetWaitTime(requestWeight);
if (delay == default)
return LimitCheck.NotNeeded(Limit, TimeSpan, tracker.Current);
@@ -172,6 +172,33 @@ namespace CryptoExchange.Net.RateLimiting.Guards
return RateLimitState.Applied(Limit, TimeSpan, tracker.Current);
}
/// <inheritdoc />
public void Reset(RateLimitItemType type, RequestDefinition definition, string host, string? apiKey, string? keySuffix)
{
foreach (var filter in _filters)
{
if (!filter.Passes(type, definition, host, apiKey))
return;
}
if (SharedGuard)
_sharedGuardSemaphore!.Wait();
try
{
var key = _keySelector(definition, host, apiKey) + keySuffix;
if (!_trackers.TryGetValue(key, out var tracker))
return;
tracker.Reset();
}
finally
{
if (SharedGuard)
_sharedGuardSemaphore!.Release();
}
}
/// <summary>
/// Create a new WindowTracker
/// </summary>
@@ -65,5 +65,11 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// </summary>
/// <param name="after"></param>
public void UpdateAfter(DateTime after) => After = after;
/// <inheritdoc />
public void Reset(RateLimitItemType type, RequestDefinition definition, string host, string? apiKey, string? keySuffix)
{
After = DateTime.UtcNow;
}
}
}
@@ -88,5 +88,15 @@ namespace CryptoExchange.Net.RateLimiting.Guards
: _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(_limit, _period) :
new DecayWindowTracker(_limit, _period, _decayRate ?? throw new InvalidOperationException("Decay rate not provided"));
}
/// <inheritdoc />
public void Reset(RateLimitItemType type, RequestDefinition definition, string host, string? apiKey, string? keySuffix)
{
var key = _keySelector(definition, host, apiKey) + keySuffix;
if (!_trackers.TryGetValue(key, out var tracker))
return;
tracker.Reset();
}
}
}
@@ -74,5 +74,22 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
/// <param name="ct">Cancelation token</param>
/// <returns>Error if RateLimitingBehaviour is Fail and rate limit is hit</returns>
ValueTask<CallResult> ProcessSingleAsync(ILogger logger, int itemId, IRateLimitGuard guard, RateLimitItemType type, RequestDefinition definition, string baseAddress, string? apiKey, int requestWeight, RateLimitingBehaviour behaviour, string? keySuffix, CancellationToken ct);
/// <summary>
/// Reset the limit for the specified parameters
/// </summary>
/// <param name="type">The rate limit item type</param>
/// <param name="definition">The request definition</param>
/// <param name="host">The host address</param>
/// <param name="apiKey">The API key</param>
/// <param name="keySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
/// <param name="ct">Cancelation token</param>
Task ResetAsync(
RateLimitItemType type,
RequestDefinition definition,
string host,
string? apiKey,
string? keySuffix,
CancellationToken ct);
}
}
@@ -40,5 +40,15 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
/// <param name="keySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
/// <returns></returns>
RateLimitState ApplyWeight(RateLimitItemType type, RequestDefinition definition, string host, string? apiKey, int requestWeight, string? keySuffix);
/// <summary>
/// Reset the limit for the specified parameters
/// </summary>
/// <param name="type">The rate limit item type</param>
/// <param name="definition">The request definition</param>
/// <param name="host">The host address</param>
/// <param name="apiKey">The API key</param>
/// <param name="keySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
void Reset(RateLimitItemType type, RequestDefinition definition, string host, string? apiKey, string? keySuffix);
}
}
@@ -30,5 +30,9 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
/// </summary>
/// <param name="weight">Request weight</param>
void ApplyWeight(int weight);
/// <summary>
/// Reset the limit counter for this tracker
/// </summary>
void Reset();
}
}
@@ -192,5 +192,27 @@ namespace CryptoExchange.Net.RateLimiting
_semaphore.Release();
}
}
/// <inheritdoc />
public async Task ResetAsync(
RateLimitItemType type,
RequestDefinition definition,
string host,
string? apiKey,
string? keySuffix,
CancellationToken ct)
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
try
{
foreach (var guard in _guards)
guard.Reset(type, definition, host, apiKey, keySuffix);
}
finally
{
_semaphore.Release();
}
}
}
}
@@ -26,6 +26,13 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
DecreaseRate = decayRate;
}
/// <inheritdoc />
public void Reset()
{
_currentWeight = 0;
_lastDecrease = DateTime.UtcNow;
}
/// <inheritdoc />
public TimeSpan GetWaitTime(int weight)
{
@@ -29,6 +29,14 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
_entries = new Queue<LimitEntry>();
}
/// <inheritdoc />
public void Reset()
{
_entries.Clear();
_currentWeight = 0;
_nextReset = null;
}
public TimeSpan GetWaitTime(int weight)
{
// Remove requests no longer in time period from the history
@@ -28,6 +28,13 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
_entries = new Queue<LimitEntry>();
}
/// <inheritdoc />
public void Reset()
{
_entries.Clear();
_currentWeight = 0;
}
/// <inheritdoc />
public TimeSpan GetWaitTime(int weight)
{
@@ -28,6 +28,13 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
_entries = new List<LimitEntry>();
}
/// <inheritdoc />
public void Reset()
{
_entries.Clear();
_currentWeight = 0;
}
/// <inheritdoc />
public TimeSpan GetWaitTime(int weight)
{
@@ -48,6 +48,7 @@ namespace CryptoExchange.Net.Sockets.Default
private readonly string _baseAddress;
private int _reconnectAttempt;
private readonly int _receiveBufferSize;
private readonly RequestDefinition _requestDefinition;
private const int _sendBufferSize = 4096;
@@ -137,6 +138,7 @@ namespace CryptoExchange.Net.Sockets.Default
_sendBuffer = new ConcurrentQueue<SendItem>();
_ctsSource = new CancellationTokenSource();
_receiveBufferSize = websocketParameters.ReceiveBufferSize ?? 65536;
_requestDefinition = new RequestDefinition(Uri.AbsolutePath, HttpMethod.Get) { ConnectionId = Id };
_closeSem = new SemaphoreSlim(1, 1);
_socket = CreateSocket();
@@ -206,8 +208,7 @@ namespace CryptoExchange.Net.Sockets.Default
{
if (Parameters.RateLimiter != null)
{
var definition = new RequestDefinition(Uri.AbsolutePath, HttpMethod.Get) { ConnectionId = Id };
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, Id, RateLimitItemType.Connection, definition, _baseAddress, null, 1, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false);
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, Id, RateLimitItemType.Connection, _requestDefinition, _baseAddress, null, 1, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false);
if (!limitResult)
return new CallResult(new ClientRateLimitError("Connection limit reached"));
}
@@ -296,6 +297,9 @@ namespace CryptoExchange.Net.Sockets.Default
await (OnReconnecting?.Invoke() ?? Task.CompletedTask).ConfigureAwait(false);
}
if (Parameters.RateLimiter != null)
await Parameters.RateLimiter.ResetAsync(RateLimitItemType.Request, _requestDefinition, _baseAddress, null, null, default).ConfigureAwait(false);
// Delay here to prevent very rapid looping when a connection to the server is accepted and immediately disconnected
var initialDelay = GetReconnectDelay();
await Task.Delay(initialDelay).ConfigureAwait(false);
@@ -496,7 +500,6 @@ namespace CryptoExchange.Net.Sockets.Default
/// <returns></returns>
private async Task SendLoopAsync()
{
var requestDefinition = new RequestDefinition(Uri.AbsolutePath, HttpMethod.Get) { ConnectionId = Id };
try
{
while (true)
@@ -520,7 +523,7 @@ namespace CryptoExchange.Net.Sockets.Default
{
try
{
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, data.Id, RateLimitItemType.Request, requestDefinition, _baseAddress, null, data.Weight, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false);
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, data.Id, RateLimitItemType.Request, _requestDefinition, _baseAddress, null, data.Weight, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false);
if (!limitResult)
{
await (OnRequestRateLimited?.Invoke(data.Id) ?? Task.CompletedTask).ConfigureAwait(false);
@@ -395,9 +395,9 @@ namespace CryptoExchange.Net.Testing.Comparers
}
else if (objectValue is bool bl)
{
if (bl && (stringValue != "1" && stringValue != "true" && stringValue != "True"))
if (bl && (stringValue != "1" && stringValue != "true" && stringValue != "True" && stringValue != "yes" && stringValue != "YES"))
throw new Exception($"{method}: {property} not equal: {stringValue} vs {bl}");
if (!bl && (stringValue != "0" && stringValue != "-1" && stringValue != "false" && stringValue != "False"))
if (!bl && (stringValue != "0" && stringValue != "-1" && stringValue != "false" && stringValue != "False" && stringValue != "no" && stringValue != "NO"))
throw new Exception($"{method}: {property} not equal: {stringValue} vs {bl}");
}
else if (propertyType.IsEnum || Nullable.GetUnderlyingType(propertyType)?.IsEnum == true)
@@ -3,11 +3,20 @@ using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Testing.Comparers;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Web;
#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
namespace CryptoExchange.Net.Testing
{
@@ -48,6 +57,7 @@ namespace CryptoExchange.Net.Testing
/// <param name="name">Method name for looking up json test values</param>
/// <param name="nestedJsonProperty">Use nested json property for compare</param>
/// <param name="ignoreProperties">Ignore certain properties</param>
/// <param name="ignoreParamValidation">Ignore certain parameter validation</param>
/// <param name="useSingleArrayItem">Use the first item of an json array response</param>
/// <param name="skipResponseValidation">Whether to skip the response model validation</param>
/// <returns></returns>
@@ -57,9 +67,10 @@ namespace CryptoExchange.Net.Testing
string name,
string? nestedJsonProperty = null,
List<string>? ignoreProperties = null,
List<string>? ignoreParamValidation = null,
bool useSingleArrayItem = false,
bool skipResponseValidation = false)
=> ValidateAsync<TResponse, TResponse>(methodInvoke, name, nestedJsonProperty, ignoreProperties, useSingleArrayItem, skipResponseValidation);
=> ValidateAsync<TResponse, TResponse>(methodInvoke, name, nestedJsonProperty, ignoreProperties, ignoreParamValidation, useSingleArrayItem, skipResponseValidation);
/// <summary>
/// Validate a request
@@ -70,6 +81,7 @@ namespace CryptoExchange.Net.Testing
/// <param name="name">Method name for looking up json test values</param>
/// <param name="nestedJsonProperty">Use nested json property for compare</param>
/// <param name="ignoreProperties">Ignore certain properties</param>
/// <param name="ignoreParamValidation">Ignore certain parameter validation</param>
/// <param name="useSingleArrayItem">Use the first item of an json array response</param>
/// <param name="skipResponseValidation">Whether to skip the response model validation</param>
/// <returns></returns>
@@ -79,6 +91,7 @@ namespace CryptoExchange.Net.Testing
string name,
string? nestedJsonProperty = null,
List<string>? ignoreProperties = null,
List<string>? ignoreParamValidation = null,
bool useSingleArrayItem = false,
bool skipResponseValidation = false) where TActualResponse : TResponse
{
@@ -105,8 +118,26 @@ namespace CryptoExchange.Net.Testing
var expectedMethod = reader.ReadLine();
var expectedPath = reader.ReadLine();
var expectedAuth = bool.Parse(reader.ReadLine()!);
var response = reader.ReadToEnd();
var paramsAndResponseBody = reader.ReadToEnd();
var paramsAndResponseBodySplit = paramsAndResponseBody.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var uriParamsLine = paramsAndResponseBodySplit.FirstOrDefault(x => x.StartsWith("UriParams: "));
Dictionary<string, object>? expectedUriParams = null;
var bodyParamsLine = paramsAndResponseBodySplit.FirstOrDefault(x => x.StartsWith("BodyParams: "));
Dictionary<string, object>? expectedBodyParams = null;
var response = string.Join("\r\n", paramsAndResponseBodySplit.Where(x => !x.StartsWith("UriParams: ") && !x.StartsWith("BodyParams: ")));
if (uriParamsLine != null)
{
var expectedUriParamsJson = uriParamsLine.Substring(11);
expectedUriParams = JsonSerializer.Deserialize<Dictionary<string, object>>(expectedUriParamsJson)!;
}
if (bodyParamsLine != null)
{
var expectedBodyParamsJson = bodyParamsLine.Substring(12);
expectedBodyParams = JsonSerializer.Deserialize<Dictionary<string, object>>(expectedBodyParamsJson)!;
}
TestHelpers.ConfigureRestClient(_client, response, System.Net.HttpStatusCode.OK);
var result = await methodInvoke(_client).ConfigureAwait(false);
@@ -120,6 +151,35 @@ namespace CryptoExchange.Net.Testing
if (expectedPath != result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0])
throw new Exception(name + $" path not matched. Expected: {expectedPath}, Actual: {result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0]}");
if (expectedUriParams != null)
{
// Validate request parameters
var urlParamsSplit = result.RequestUrl!.Split(new char[] { '?' });
var urlParametersString = urlParamsSplit.Length > 1 ? urlParamsSplit[1] : null;
var urlParameters = (urlParametersString != null
? urlParametersString.Split('&').ToDictionary(x => x.Split('=')[0], x => (object)x.Split('=')[1])
: new());
CompareParameters(expectedUriParams, urlParameters, ignoreParamValidation);
}
if (expectedBodyParams != null)
{
// Validate request body
Dictionary<string, object> bodyParameters;
if (result.RequestBody!.StartsWith("{") || result.RequestBody.StartsWith("["))
{
bodyParameters = JsonSerializer.Deserialize<Dictionary<string, object>>(result.RequestBody!)!;
}
else
{
var splitKvp = result.RequestBody.Split('&');
bodyParameters = splitKvp.Select(x => x.Split('=')).ToDictionary(x => x[0], x => (object)x[1]);
}
CompareParameters(expectedBodyParams, bodyParameters, ignoreParamValidation);
}
if (!skipResponseValidation)
{
// Check response data
@@ -130,16 +190,37 @@ namespace CryptoExchange.Net.Testing
Trace.Listeners.Remove(listener);
}
private void CompareParameters(Dictionary<string, object> expectedUrlParameters, Dictionary<string, object> parameters, List<string>? ignoreParamValidation)
{
if (expectedUrlParameters.Count > parameters.Count)
throw new Exception($"Url parameters count not matched. Expected: {expectedUrlParameters.Count}, Actual: {parameters.Count}");
foreach (var kvp in expectedUrlParameters)
{
if (ignoreParamValidation != null && ignoreParamValidation.Contains(kvp.Key))
continue;
if (!parameters.TryGetValue(kvp.Key, out var value))
throw new Exception($"Url parameter {kvp.Key} not found in actual parameters");
if (Convert.ToString(kvp.Value, CultureInfo.InvariantCulture) != Convert.ToString(value, CultureInfo.InvariantCulture))
throw new Exception($"Url parameter {kvp.Key} value not matched. Expected: {kvp.Value}, Actual: {value}");
}
}
/// <summary>
/// Validate a request
/// </summary>
/// <param name="methodInvoke">Method invocation</param>
/// <param name="name">Method name for looking up json test values</param>
/// <param name="ignoreParamValidation">Ignore certain parameter validation</param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task ValidateAsync(
Func<TClient, Task<WebCallResult>> methodInvoke,
string name)
string name,
List<string>? ignoreParamValidation = null
)
{
var listener = new EnumValueTraceListener();
Trace.Listeners.Add(listener);
@@ -164,7 +245,26 @@ namespace CryptoExchange.Net.Testing
var expectedMethod = reader.ReadLine();
var expectedPath = reader.ReadLine();
var expectedAuth = bool.Parse(reader.ReadLine()!);
var response = reader.ReadToEnd();
var paramsAndResponseBody = reader.ReadToEnd();
var paramsAndResponseBodySplit = paramsAndResponseBody.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var uriParamsLine = paramsAndResponseBodySplit.FirstOrDefault(x => x.StartsWith("UriParams: "));
Dictionary<string, object>? expectedUriParams = null;
var bodyParamsLine = paramsAndResponseBodySplit.FirstOrDefault(x => x.StartsWith("BodyParams: "));
Dictionary<string, object>? expectedBodyParams = null;
var response = string.Join("\r\n", paramsAndResponseBodySplit.Where(x => !x.StartsWith("UriParams: ") && !x.StartsWith("BodyParams: ")));
if (uriParamsLine != null)
{
var expectedUriParamsJson = uriParamsLine.Substring(11);
expectedUriParams = JsonSerializer.Deserialize<Dictionary<string, object>>(expectedUriParamsJson)!;
}
if (bodyParamsLine != null)
{
var expectedBodyParamsJson = bodyParamsLine.Substring(12);
expectedBodyParams = JsonSerializer.Deserialize<Dictionary<string, object>>(expectedBodyParamsJson)!;
}
TestHelpers.ConfigureRestClient(_client, response, System.Net.HttpStatusCode.OK);
var result = await methodInvoke(_client).ConfigureAwait(false);
@@ -178,6 +278,35 @@ namespace CryptoExchange.Net.Testing
throw new Exception(name + $" http method not matched. Expected {expectedMethod}, Actual: {result.RequestMethod}");
if (expectedPath != result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0])
throw new Exception(name + $" path not matched. Expected: {expectedPath}, Actual: {result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0]}");
if (expectedUriParams != null)
{
// Validate request parameters
var urlParamsSplit = result.RequestUrl!.Split(new char[] { '?' });
var urlParametersString = urlParamsSplit.Length > 1 ? urlParamsSplit[1] : null;
var urlParameters = (urlParametersString != null
? urlParametersString.Split('&').ToDictionary(x => x.Split('=')[0], x => (object)x.Split('=')[1])
: new());
CompareParameters(expectedUriParams, urlParameters, ignoreParamValidation);
}
if (expectedBodyParams != null)
{
// Validate request body
Dictionary<string, object> bodyParameters;
if (result.RequestBody!.StartsWith("{") || result.RequestBody.StartsWith("["))
{
bodyParameters = JsonSerializer.Deserialize<Dictionary<string, object>>(result.RequestBody!)!;
}
else
{
var splitKvp = result.RequestBody.Split('&');
bodyParameters = splitKvp.Select(x => x.Split('=')).ToDictionary(x => x[0], x => (object)x[1]);
}
CompareParameters(expectedBodyParams, bodyParameters, ignoreParamValidation);
}
Trace.Listeners.Remove(listener);
}
+26 -25
View File
@@ -5,32 +5,33 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Binance.Net" Version="12.11.0" />
<PackageReference Include="Bitfinex.Net" Version="10.10.1" />
<PackageReference Include="BitMart.Net" Version="3.9.1" />
<PackageReference Include="BloFin.Net" Version="2.10.1" />
<PackageReference Include="Bybit.Net" Version="6.10.0" />
<PackageReference Include="CoinEx.Net" Version="10.9.1" />
<PackageReference Include="CoinW.Net" Version="2.9.1" />
<PackageReference Include="CryptoCom.Net" Version="3.9.1" />
<PackageReference Include="DeepCoin.Net" Version="3.9.1" />
<PackageReference Include="GateIo.Net" Version="3.10.1" />
<PackageReference Include="HyperLiquid.Net" Version="4.0.1" />
<PackageReference Include="JK.BingX.Net" Version="3.9.1" />
<PackageReference Include="JK.Bitget.Net" Version="3.9.0" />
<PackageReference Include="JK.Mexc.Net" Version="4.9.0" />
<PackageReference Include="JK.OKX.Net" Version="4.10.1" />
<PackageReference Include="Jkorf.Aster.Net" Version="3.0.0" />
<PackageReference Include="JKorf.BitMEX.Net" Version="3.9.1" />
<PackageReference Include="JKorf.Coinbase.Net" Version="3.9.1" />
<PackageReference Include="JKorf.HTX.Net" Version="8.9.0" />
<PackageReference Include="JKorf.Upbit.Net" Version="2.9.0" />
<PackageReference Include="KrakenExchange.Net" Version="7.9.0" />
<PackageReference Include="Kucoin.Net" Version="8.10.1" />
<PackageReference Include="Binance.Net" Version="12.11.3" />
<PackageReference Include="Bitfinex.Net" Version="10.10.2" />
<PackageReference Include="BitMart.Net" Version="3.10.0" />
<PackageReference Include="BloFin.Net" Version="2.10.2" />
<PackageReference Include="Bybit.Net" Version="6.11.0" />
<PackageReference Include="CoinEx.Net" Version="10.9.2" />
<PackageReference Include="CoinW.Net" Version="2.9.2" />
<PackageReference Include="CryptoCom.Net" Version="3.10.0" />
<PackageReference Include="DeepCoin.Net" Version="3.9.2" />
<PackageReference Include="GateIo.Net" Version="3.10.2" />
<PackageReference Include="HyperLiquid.Net" Version="4.3.0" />
<PackageReference Include="JK.BingX.Net" Version="3.10.0" />
<PackageReference Include="JK.Bitget.Net" Version="3.10.0" />
<PackageReference Include="JK.Mexc.Net" Version="5.0.1" />
<PackageReference Include="JK.OKX.Net" Version="4.12.0" />
<PackageReference Include="Jkorf.Aster.Net" Version="3.1.0" />
<PackageReference Include="JKorf.BitMEX.Net" Version="3.9.2" />
<PackageReference Include="JKorf.Coinbase.Net" Version="3.9.2" />
<PackageReference Include="JKorf.HTX.Net" Version="8.9.1" />
<PackageReference Include="JKorf.Upbit.Net" Version="2.9.2" />
<PackageReference Include="KrakenExchange.Net" Version="7.9.1" />
<PackageReference Include="Kucoin.Net" Version="8.11.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Toobit.Net" Version="3.9.1" />
<PackageReference Include="WhiteBit.Net" Version="3.9.1" />
<PackageReference Include="XT.Net" Version="3.9.1" />
<PackageReference Include="Toobit.Net" Version="3.9.2" />
<PackageReference Include="Weex.Net" Version="1.0.0" />
<PackageReference Include="WhiteBit.Net" Version="3.9.2" />
<PackageReference Include="XT.Net" Version="3.9.2" />
</ItemGroup>
</Project>
+5
View File
@@ -22,6 +22,7 @@
@inject IOKXRestClient okxClient
@inject IToobitRestClient toobitClient
@inject IUpbitRestClient upbitClient
@inject IWeexRestClient weexClient
@inject IWhiteBitRestClient whitebitClient
@inject IXTRestClient xtClient
@@ -59,6 +60,7 @@
var okxTask = okxClient.UnifiedApi.ExchangeData.GetTickerAsync("BTC-USDT");
var toobitTask = toobitClient.SpotApi.ExchangeData.GetTickersAsync("BTCUSDT");
var upbitTask = upbitClient.SpotApi.ExchangeData.GetTickerAsync("USDT-BTC");
var weexTask = weexClient.SpotApi.ExchangeData.GetTickersAsync(["BTCUSDT"]);
var whitebitTask = whitebitClient.V4Api.ExchangeData.GetTickersAsync();
var xtTask = xtClient.SpotApi.ExchangeData.GetTickersAsync("btc_usdt");
@@ -141,6 +143,9 @@
if (upbitTask.Result.Success)
_prices.Add("Upbit", upbitTask.Result.Data.LastPrice ?? 0);
if (weexTask.Result.Success)
_prices.Add("Weex", weexTask.Result.Data.Single().LastPrice);
if (whitebitTask.Result.Success){
// WhiteBit API doesn't offer an endpoint to filter for a specific ticker, so we have to filter client side
var tickers = whitebitTask.Result.Data;
@@ -22,6 +22,7 @@
@inject IOKXSocketClient okxSocketClient
@inject IToobitSocketClient toobitSocketClient
@inject IUpbitSocketClient upbitSocketClient
@inject IWeexSocketClient weexSocketClient
@inject IWhiteBitSocketClient whitebitSocketClient
@inject IXTSocketClient xtSocketClient
@using System.Collections.Concurrent
@@ -28,6 +28,7 @@
@using OKX.Net.Interfaces;
@using Upbit.Net.Interfaces;
@using Toobit.Net.Interfaces;
@using Weex.Net.Interfaces
@using WhiteBit.Net.Interfaces
@using XT.Net.Interfaces
@inject IAsterOrderBookFactory asterFactory
@@ -53,6 +54,7 @@
@inject IOKXOrderBookFactory okxFactory
@inject IToobitOrderBookFactory toobitFactory
@inject IUpbitOrderBookFactory upbitFactory
@inject IWeexOrderBookFactory weexFactory
@inject IWhiteBitOrderBookFactory whitebitFactory
@inject IXTOrderBookFactory xtFactory
@implements IDisposable
@@ -112,6 +114,7 @@
{ "OKX", okxFactory.Create("ETH-BTC") },
{ "Toobit", toobitFactory.CreateSpot("ETHUSDT") },
{ "Upbit", upbitFactory.CreateSpot("BTC-ETH") },
{ "Weex", weexFactory.CreateSpot("ETHUSDT") },
{ "WhiteBit", whitebitFactory.CreateV4("ETH_BTC") },
{ "XT", xtFactory.CreateSpot("eth_btc") },
};
@@ -28,6 +28,7 @@
@using OKX.Net.Interfaces;
@using Upbit.Net.Interfaces;
@using Toobit.Net.Interfaces;
@using Weex.Net.Interfaces
@using WhiteBit.Net.Interfaces
@using XT.Net.Interfaces
@inject IAsterTrackerFactory asterFactory
@@ -53,6 +54,7 @@
@inject IOKXTrackerFactory okxFactory
@inject IToobitTrackerFactory toobitFactory
@inject IUpbitTrackerFactory upbitFactory
@inject IWeexTrackerFactory weexFactory
@inject IWhiteBitTrackerFactory whitebitFactory
@inject IXTTrackerFactory xtFactory
@implements IDisposable
@@ -105,6 +107,7 @@
{ okxFactory.CreateTradeTracker(symbol, period: TimeSpan.FromMinutes(5)) },
{ toobitFactory.CreateTradeTracker(symbol, period: TimeSpan.FromMinutes(5)) },
{ upbitFactory.CreateTradeTracker(symbol, period: TimeSpan.FromMinutes(5)) },
{ weexFactory.CreateTradeTracker(symbol, period: TimeSpan.FromMinutes(5)) },
{ whitebitFactory.CreateTradeTracker(symbol, period: TimeSpan.FromMinutes(5)) },
{ xtFactory.CreateTradeTracker(symbol, period: TimeSpan.FromMinutes(5)) },
};
+1
View File
@@ -56,6 +56,7 @@ namespace BlazorClient
services.AddOKX();
services.AddToobit();
services.AddUpbit();
services.AddWeex();
services.AddWhiteBit();
services.AddXT();
}
+1
View File
@@ -31,6 +31,7 @@
@using OKX.Net.Interfaces.Clients;
@using Upbit.Net.Interfaces.Clients;
@using Toobit.Net.Interfaces.Clients;
@using Weex.Net.Interfaces.Clients
@using WhiteBit.Net.Interfaces.Clients
@using XT.Net.Interfaces.Clients
@using CryptoExchange.Net.Interfaces;
@@ -0,0 +1,65 @@
// 01-shared-clients-quickstart.cs
//
// Demonstrates: the SharedApis pattern — same code calling multiple exchanges.
//
// Setup:
// dotnet add package Binance.Net
// dotnet add package JK.OKX.Net
using Binance.Net.Clients;
using OKX.Net.Clients;
using CryptoExchange.Net.SharedApis;
// ---- THE PATTERN ----
// Each exchange library exposes `.SharedClient` properties on its API surfaces.
// Those implement common interfaces from CryptoExchange.Net.SharedApis.
// You write code against the interface — it works against any exchange.
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
// ---- SYMBOL NORMALIZATION ----
// Different exchanges use different formats: "BTCUSDT" (Binance), "BTC-USDT" (OKX).
// SharedSymbol normalizes — pass it instead of raw strings to shared methods.
var btcusdt = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// ---- AGNOSTIC METHOD — runs against any exchange ----
async Task PrintTicker(ISpotTickerRestClient client, SharedSymbol symbol)
{
var result = await client.GetSpotTickerAsync(new GetTickerRequest(symbol));
if (!result.Success)
{
Console.WriteLine($"[{client.Exchange}] Failed: {result.Error}");
return;
}
// SharedSpotTicker has a unified shape regardless of the source exchange
Console.WriteLine($"[{client.Exchange}] {result.Data.Symbol}: last={result.Data.LastPrice}, 24h-vol={result.Data.Volume}");
}
await PrintTicker(binance, btcusdt);
await PrintTicker(okx, btcusdt);
// ---- WEBSOCKET PATTERN ----
ITickerSocketClient binanceTickerSocket = new BinanceSocketClient().SpotApi.SharedClient;
ITickerSocketClient okxTickerSocket = new OKXSocketClient().UnifiedApi.SharedClient;
var sub1 = await binanceTickerSocket.SubscribeToTickerUpdatesAsync(
new SubscribeTickerRequest(btcusdt),
update => Console.WriteLine($"[{binanceTickerSocket.Exchange}] {update.Data.Symbol}: {update.Data.LastPrice}"));
var sub2 = await okxTickerSocket.SubscribeToTickerUpdatesAsync(
new SubscribeTickerRequest(btcusdt),
update => Console.WriteLine($"[{okxTickerSocket.Exchange}] {update.Data.Symbol}: {update.Data.LastPrice}"));
Console.WriteLine("Press Enter to exit");
Console.ReadLine();
if (sub1.Success) await sub1.Data.CloseAsync();
if (sub2.Success) await sub2.Data.CloseAsync();
// Common variations:
// Add Bybit: ISpotTickerRestClient bybit = new BybitRestClient().V5Api.SharedClient;
// Add Kraken: ISpotTickerRestClient kraken = new KrakenRestClient().SpotApi.SharedClient;
// Add Coinbase: ISpotTickerRestClient cb = new CoinbaseRestClient().AdvancedTradeApi.SharedClient;
// Other interfaces: ISpotOrderRestClient (place/cancel orders), IBalanceRestClient (balances),
// IFuturesOrderRestClient, IPositionRestClient, IOrderBookSocketClient, etc.
@@ -0,0 +1,70 @@
// 02-multi-exchange-tickers.cs
//
// Demonstrates: aggregating ticker data across N exchanges concurrently.
// Pattern is foundational for arbitrage scanners, best-execution routers,
// portfolio dashboards, and cross-exchange comparison tools.
//
// Setup:
// dotnet add package Binance.Net
// dotnet add package JK.OKX.Net
// dotnet add package Bybit.Net
using Binance.Net.Clients;
using OKX.Net.Clients;
using Bybit.Net.Clients;
using CryptoExchange.Net.SharedApis;
// ---- BUILD A LIST OF EXCHANGE CLIENTS ----
// All implement ISpotTickerRestClient, so we can iterate uniformly.
var exchanges = new List<ISpotTickerRestClient>
{
new BinanceRestClient().SpotApi.SharedClient,
new OKXRestClient().UnifiedApi.SharedClient,
new BybitRestClient().V5Api.SharedClient,
// Add as many as you want — same interface
};
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// ---- CONCURRENT FETCH ----
// Fire all requests in parallel, await all together.
// Each request runs on its own connection — no inter-exchange interference.
var tasks = exchanges
.Select(c => FetchAsync(c, symbol))
.ToList();
var results = await Task.WhenAll(tasks);
// ---- PRINT SORTED BY PRICE ----
// Highest bid first — useful for "where to sell" decisions.
foreach (var r in results.Where(r => r != null).OrderByDescending(r => r!.LastPrice))
{
Console.WriteLine($"{r!.Exchange,-12} {r.LastPrice,15} (24h vol: {r.Volume:F2})");
}
// ---- HELPER ----
async Task<TickerSnapshot?> FetchAsync(ISpotTickerRestClient client, SharedSymbol sym)
{
var result = await client.GetSpotTickerAsync(new GetTickerRequest(sym));
if (!result.Success)
{
Console.WriteLine($"[{client.Exchange}] error: {result.Error}");
return null;
}
return new TickerSnapshot(
Exchange: client.Exchange,
Symbol: result.Data.Symbol,
LastPrice: result.Data.LastPrice ?? 0,
Volume: result.Data.Volume);
}
record TickerSnapshot(string Exchange, string Symbol, decimal LastPrice, decimal Volume);
// Common variations:
// Periodic polling: wrap in `while(true) { await ...; await Task.Delay(...); }`
// Better: use ITickerSocketClient for push updates instead of polling
// With timeout per call: pass `ct: cts.Token` and use `CancellationTokenSource(timeout)`
// With retry: wrap FetchAsync in retry policy (see Binance.Net 05-error-handling.cs)
// Different metric: use IBookTickerRestClient for tighter best-bid/ask data
// Spread analysis: instead of ticker, use IOrderBookRestClient and compute mid/spread
@@ -0,0 +1,112 @@
// 03-cross-exchange-arbitrage-skeleton.cs
//
// Demonstrates: skeleton pattern for a cross-exchange spot arbitrage scanner.
// This is a structural example — production arbitrage requires also:
// - real-time WebSocket feeds (not REST polling)
// - orderbook depth analysis (not just ticker)
// - slippage / fees modeling
// - withdrawal availability and timing
// - inventory management on both sides
// Use this as a starting structure, not a deployable bot.
//
// Setup:
// dotnet add package Binance.Net
// dotnet add package JK.OKX.Net
// dotnet add package Bybit.Net
using Binance.Net.Clients;
using OKX.Net.Clients;
using Bybit.Net.Clients;
using CryptoExchange.Net.SharedApis;
// ---- CONFIGURATION ----
// Symbols to monitor and minimum profit threshold (gross, before fees)
var symbols = new[]
{
new SharedSymbol(TradingMode.Spot, "BTC", "USDT"),
new SharedSymbol(TradingMode.Spot, "ETH", "USDT"),
new SharedSymbol(TradingMode.Spot, "SOL", "USDT"),
};
const decimal minSpreadBps = 30; // 0.30% — must exceed total fees on both legs
// ---- USE BOOK TICKER FOR TIGHTER SPREADS ----
// IBookTickerRestClient gives best bid/ask, narrower than 24h ticker.
// For real arbitrage you'd use IOrderBookSocketClient for depth + push updates.
var exchanges = new List<IBookTickerRestClient>
{
new BinanceRestClient().SpotApi.SharedClient,
new OKXRestClient().UnifiedApi.SharedClient,
new BybitRestClient().V5Api.SharedClient,
};
// ---- MAIN LOOP (simplified: REST polling, 5-second intervals) ----
// In production: replace with concurrent WebSocket subscriptions.
while (true)
{
foreach (var symbol in symbols)
{
await ScanSymbolAsync(symbol, exchanges);
}
Console.WriteLine($"--- waiting 5s --- ({DateTime.UtcNow:HH:mm:ss})");
await Task.Delay(TimeSpan.FromSeconds(5));
}
// ---- SCAN ONE SYMBOL ACROSS ALL EXCHANGES ----
async Task ScanSymbolAsync(SharedSymbol symbol, List<IBookTickerRestClient> clients)
{
// Fetch best bid/ask from every exchange in parallel
var tasks = clients.Select(c => GetBookAsync(c, symbol)).ToArray();
var quotes = (await Task.WhenAll(tasks)).Where(q => q != null).Cast<Quote>().ToList();
if (quotes.Count < 2) return;
// Find best buy venue (lowest ask) and best sell venue (highest bid)
var bestBuy = quotes.OrderBy(q => q.AskPrice).First();
var bestSell = quotes.OrderByDescending(q => q.BidPrice).First();
if (bestBuy.Exchange == bestSell.Exchange) return; // no cross-venue arbitrage
// Spread in basis points
var spreadBps = (bestSell.BidPrice - bestBuy.AskPrice) / bestBuy.AskPrice * 10_000;
if (spreadBps >= minSpreadBps)
{
Console.WriteLine(
$"[{symbol.BaseAsset}/{symbol.QuoteAsset}] BUY {bestBuy.Exchange}@{bestBuy.AskPrice} " +
$"SELL {bestSell.Exchange}@{bestSell.BidPrice} " +
$"spread={spreadBps:F1}bps");
// Production hooks would go here:
// - check available inventory on both venues
// - simulate execution against orderbook depth
// - compute net P&L after fees
// - if profitable, execute via ISpotOrderRestClient on both venues
}
}
async Task<Quote?> GetBookAsync(IBookTickerRestClient client, SharedSymbol symbol)
{
var result = await client.GetBookTickerAsync(new GetBookTickerRequest(symbol));
if (!result.Success || result.Data == null)
return null;
return new Quote(
Exchange: client.Exchange,
BidPrice: result.Data.BestBidPrice,
AskPrice: result.Data.BestAskPrice);
}
record Quote(string Exchange, decimal BidPrice, decimal AskPrice);
// Production checklist (NOT in this skeleton):
// ✓ Use WebSocket book tickers (IBookTickerSocketClient) instead of REST polling
// ✓ Track full orderbook depth (IOrderBookSocketClient) to estimate fill price for size > top-of-book
// ✓ Model fees per exchange per pair (taker vs maker, BNB discount, etc.)
// ✓ Track inventory on both venues — can't sell what you don't have
// ✓ Account for withdrawal delays if rebalancing inventory
// ✓ Set hard P&L stops, position limits, maximum exposure per pair
// ✓ Use ISpotOrderRestClient with exchange-supported IOC/fill-or-kill order options where available
// ✓ Monitor connection health and have failover logic
// ✓ Log everything — arbitrage P&L analysis requires complete audit trails
+28
View File
@@ -0,0 +1,28 @@
# AI-Friendly Examples
Cross-exchange examples using `CryptoExchange.Net.SharedApis`. These examples are optimized for AI coding assistants and quick onboarding.
## Files
| File | What it shows |
|---|---|
| `01-shared-clients-quickstart.cs` | Same code calling Binance and OKX via SharedApis |
| `02-multi-exchange-tickers.cs` | Aggregating ticker data across N exchanges concurrently |
| `03-cross-exchange-arbitrage-skeleton.cs` | Pattern for building a price difference scanner |
## Running
```bash
dotnet new console -n MyMultiExchangeApp
cd MyMultiExchangeApp
# Add the exchange libraries you want
dotnet add package Binance.Net
dotnet add package JK.OKX.Net
dotnet add package Bybit.Net
# Copy example file content into Program.cs and run
dotnet run
```
These are public market data examples — no API keys needed.
+31
View File
@@ -7,6 +7,23 @@ Note that the CryptoExchange.Net package itself can not be used directly for acc
For more information on what CryptoExchange.Net and it's client libraries offers see the [Documentation](https://cryptoexchange.jkorf.dev/).
### For AI Coding Assistants
This library and the entire CryptoExchange.Net ecosystem provide first-class support for AI coding assistants. The relevant skill files are in this repository:
- **Agents**: `AGENTS.md` (auto-detected at repo root)
- **Cursor**: `.cursor/rules/cryptoexchange-net.mdc`
- **GitHub Copilot**: `.github/copilot-instructions.md`
- **Other tools** (Windsurf, Codex, Continue, Aider, etc.): `llms.txt` at repo root
- **Compilable examples**: `Examples/ai-friendly/`
For single-exchange code, see also the AI files in each exchange's repository (Binance.Net, Bybit.Net, OKX.Net, ...) — they cover exchange-specific patterns.
**Quick prompt to verify your assistant is using these:**
> "Show me how to fetch BTC/USDT spot tickers from Binance and OKX concurrently in C# using the SharedApis pattern."
The expected output should use `.SharedClient` properties, `SharedSymbol`, `ISpotTickerRestClient`, and `Task.WhenAll`.
### CryptoExchange.Net Ecosystem
Full list of all libraries part of the CryptoExchange.Net ecosystem. Consider using a referral link to support development, as well as potentially get some trading fee discount!
@@ -38,6 +55,7 @@ Full list of all libraries part of the CryptoExchange.Net ecosystem. Consider us
|![Polymarket](https://raw.githubusercontent.com/JKorf/Polymarket.Net/main/Polymarket.Net/Icon/icon.png)|Polymarket|DEX|[JKorf/Polymarket.Net](https://github.com/JKorf/Polymarket.Net)|[![Nuget version](https://img.shields.io/nuget/v/Polymarket.net.svg?style=flat-square)](https://www.nuget.org/packages/Polymarket.Net)|-|-|
|![Toobit](https://raw.githubusercontent.com/JKorf/Toobit.Net/refs/heads/main/Toobit.Net/Icon/icon.png)|Toobit|CEX|[JKorf/Toobit.Net](https://github.com/JKorf/Toobit.Net)|[![Nuget version](https://img.shields.io/nuget/v/Toobit.net.svg?style=flat-square)](https://www.nuget.org/packages/Toobit.Net)|[Link](https://www.toobit.com/en-US/register?invite_code=zsV19h)|-|
|![Upbit](https://raw.githubusercontent.com/JKorf/Upbit.Net/refs/heads/main/Upbit.Net/Icon/icon.png)|Upbit|CEX|[JKorf/Upbit.Net](https://github.com/JKorf/Upbit.Net)|[![Nuget version](https://img.shields.io/nuget/v/JKorf.Upbit.net.svg?style=flat-square)](https://www.nuget.org/packages/JKorf.Upbit.Net)|-|-|
|![Weex](https://raw.githubusercontent.com/JKorf/Weex.Net/refs/heads/main/Weex.Net/Icon/icon.png)|Weex|CEX|[JKorf/Weex.Net](https://github.com/JKorf/Weex.Net)|[![Nuget version](https://img.shields.io/nuget/v/Weex.net.svg?style=flat-square)](https://www.nuget.org/packages/Weex.Net)|-|-|
|![WhiteBit](https://raw.githubusercontent.com/JKorf/WhiteBit.Net/refs/heads/main/WhiteBit.Net/Icon/icon.png)|WhiteBit|CEX|[JKorf/WhiteBit.Net](https://github.com/JKorf/WhiteBit.Net)|[![Nuget version](https://img.shields.io/nuget/v/WhiteBit.net.svg?style=flat-square)](https://www.nuget.org/packages/WhiteBit.Net)|[Link](https://whitebit.com/referral/a8e59b59-186c-4662-824c-3095248e0edf)|-|
|![XT](https://raw.githubusercontent.com/JKorf/XT.Net/refs/heads/main/XT.Net/Icon/icon.png)|XT|CEX|[JKorf/XT.Net](https://github.com/JKorf/XT.Net)|[![Nuget version](https://img.shields.io/nuget/v/XT.net.svg?style=flat-square)](https://www.nuget.org/packages/XT.Net)|[Link](https://www.xt.com/ru/accounts/register?ref=CZG39C)|25%|
@@ -68,6 +86,19 @@ Make a one time donation in a crypto currency of your choice. If you prefer to d
Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf).
## Release notes
* Version 11.2.1 - 02 Jun 2026
* Fixed some testing logic
* Version 11.2.0 - 26 May 2026
* Added RateLimitGroup client option to allow for specifying different rate limiting groups
* Added request parameter checking in RestRequestValidator
* Improved EnumConverter initialization performance
* Fixed timing issue in SymbolOrderBook stopping
* Version 11.1.1 - 10 Apr 2026
* Added Reset functionality to rate limiter implementation
* Added reset of rate limit per connection when connection is disconnected
* Version 11.1.0 - 09 Apr 2026
* Updated WebSocket message routing improving performance for scenarios with multiple different subscriptions and topics
* Added AddCommaSeparated helper for Enum value arrays to ParameterCollection
+34
View File
@@ -0,0 +1,34 @@
# CryptoExchange.Net
> Base C#/.NET library for cryptocurrency exchange API client implementations. Provides a standardized abstraction (REST, WebSocket, authentication, rate limiting, error handling, order book management, shared cross-exchange interfaces) that 28+ exchange-specific libraries are built on top of.
CryptoExchange.Net itself is not used directly — install one of the exchange-specific libraries (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.) or `CryptoClients.Net` to access all exchanges via a single bundle. The base library is what makes the entire ecosystem feel consistent: same `WebCallResult<T>` result pattern, same WebSocket subscription model, same DI registration, same shared interfaces across all exchanges. Current version: 11.x. Targets netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0. Native AOT supported.
The standout feature for cross-exchange code is `CryptoExchange.Net.SharedApis` — a set of interfaces (`ISpotTickerRestClient`, `ISpotOrderRestClient`, `IBalanceRestClient`, etc.) implemented by every exchange library. Same call signature works against any exchange.
## Documentation
- [README](https://github.com/JKorf/CryptoExchange.Net/blob/master/README.md): Overview, full ecosystem table (28+ exchange libraries), installation per exchange, complete release notes
- [Documentation Site](https://cryptoexchange.jkorf.dev/): Full documentation hub with sections per topic
- [SharedApis Documentation](https://cryptoexchange.jkorf.dev/CryptoExchange.Net/idocs_shared.html): Cross-exchange shared interface guide
## Examples
- [AI-friendly examples directory](https://github.com/JKorf/CryptoExchange.Net/tree/master/Examples/ai-friendly): Compact, fully runnable examples optimized for AI assistants
- [Shared Clients Quickstart](https://github.com/JKorf/CryptoExchange.Net/blob/master/Examples/ai-friendly/01-shared-clients-quickstart.cs): Same code calling multiple exchanges via SharedApis
- [Multi-Exchange Tickers](https://github.com/JKorf/CryptoExchange.Net/blob/master/Examples/ai-friendly/02-multi-exchange-tickers.cs): Aggregating ticker data across N exchanges concurrently
- [Cross-Exchange Arbitrage Skeleton](https://github.com/JKorf/CryptoExchange.Net/blob/master/Examples/ai-friendly/03-cross-exchange-arbitrage-skeleton.cs): Pattern for building a price difference scanner
- [Full Examples Repository](https://github.com/JKorf/CryptoExchange.Net/tree/master/Examples): ConsoleClient with multiple exchange exchanges, BlazorClient, SharedClients
## Reference
- [Ecosystem libraries list](https://github.com/JKorf/CryptoExchange.Net#cryptoexchangenet-ecosystem): Aster, Binance, BingX, Bitfinex, Bitget, BitMart, BitMEX, Bitstamp, BloFin, Bybit, Coinbase, CoinEx, CoinW, CoinGecko, Crypto.com, DeepCoin, Gate.io, HTX, HyperLiquid, Kraken, Kucoin, Mexc, OKX, Polymarket, Toobit, Upbit, Weex, WhiteBit, XT
- [CryptoClients.Net](https://github.com/JKorf/CryptoClients.Net): Single bundle package for all exchange libraries
- [CryptoManager.Net](https://github.com/JKorf/CryptoManager.Net): Full demo application using CryptoClients.Net
- [NuGet Package](https://www.nuget.org/packages/CryptoExchange.Net): Latest stable release on NuGet
## Optional
- [Discord Community](https://discord.gg/MSpeEtSY8t): Maintainer-supported Discord for ecosystem-wide discussion
- [GitHub Issues](https://github.com/JKorf/CryptoExchange.Net/issues): Bug reports and feature requests
- [GitHub Sponsors](https://github.com/sponsors/JKorf): Support the maintainer