Files
PineSript/Buyer/BitgetOrderClient.cs
2026-04-26 19:00:54 +09:00

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;
}
}
}