优化平仓

This commit is contained in:
yhydev
2026-02-05 11:32:10 +08:00
parent 48d31cd1d0
commit 03c296e235

View File

@@ -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(
"合约服务器时间差 {} msrecvWindow={} 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)