138 lines
4.6 KiB
Python
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)
|