93 lines
3.1 KiB
Python
93 lines
3.1 KiB
Python
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
from hydra.logging.setup import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
|
|
DAILY_PNL_KEY = "hydra:daily_pnl"
|
|
KILL_THRESHOLD = -0.05
|
|
|
|
|
|
@dataclass
|
|
class KillSwitchResult:
|
|
success: bool
|
|
source: str
|
|
reason: str
|
|
duration_ms: float
|
|
closed_positions: list = field(default_factory=list)
|
|
errors: list = field(default_factory=list)
|
|
|
|
|
|
class KillSwitch:
|
|
def __init__(self, exchanges: dict, position_tracker, telegram, redis_client):
|
|
self._exchanges = exchanges
|
|
self._positions = position_tracker
|
|
self._telegram = telegram
|
|
self._redis = redis_client
|
|
|
|
async def execute(self, reason: str, source: str) -> KillSwitchResult:
|
|
t0 = time.monotonic()
|
|
logger.warning("kill_switch_triggered", reason=reason, source=source)
|
|
|
|
# 1. 신규 주문 차단
|
|
self._redis.set(KILL_BLOCKED_KEY, "1")
|
|
|
|
# 2. 전 거래소 미체결 취소
|
|
errors = []
|
|
for name, ex in self._exchanges.items():
|
|
try:
|
|
await ex.cancel_all()
|
|
except Exception as e:
|
|
errors.append(f"{name}: cancel_all failed: {e}")
|
|
logger.error("kill_switch_cancel_error", exchange=name, error=str(e))
|
|
|
|
# 3. 전 포지션 시장가 청산
|
|
positions = self._positions.get_all()
|
|
closed = []
|
|
for pos in positions:
|
|
market = pos["market"]
|
|
ex = self._exchanges.get(market)
|
|
if ex:
|
|
try:
|
|
close_side = "sell" if pos["side"] == "buy" else "buy"
|
|
await ex.create_order(
|
|
symbol=pos["symbol"],
|
|
side=close_side,
|
|
order_type="market",
|
|
qty=pos["qty"],
|
|
)
|
|
closed.append(pos["symbol"])
|
|
except Exception as e:
|
|
errors.append(f"close {pos['symbol']}: {e}")
|
|
|
|
duration_ms = (time.monotonic() - t0) * 1000
|
|
|
|
# 4. Telegram 알림
|
|
msg = f"🚨 Kill Switch 발동\n원인: {reason}\n경로: {source}\n청산: {len(closed)}개\n소요: {duration_ms:.0f}ms"
|
|
try:
|
|
await self._telegram.send_message(msg)
|
|
except Exception as e:
|
|
logger.error("kill_switch_telegram_error", error=str(e))
|
|
|
|
logger.warning("kill_switch_complete", closed=len(closed), errors=len(errors), duration_ms=duration_ms)
|
|
return KillSwitchResult(
|
|
success=len(errors) == 0,
|
|
source=source,
|
|
reason=reason,
|
|
duration_ms=duration_ms,
|
|
closed_positions=closed,
|
|
errors=errors,
|
|
)
|
|
|
|
async def check_auto_triggers(self) -> tuple[bool, str]:
|
|
raw = self._redis.get(DAILY_PNL_KEY)
|
|
daily_pnl = float(raw) if raw else 0.0
|
|
if daily_pnl <= KILL_THRESHOLD:
|
|
return True, f"daily_loss:{daily_pnl*100:.1f}%"
|
|
return False, ""
|
|
|
|
def is_active(self) -> bool:
|
|
return bool(self._redis.get(KILL_BLOCKED_KEY))
|