feat: risk_guard.py - 风控熔断模块
- 单日亏损超限(-5R): 已实现+未实现亏损预估 → 全平+停机 - 连续亏损(5连亏): 暂停开仓1小时,冷却期后自动恢复 - API连接异常(>30秒): 暂停开仓,恢复后自动解除 - 余额不足(<风险×2): 拒绝开仓 - 持仓超时: 45min黄/60min红+10min人工窗口/70min自动平仓 - 写/tmp/risk_guard_state.json供live_executor读取 - 每5秒检查一次,每60秒输出状态日志
This commit is contained in:
parent
fab3a3d909
commit
b08ea8f772
480
backend/risk_guard.py
Normal file
480
backend/risk_guard.py
Normal file
@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Risk Guard - 风控熔断模块
|
||||
实时监控风险指标,触发熔断时自动执行保护动作
|
||||
|
||||
熔断规则:
|
||||
1. 单日亏损超限(-5R) → 全平+停机
|
||||
2. 连续亏损(5连亏) → 暂停开仓1小时
|
||||
3. API连接异常(>30秒) → 暂停开仓
|
||||
4. 余额不足(< 风险×2) → 拒绝开仓
|
||||
5. 数据新鲜度超时 → 禁止新开仓
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg2
|
||||
import aiohttp
|
||||
|
||||
# ============ 配置 ============
|
||||
|
||||
TRADE_ENV = os.getenv("TRADE_ENV", "testnet")
|
||||
BINANCE_ENDPOINTS = {
|
||||
"testnet": "https://testnet.binancefuture.com",
|
||||
"production": "https://fapi.binance.com",
|
||||
}
|
||||
BASE_URL = BINANCE_ENDPOINTS[TRADE_ENV]
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
# 风控参数
|
||||
DAILY_LOSS_LIMIT_R = float(os.getenv("DAILY_LOSS_LIMIT_R", "-5"))
|
||||
CONSECUTIVE_LOSS_LIMIT = int(os.getenv("CONSECUTIVE_LOSS_LIMIT", "5"))
|
||||
CONSECUTIVE_LOSS_COOLDOWN_MIN = int(os.getenv("CONSECUTIVE_LOSS_COOLDOWN_MIN", "60"))
|
||||
API_DISCONNECT_THRESHOLD_SEC = int(os.getenv("API_DISCONNECT_THRESHOLD_SEC", "30"))
|
||||
MIN_BALANCE_MULTIPLE = float(os.getenv("MIN_BALANCE_MULTIPLE", "2"))
|
||||
RISK_PER_TRADE_USD = float(os.getenv("RISK_PER_TRADE_USD", "2"))
|
||||
|
||||
# 超时处置
|
||||
HOLD_TIMEOUT_YELLOW_MIN = 45
|
||||
HOLD_TIMEOUT_RED_MIN = 60
|
||||
HOLD_TIMEOUT_GRACE_MIN = 10 # 红灯后10分钟人工窗口
|
||||
HOLD_TIMEOUT_AUTO_CLOSE_MIN = HOLD_TIMEOUT_RED_MIN + HOLD_TIMEOUT_GRACE_MIN # 70分钟
|
||||
|
||||
# 数据新鲜度
|
||||
MARKET_DATA_STALE_SEC = 10
|
||||
ACCOUNT_UPDATE_STALE_SEC = 20
|
||||
|
||||
CHECK_INTERVAL = 5 # 风控检查间隔(秒)
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("risk-guard")
|
||||
|
||||
# ============ 状态 ============
|
||||
|
||||
class RiskState:
|
||||
"""风控状态管理"""
|
||||
def __init__(self):
|
||||
self.status = "normal" # normal / warning / circuit_break
|
||||
self.block_new_entries = False
|
||||
self.reduce_only = False
|
||||
self.manual_override = False
|
||||
self.circuit_break_reason = None
|
||||
self.circuit_break_time = None
|
||||
self.auto_resume_time = None
|
||||
self.last_api_success = time.time()
|
||||
self.last_market_data = time.time()
|
||||
self.last_account_update = time.time()
|
||||
self.consecutive_losses = 0
|
||||
self.today_realized_r = 0.0
|
||||
self.today_unrealized_r = 0.0
|
||||
self.breaker_history = []
|
||||
# 超时处置队列: {trade_id: {"entered_queue_ts": ..., "notified": bool}}
|
||||
self.timeout_queue = {}
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"status": self.status,
|
||||
"block_new_entries": self.block_new_entries,
|
||||
"reduce_only": self.reduce_only,
|
||||
"circuit_break_reason": self.circuit_break_reason,
|
||||
"circuit_break_time": self.circuit_break_time,
|
||||
"auto_resume_time": self.auto_resume_time,
|
||||
"consecutive_losses": self.consecutive_losses,
|
||||
"today_realized_r": round(self.today_realized_r, 2),
|
||||
"today_unrealized_r": round(self.today_unrealized_r, 2),
|
||||
"today_total_r": round(self.today_realized_r + min(self.today_unrealized_r, 0), 2),
|
||||
}
|
||||
|
||||
|
||||
risk_state = RiskState()
|
||||
|
||||
# ============ API Key ============
|
||||
_api_key = None
|
||||
_secret_key = None
|
||||
|
||||
|
||||
def load_api_keys():
|
||||
global _api_key, _secret_key
|
||||
_api_key = os.getenv("BINANCE_API_KEY")
|
||||
_secret_key = os.getenv("BINANCE_SECRET_KEY")
|
||||
if _api_key and _secret_key:
|
||||
return
|
||||
try:
|
||||
from google.cloud import secretmanager
|
||||
client = secretmanager.SecretManagerServiceClient()
|
||||
project = os.getenv("GCP_PROJECT", "gen-lang-client-0835616737")
|
||||
prefix = "binance-testnet" if TRADE_ENV == "testnet" else "binance-live"
|
||||
_api_key = client.access_secret_version(
|
||||
name=f"projects/{project}/secrets/{prefix}-api-key/versions/latest"
|
||||
).payload.data.decode()
|
||||
_secret_key = client.access_secret_version(
|
||||
name=f"projects/{project}/secrets/{prefix}-secret-key/versions/latest"
|
||||
).payload.data.decode()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load API keys: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def sign_params(params):
|
||||
params["timestamp"] = int(time.time() * 1000)
|
||||
query_string = urlencode(params)
|
||||
signature = hmac.new(_secret_key.encode(), query_string.encode(), hashlib.sha256).hexdigest()
|
||||
params["signature"] = signature
|
||||
return params
|
||||
|
||||
|
||||
async def binance_request(session, method, path, params=None):
|
||||
url = f"{BASE_URL}{path}"
|
||||
headers = {"X-MBX-APIKEY": _api_key}
|
||||
if params is None:
|
||||
params = {}
|
||||
params = sign_params(params)
|
||||
try:
|
||||
if method == "GET":
|
||||
async with session.get(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
risk_state.last_api_success = time.time()
|
||||
return data, resp.status
|
||||
elif method == "POST":
|
||||
async with session.post(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
risk_state.last_api_success = time.time()
|
||||
return data, resp.status
|
||||
elif method == "DELETE":
|
||||
async with session.delete(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
risk_state.last_api_success = time.time()
|
||||
return data, resp.status
|
||||
except Exception as e:
|
||||
logger.error(f"API request failed: {e}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
# ============ 风控检查 ============
|
||||
|
||||
def check_daily_loss(conn):
|
||||
"""检查今日已实现亏损"""
|
||||
cur = conn.cursor()
|
||||
# 今日UTC起始
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start_ms = int(today_start.timestamp() * 1000)
|
||||
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(pnl_r), 0) as total_r,
|
||||
COUNT(*) FILTER (WHERE pnl_r < 0) as loss_count
|
||||
FROM live_trades
|
||||
WHERE exit_ts >= %s AND status NOT IN ('active', 'tp1_hit')
|
||||
""", (today_start_ms,))
|
||||
row = cur.fetchone()
|
||||
realized_r = float(row[0]) if row[0] else 0
|
||||
risk_state.today_realized_r = realized_r
|
||||
|
||||
# 检查连续亏损
|
||||
cur.execute("""
|
||||
SELECT pnl_r FROM live_trades
|
||||
WHERE status NOT IN ('active', 'tp1_hit')
|
||||
ORDER BY exit_ts DESC LIMIT %s
|
||||
""", (CONSECUTIVE_LOSS_LIMIT,))
|
||||
recent = [r[0] for r in cur.fetchall()]
|
||||
consecutive = 0
|
||||
for r in recent:
|
||||
if r and r < 0:
|
||||
consecutive += 1
|
||||
else:
|
||||
break
|
||||
risk_state.consecutive_losses = consecutive
|
||||
|
||||
return realized_r, consecutive
|
||||
|
||||
|
||||
async def check_unrealized_loss(session):
|
||||
"""检查未实现亏损"""
|
||||
data, status = await binance_request(session, "GET", "/fapi/v2/positionRisk")
|
||||
total_unrealized = 0
|
||||
if status == 200 and isinstance(data, list):
|
||||
for pos in data:
|
||||
pnl = float(pos.get("unRealizedProfit", 0))
|
||||
total_unrealized += pnl
|
||||
# 转为R
|
||||
unrealized_r = total_unrealized / RISK_PER_TRADE_USD if RISK_PER_TRADE_USD > 0 else 0
|
||||
risk_state.today_unrealized_r = unrealized_r
|
||||
return unrealized_r
|
||||
|
||||
|
||||
async def check_balance(session):
|
||||
"""检查余额是否足够"""
|
||||
data, status = await binance_request(session, "GET", "/fapi/v2/balance")
|
||||
if status == 200 and isinstance(data, list):
|
||||
for asset in data:
|
||||
if asset.get("asset") == "USDT":
|
||||
available = float(asset.get("availableBalance", 0))
|
||||
return available
|
||||
return 0
|
||||
|
||||
|
||||
def check_data_freshness():
|
||||
"""检查数据新鲜度"""
|
||||
now = time.time()
|
||||
issues = []
|
||||
|
||||
api_gap = now - risk_state.last_api_success
|
||||
if api_gap > API_DISCONNECT_THRESHOLD_SEC:
|
||||
issues.append(f"API无响应{api_gap:.0f}秒")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def check_hold_timeout(session, conn):
|
||||
"""检查持仓超时,管理处置队列"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, symbol, direction, entry_ts, entry_price, risk_distance
|
||||
FROM live_trades
|
||||
WHERE status IN ('active', 'tp1_hit')
|
||||
""")
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
for row in cur.fetchall():
|
||||
trade_id, symbol, direction, entry_ts, entry_price, rd = row
|
||||
hold_min = (now_ms - entry_ts) / 60000
|
||||
|
||||
if hold_min >= HOLD_TIMEOUT_AUTO_CLOSE_MIN:
|
||||
# 70分钟:人工窗口到期,自动平仓
|
||||
if trade_id in risk_state.timeout_queue:
|
||||
logger.warning(f"[{symbol}] ⏰ 持仓{hold_min:.0f}分钟,人工窗口到期,自动市价平仓!")
|
||||
close_side = "SELL" if direction == "LONG" else "BUY"
|
||||
# 取消所有挂单
|
||||
await binance_request(session, "DELETE", "/fapi/v1/allOpenOrders", {"symbol": symbol})
|
||||
# 查仓位大小
|
||||
pos_data, _ = await binance_request(session, "GET", "/fapi/v2/positionRisk", {"symbol": symbol})
|
||||
if isinstance(pos_data, list):
|
||||
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", {
|
||||
"symbol": symbol, "side": close_side, "type": "MARKET",
|
||||
"quantity": str(amt), "reduceOnly": "true",
|
||||
})
|
||||
logger.info(f"[{symbol}] 🔴 自动平仓完成 qty={amt}")
|
||||
del risk_state.timeout_queue[trade_id]
|
||||
|
||||
elif hold_min >= HOLD_TIMEOUT_RED_MIN:
|
||||
# 60分钟:进入处置队列+10分钟倒计时
|
||||
if trade_id not in risk_state.timeout_queue:
|
||||
risk_state.timeout_queue[trade_id] = {
|
||||
"entered_queue_ts": time.time(),
|
||||
"notified": False,
|
||||
"symbol": symbol,
|
||||
}
|
||||
remaining = HOLD_TIMEOUT_GRACE_MIN
|
||||
logger.warning(f"[{symbol}] 🔴 持仓{hold_min:.0f}分钟超时! 进入处置队列, {remaining}分钟后自动平仓")
|
||||
# TODO: Discord通知范总
|
||||
|
||||
elif not risk_state.timeout_queue[trade_id]["notified"]:
|
||||
risk_state.timeout_queue[trade_id]["notified"] = True
|
||||
# TODO: Discord紧急通知
|
||||
|
||||
elif hold_min >= HOLD_TIMEOUT_YELLOW_MIN:
|
||||
logger.info(f"[{symbol}] 🟡 持仓{hold_min:.0f}分钟,接近超时")
|
||||
|
||||
|
||||
# ============ 熔断动作 ============
|
||||
|
||||
async def trigger_circuit_break(session, conn, reason: str, action: str = "block_new"):
|
||||
"""触发熔断"""
|
||||
now = time.time()
|
||||
risk_state.status = "circuit_break"
|
||||
risk_state.circuit_break_reason = reason
|
||||
risk_state.circuit_break_time = now
|
||||
|
||||
if action == "block_new":
|
||||
risk_state.block_new_entries = True
|
||||
logger.error(f"🔴 熔断触发: {reason} | 动作: 禁止新开仓")
|
||||
|
||||
elif action == "reduce_only":
|
||||
risk_state.block_new_entries = True
|
||||
risk_state.reduce_only = True
|
||||
logger.error(f"🔴 熔断触发: {reason} | 动作: 只减仓模式")
|
||||
|
||||
elif action == "close_all":
|
||||
risk_state.block_new_entries = True
|
||||
risk_state.reduce_only = True
|
||||
logger.error(f"🔴🔴 熔断触发: {reason} | 动作: 全部平仓!")
|
||||
|
||||
# 执行全平
|
||||
for symbol in SYMBOLS:
|
||||
await binance_request(session, "DELETE", "/fapi/v1/allOpenOrders", {"symbol": symbol})
|
||||
pos_data, _ = await binance_request(session, "GET", "/fapi/v2/positionRisk", {"symbol": symbol})
|
||||
if isinstance(pos_data, list):
|
||||
for p in pos_data:
|
||||
amt = float(p.get("positionAmt", 0))
|
||||
if amt != 0:
|
||||
close_side = "SELL" if amt > 0 else "BUY"
|
||||
await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "MARKET",
|
||||
"quantity": str(abs(amt)), "reduceOnly": "true",
|
||||
})
|
||||
logger.info(f"[{symbol}] 🔴 紧急平仓 {close_side} qty={abs(amt)}")
|
||||
|
||||
# 记录历史
|
||||
risk_state.breaker_history.append({
|
||||
"time": now,
|
||||
"reason": reason,
|
||||
"action": action,
|
||||
})
|
||||
|
||||
# 写状态文件(供其他进程读取)
|
||||
write_risk_state()
|
||||
|
||||
|
||||
def write_risk_state():
|
||||
"""写风控状态到文件(供live_executor读取判断是否可开仓)"""
|
||||
state_path = "/tmp/risk_guard_state.json"
|
||||
try:
|
||||
with open(state_path, "w") as f:
|
||||
json.dump(risk_state.to_dict(), f)
|
||||
except Exception as e:
|
||||
logger.error(f"写风控状态失败: {e}")
|
||||
|
||||
|
||||
def check_auto_resume():
|
||||
"""检查是否可以自动恢复"""
|
||||
if risk_state.status != "circuit_break":
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
|
||||
# 连续亏损冷却期到了
|
||||
if (risk_state.circuit_break_reason
|
||||
and "连续亏损" in risk_state.circuit_break_reason
|
||||
and risk_state.circuit_break_time):
|
||||
elapsed_min = (now - risk_state.circuit_break_time) / 60
|
||||
if elapsed_min >= CONSECUTIVE_LOSS_COOLDOWN_MIN:
|
||||
logger.info(f"✅ 连续亏损冷却期结束({CONSECUTIVE_LOSS_COOLDOWN_MIN}分钟),自动恢复交易")
|
||||
risk_state.status = "normal"
|
||||
risk_state.block_new_entries = False
|
||||
risk_state.reduce_only = False
|
||||
risk_state.circuit_break_reason = None
|
||||
write_risk_state()
|
||||
|
||||
# API恢复
|
||||
if (risk_state.circuit_break_reason
|
||||
and "API" in risk_state.circuit_break_reason):
|
||||
api_gap = now - risk_state.last_api_success
|
||||
if api_gap < 10: # API恢复正常10秒
|
||||
logger.info("✅ API连接恢复,自动恢复交易")
|
||||
risk_state.status = "normal"
|
||||
risk_state.block_new_entries = False
|
||||
risk_state.circuit_break_reason = None
|
||||
write_risk_state()
|
||||
|
||||
|
||||
# ============ 主循环 ============
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"🛡 Risk Guard 启动 | 环境={TRADE_ENV}")
|
||||
logger.info(f" 日限={DAILY_LOSS_LIMIT_R}R | 连亏限={CONSECUTIVE_LOSS_LIMIT}次 | 冷却={CONSECUTIVE_LOSS_COOLDOWN_MIN}分钟")
|
||||
logger.info(f" 超时: {HOLD_TIMEOUT_YELLOW_MIN}min黄/{HOLD_TIMEOUT_RED_MIN}min红/{HOLD_TIMEOUT_AUTO_CLOSE_MIN}min自动平")
|
||||
logger.info("=" * 60)
|
||||
|
||||
load_api_keys()
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
# 初始状态写入
|
||||
write_risk_state()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
try:
|
||||
# 0. 检查自动恢复
|
||||
check_auto_resume()
|
||||
|
||||
# 1. 今日亏损检查
|
||||
realized_r, consecutive = check_daily_loss(conn)
|
||||
unrealized_r = await check_unrealized_loss(session)
|
||||
total_r = realized_r + min(unrealized_r, 0) # 已实现 + 未实现亏损
|
||||
|
||||
if total_r <= DAILY_LOSS_LIMIT_R and risk_state.status != "circuit_break":
|
||||
await trigger_circuit_break(
|
||||
session, conn,
|
||||
f"今日亏损{total_r:.2f}R,超过日限{DAILY_LOSS_LIMIT_R}R",
|
||||
"close_all"
|
||||
)
|
||||
|
||||
# 2. 连续亏损检查
|
||||
if (consecutive >= CONSECUTIVE_LOSS_LIMIT
|
||||
and risk_state.status != "circuit_break"):
|
||||
await trigger_circuit_break(
|
||||
session, conn,
|
||||
f"连续亏损{consecutive}次,超过限制{CONSECUTIVE_LOSS_LIMIT}次",
|
||||
"block_new"
|
||||
)
|
||||
risk_state.auto_resume_time = time.time() + CONSECUTIVE_LOSS_COOLDOWN_MIN * 60
|
||||
|
||||
# 3. API连接检查
|
||||
freshness_issues = check_data_freshness()
|
||||
if freshness_issues and risk_state.status != "circuit_break":
|
||||
await trigger_circuit_break(
|
||||
session, conn,
|
||||
f"数据异常: {'; '.join(freshness_issues)}",
|
||||
"block_new"
|
||||
)
|
||||
|
||||
# 4. 余额检查
|
||||
balance = await check_balance(session)
|
||||
if balance < RISK_PER_TRADE_USD * MIN_BALANCE_MULTIPLE:
|
||||
if not risk_state.block_new_entries:
|
||||
risk_state.block_new_entries = True
|
||||
logger.warning(f"🟡 余额不足: ${balance:.2f} < ${RISK_PER_TRADE_USD * MIN_BALANCE_MULTIPLE:.2f},暂停开仓")
|
||||
|
||||
# 5. 持仓超时检查
|
||||
await check_hold_timeout(session, conn)
|
||||
|
||||
# 6. 写状态
|
||||
write_risk_state()
|
||||
|
||||
# 日志(每60秒输出一次状态)
|
||||
if int(time.time()) % 60 < CHECK_INTERVAL:
|
||||
logger.info(
|
||||
f"📊 风控状态: {risk_state.status} | "
|
||||
f"已实现={realized_r:+.2f}R | 未实现={unrealized_r:+.2f}R | "
|
||||
f"合计={total_r:+.2f}R | 连亏={consecutive} | "
|
||||
f"余额=${balance:.2f}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 风控检查异常: {e}", exc_info=True)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
conn.close()
|
||||
logger.info("Risk Guard 已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Reference in New Issue
Block a user