fix: P0第二轮修复 — JWT安全/DB密码/SL紧急平仓reduceOnly/TP1状态守卫/超时精度/跨策略去重 + 硬编码消除
P0-1: JWT_SECRET生产环境强制配置,测试环境保留默认 P0-2: DB密码生产环境强制从env读,测试环境保留fallback P0-3: SL三次失败→查真实持仓→reduceOnly平仓→校验结果→写event P0-4: TP1后SL重挂失败则不推进tp1_hit状态,continue等下轮重试 P0-5: 超时自动平仓用SYMBOL_QTY_PRECISION格式化+校验结果 P0-6: 同币种去重改为不区分策略(币安单向模式共享净仓位) P1-1: 手续费窗口entry_ts-200→+200(避免纳入开仓前成交) 额外: 模拟盘*200和实盘*2硬编码→从配置动态读取
This commit is contained in:
parent
8694e5cf3a
commit
27a51b4d19
@ -12,7 +12,11 @@ from pydantic import BaseModel, EmailStr
|
||||
|
||||
from db import get_sync_conn
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "arb-engine-jwt-secret-v2-2026")
|
||||
_TRADE_ENV = os.getenv("TRADE_ENV", "testnet")
|
||||
_jwt_default = "arb-engine-jwt-secret-v2-2026" if _TRADE_ENV == "testnet" else None
|
||||
JWT_SECRET = os.getenv("JWT_SECRET") or _jwt_default
|
||||
if not JWT_SECRET or len(JWT_SECRET) < 32:
|
||||
raise RuntimeError("JWT_SECRET 未配置或长度不足(>=32),生产环境必须设置环境变量")
|
||||
ACCESS_TOKEN_HOURS = 24
|
||||
REFRESH_TOKEN_DAYS = 7
|
||||
|
||||
|
||||
@ -41,12 +41,17 @@ BINANCE_ENDPOINTS = {
|
||||
BASE_URL = BINANCE_ENDPOINTS[TRADE_ENV]
|
||||
|
||||
# 数据库
|
||||
_DB_PASSWORD = os.getenv("DB_PASSWORD", "arb_engine_2026" if TRADE_ENV == "testnet" else "")
|
||||
if not _DB_PASSWORD:
|
||||
print("FATAL: DB_PASSWORD 未设置(生产环境必须配置)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "10.106.0.3"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "arb_engine"),
|
||||
"user": os.getenv("DB_USER", "arb"),
|
||||
"password": os.getenv("DB_PASSWORD", "arb_engine_2026"),
|
||||
"password": _DB_PASSWORD,
|
||||
}
|
||||
|
||||
# 策略
|
||||
@ -354,10 +359,11 @@ async def execute_entry(session: aiohttp.ClientSession, signal: dict, db_conn):
|
||||
logger.warning(f"[{symbol}] ❌ 已达最大持仓数 {MAX_POSITIONS}")
|
||||
return None
|
||||
|
||||
# 3. 检查是否已有同币种同方向持仓
|
||||
cur.execute("SELECT id FROM live_trades WHERE symbol=%s AND strategy=%s AND status='active'", (symbol, strategy))
|
||||
if cur.fetchone():
|
||||
logger.info(f"[{symbol}] ⏭ 已有活跃持仓,跳过")
|
||||
# 3. 检查是否已有同币种持仓(不区分策略,币安单向模式下同币共享净仓位)
|
||||
cur.execute("SELECT id, strategy FROM live_trades WHERE symbol=%s AND status IN ('active', 'tp1_hit')", (symbol,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
logger.info(f"[{symbol}] ⏭ 已有活跃持仓(id={existing[0]}, strategy={existing[1]}),跳过")
|
||||
return None
|
||||
|
||||
# 4. 设置杠杆和逐仓
|
||||
@ -419,9 +425,28 @@ async def execute_entry(session: aiohttp.ClientSession, signal: dict, db_conn):
|
||||
if sl_status == 200:
|
||||
break
|
||||
if sl_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ SL 3次全部失败,紧急市价平仓! data={sl_data}")
|
||||
await place_market_order(session, symbol, close_side, qty)
|
||||
_log_event(db_conn, "critical", "trade", f"SL挂单3次失败,已紧急平仓", symbol, {"sl_data": str(sl_data)})
|
||||
logger.error(f"[{symbol}] ❌ SL 3次全部失败,紧急reduceOnly平仓! data={sl_data}")
|
||||
# 查真实持仓量,用reduceOnly市价平仓(避免反向开仓)
|
||||
emergency_pos = await get_position(session, symbol)
|
||||
if emergency_pos:
|
||||
emergency_amt = abs(float(emergency_pos.get("positionAmt", 0)))
|
||||
emergency_prec = SYMBOL_PRECISION.get(symbol, {"qty": 3})
|
||||
emergency_qty_str = f"{emergency_amt:.{emergency_prec['qty']}f}"
|
||||
close_data, close_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "MARKET",
|
||||
"quantity": emergency_qty_str, "reduceOnly": "true",
|
||||
})
|
||||
if close_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ 紧急平仓也失败! close_data={close_data}")
|
||||
_log_event(db_conn, "critical", "trade",
|
||||
f"SL失败后紧急平仓也失败,需人工介入", symbol,
|
||||
{"sl_data": str(sl_data), "close_data": str(close_data)})
|
||||
else:
|
||||
logger.info(f"[{symbol}] ✅ 紧急reduceOnly平仓成功 qty={emergency_qty_str}")
|
||||
_log_event(db_conn, "critical", "trade", f"SL挂单3次失败,已紧急reduceOnly平仓", symbol, {"sl_data": str(sl_data)})
|
||||
else:
|
||||
logger.warning(f"[{symbol}] SL失败但币安已无持仓,无需平仓")
|
||||
_log_event(db_conn, "critical", "trade", f"SL挂单3次失败,但币安无持仓", symbol, {"sl_data": str(sl_data)})
|
||||
return None
|
||||
t_after_sl = time.time() * 1000
|
||||
protection_gap_ms = int(t_after_sl - t_fill)
|
||||
|
||||
@ -683,8 +683,9 @@ async def paper_summary(
|
||||
total = len(closed)
|
||||
wins = len([r for r in closed if r["pnl_r"] > 0])
|
||||
total_pnl = sum(r["pnl_r"] for r in closed)
|
||||
total_pnl_usdt = total_pnl * 200 # 1R = $200
|
||||
balance = 10000 + total_pnl_usdt
|
||||
paper_1r_usd = paper_config["initial_balance"] * paper_config["risk_per_trade"]
|
||||
total_pnl_usdt = total_pnl * paper_1r_usd
|
||||
balance = paper_config["initial_balance"] + total_pnl_usdt
|
||||
win_rate = (wins / total * 100) if total > 0 else 0
|
||||
gross_profit = sum(r["pnl_r"] for r in closed if r["pnl_r"] > 0)
|
||||
gross_loss = abs(sum(r["pnl_r"] for r in closed if r["pnl_r"] <= 0))
|
||||
@ -760,7 +761,8 @@ async def paper_positions(
|
||||
else:
|
||||
d["unrealized_pnl_r"] = round((entry - current_price) / rd, 2)
|
||||
# 浮动盈亏(USDT) — 假设1R = risk_per_trade
|
||||
d["unrealized_pnl_usdt"] = round(d["unrealized_pnl_r"] * 200, 2) # 2% of 10000
|
||||
paper_1r = paper_config["initial_balance"] * paper_config["risk_per_trade"]
|
||||
d["unrealized_pnl_usdt"] = round(d["unrealized_pnl_r"] * paper_1r, 2)
|
||||
else:
|
||||
d["unrealized_pnl_r"] = 0
|
||||
d["unrealized_pnl_usdt"] = 0
|
||||
@ -1278,7 +1280,7 @@ async def live_positions(
|
||||
d["unrealized_pnl_r"] = round((current_price - entry) / rd, 4)
|
||||
else:
|
||||
d["unrealized_pnl_r"] = round((entry - current_price) / rd, 4)
|
||||
d["unrealized_pnl_usdt"] = round(d["unrealized_pnl_r"] * 2, 2)
|
||||
d["unrealized_pnl_usdt"] = round(d["unrealized_pnl_r"] * (await _get_risk_usd()), 2)
|
||||
else:
|
||||
d["unrealized_pnl_r"] = 0
|
||||
d["unrealized_pnl_usdt"] = 0
|
||||
|
||||
@ -34,12 +34,17 @@ BINANCE_ENDPOINTS = {
|
||||
}
|
||||
BASE_URL = BINANCE_ENDPOINTS[TRADE_ENV]
|
||||
|
||||
_DB_PASSWORD = os.getenv("DB_PASSWORD", "arb_engine_2026" if TRADE_ENV == "testnet" else "")
|
||||
if not _DB_PASSWORD:
|
||||
print("FATAL: DB_PASSWORD 未设置(生产环境必须配置)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "10.106.0.3"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "arb_engine"),
|
||||
"user": os.getenv("DB_USER", "arb"),
|
||||
"password": os.getenv("DB_PASSWORD", "arb_engine_2026"),
|
||||
"password": _DB_PASSWORD,
|
||||
}
|
||||
|
||||
CHECK_INTERVAL = 30 # 对账间隔(秒)
|
||||
@ -368,20 +373,28 @@ async def check_tp1_triggers(session, conn):
|
||||
else:
|
||||
new_sl = lp["entry_price"] * 0.9995
|
||||
|
||||
# 挂新SL(半仓)
|
||||
# 挂新SL(半仓)— 失败则不推进状态
|
||||
prec = SYMBOL_PRECISION.get(symbol, {"qty": 3, "price": 2})
|
||||
ok, _ = await rehang_sl(session, symbol, lp["direction"], new_sl, bp["amount"])
|
||||
ok, sl_resp = await rehang_sl(session, symbol, lp["direction"], new_sl, bp["amount"])
|
||||
if not ok:
|
||||
logger.error(f"[{symbol}] ❌ TP1后重挂SL失败: {sl_resp},不推进tp1_hit状态")
|
||||
_log_event(conn, "critical", "trade",
|
||||
"TP1后重挂SL失败,仓位可能裸奔,需人工确认", symbol,
|
||||
{"trade_id": lp["id"], "sl_resp": str(sl_resp)})
|
||||
continue
|
||||
|
||||
# 重新挂TP2(半仓)
|
||||
tp2_price = lp["tp2_price"]
|
||||
qty_str = f"{abs(bp['amount']):.{prec['qty']}f}"
|
||||
price_str = f"{tp2_price:.{prec['price']}f}"
|
||||
await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
tp2_data, tp2_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "TAKE_PROFIT_MARKET",
|
||||
"stopPrice": price_str, "quantity": qty_str, "reduceOnly": "true",
|
||||
})
|
||||
if tp2_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ TP2重挂失败: {tp2_data},SL已挂但TP2缺失")
|
||||
|
||||
# 更新DB
|
||||
# SL成功才更新DB
|
||||
cur.execute("""
|
||||
UPDATE live_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit'
|
||||
WHERE id=%s
|
||||
@ -436,7 +449,7 @@ async def check_closed_positions(session, conn):
|
||||
# 汇总手续费(开仓后200ms起算,避免含其他策略成交)
|
||||
for t in trades_data:
|
||||
t_time = int(t.get("time", 0))
|
||||
if t_time >= entry_ts - 200:
|
||||
if t_time >= entry_ts + 200: # 开仓后200ms起算,避免纳入开仓前成交
|
||||
actual_fee_usdt += abs(float(t.get("commission", 0)))
|
||||
|
||||
# 计算pnl — gross(不含费)
|
||||
|
||||
@ -39,12 +39,17 @@ BINANCE_ENDPOINTS = {
|
||||
}
|
||||
BASE_URL = BINANCE_ENDPOINTS[TRADE_ENV]
|
||||
|
||||
_DB_PASSWORD = os.getenv("DB_PASSWORD", "arb_engine_2026" if TRADE_ENV == "testnet" else "")
|
||||
if not _DB_PASSWORD:
|
||||
print("FATAL: DB_PASSWORD 未设置(生产环境必须配置)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "10.106.0.3"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "arb_engine"),
|
||||
"user": os.getenv("DB_USER", "arb"),
|
||||
"password": os.getenv("DB_PASSWORD", "arb_engine_2026"),
|
||||
"password": _DB_PASSWORD,
|
||||
}
|
||||
|
||||
# 风控参数
|
||||
@ -279,11 +284,16 @@ async def check_hold_timeout(session, conn):
|
||||
for p in pos_data:
|
||||
amt = abs(float(p.get("positionAmt", 0)))
|
||||
if amt > 0 and p["symbol"] == symbol:
|
||||
await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
qty_prec = SYMBOL_QTY_PRECISION.get(symbol, 3)
|
||||
qty_str = f"{amt:.{qty_prec}f}"
|
||||
close_data, close_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "MARKET",
|
||||
"quantity": str(amt), "reduceOnly": "true",
|
||||
"quantity": qty_str, "reduceOnly": "true",
|
||||
})
|
||||
logger.info(f"[{symbol}] 🔴 自动平仓完成 qty={amt}")
|
||||
if close_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ 超时自动平仓失败: {close_data}")
|
||||
else:
|
||||
logger.info(f"[{symbol}] 🔴 超时自动平仓完成 qty={qty_str}")
|
||||
del risk_state.timeout_queue[trade_id]
|
||||
|
||||
elif hold_min >= HOLD_TIMEOUT_RED_MIN:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user