3491681c28
Co-authored-by: Cursor <cursoragent@cursor.com>
605 lines
22 KiB
Python
605 lines
22 KiB
Python
"""币安私有交易:USDT-M 永续 (fapi) + 欧洲期权 (eapi)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
from urllib.parse import urlencode
|
|
|
|
import httpx
|
|
|
|
from ..config import Settings, get_settings
|
|
from ..exchange.okx.parse import safe_float
|
|
from .okx_trade import LiveFill
|
|
from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BinanceTradeClient:
|
|
def __init__(self, settings: Settings | None = None) -> None:
|
|
self.settings = settings or get_settings()
|
|
proxy = (self.settings.binance_http_proxy or "").strip() or None
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"User-Agent": "eth-hedge-live/0.1",
|
|
"X-MBX-APIKEY": self.settings.binance_api_key or "",
|
|
}
|
|
self._fapi = httpx.Client(
|
|
base_url=self.settings.binance_fapi_base.rstrip("/"),
|
|
timeout=20.0,
|
|
proxy=proxy,
|
|
headers=headers,
|
|
trust_env=False,
|
|
)
|
|
self._eapi = httpx.Client(
|
|
base_url=self.settings.binance_eapi_base.rstrip("/"),
|
|
timeout=20.0,
|
|
proxy=proxy,
|
|
headers=headers,
|
|
trust_env=False,
|
|
)
|
|
self._hedge: bool | None = None
|
|
self._fapi_throttle = get_throttle("binance_fapi_trade", min_interval_sec=1.0)
|
|
self._eapi_throttle = get_throttle(
|
|
"binance_eapi_trade",
|
|
min_interval_sec=1.0,
|
|
cooldown_429_sec=20.0,
|
|
cooldown_418_sec=120.0,
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self._fapi.close()
|
|
self._eapi.close()
|
|
|
|
def _sign(self, params: dict[str, Any]) -> str:
|
|
qs = urlencode(params, doseq=True)
|
|
secret = (self.settings.binance_api_secret or "").encode("utf-8")
|
|
return hmac.new(secret, qs.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
|
|
def _throttle_for(self, client: httpx.Client):
|
|
if client is self._eapi:
|
|
return self._eapi_throttle
|
|
return self._fapi_throttle
|
|
|
|
def _signed(
|
|
self,
|
|
client: httpx.Client,
|
|
method: str,
|
|
path: str,
|
|
params: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
throttle = self._throttle_for(client)
|
|
throttle.before_request()
|
|
p = dict(params or {})
|
|
p["timestamp"] = int(time.time() * 1000)
|
|
p["signature"] = self._sign(p)
|
|
r = client.request(method.upper(), path, params=p)
|
|
if r.status_code in (418, 429):
|
|
ra = parse_retry_after_header(r.headers)
|
|
throttle.mark_http(r.status_code, ra)
|
|
raise RateLimitError(
|
|
f"Binance {path} HTTP {r.status_code}: {r.text[:200]}",
|
|
retry_after=throttle.remaining_cooldown(),
|
|
)
|
|
if r.status_code >= 400:
|
|
raise RuntimeError(f"Binance {path} HTTP {r.status_code}: {r.text[:400]}")
|
|
data = r.json()
|
|
if isinstance(data, dict) and "code" in data and "orderId" not in data:
|
|
code = data.get("code")
|
|
try:
|
|
code_i = int(code)
|
|
except (TypeError, ValueError):
|
|
code_i = None
|
|
msg = str(data.get("msg") or "")
|
|
# -1003 too many requests; -1015 too many orders
|
|
if code_i in (-1003, -1015) or "too many" in msg.lower():
|
|
throttle.mark_seconds(20.0)
|
|
raise RateLimitError(
|
|
f"Binance rate-limited code={code} msg={msg}",
|
|
retry_after=throttle.remaining_cooldown(),
|
|
)
|
|
if code_i is not None and code_i != 0:
|
|
raise RuntimeError(f"Binance error code={code} msg={msg}")
|
|
if code_i is None:
|
|
raise RuntimeError(f"Binance error code={code} msg={msg}")
|
|
return data
|
|
|
|
def is_hedge_mode(self) -> bool:
|
|
if self._hedge is not None:
|
|
return self._hedge
|
|
try:
|
|
data = self._signed(self._fapi, "GET", "/fapi/v1/positionSide/dual")
|
|
self._hedge = bool(data.get("dualSidePosition") in (True, "true", "True"))
|
|
except Exception as e:
|
|
logger.warning("binance hedge mode probe failed: %s; assume one-way", e)
|
|
self._hedge = False
|
|
return self._hedge
|
|
|
|
def place_perp_market(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str, # BUY|SELL
|
|
qty_eth: float,
|
|
position_side: str | None = None, # LONG|SHORT|None
|
|
reduce_only: bool = False,
|
|
) -> LiveFill:
|
|
# ETHUSDT 数量单位为 ETH
|
|
qty = f"{float(qty_eth):.3f}".rstrip("0").rstrip(".")
|
|
if not qty or qty == "0":
|
|
qty = "0.001"
|
|
params: dict[str, Any] = {
|
|
"symbol": symbol,
|
|
"side": side.upper(),
|
|
"type": "MARKET",
|
|
"quantity": qty,
|
|
}
|
|
hedge = self.is_hedge_mode()
|
|
if hedge:
|
|
ps = (position_side or ("LONG" if side.upper() == "BUY" else "SHORT")).upper()
|
|
params["positionSide"] = ps
|
|
elif reduce_only:
|
|
params["reduceOnly"] = "true"
|
|
data = self._signed(self._fapi, "POST", "/fapi/v1/order", params)
|
|
return self._fill_from_fapi(symbol, data)
|
|
|
|
def _fill_from_fapi(self, symbol: str, data: dict[str, Any]) -> LiveFill:
|
|
ord_id = str(data.get("orderId") or "")
|
|
avg = safe_float(data.get("avgPrice"))
|
|
sz = safe_float(data.get("executedQty"))
|
|
if (not avg or avg <= 0) and ord_id:
|
|
q = self._signed(
|
|
self._fapi,
|
|
"GET",
|
|
"/fapi/v1/order",
|
|
{"symbol": symbol, "orderId": ord_id},
|
|
)
|
|
avg = safe_float(q.get("avgPrice")) or avg
|
|
sz = safe_float(q.get("executedQty")) or sz
|
|
data = q
|
|
if not avg or avg <= 0:
|
|
raise RuntimeError(f"币安永续无成交均价 orderId={ord_id} last={data}")
|
|
from .money import abs_fee_usdt
|
|
|
|
fee = abs(safe_float(data.get("cumCommission")) or 0.0)
|
|
fee_asset = str(data.get("commissionAsset") or "USDT")
|
|
if fee <= 0 and ord_id:
|
|
fee, fee_asset = self.sum_perp_trade_fees(symbol, ord_id)
|
|
return LiveFill(
|
|
inst_id=symbol,
|
|
side=str(data.get("side") or "").lower(),
|
|
avg_px=float(avg),
|
|
sz=float(sz or 0),
|
|
fee=abs_fee_usdt(fee, fee_asset),
|
|
ord_id=ord_id,
|
|
raw=data if isinstance(data, dict) else {},
|
|
)
|
|
|
|
def place_option_market(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str, # BUY|SELL
|
|
quantity: float,
|
|
reduce_only: bool = False,
|
|
) -> LiveFill:
|
|
qty = str(int(round(quantity)))
|
|
if qty == "0":
|
|
qty = "1"
|
|
params: dict[str, Any] = {
|
|
"symbol": symbol,
|
|
"side": side.upper(),
|
|
"type": "MARKET",
|
|
"quantity": qty,
|
|
}
|
|
if reduce_only:
|
|
params["reduceOnly"] = "true"
|
|
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
|
|
return self._fill_from_eapi(symbol, data)
|
|
|
|
def place_option_ioc(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str, # BUY|SELL
|
|
quantity: float,
|
|
price: float,
|
|
reduce_only: bool = False,
|
|
) -> LiveFill:
|
|
"""期权限价 IOC:按买一/卖一价吃单,未成交部分取消。"""
|
|
qty = str(int(round(quantity)))
|
|
if qty == "0":
|
|
qty = "1"
|
|
px = f"{float(price):.8f}".rstrip("0").rstrip(".")
|
|
if not px or px == "0":
|
|
raise RuntimeError("币安期权 IOC 价格无效")
|
|
params: dict[str, Any] = {
|
|
"symbol": symbol,
|
|
"side": side.upper(),
|
|
"type": "LIMIT",
|
|
"timeInForce": "IOC",
|
|
"quantity": qty,
|
|
"price": px,
|
|
}
|
|
if reduce_only:
|
|
params["reduceOnly"] = "true"
|
|
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
|
|
return self._fill_from_eapi(symbol, data, allow_partial=True)
|
|
|
|
def _fill_from_eapi(
|
|
self, symbol: str, data: dict[str, Any], *, allow_partial: bool = False
|
|
) -> LiveFill:
|
|
ord_id = str(data.get("orderId") or data.get("id") or "")
|
|
avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price"))
|
|
sz = safe_float(data.get("executedQty")) or safe_float(data.get("quantity"))
|
|
if (not avg or avg <= 0) and ord_id:
|
|
# 轮询几轮
|
|
for _ in range(8):
|
|
time.sleep(0.2)
|
|
q = self._signed(
|
|
self._eapi,
|
|
"GET",
|
|
"/eapi/v1/order",
|
|
{"symbol": symbol, "orderId": ord_id},
|
|
)
|
|
avg = safe_float(q.get("avgPrice")) or safe_float(q.get("price"))
|
|
sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity"))
|
|
st = str(q.get("status") or "").upper()
|
|
data = q
|
|
if avg and avg > 0 and st == "FILLED":
|
|
break
|
|
if st in ("CANCELED", "REJECTED", "EXPIRED"):
|
|
if allow_partial and sz and sz > 1e-12 and avg and avg > 0:
|
|
break
|
|
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
|
|
if st == "PARTIALLY_FILLED":
|
|
continue
|
|
if not avg or avg <= 0:
|
|
raise RuntimeError(f"币安期权无成交均价 orderId={ord_id} last={data}")
|
|
st_final = str(data.get("status") or "").upper()
|
|
executed = safe_float(data.get("executedQty")) or float(sz or 0)
|
|
if st_final and st_final != "FILLED":
|
|
if not (
|
|
allow_partial
|
|
and executed > 1e-12
|
|
and st_final in ("CANCELED", "EXPIRED", "PARTIALLY_FILLED")
|
|
):
|
|
raise RuntimeError(
|
|
f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}"
|
|
)
|
|
sz = executed
|
|
from .money import abs_fee_usdt
|
|
|
|
fee = abs(safe_float(data.get("fee")) or 0.0)
|
|
fee_asset = "USDT"
|
|
if fee <= 0 and ord_id:
|
|
fee, fee_asset = self.sum_option_trade_fees(symbol, ord_id)
|
|
return LiveFill(
|
|
inst_id=symbol,
|
|
side=str(data.get("side") or "").lower(),
|
|
avg_px=float(avg),
|
|
sz=float(sz or 0),
|
|
fee=abs_fee_usdt(fee, fee_asset),
|
|
ord_id=ord_id,
|
|
raw=data if isinstance(data, dict) else {},
|
|
)
|
|
|
|
def sum_perp_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
|
|
try:
|
|
rows = self._signed(
|
|
self._fapi,
|
|
"GET",
|
|
"/fapi/v1/userTrades",
|
|
{"symbol": symbol, "orderId": order_id},
|
|
)
|
|
except Exception as e:
|
|
logger.warning("binance perp userTrades fee failed: %s", e)
|
|
return 0.0, "USDT"
|
|
if not isinstance(rows, list):
|
|
rows = [rows] if isinstance(rows, dict) else []
|
|
total = 0.0
|
|
asset = "USDT"
|
|
for row in rows:
|
|
total += abs(safe_float(row.get("commission")) or 0.0)
|
|
if row.get("commissionAsset"):
|
|
asset = str(row.get("commissionAsset"))
|
|
return total, asset
|
|
|
|
def sum_option_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
|
|
try:
|
|
rows = self._signed(
|
|
self._eapi,
|
|
"GET",
|
|
"/eapi/v1/userTrades",
|
|
{"symbol": symbol, "orderId": order_id},
|
|
)
|
|
except Exception as e:
|
|
logger.warning("binance option userTrades fee failed: %s", e)
|
|
return 0.0, "USDT"
|
|
if not isinstance(rows, list):
|
|
rows = [rows] if isinstance(rows, dict) else []
|
|
total = 0.0
|
|
asset = "USDT"
|
|
for row in rows:
|
|
total += abs(safe_float(row.get("commission")) or safe_float(row.get("fee")) or 0.0)
|
|
if row.get("commissionAsset") or row.get("feeAsset"):
|
|
asset = str(row.get("commissionAsset") or row.get("feeAsset"))
|
|
return total, asset
|
|
|
|
def get_perp_upl_usdt(self, symbol: str, *, position_side: str | None = None) -> float | None:
|
|
from .money import to_usdt
|
|
|
|
try:
|
|
rows = self._signed(
|
|
self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol}
|
|
)
|
|
except Exception as e:
|
|
logger.warning("binance positionRisk failed: %s", e)
|
|
return None
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
want = (position_side or "").strip().upper()
|
|
for row in rows:
|
|
amt = safe_float(row.get("positionAmt")) or 0.0
|
|
if abs(amt) < 1e-12:
|
|
continue
|
|
ps = str(row.get("positionSide") or "").upper()
|
|
if want and ps and ps not in ("BOTH",) and ps != want:
|
|
continue
|
|
upl = safe_float(row.get("unRealizedProfit"))
|
|
if upl is None:
|
|
continue
|
|
return to_usdt(float(upl), "USDT")
|
|
return 0.0
|
|
|
|
def fetch_balances(self) -> dict[str, float | None]:
|
|
"""交易侧可用:USDT-M 钱包 USDT + 期权账户 USDT/USDC(尽力而为)。"""
|
|
out: dict[str, float | None] = {
|
|
"trading_usdt": None,
|
|
"trading_usdc": None,
|
|
}
|
|
try:
|
|
rows = self._signed(self._fapi, "GET", "/fapi/v2/balance")
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
for row in rows or []:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
asset = str(row.get("asset") or "").upper()
|
|
avail = safe_float(row.get("availableBalance"))
|
|
if avail is None:
|
|
avail = safe_float(row.get("balance"))
|
|
if asset == "USDT" and avail is not None:
|
|
out["trading_usdt"] = float(avail)
|
|
elif asset == "USDC" and avail is not None:
|
|
# 永续侧 USDC 少见;若有则记
|
|
if out["trading_usdc"] is None:
|
|
out["trading_usdc"] = float(avail)
|
|
except Exception as e:
|
|
logger.warning("binance fapi balance failed: %s", e)
|
|
try:
|
|
data = self._signed(self._eapi, "GET", "/eapi/v1/marginAccount")
|
|
asset_list = []
|
|
if isinstance(data, dict):
|
|
asset_list = data.get("asset") or data.get("assets") or []
|
|
if isinstance(asset_list, list):
|
|
for row in asset_list:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
asset = str(
|
|
row.get("asset") or row.get("currency") or ""
|
|
).upper()
|
|
avail = (
|
|
safe_float(row.get("available"))
|
|
or safe_float(row.get("marginBalance"))
|
|
or safe_float(row.get("equity"))
|
|
)
|
|
if asset == "USDT" and avail is not None:
|
|
# 期权保证金常用 USDT;与 fapi 取较大可用
|
|
cur = out.get("trading_usdt")
|
|
out["trading_usdt"] = (
|
|
float(avail)
|
|
if cur is None
|
|
else max(float(cur), float(avail))
|
|
)
|
|
elif asset == "USDC" and avail is not None:
|
|
out["trading_usdc"] = float(avail)
|
|
except Exception as e:
|
|
logger.warning("binance eapi marginAccount failed: %s", e)
|
|
# 币安期权常用 USDT 保证金:eapi 无独立 USDC 时,用 USDT 作为期权侧可用
|
|
if out.get("trading_usdt") is not None and out.get("trading_usdc") is None:
|
|
out["trading_usdc"] = float(out["trading_usdt"])
|
|
return out
|
|
|
|
def get_perp_pos_sz(self, symbol: str, *, position_side: str | None = None) -> float | None:
|
|
"""当前永续绝对持仓(ETH)。"""
|
|
try:
|
|
rows = self._signed(
|
|
self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol}
|
|
)
|
|
except Exception as e:
|
|
logger.warning("binance get_perp_pos_sz failed: %s", e)
|
|
return None
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
want = (position_side or "").strip().upper()
|
|
for row in rows:
|
|
amt = safe_float(row.get("positionAmt")) or 0.0
|
|
if abs(amt) < 1e-12:
|
|
continue
|
|
ps = str(row.get("positionSide") or "").upper()
|
|
if want and ps and ps not in ("BOTH",) and ps != want:
|
|
continue
|
|
return abs(float(amt))
|
|
return 0.0
|
|
|
|
def get_option_pos_sz(self, symbol: str) -> float | None:
|
|
"""期权持仓绝对张数;查不到接口时返回 None。"""
|
|
try:
|
|
rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {"symbol": symbol})
|
|
except Exception as e:
|
|
logger.warning("binance get_option_pos_sz failed: %s", e)
|
|
return None
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
total = 0.0
|
|
hit = False
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol:
|
|
continue
|
|
qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0
|
|
hit = True
|
|
total += abs(float(qty))
|
|
return total if hit else 0.0
|
|
|
|
def any_option_pos_abs(self) -> float | None:
|
|
"""账户任意期权绝对持仓合计(ETH 期权)。"""
|
|
try:
|
|
rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {})
|
|
except Exception as e:
|
|
logger.warning("binance any_option_pos_abs failed: %s", e)
|
|
return None
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
total = 0.0
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
sym = str(row.get("symbol") or "")
|
|
if sym and not sym.upper().startswith("ETH"):
|
|
continue
|
|
qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0
|
|
total += abs(float(qty))
|
|
return total
|
|
|
|
def set_margin_type(self, symbol: str, margin_type: str) -> None:
|
|
"""ISOLATED | CROSSED。"""
|
|
mt = "ISOLATED" if str(margin_type).lower() == "isolated" else "CROSSED"
|
|
try:
|
|
self._signed(
|
|
self._fapi,
|
|
"POST",
|
|
"/fapi/v1/marginType",
|
|
{"symbol": symbol, "marginType": mt},
|
|
)
|
|
except Exception as e:
|
|
# 已是目标模式时币安常报错,忽略
|
|
msg = str(e).lower()
|
|
if "no need to change" in msg or "-4046" in msg:
|
|
return
|
|
raise
|
|
|
|
def set_leverage(self, symbol: str, leverage: int | float) -> None:
|
|
lev = int(round(float(leverage)))
|
|
if lev < 1:
|
|
lev = 1
|
|
self._signed(
|
|
self._fapi,
|
|
"POST",
|
|
"/fapi/v1/leverage",
|
|
{"symbol": symbol, "leverage": lev},
|
|
)
|
|
|
|
def get_option_exercise_records(
|
|
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
|
) -> list[dict] | None:
|
|
"""用户期权行权/到期结算记录 GET /eapi/v1/exerciseRecord。"""
|
|
end = int(end_ms or int(time.time() * 1000))
|
|
begin = int(begin_ms)
|
|
try:
|
|
rows = self._signed(
|
|
self._eapi,
|
|
"GET",
|
|
"/eapi/v1/exerciseRecord",
|
|
{
|
|
"symbol": symbol,
|
|
"startTime": begin,
|
|
"endTime": end,
|
|
"limit": 100,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.warning("binance exerciseRecord failed: %s", e)
|
|
return None
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
if not isinstance(rows, list):
|
|
return []
|
|
return [r for r in rows if isinstance(r, dict)]
|
|
|
|
def get_funding_usdt(
|
|
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
|
) -> float:
|
|
from .money import to_usdt
|
|
|
|
end = int(end_ms or int(time.time() * 1000))
|
|
try:
|
|
rows = self._signed(
|
|
self._fapi,
|
|
"GET",
|
|
"/fapi/v1/income",
|
|
{
|
|
"symbol": symbol,
|
|
"incomeType": "FUNDING_FEE",
|
|
"startTime": int(begin_ms),
|
|
"endTime": end,
|
|
"limit": 1000,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.warning("binance funding income failed: %s", e)
|
|
return 0.0
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
total = 0.0
|
|
for row in rows:
|
|
raw = safe_float(row.get("income"))
|
|
if raw is None:
|
|
continue
|
|
asset = str(row.get("asset") or "USDT")
|
|
total += to_usdt(float(raw), asset)
|
|
return total
|
|
|
|
def get_closed_perp_pnl_usdt(
|
|
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
|
) -> float | None:
|
|
"""用 REALIZED_PNL income 近似已实现(含部分平仓);资金费另计。"""
|
|
from .money import to_usdt
|
|
|
|
end = int(end_ms or int(time.time() * 1000))
|
|
try:
|
|
rows = self._signed(
|
|
self._fapi,
|
|
"GET",
|
|
"/fapi/v1/income",
|
|
{
|
|
"symbol": symbol,
|
|
"incomeType": "REALIZED_PNL",
|
|
"startTime": int(begin_ms),
|
|
"endTime": end,
|
|
"limit": 1000,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.warning("binance realized income failed: %s", e)
|
|
return None
|
|
if isinstance(rows, dict):
|
|
rows = [rows]
|
|
if not rows:
|
|
return None
|
|
total = 0.0
|
|
for row in rows:
|
|
raw = safe_float(row.get("income"))
|
|
if raw is None:
|
|
continue
|
|
asset = str(row.get("asset") or "USDT")
|
|
total += to_usdt(float(raw), asset)
|
|
return total
|
|
|