From 03c296e2351cb11871b2abb1b16d89f7bcd8e702 Mon Sep 17 00:00:00 2001 From: yhydev Date: Thu, 5 Feb 2026 11:32:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B9=B3=E4=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- position_closer.py | 107 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 96 insertions(+), 11 deletions(-) diff --git a/position_closer.py b/position_closer.py index a3e4253..49a8854 100644 --- a/position_closer.py +++ b/position_closer.py @@ -1,8 +1,10 @@ """ 币安永续合约定时平仓脚本。 -平仓触发条件: -- 多空名义价值总和 ≤ 20 USDT 且未实现盈亏 > 0.05。 +平仓触发条件(满足其一即平仓): +- 多空名义价值总和 ≤ 20 USDT 且未实现盈亏 > 0.05; +- 未实现盈利 > 10 USDT; +- 未实现亏损 > 3 USDT(即未实现盈亏 < -3)。 流程: 1. 获取当前持仓,得到需要平仓的交易对; @@ -30,12 +32,14 @@ SHORT_CLOSE_PRICE_RATIO = Decimal("0.997") def round_to_step(value: Decimal, step: str) -> str: - """按 step 精度舍入。""" + """按 step 精度舍入。返回普通小数/整数字符串,避免科学计数法(如 5E+1),否则币安 API 会报签名错误。""" step_d = Decimal(step) if step_d <= 0: return str(value) q = (value / step_d).quantize(Decimal("1"), rounding="ROUND_DOWN") - return str((q * step_d).normalize()) + result = (q * step_d).normalize() + s = f"{result:.10f}".rstrip("0").rstrip(".") + return s if s else "0" def round_price_to_tick(price: Decimal, tick_size: str) -> str: @@ -104,11 +108,20 @@ def should_close_symbol( unrealized_profit: float, notional_threshold: float = 20, min_profit: float = 0.05, + min_profit_large: float = 10, + max_loss: float = 3, ) -> bool: """ 是否对该合约执行平仓。 - 条件:多空名义价值总和 ≤ notional_threshold 且 未实现盈亏 > min_profit。 + 条件(满足其一即平仓): + - 多空名义价值总和 ≤ notional_threshold 且 未实现盈亏 > min_profit; + - 未实现盈利 > min_profit_large; + - 未实现亏损 > max_loss(即 unrealized_profit < -max_loss)。 """ + if unrealized_profit < -max_loss: + return True + if unrealized_profit > min_profit_large: + return True total_notional = long_notional + short_notional return total_notional <= notional_threshold and unrealized_profit > min_profit @@ -117,10 +130,12 @@ def get_symbols_to_close( by_symbol: dict[str, list[dict]], notional_threshold: float = 20, min_profit: float = 0.05, + min_profit_large: float = 10, + max_loss: float = 3, ) -> tuple[set[str], dict[str, list[dict]]]: """ 根据持仓得到需要平仓的交易对及其持仓子集。 - 平仓条件:多空名义价值总和 ≤ notional_threshold 且 未实现盈亏 > min_profit。 + 平仓条件:名义≤阈值且盈利>min_profit,或 盈利>min_profit_large,或 亏损>max_loss。 返回 (symbols_to_close, by_symbol_filtered)。 """ symbols_to_close: set[str] = set() @@ -131,6 +146,8 @@ def get_symbols_to_close( long_n, short_n, unrealized_profit, notional_threshold=notional_threshold, min_profit=min_profit, + min_profit_large=min_profit_large, + max_loss=max_loss, ): symbols_to_close.add(symbol) by_symbol_filtered = {k: v for k, v in by_symbol.items() if k in symbols_to_close} @@ -213,8 +230,12 @@ def build_close_order_params( "quantity": quantity, "price": price_str, } + # 双向持仓(对冲)模式必须传 positionSide,否则报 -4061 if side_upper in ("LONG", "SHORT"): params["positionSide"] = side_upper + elif side_upper == "BOTH": + # 单向模式下 API 可能仍返回 BOTH;对冲模式下根据持仓方向推断 + params["positionSide"] = "LONG" if position_amt > 0 else "SHORT" if reduce_only or side_upper == "BOTH": params["reduceOnly"] = "true" return params @@ -222,6 +243,57 @@ def build_close_order_params( # ---------- I/O 与编排 ---------- +def _normalize_api_credentials(api_key: str, api_secret: str) -> tuple[str, str]: + """去除首尾空白、引号、换行,避免 toml/环境变量带入导致签名错误。""" + key = (api_key or "").strip().strip('"').strip("'").replace("\r", "").replace("\n", "") + secret = (api_secret or "").strip().strip('"').strip("'").replace("\r", "").replace("\n", "") + return key, secret + + +async def create_futures_client( + api_key: str, + api_secret: str, + testnet: bool = False, + recv_window: int = 60000, + verbose: bool = False, + https_proxy: str | None = None, +): + """ + 创建仅使用合约接口初始化的客户端,避免 create() 调用现货 ping/time 导致 + 「仅启用合约」的 API Key 报 Signature for this request is not valid。 + recv_window: 放宽时间窗(毫秒),本机时间有偏差时可增大,默认 60 秒。 + """ + import time + from binance import AsyncClient as BinanceAsyncClient + from loguru import logger + + key, secret = _normalize_api_credentials(api_key, api_secret) + if not key or not secret: + raise ValueError("api_key / api_secret 不能为空") + + client = BinanceAsyncClient( + api_key=key, + api_secret=secret, + testnet=testnet, + verbose=verbose, + https_proxy=https_proxy, + ) + try: + await client.futures_ping() + res = await client.futures_time() + client.timestamp_offset = res["serverTime"] - int(time.time() * 1000) + client.REQUEST_RECVWINDOW = recv_window + logger.debug( + "合约服务器时间差 {} ms,recvWindow={} ms", + client.timestamp_offset, + recv_window, + ) + return client + except Exception: + await client.close_connection() + raise + + async def get_symbol_precision(client: BinanceAsyncClient) -> dict[str, dict]: """获取合约的 quantity/price 精度。""" info = await client.futures_exchange_info() @@ -372,14 +444,18 @@ async def main_async() -> None: from config import settings - api_key = (getattr(settings, "binance_api_key", None) or "").strip() - api_secret = (getattr(settings, "binance_api_secret", None) or "").strip() - if not api_key or not api_secret: + api_key = getattr(settings, "binance_api_key", None) or "" + api_secret = getattr(settings, "binance_api_secret", None) or "" + if not (api_key and api_secret): logger.error("请在 .secrets.toml 或环境变量中设置 binance_api_key / binance_api_secret") return import os + recv_window = int(getattr(settings, "binance_recv_window", None) or 60000) + verbose = bool(getattr(settings, "binance_verbose", None) or os.environ.get("BINANCE_VERBOSE", "")) + https_proxy = (getattr(settings, "https_proxy", None) or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")) or None + env_dry = os.environ.get("DRY_RUN", "").strip().lower() if env_dry in ("0", "false", "no"): dry_run = False @@ -394,11 +470,18 @@ async def main_async() -> None: testnet = "testnet" in str(base_url).lower() notional_threshold = float(getattr(settings, "notional_close_threshold", None) or 20) min_profit = float(getattr(settings, "close_min_profit", None) or 0.05) + min_profit_large = float(getattr(settings, "close_min_profit_large", None) or 10) + max_loss = float(getattr(settings, "close_max_loss", None) or 3) ws_connection_timeout = float(getattr(settings, "ws_connection_timeout", None) or 30) - client = await BinanceAsyncClient.create( + # 使用合约接口初始化;recv_window 放宽时间窗,本机时间有偏差时可减少签名错误 + client = await create_futures_client( api_key=api_key, - api_secret=api_secret + api_secret=api_secret, + testnet=testnet, + recv_window=recv_window, + verbose=verbose, + https_proxy=https_proxy, ) try: positions = await get_positions(client) @@ -422,6 +505,8 @@ async def main_async() -> None: by_symbol, notional_threshold=notional_threshold, min_profit=min_profit, + min_profit_large=min_profit_large, + max_loss=max_loss, ) for symbol, sym_positions in by_symbol_filtered.items(): long_n, short_n = get_notional_by_side(sym_positions)