278 lines
12 KiB
C#
278 lines
12 KiB
C#
|
|
using System.Collections.Specialized;
|
||
|
|
using System.Net;
|
||
|
|
using System.Security.Cryptography;
|
||
|
|
using System.Text;
|
||
|
|
using Bitget_Server.Application.State;
|
||
|
|
using Bitget_Server.Domain;
|
||
|
|
using Bitget_Server.Domain.Enums;
|
||
|
|
using Bitget_Server.Domain.Models;
|
||
|
|
using Bitget_Server.Infrastructure.Utils;
|
||
|
|
using Newtonsoft.Json;
|
||
|
|
|
||
|
|
namespace Bitget_Server.Infrastructure.Bitget;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 기존 WinForms WebConnection.SendWebServer / GenerateSignString 로직을 이식.
|
||
|
|
/// HttpClient 기반으로 재작성하고, 모든 외부 호출 전에 BitgetRateLimiter를 거친다.
|
||
|
|
/// </summary>
|
||
|
|
public class BitgetClient
|
||
|
|
{
|
||
|
|
private readonly HttpClient _http;
|
||
|
|
private readonly BitgetRateLimiter _limiter;
|
||
|
|
private readonly MarketState _state;
|
||
|
|
private readonly ILogger<BitgetClient> _logger;
|
||
|
|
|
||
|
|
public BitgetClient(
|
||
|
|
HttpClient http,
|
||
|
|
BitgetRateLimiter limiter,
|
||
|
|
MarketState state,
|
||
|
|
ILogger<BitgetClient> logger)
|
||
|
|
{
|
||
|
|
_http = http;
|
||
|
|
_limiter = limiter;
|
||
|
|
_state = state;
|
||
|
|
_logger = logger;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 기존 WebConnection.SendWebServer와 동일한 시그니처를 유지한다.
|
||
|
|
/// 응답을 ExchangeWebReqType에 따라 적절히 역직렬화하여 반환한다.
|
||
|
|
/// </summary>
|
||
|
|
public async Task<object> SendWebServerAsync(
|
||
|
|
ExchangeWebReqType exchangeWebReqType,
|
||
|
|
string url = "",
|
||
|
|
MarketCandleDataList marketCandleDataList = null,
|
||
|
|
MCandleTimeType interval = MCandleTimeType.M1,
|
||
|
|
string postData = "",
|
||
|
|
string subUri = "",
|
||
|
|
NameValueCollection headers = null,
|
||
|
|
CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var requestUrl = string.IsNullOrEmpty(url) ? GetDefaultUrl(exchangeWebReqType) : url;
|
||
|
|
if (string.IsNullOrEmpty(requestUrl))
|
||
|
|
{
|
||
|
|
_logger.LogWarning("URL 미정 ExchangeWebReqType: {Type}", exchangeWebReqType);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1차 호출 → 429 시 1회 재시도
|
||
|
|
for (int attempt = 0; attempt < 2; attempt++)
|
||
|
|
{
|
||
|
|
await _limiter.AcquireAsync(ct).ConfigureAwait(false);
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
HttpRequestMessage req;
|
||
|
|
if (!string.IsNullOrEmpty(postData))
|
||
|
|
{
|
||
|
|
req = new HttpRequestMessage(HttpMethod.Post, requestUrl)
|
||
|
|
{
|
||
|
|
Content = new StringContent(postData, Encoding.UTF8,
|
||
|
|
headers != null ? "application/json" : "application/x-www-form-urlencoded")
|
||
|
|
};
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
req = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (headers != null)
|
||
|
|
{
|
||
|
|
foreach (string key in headers)
|
||
|
|
req.Headers.TryAddWithoutValidation(key, headers[key]);
|
||
|
|
|
||
|
|
if (req.Content != null)
|
||
|
|
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||
|
|
}
|
||
|
|
|
||
|
|
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||
|
|
var resResult = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||
|
|
|
||
|
|
// 429 (TooManyRequests) 는 1회에 한해 500ms 후 재시도
|
||
|
|
if ((int)resp.StatusCode == 429 && attempt == 0)
|
||
|
|
{
|
||
|
|
_logger.LogWarning("Bitget 429 TooManyRequests — 500ms 후 1회 재시도 ({Type})", exchangeWebReqType);
|
||
|
|
await Task.Delay(500, ct).ConfigureAwait(false);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!resp.IsSuccessStatusCode)
|
||
|
|
{
|
||
|
|
_logger.LogError("Bitget API 에러 {Status}: {Body}", (int)resp.StatusCode, resResult);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return Parse(exchangeWebReqType, resResult, marketCandleDataList, interval);
|
||
|
|
}
|
||
|
|
catch (HttpRequestException webEx)
|
||
|
|
{
|
||
|
|
_logger.LogError(webEx, "Bitget HTTP 호출 실패: {Type}", exchangeWebReqType);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
catch (OperationCanceledException)
|
||
|
|
{
|
||
|
|
throw;
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger.LogError(ex, "Bitget 처리 중 예외");
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private string GetDefaultUrl(ExchangeWebReqType type) => type switch
|
||
|
|
{
|
||
|
|
ExchangeWebReqType.UpbitMarket => UpbitEndpoints.MarketInfo,
|
||
|
|
ExchangeWebReqType.BitgetMarket_Spot => BitgetEndpoints.MarketInfo_Coin,
|
||
|
|
ExchangeWebReqType.BitgetMarket_Future => BitgetEndpoints.MarketInfo_Future,
|
||
|
|
ExchangeWebReqType.BitgetCandleStick_Coin => BitgetEndpoints.MarketCandle_Coin,
|
||
|
|
ExchangeWebReqType.BitgetCandleStick_Future => BitgetEndpoints.MarketCandle_Future,
|
||
|
|
_ => string.Empty
|
||
|
|
};
|
||
|
|
|
||
|
|
private object Parse(ExchangeWebReqType type, string resResult, MarketCandleDataList marketCandleDataList, MCandleTimeType interval)
|
||
|
|
{
|
||
|
|
switch (type)
|
||
|
|
{
|
||
|
|
case ExchangeWebReqType.BitgetMarket_Spot:
|
||
|
|
{
|
||
|
|
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
|
||
|
|
var marketInfo = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
|
||
|
|
return marketInfo.FindAll(x => x["coin"].ToString().Contains("USDT"));
|
||
|
|
}
|
||
|
|
case ExchangeWebReqType.BitgetMarket_Future:
|
||
|
|
{
|
||
|
|
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
|
||
|
|
var marketInfos = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
|
||
|
|
|
||
|
|
var newList = new List<MarketInfo>();
|
||
|
|
foreach (var item in marketInfos)
|
||
|
|
{
|
||
|
|
var marketInfo = new MarketInfo
|
||
|
|
{
|
||
|
|
MarketName = item["symbol"].ToString(),
|
||
|
|
ExchangePlace = Enum.GetName(typeof(ExchangePlace), _state.selectedChangePlace),
|
||
|
|
lastPr = Convert.ToDouble(item["lastPr"]),
|
||
|
|
change24h = Convert.ToDouble(item["change24h"]),
|
||
|
|
baseVolume = Convert.ToDouble(item["baseVolume"]),
|
||
|
|
quoteVolume = Convert.ToDouble(item["quoteVolume"]),
|
||
|
|
high24h = Convert.ToDouble(item["high24h"]),
|
||
|
|
low24h = Convert.ToDouble(item["low24h"]),
|
||
|
|
open24h = Convert.ToDouble(item["open24h"])
|
||
|
|
};
|
||
|
|
newList.Add(marketInfo);
|
||
|
|
}
|
||
|
|
|
||
|
|
lock (_state.SyncRoot) { _state.marketInfoList = newList; }
|
||
|
|
return newList;
|
||
|
|
}
|
||
|
|
case ExchangeWebReqType.BitgetCandleStick_Future:
|
||
|
|
{
|
||
|
|
if (marketCandleDataList == null) return null;
|
||
|
|
|
||
|
|
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
|
||
|
|
var datas = JsonConvert.DeserializeObject<List<List<object>>>(datalist["data"]?.ToString());
|
||
|
|
|
||
|
|
var candleDataList = marketCandleDataList.candleDataList[interval];
|
||
|
|
|
||
|
|
lock (_state.SyncRoot)
|
||
|
|
{
|
||
|
|
foreach (var item in datas)
|
||
|
|
{
|
||
|
|
var candleData = new CandleData
|
||
|
|
{
|
||
|
|
Argument = Convertor.GetUnixtimeStampToDateTime(long.Parse(item[0].ToString())),
|
||
|
|
Open = Convert.ToDouble(item[1]),
|
||
|
|
High = Convert.ToDouble(item[2]),
|
||
|
|
Low = Convert.ToDouble(item[3]),
|
||
|
|
Close = Convert.ToDouble(item[4]),
|
||
|
|
Vol_C = Convert.ToDouble(item[5]),
|
||
|
|
Vol_USD = Convert.ToDouble(item[6])
|
||
|
|
};
|
||
|
|
|
||
|
|
if (item == datas.Last())
|
||
|
|
{
|
||
|
|
var span = GetIntervalSpan(interval);
|
||
|
|
var same = candleDataList.Find(i =>
|
||
|
|
candleData.Argument <= i.Argument &&
|
||
|
|
i.Argument < (candleData.Argument + span) &&
|
||
|
|
i.WebSocketUpdate);
|
||
|
|
|
||
|
|
if (same != null)
|
||
|
|
{
|
||
|
|
same.Open = candleData.Open;
|
||
|
|
same.Low = Math.Min(same.Low, candleData.Low);
|
||
|
|
same.High = Math.Max(same.High, candleData.High);
|
||
|
|
same.WebServiceUpdate = true;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
candleDataList.Add(candleData);
|
||
|
|
candleData.WebServiceUpdate = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
candleDataList.Add(candleData);
|
||
|
|
candleData.WebServiceUpdate = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
case ExchangeWebReqType.BitgetAccountList_Future:
|
||
|
|
{
|
||
|
|
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
|
||
|
|
return JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
|
||
|
|
}
|
||
|
|
case ExchangeWebReqType.BitgetOrder_Future:
|
||
|
|
case ExchangeWebReqType.BitgetCancelOrder_Future:
|
||
|
|
case ExchangeWebReqType.BitgetChangeLeverage_Future:
|
||
|
|
case ExchangeWebReqType.BitgetChangeMarginMode_Future:
|
||
|
|
case ExchangeWebReqType.BitgetChangePositionMode_Future:
|
||
|
|
case ExchangeWebReqType.BitgetOrderDetail_Future:
|
||
|
|
case ExchangeWebReqType.BitgetGetTimeStamp:
|
||
|
|
case ExchangeWebReqType.BitgetPendingOrder_Future:
|
||
|
|
return JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
|
||
|
|
|
||
|
|
default:
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static TimeSpan GetIntervalSpan(MCandleTimeType interval) => interval switch
|
||
|
|
{
|
||
|
|
MCandleTimeType.M1 => TimeSpan.FromMinutes(1),
|
||
|
|
MCandleTimeType.M3 => TimeSpan.FromMinutes(3),
|
||
|
|
MCandleTimeType.M5 => TimeSpan.FromMinutes(5),
|
||
|
|
MCandleTimeType.M15 => TimeSpan.FromMinutes(15),
|
||
|
|
MCandleTimeType.M30 => TimeSpan.FromMinutes(30),
|
||
|
|
MCandleTimeType.H1 => TimeSpan.FromHours(1),
|
||
|
|
MCandleTimeType.H2 => TimeSpan.FromHours(2),
|
||
|
|
MCandleTimeType.H3 => TimeSpan.FromHours(3),
|
||
|
|
MCandleTimeType.H4 => TimeSpan.FromHours(4),
|
||
|
|
MCandleTimeType.H6 => TimeSpan.FromHours(6),
|
||
|
|
MCandleTimeType.H12 => TimeSpan.FromHours(12),
|
||
|
|
_ => TimeSpan.Zero
|
||
|
|
};
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 기존 IndicatorProcess.GenerateSignString을 그대로 이식.
|
||
|
|
/// </summary>
|
||
|
|
public static string GenerateSignString(string timestamp, string method, string requestPath, string queryString, string body, string secretKey)
|
||
|
|
{
|
||
|
|
method = method.ToUpper();
|
||
|
|
body = body == null ? "" : body;
|
||
|
|
queryString = string.IsNullOrWhiteSpace(queryString) ? "" : "?" + queryString;
|
||
|
|
string preHash = timestamp + method + requestPath + queryString + body;
|
||
|
|
|
||
|
|
var encSecretKey_byteData = Encoding.UTF8.GetBytes(secretKey);
|
||
|
|
var encPreHash_byteData = Encoding.UTF8.GetBytes(preHash);
|
||
|
|
|
||
|
|
return Convert.ToBase64String(new HMACSHA256(encSecretKey_byteData).ComputeHash(encPreHash_byteData));
|
||
|
|
}
|
||
|
|
}
|