Files
Hydra-Engine/hydra/core/order_queue.py
2026-03-30 13:19:11 +09:00

138 lines
4.6 KiB
Python

import json
from dataclasses import dataclass
from typing import Literal, Optional
from uuid import uuid4
from pydantic import BaseModel, Field, field_validator
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
LOCK_TIMEOUT = 10
IDEMPOTENCY_TTL = 86400 # 24 hours
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
FUTURES_MARKETS = {"binance", "hl"} # 선물 거래를 지원하는 시장
class OrderLockError(Exception):
pass
class OrderRequest(BaseModel):
market: Literal["kr", "us", "upbit", "binance", "hl", "poly"]
symbol: str
side: Literal["buy", "sell"]
order_type: Literal["market", "limit"] = "market"
qty: Optional[float] = None
price: Optional[float] = None
amount: Optional[float] = None
idempotency_key: str = ""
exchange: Optional[str] = None
leverage: int = Field(default=1, ge=1, le=125, description="선물 레버리지 (1~125x, 현물은 1로 고정)")
is_futures: bool = Field(default=False, description="선물 주문 여부")
def model_post_init(self, __context) -> None:
if not self.idempotency_key:
self.idempotency_key = str(uuid4())
# 선물 지원 시장인데 leverage > 1이면 자동으로 is_futures=True
if self.leverage > 1 and self.market in FUTURES_MARKETS:
object.__setattr__(self, "is_futures", True)
@field_validator("qty")
@classmethod
def validate_qty(cls, v):
if v is not None and v <= 0:
raise ValueError("수량은 양수여야 합니다")
return v
@field_validator("leverage")
@classmethod
def validate_leverage(cls, v):
if v < 1 or v > 125:
raise ValueError("레버리지는 1~125 사이여야 합니다")
return v
@dataclass
class OrderResult:
order_id: str
status: str
symbol: str
market: str
class OrderQueue:
def __init__(self, redis_client, risk_engine, position_tracker, exchanges: dict):
self._redis = redis_client
self._risk = risk_engine
self._positions = position_tracker
self._exchanges = exchanges
self._blocked = False
def block_new_orders(self) -> None:
self._blocked = True
async def submit(self, order: OrderRequest) -> OrderResult:
# 멱등성 체크 (Kill Switch보다 먼저 — 캐시된 결과는 항상 반환)
cached_raw = self._redis.get(f"idem:{order.idempotency_key}")
if cached_raw:
cached = json.loads(cached_raw)
return OrderResult(
order_id=cached["order_id"],
status=cached["status"],
symbol=order.symbol,
market=order.market,
)
# Kill Switch 체크
if self._blocked or self._redis.get(KILL_BLOCKED_KEY):
raise OrderLockError("Kill Switch 활성 — 신규 주문 불가")
# Redis 락 획득
lock_key = f"order_lock:{order.market}:{order.symbol}:{order.side}"
acquired = self._redis.set(lock_key, "1", nx=True, ex=LOCK_TIMEOUT)
if not acquired:
raise OrderLockError(f"주문 락 획득 실패: {order.symbol} {order.side}")
try:
# 리스크 검증
allowed, reason = self._risk.check_order_allowed(order.market, order.symbol, 0.0)
if not allowed:
raise OrderLockError(f"리스크 검증 실패: {reason}")
# 거래소 주문
ex = self._exchanges.get(order.market)
if not ex:
raise OrderLockError(f"비활성 시장: {order.market}")
# 선물 레버리지 설정 (주문 전)
if order.is_futures and order.leverage > 1:
await ex.set_leverage(order.symbol, order.leverage)
raw = await ex.create_order(
symbol=order.symbol,
side=order.side,
order_type=order.order_type,
qty=order.qty,
price=order.price,
)
result = OrderResult(
order_id=raw.get("order_id", str(uuid4())),
status=raw.get("status", "submitted"),
symbol=order.symbol,
market=order.market,
)
# 멱등성 캐시 저장
self._redis.set(
f"idem:{order.idempotency_key}",
json.dumps({"order_id": result.order_id, "status": result.status}),
ex=IDEMPOTENCY_TTL,
)
logger.info("order_submitted", order_id=result.order_id, symbol=order.symbol, side=order.side, leverage=order.leverage)
return result
finally:
self._redis.delete(lock_key)