diff --git a/position_closer.py b/position_closer.py index 16c79a6..e0719a8 100644 --- a/position_closer.py +++ b/position_closer.py @@ -1,15 +1,18 @@ """ 币安永续合约定时平仓脚本。 +平仓前置条件(不满足则直接退出,不执行平仓): +- 当前总未实现盈亏 - 近 N 分钟最小总未实现盈亏 > 配置阈值(默认 2 USDT);未实现盈亏历史存 Redis Sorted Set。 + 平仓触发条件(满足其一即平仓): - 在 Redis 指定 Set 中的合约强制平仓; - 小仓位:多空名义价值总和 < 30 USDT 且未实现盈亏 > 0.03; - 大仓位:多空名义价值总和 > 50 USDT 且未实现盈亏 > 0.3。 流程: -1. 获取当前持仓; -2. 得到需要平仓的交易对; -3. 创建 BinanceSocketManager,监听需要平仓的合约交易对,收到 WS 事件后按规则创建平仓订单。 +1. 获取当前总未实现盈亏,写入 Redis 并取近 N 分钟最小;若不满足「当前 - 最小 > 阈值」则退出; +2. 获取当前持仓,得到需要平仓的交易对; +3. 创建 BinanceSocketManager,监听需平仓合约,收到 WS 事件后按规则创建平仓订单。 平仓规则:多头限价卖出 = 当前价 * 1.003,空头限价买入 = 当前价 * 0.997。 纯逻辑为顶层函数,便于单元测试(from position_closer import round_to_step, ...); @@ -246,6 +249,16 @@ async def get_symbol_precision(client: BinanceAsyncClient) -> dict[str, dict]: return parse_exchange_info_to_precisions(info) +async def get_total_unrealized_profit(client: BinanceAsyncClient) -> float: + """获取账户永续合约总未实现盈亏(所有持仓的 unrealizedProfit 之和)。""" + account = await client.futures_account() + positions = account.get("positions", []) + total = 0.0 + for p in positions: + total += float(p.get("unrealizedProfit", 0) or 0) + return total + + async def get_positions(client: BinanceAsyncClient) -> list[dict]: """获取永续合约持仓(只保留有仓位的)。""" account = await client.futures_account() @@ -253,6 +266,65 @@ async def get_positions(client: BinanceAsyncClient) -> list[dict]: return filter_nonzero_positions(positions) +async def save_unrealized_profit_history( + redis_url: str, + history_key: str, + profit: float, + window_seconds: float, +) -> None: + """ + 将当前总未实现盈亏写入 Redis Sorted Set(score=时间戳, member=时间戳:盈亏值), + 并删除早于 (now - window_seconds) 的旧数据。 + """ + import time + from redis.asyncio import Redis as Aioredis + + now = time.time() + member = f"{now}:{profit}" + redis_client = Aioredis.from_url(redis_url, decode_responses=True) + try: + await redis_client.zadd(history_key, {member: now}) + await redis_client.zremrangebyscore(history_key, "-inf", f"({now - window_seconds}") + finally: + await redis_client.aclose() + + +async def get_min_unrealized_profit_in_window( + redis_url: str, + history_key: str, + window_seconds: float, +) -> float | None: + """ + 从 Redis Sorted Set 中取近 window_seconds 秒内记录的最小未实现盈亏。 + 无数据或解析失败时返回 None。 + """ + import time + from redis.asyncio import Redis as Aioredis + + now = time.time() + min_score = now - window_seconds + redis_client = Aioredis.from_url(redis_url, decode_responses=True) + try: + members = await redis_client.zrangebyscore( + history_key, min_score, now, start=0, num=-1 + ) + finally: + await redis_client.aclose() + + if not members: + return None + values: list[float] = [] + for m in members: + # member 格式为 "timestamp:profit" + parts = str(m).split(":", 1) + if len(parts) == 2: + try: + values.append(float(parts[1])) + except ValueError: + continue + return min(values) if values else None + + async def place_close_order( client: BinanceAsyncClient, symbol: str, @@ -431,12 +503,55 @@ async def main_async() -> None: large_min_profit = float(getattr(settings, "notional_large_close_min_profit", None) or 0.3) redis_key = getattr(settings, "redis_close_key", None) or "close_position:contracts" ws_connection_timeout = float(getattr(settings, "ws_connection_timeout", None) or 30) + redis_profit_history_key = ( + getattr(settings, "redis_unrealized_profit_history_key", None) + or "close_position:unrealized_profit_history" + ) + profit_window_seconds = float( + getattr(settings, "unrealized_profit_window_seconds", None) or 300 + ) + profit_min_rise = float( + getattr(settings, "unrealized_profit_min_rise", None) or 2 + ) client = await BinanceAsyncClient.create( api_key=api_key, api_secret=api_secret ) try: + total_profit = await get_total_unrealized_profit(client) + await save_unrealized_profit_history( + redis_url, redis_profit_history_key, total_profit, profit_window_seconds + ) + min_profit = await get_min_unrealized_profit_in_window( + redis_url, redis_profit_history_key, profit_window_seconds + ) + if min_profit is None: + logger.info( + "近 {} 秒内无未实现盈亏历史,本次不执行平仓(已记录当前总未实现盈亏 {:.2f})", + profit_window_seconds, + total_profit, + ) + return + if total_profit - min_profit <= profit_min_rise: + logger.info( + "未满足平仓前置条件:当前总未实现盈亏 {:.2f} - 近 {} 秒最小 {:.2f} = {:.2f} <= {},退出", + total_profit, + profit_window_seconds, + min_profit, + total_profit - min_profit, + profit_min_rise, + ) + return + logger.info( + "满足平仓前置条件:当前总未实现盈亏 {:.2f},近 {} 秒最小 {:.2f},差值 {:.2f} > {}", + total_profit, + profit_window_seconds, + min_profit, + total_profit - min_profit, + profit_min_rise, + ) + positions = await get_positions(client) by_symbol = group_positions_by_symbol(positions)