position_closer: 平仓条件与日志优化
- 平仓条件改为:多空名义≤20 USDT 且未实现盈亏>0.05 - 去掉总盈亏前置条件及 Redis 盈亏历史相关逻辑 - 每个合约打印未实现盈亏、多头/空头名义(按未实现盈亏排序) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,18 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
币安永续合约定时平仓脚本。
|
币安永续合约定时平仓脚本。
|
||||||
|
|
||||||
平仓前置条件(不满足则直接退出,不执行平仓):
|
平仓触发条件:
|
||||||
- 当前总未实现盈亏 - 近 N 分钟最小总未实现盈亏 > 配置阈值(默认 2 USDT);未实现盈亏历史存 Redis Sorted Set。
|
- 多空名义价值总和 ≤ 20 USDT 且未实现盈亏 > 0.05。
|
||||||
|
|
||||||
平仓触发条件(满足其一即平仓):
|
|
||||||
- 在 Redis 指定 Set 中的合约强制平仓;
|
|
||||||
- 小仓位:多空名义价值总和 < 30 USDT 且未实现盈亏 > 0.03;
|
|
||||||
- 大仓位:多空名义价值总和 > 50 USDT 且未实现盈亏 > 0.3。
|
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1. 获取当前总未实现盈亏,写入 Redis 并取近 N 分钟最小;若不满足「当前 - 最小 > 阈值」则退出;
|
1. 获取当前持仓,得到需要平仓的交易对;
|
||||||
2. 获取当前持仓,得到需要平仓的交易对;
|
2. 创建 BinanceSocketManager,监听需平仓合约,收到 WS 事件后按规则创建平仓订单。
|
||||||
3. 创建 BinanceSocketManager,监听需平仓合约,收到 WS 事件后按规则创建平仓订单。
|
|
||||||
平仓规则:多头限价卖出 = 当前价 * 1.003,空头限价买入 = 当前价 * 0.997。
|
平仓规则:多头限价卖出 = 当前价 * 1.003,空头限价买入 = 当前价 * 0.997。
|
||||||
|
|
||||||
纯逻辑为顶层函数,便于单元测试(from position_closer import round_to_step, ...);
|
纯逻辑为顶层函数,便于单元测试(from position_closer import round_to_step, ...);
|
||||||
@@ -108,50 +102,35 @@ def should_close_symbol(
|
|||||||
long_notional: float,
|
long_notional: float,
|
||||||
short_notional: float,
|
short_notional: float,
|
||||||
unrealized_profit: float,
|
unrealized_profit: float,
|
||||||
threshold: float,
|
notional_threshold: float = 20,
|
||||||
in_redis_set: bool = False,
|
min_profit: float = 0.05,
|
||||||
small_threshold: float = 30,
|
|
||||||
small_min_profit: float = 0.03,
|
|
||||||
large_min_profit: float = 0.3,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
是否对该合约执行平仓。
|
是否对该合约执行平仓。
|
||||||
条件(满足其一即平仓):
|
条件:多空名义价值总和 ≤ notional_threshold 且 未实现盈亏 > min_profit。
|
||||||
- 在 Redis 集合中;
|
|
||||||
- 多空名义价值总和 < small_threshold 且 未实现盈亏 > small_min_profit(小仓位);
|
|
||||||
- 多空名义价值总和 > threshold 且 未实现盈亏 > large_min_profit(大仓位)。
|
|
||||||
"""
|
"""
|
||||||
if in_redis_set:
|
|
||||||
return True
|
|
||||||
total_notional = long_notional + short_notional
|
total_notional = long_notional + short_notional
|
||||||
if total_notional < small_threshold and unrealized_profit > small_min_profit:
|
return total_notional <= notional_threshold and unrealized_profit > min_profit
|
||||||
return True
|
|
||||||
if total_notional > threshold and unrealized_profit > large_min_profit:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_symbols_to_close(
|
def get_symbols_to_close(
|
||||||
by_symbol: dict[str, list[dict]],
|
by_symbol: dict[str, list[dict]],
|
||||||
threshold: float,
|
notional_threshold: float = 20,
|
||||||
redis_contracts: set[str],
|
min_profit: float = 0.05,
|
||||||
small_threshold: float = 30,
|
|
||||||
small_min_profit: float = 0.03,
|
|
||||||
large_min_profit: float = 0.3,
|
|
||||||
) -> tuple[set[str], dict[str, list[dict]]]:
|
) -> tuple[set[str], dict[str, list[dict]]]:
|
||||||
"""
|
"""
|
||||||
根据持仓和 Redis 集合,得到需要平仓的交易对及其持仓子集。
|
根据持仓得到需要平仓的交易对及其持仓子集。
|
||||||
平仓条件:在 Redis 中 或 (小仓位且盈利>small_min_profit) 或 (大仓位且盈利>large_min_profit)。
|
平仓条件:多空名义价值总和 ≤ notional_threshold 且 未实现盈亏 > min_profit。
|
||||||
返回 (symbols_to_close, by_symbol_filtered)。
|
返回 (symbols_to_close, by_symbol_filtered)。
|
||||||
"""
|
"""
|
||||||
symbols_to_close: set[str] = set()
|
symbols_to_close: set[str] = set()
|
||||||
for symbol, sym_positions in by_symbol.items():
|
for symbol, sym_positions in by_symbol.items():
|
||||||
long_n, short_n = get_notional_by_side(sym_positions)
|
long_n, short_n = get_notional_by_side(sym_positions)
|
||||||
unrealized_profit = get_unrealized_profit(sym_positions)
|
unrealized_profit = get_unrealized_profit(sym_positions)
|
||||||
in_redis = symbol in redis_contracts
|
|
||||||
if should_close_symbol(
|
if should_close_symbol(
|
||||||
long_n, short_n, unrealized_profit, threshold, in_redis,
|
long_n, short_n, unrealized_profit,
|
||||||
small_threshold, small_min_profit, large_min_profit,
|
notional_threshold=notional_threshold,
|
||||||
|
min_profit=min_profit,
|
||||||
):
|
):
|
||||||
symbols_to_close.add(symbol)
|
symbols_to_close.add(symbol)
|
||||||
by_symbol_filtered = {k: v for k, v in by_symbol.items() if k in symbols_to_close}
|
by_symbol_filtered = {k: v for k, v in by_symbol.items() if k in symbols_to_close}
|
||||||
@@ -249,16 +228,6 @@ async def get_symbol_precision(client: BinanceAsyncClient) -> dict[str, dict]:
|
|||||||
return parse_exchange_info_to_precisions(info)
|
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]:
|
async def get_positions(client: BinanceAsyncClient) -> list[dict]:
|
||||||
"""获取永续合约持仓(只保留有仓位的)。"""
|
"""获取永续合约持仓(只保留有仓位的)。"""
|
||||||
account = await client.futures_account()
|
account = await client.futures_account()
|
||||||
@@ -266,65 +235,6 @@ async def get_positions(client: BinanceAsyncClient) -> list[dict]:
|
|||||||
return filter_nonzero_positions(positions)
|
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(
|
async def place_close_order(
|
||||||
client: BinanceAsyncClient,
|
client: BinanceAsyncClient,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -497,64 +407,33 @@ async def main_async() -> None:
|
|||||||
base_url = getattr(settings, "binance_base_url", None) or "https://fapi.binance.com"
|
base_url = getattr(settings, "binance_base_url", None) or "https://fapi.binance.com"
|
||||||
testnet = "testnet" in str(base_url).lower()
|
testnet = "testnet" in str(base_url).lower()
|
||||||
redis_url = getattr(settings, "redis_url", None) or "redis://localhost:6379/0"
|
redis_url = getattr(settings, "redis_url", None) or "redis://localhost:6379/0"
|
||||||
threshold = float(getattr(settings, "notional_threshold", None) or 50)
|
notional_threshold = float(getattr(settings, "notional_close_threshold", None) or 20)
|
||||||
small_threshold = float(getattr(settings, "notional_small_close_threshold", None) or 30)
|
min_profit = float(getattr(settings, "close_min_profit", None) or 0.05)
|
||||||
small_min_profit = float(getattr(settings, "small_close_min_profit", None) or 0.03)
|
|
||||||
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"
|
redis_key = getattr(settings, "redis_close_key", None) or "close_position:contracts"
|
||||||
ws_connection_timeout = float(getattr(settings, "ws_connection_timeout", None) or 30)
|
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(
|
client = await BinanceAsyncClient.create(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_secret=api_secret
|
api_secret=api_secret
|
||||||
)
|
)
|
||||||
try:
|
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)
|
positions = await get_positions(client)
|
||||||
by_symbol = group_positions_by_symbol(positions)
|
by_symbol = group_positions_by_symbol(positions)
|
||||||
|
|
||||||
|
logger.info("当前持仓合约数: {}", len(by_symbol))
|
||||||
|
by_symbol_sorted = sorted(
|
||||||
|
by_symbol.items(),
|
||||||
|
key=lambda x: get_unrealized_profit(x[1]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for symbol, sym_positions in by_symbol_sorted:
|
||||||
|
unrealized = get_unrealized_profit(sym_positions)
|
||||||
|
long_n, short_n = get_notional_by_side(sym_positions)
|
||||||
|
logger.info(
|
||||||
|
"合约 {} 未实现盈亏={:.2f} 多头名义={:.2f} 空头名义={:.2f}",
|
||||||
|
symbol, unrealized, long_n, short_n,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
redis_client = Aioredis.from_url(redis_url, decode_responses=True)
|
redis_client = Aioredis.from_url(redis_url, decode_responses=True)
|
||||||
redis_contracts = set(await redis_client.smembers(redis_key) or [])
|
redis_contracts = set(await redis_client.smembers(redis_key) or [])
|
||||||
@@ -564,19 +443,19 @@ async def main_async() -> None:
|
|||||||
redis_contracts = set()
|
redis_contracts = set()
|
||||||
|
|
||||||
symbols_to_close, by_symbol_filtered = get_symbols_to_close(
|
symbols_to_close, by_symbol_filtered = get_symbols_to_close(
|
||||||
by_symbol, threshold, redis_contracts,
|
by_symbol,
|
||||||
small_threshold, small_min_profit, large_min_profit,
|
notional_threshold=notional_threshold,
|
||||||
|
min_profit=min_profit,
|
||||||
)
|
)
|
||||||
for symbol, sym_positions in by_symbol_filtered.items():
|
for symbol, sym_positions in by_symbol_filtered.items():
|
||||||
long_n, short_n = get_notional_by_side(sym_positions)
|
long_n, short_n = get_notional_by_side(sym_positions)
|
||||||
profit = get_unrealized_profit(sym_positions)
|
profit = get_unrealized_profit(sym_positions)
|
||||||
logger.info(
|
logger.info(
|
||||||
"需平仓: symbol={} long_notional={:.2f} short_notional={:.2f} unrealized_profit={:.2f} in_redis={}",
|
"需平仓: symbol={} long_notional={:.2f} short_notional={:.2f} unrealized_profit={:.2f}",
|
||||||
symbol,
|
symbol,
|
||||||
long_n,
|
long_n,
|
||||||
short_n,
|
short_n,
|
||||||
profit,
|
profit,
|
||||||
symbol in redis_contracts,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not symbols_to_close:
|
if not symbols_to_close:
|
||||||
|
|||||||
Reference in New Issue
Block a user