diff --git a/backend/position_sync.py b/backend/position_sync.py index 93cb653..b96bcb9 100644 --- a/backend/position_sync.py +++ b/backend/position_sync.py @@ -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 - # 扣手续费 - fee_r = 0.001 * lp["entry_price"] / rd - pnl_r -= fee_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 + + # 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: