feat: 资金费率结算追踪 + 平仓PnL用真实手续费
资金费率追踪(position_sync.py): - 每8小时结算窗口(UTC 0/8/16后5分钟内)查询币安income API - 按symbol汇总FUNDING_FEE,累加到live_trades.funding_fee_usdt - 只查最近30分钟内的记录,防止重复计入 平仓PnL改进: - 从币安userTrades获取真实commission手续费(不再估算) - PnL拆解: net = gross - fee_r - funding_r - fee_usdt写入DB - 日志输出完整拆解: gross/fee/funding/net
This commit is contained in:
parent
7e8f83fd5a
commit
ab27e5a4da
@ -41,6 +41,7 @@ CHECK_INTERVAL = 30 # 对账间隔(秒)
|
||||
SL_REHANG_DELAYS = [0, 3] # SL补挂重试延迟(秒)
|
||||
MAX_REHANG_RETRIES = 2
|
||||
MISMATCH_ESCALATION_SEC = 60 # 差异持续超过此秒数升级告警
|
||||
RISK_PER_TRADE_USD = float(os.getenv("RISK_PER_TRADE_USD", "2")) # $2=1R
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
|
||||
|
||||
@ -152,7 +153,7 @@ def get_local_positions(conn):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, symbol, strategy, direction, entry_price, sl_price, tp1_price, tp2_price,
|
||||
tp1_hit, status, risk_distance, binance_order_id
|
||||
tp1_hit, status, risk_distance, binance_order_id, entry_ts
|
||||
FROM live_trades
|
||||
WHERE status IN ('active', 'tp1_hit')
|
||||
ORDER BY entry_ts DESC
|
||||
@ -163,6 +164,7 @@ def get_local_positions(conn):
|
||||
"id": row[0], "symbol": row[1], "strategy": row[2], "direction": row[3],
|
||||
"entry_price": row[4], "sl_price": row[5], "tp1_price": row[6], "tp2_price": row[7],
|
||||
"tp1_hit": row[8], "status": row[9], "risk_distance": row[10],
|
||||
"binance_order_id": row[11], "entry_ts": row[12],
|
||||
"binance_order_id": row[11],
|
||||
})
|
||||
return positions
|
||||
@ -413,29 +415,49 @@ async def check_closed_positions(session, conn):
|
||||
status = "unknown"
|
||||
exit_price = lp["entry_price"] # fallback
|
||||
|
||||
# 尝试从最近交易记录获取
|
||||
# 尝试从最近交易记录获取成交价和手续费
|
||||
trades_data, trades_status = await binance_request(session, "GET", "/fapi/v1/userTrades", {
|
||||
"symbol": symbol, "limit": 5
|
||||
"symbol": symbol, "limit": 20
|
||||
})
|
||||
actual_fee_usdt = 0
|
||||
if trades_status == 200 and isinstance(trades_data, list) and trades_data:
|
||||
# 取最近的平仓成交(reduceOnly或最后几笔)
|
||||
last_trade = trades_data[-1]
|
||||
exit_price = float(last_trade.get("price", exit_price))
|
||||
# 汇总最近相关成交的手续费(开仓+平仓)
|
||||
entry_ts = lp.get("entry_ts", 0)
|
||||
for t in trades_data:
|
||||
t_time = int(t.get("time", 0))
|
||||
if t_time >= entry_ts - 5000: # 开仓前5秒到现在的所有成交
|
||||
actual_fee_usdt += abs(float(t.get("commission", 0)))
|
||||
|
||||
# 计算pnl
|
||||
# 计算pnl — gross(不含费)
|
||||
if lp["direction"] == "LONG":
|
||||
raw_pnl_r = (exit_price - lp["entry_price"]) / rd
|
||||
gross_pnl_r = (exit_price - lp["entry_price"]) / rd
|
||||
else:
|
||||
raw_pnl_r = (lp["entry_price"] - exit_price) / rd
|
||||
gross_pnl_r = (lp["entry_price"] - exit_price) / rd
|
||||
|
||||
if lp["tp1_hit"]:
|
||||
tp1_r = abs(lp["tp1_price"] - lp["entry_price"]) / rd
|
||||
pnl_r = 0.5 * tp1_r + 0.5 * raw_pnl_r
|
||||
else:
|
||||
pnl_r = raw_pnl_r
|
||||
gross_pnl_r = 0.5 * tp1_r + 0.5 * gross_pnl_r
|
||||
|
||||
# 扣手续费
|
||||
# 手续费(R) — 用实际成交手续费
|
||||
if actual_fee_usdt > 0:
|
||||
fee_r = actual_fee_usdt / (RISK_PER_TRADE_USD if RISK_PER_TRADE_USD > 0 else rd)
|
||||
else:
|
||||
# fallback: 按0.1%估算(开+平各0.05%)
|
||||
fee_r = 0.001 * lp["entry_price"] / rd
|
||||
pnl_r -= fee_r
|
||||
|
||||
# funding费(R)
|
||||
funding_usdt = 0
|
||||
cur.execute("SELECT COALESCE(funding_fee_usdt, 0) FROM live_trades WHERE id = %s", (lp["id"],))
|
||||
fr_row = cur.fetchone()
|
||||
if fr_row:
|
||||
funding_usdt = fr_row[0]
|
||||
funding_r = abs(funding_usdt) / (RISK_PER_TRADE_USD if RISK_PER_TRADE_USD > 0 else rd) if funding_usdt < 0 else 0
|
||||
|
||||
# 净PnL = gross - fee - funding_cost
|
||||
pnl_r = gross_pnl_r - fee_r - funding_r
|
||||
|
||||
# 判断状态
|
||||
if pnl_r > 0.5:
|
||||
@ -448,12 +470,103 @@ async def check_closed_positions(session, conn):
|
||||
status = "closed"
|
||||
|
||||
cur.execute("""
|
||||
UPDATE live_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s
|
||||
UPDATE live_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s, fee_usdt=%s
|
||||
WHERE id=%s
|
||||
""", (status, exit_price, now_ms, round(pnl_r, 4), lp["id"]))
|
||||
""", (status, exit_price, now_ms, round(pnl_r, 4), round(actual_fee_usdt, 4), lp["id"]))
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"[{symbol}] 📝 平仓记录: {status} | exit={exit_price:.4f} | pnl={pnl_r:+.2f}R")
|
||||
logger.info(
|
||||
f"[{symbol}] 📝 平仓: {status} | exit={exit_price:.4f} | "
|
||||
f"gross={gross_pnl_r:+.3f}R fee={fee_r:.3f}R({actual_fee_usdt:.4f}$) "
|
||||
f"funding={funding_usdt:+.4f}$ | net={pnl_r:+.3f}R"
|
||||
)
|
||||
|
||||
|
||||
# ============ 资金费率结算追踪 ============
|
||||
|
||||
# 币安结算时间:UTC 00:00, 08:00, 16:00
|
||||
FUNDING_SETTLEMENT_HOURS = [0, 8, 16]
|
||||
_last_funding_check_ts = 0 # 上次查funding的时间戳
|
||||
|
||||
|
||||
async def track_funding_fees(session, conn):
|
||||
"""
|
||||
查询币安资金费率收支,更新到live_trades的funding_fee_usdt字段。
|
||||
只在结算时间点附近查询(每8小时一次,±5分钟窗口内查一次)。
|
||||
"""
|
||||
global _last_funding_check_ts
|
||||
import datetime as _dt
|
||||
|
||||
now = _dt.datetime.now(_dt.timezone.utc)
|
||||
now_ts = time.time()
|
||||
|
||||
# 判断是否在结算窗口内(结算时间后0-5分钟)
|
||||
in_settlement_window = False
|
||||
for h in FUNDING_SETTLEMENT_HOURS:
|
||||
settlement_time = now.replace(hour=h, minute=0, second=0, microsecond=0)
|
||||
diff_sec = (now - settlement_time).total_seconds()
|
||||
if 0 <= diff_sec <= 300: # 结算后5分钟内
|
||||
in_settlement_window = True
|
||||
break
|
||||
|
||||
# 不在窗口内,或者5分钟内已经查过了
|
||||
if not in_settlement_window:
|
||||
return
|
||||
if now_ts - _last_funding_check_ts < 300:
|
||||
return
|
||||
|
||||
_last_funding_check_ts = now_ts
|
||||
logger.info("💰 资金费率结算窗口,查询funding收支...")
|
||||
|
||||
# 获取当前活跃持仓
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, symbol, direction, entry_ts, COALESCE(funding_fee_usdt, 0) as current_funding
|
||||
FROM live_trades WHERE status IN ('active', 'tp1_hit')
|
||||
""")
|
||||
active = cur.fetchall()
|
||||
|
||||
if not active:
|
||||
logger.info("💰 无活跃持仓,跳过funding查询")
|
||||
return
|
||||
|
||||
# 查币安最近的funding收入记录
|
||||
# GET /fapi/v1/income?incomeType=FUNDING_FEE&limit=100
|
||||
start_ts = int((now_ts - 3600) * 1000) # 最近1小时
|
||||
data, status = await binance_request(session, "GET", "/fapi/v1/income", {
|
||||
"incomeType": "FUNDING_FEE",
|
||||
"startTime": start_ts,
|
||||
"limit": 100,
|
||||
})
|
||||
|
||||
if status != 200 or not isinstance(data, list):
|
||||
logger.warning(f"💰 查询funding income失败: {status}")
|
||||
return
|
||||
|
||||
# 按symbol汇总本次结算的funding
|
||||
funding_by_symbol = {}
|
||||
for item in data:
|
||||
sym = item.get("symbol", "")
|
||||
income = float(item.get("income", 0))
|
||||
ts = int(item.get("time", 0))
|
||||
# 只取最近30分钟内的(本次结算的)
|
||||
if now_ts * 1000 - ts < 1800000:
|
||||
funding_by_symbol[sym] = funding_by_symbol.get(sym, 0) + income
|
||||
|
||||
if not funding_by_symbol:
|
||||
logger.info("💰 本次结算无funding记录")
|
||||
return
|
||||
|
||||
# 更新到live_trades
|
||||
for trade_id, symbol, direction, entry_ts, current_funding in active:
|
||||
fr_income = funding_by_symbol.get(symbol, 0)
|
||||
if fr_income != 0:
|
||||
new_total = current_funding + fr_income
|
||||
cur.execute("UPDATE live_trades SET funding_fee_usdt = %s WHERE id = %s", (new_total, trade_id))
|
||||
logger.info(f"[{symbol}] 💰 Funding: {fr_income:+.4f} USDT (累计: {new_total:+.4f})")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"💰 Funding更新完成: {funding_by_symbol}")
|
||||
|
||||
|
||||
# ============ 主循环 ============
|
||||
@ -478,6 +591,9 @@ async def main():
|
||||
# 3. 检查已平仓
|
||||
await check_closed_positions(session, conn)
|
||||
|
||||
# 4. 资金费率结算追踪
|
||||
await track_funding_fees(session, conn)
|
||||
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user