237 lines
11 KiB
C#
237 lines
11 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using Serilog;
|
|
|
|
namespace Buyer
|
|
{
|
|
/// <summary>
|
|
/// Bitget USDT-FUTURES 주문 클라이언트.
|
|
///
|
|
/// 진입/청산 방향:
|
|
/// BUY (롱 진입) : side=buy, tradeSide=open
|
|
/// SELL (롱 청산) : side=sell, tradeSide=close
|
|
/// SHORT (숏 진입) : side=sell, tradeSide=open
|
|
/// COVER (숏 청산) : side=buy, tradeSide=close
|
|
/// </summary>
|
|
public class BitgetOrderClient
|
|
{
|
|
private readonly BitgetAuthClient _auth;
|
|
private readonly BitgetConfig _cfg;
|
|
|
|
public BitgetOrderClient(BitgetAuthClient auth, BitgetConfig cfg)
|
|
{
|
|
_auth = auth;
|
|
_cfg = cfg;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 진입 주문
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>롱 진입 (BUY) — side=buy, tradeSide=open</summary>
|
|
public Task<string> OpenLongAsync(decimal size, CancellationToken ct = default)
|
|
=> PlaceOrderAsync("buy", "open", size, ct);
|
|
|
|
/// <summary>숏 진입 (SHORT) — side=sell, tradeSide=open</summary>
|
|
public Task<string> OpenShortAsync(decimal size, CancellationToken ct = default)
|
|
=> PlaceOrderAsync("sell", "open", size, ct);
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 청산 주문
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>롱 청산 (SELL) — side=sell, tradeSide=close, holdSide=long</summary>
|
|
public Task<string> CloseLongAsync(decimal size, CancellationToken ct = default)
|
|
=> PlaceOrderAsync("sell", "close", size, ct, holdSide: "long");
|
|
|
|
/// <summary>숏 청산 (COVER) — side=buy, tradeSide=close, holdSide=short</summary>
|
|
public Task<string> CloseShortAsync(decimal size, CancellationToken ct = default)
|
|
=> PlaceOrderAsync("buy", "close", size, ct, holdSide: "short");
|
|
|
|
/// <summary>
|
|
/// 포지션 플래시 청산 — close-positions 전용 엔드포인트.
|
|
/// place-order의 sell/close가 계정 모드 문제로 거부될 때 대안으로 사용합니다.
|
|
/// holdSide 지정 시 해당 방향 전량 청산, 미지정 시 전체 청산.
|
|
/// </summary>
|
|
public async Task<string> FlashCloseAsync(
|
|
string holdSide, CancellationToken ct = default)
|
|
{
|
|
var bodyDict = new Dictionary<string, string>
|
|
{
|
|
["symbol"] = _cfg.Symbol,
|
|
["productType"] = _cfg.ProductType,
|
|
["holdSide"] = holdSide, // "long" | "short"
|
|
};
|
|
|
|
string json = JsonSerializer.Serialize(bodyDict);
|
|
|
|
Log.Information("[Order] 플래시 청산 요청: holdSide={HoldSide} symbol={Symbol}",
|
|
holdSide, _cfg.Symbol);
|
|
Log.Debug("[Order] 요청 JSON: {Json}", json);
|
|
|
|
string responseBody = await _auth.PostAsync(
|
|
BitgetEndpoints.ClosePositions_Future, json, ct);
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
string code = root.TryGetProperty("code", out var cEl)
|
|
? cEl.GetString() ?? "" : "";
|
|
|
|
if (code != "00000")
|
|
{
|
|
string msg = root.TryGetProperty("msg", out var mEl)
|
|
? mEl.GetString() ?? "" : "";
|
|
Log.Error("[Order] 플래시 청산 오류 code={Code} msg={Msg} body={Body}",
|
|
code, msg, responseBody);
|
|
throw new Exception($"Bitget 플래시 청산 오류: {code} {msg}");
|
|
}
|
|
|
|
// close-positions 응답에는 orderId가 없을 수 있음
|
|
string orderId = "";
|
|
if (root.TryGetProperty("data", out var dataEl))
|
|
{
|
|
if (dataEl.ValueKind == JsonValueKind.Array)
|
|
{
|
|
// 배열 형태: 첫 번째 주문 ID 반환
|
|
foreach (var el in dataEl.EnumerateArray())
|
|
{
|
|
if (el.TryGetProperty("orderId", out var idEl))
|
|
{ orderId = idEl.GetString() ?? ""; break; }
|
|
}
|
|
}
|
|
else if (dataEl.TryGetProperty("orderId", out var idEl))
|
|
orderId = idEl.GetString() ?? "";
|
|
}
|
|
|
|
Log.Information("[Order] 플래시 청산 완료: orderId={OrderId}", orderId);
|
|
return orderId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 포지션 정보로 전량 청산 (StartupReconciler Case 2에서 사용).
|
|
/// 포지션의 MarginMode를 우선 적용합니다.
|
|
/// </summary>
|
|
public Task<string> ClosePositionAsync(PositionInfo position,
|
|
CancellationToken ct = default)
|
|
{
|
|
string side = position.IsShort ? "buy" : "sell";
|
|
string holdSide = position.IsShort ? "short" : "long";
|
|
return PlaceOrderAsync(side, "close", position.Total, ct,
|
|
marginModeOverride: position.MarginMode,
|
|
holdSide: holdSide);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 미체결 주문 취소
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// 해당 심볼의 미체결 진입 주문을 전량 취소합니다.
|
|
/// 청산 신호 발생 전에 호출하여 부분 체결 잔량을 정리합니다.
|
|
/// </summary>
|
|
public async Task CancelAllPendingOrdersAsync(CancellationToken ct = default)
|
|
{
|
|
var body = new
|
|
{
|
|
symbol = _cfg.Symbol,
|
|
productType = _cfg.ProductType,
|
|
};
|
|
|
|
string json = JsonSerializer.Serialize(body);
|
|
|
|
Log.Information("[Order] 미체결 주문 일괄 취소 요청: symbol={Symbol}", _cfg.Symbol);
|
|
|
|
try
|
|
{
|
|
string responseBody = await _auth.PostAsync(
|
|
BitgetEndpoints.CancelAllOrders_Future, json, ct);
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
string code = root.TryGetProperty("code", out var cEl)
|
|
? cEl.GetString() ?? "" : "";
|
|
|
|
if (code == "00000")
|
|
Log.Information("[Order] 미체결 주문 취소 완료");
|
|
else
|
|
{
|
|
string msg = root.TryGetProperty("msg", out var mEl)
|
|
? mEl.GetString() ?? "" : "";
|
|
// 취소할 주문이 없는 경우(43003 등)는 경고만
|
|
Log.Warning("[Order] 미체결 주문 취소 응답 code={Code} msg={Msg}", code, msg);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "[Order] 미체결 주문 취소 실패 — 청산은 계속 진행");
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 내부 공통 주문 메서드
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
private async Task<string> PlaceOrderAsync(
|
|
string side, string tradeSide, decimal size,
|
|
CancellationToken ct,
|
|
string? marginModeOverride = null,
|
|
string? holdSide = null)
|
|
{
|
|
string marginMode = marginModeOverride ?? _cfg.MarginMode;
|
|
string sizeStr = size.ToString("F4", CultureInfo.InvariantCulture);
|
|
|
|
// holdSide: 헤지 모드에서 청산 시 롱/숏 구분 필수
|
|
// - 롱 청산(SELL): holdSide=long
|
|
// - 숏 청산(COVER): holdSide=short
|
|
// - 진입 주문: holdSide 미포함
|
|
var bodyDict = new Dictionary<string, string>
|
|
{
|
|
["symbol"] = _cfg.Symbol,
|
|
["productType"] = _cfg.ProductType,
|
|
["marginMode"] = marginMode,
|
|
["marginCoin"] = _cfg.MarginCoin,
|
|
["size"] = sizeStr,
|
|
["side"] = side,
|
|
["tradeSide"] = tradeSide,
|
|
["orderType"] = "market",
|
|
["force"] = "gtc",
|
|
};
|
|
if (holdSide != null)
|
|
bodyDict["holdSide"] = holdSide;
|
|
|
|
string json = JsonSerializer.Serialize(bodyDict);
|
|
|
|
Log.Information("[Order] 주문 전송: {Side}/{TradeSide} size={Size} symbol={Symbol}",
|
|
side, tradeSide, sizeStr, _cfg.Symbol);
|
|
Log.Debug("[Order] 요청 JSON: {Json}", json);
|
|
|
|
string responseBody = await _auth.PostAsync(
|
|
BitgetEndpoints.PlaceOrder_Future, json, ct);
|
|
|
|
// ── 응답 확인 ──────────────────────────────────────────────
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
|
|
string code = root.TryGetProperty("code", out var cEl)
|
|
? cEl.GetString() ?? "" : "";
|
|
|
|
if (code != "00000")
|
|
{
|
|
string msg = root.TryGetProperty("msg", out var mEl)
|
|
? mEl.GetString() ?? "" : "";
|
|
Log.Error("[Order] 주문 오류 code={Code} msg={Msg} body={Body}",
|
|
code, msg, responseBody);
|
|
throw new Exception($"Bitget 주문 오류: {code} {msg}");
|
|
}
|
|
|
|
string orderId = "";
|
|
if (root.TryGetProperty("data", out var dataEl)
|
|
&& dataEl.TryGetProperty("orderId", out var idEl))
|
|
orderId = idEl.GetString() ?? "";
|
|
|
|
Log.Information("[Order] 주문 접수 완료: orderId={OrderId}", orderId);
|
|
return orderId;
|
|
}
|
|
}
|
|
}
|