Initial public release
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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))
|
||||
@@ -0,0 +1,137 @@
|
||||
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)
|
||||
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
PNL_REALIZED_KEY = "hydra:pnl:realized:total"
|
||||
PNL_TRADE_COUNT_KEY = "hydra:pnl:trade_count"
|
||||
|
||||
|
||||
def _daily_key() -> str:
|
||||
return f"hydra:pnl:daily:{date.today().isoformat()}"
|
||||
|
||||
|
||||
class PnlTracker:
|
||||
"""
|
||||
시스템 전체 손익 추적.
|
||||
- 실현 손익: 포지션 청산 시 record_trade()로 기록
|
||||
- 미실현 손익: 포지션 데이터(mark_price)로 계산
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
|
||||
# ─── 실현 손익 ───────────────────────────────────────────────
|
||||
|
||||
def record_trade(self, market: str, symbol: str, realized_pnl: float) -> None:
|
||||
"""포지션 청산 시 실현 손익 기록."""
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.incrbyfloat(PNL_REALIZED_KEY, realized_pnl)
|
||||
pipe.incrbyfloat(_daily_key(), realized_pnl)
|
||||
pipe.incr(PNL_TRADE_COUNT_KEY)
|
||||
# 개별 심볼 실현 손익
|
||||
pipe.incrbyfloat(f"hydra:pnl:symbol:{market}:{symbol}", realized_pnl)
|
||||
pipe.execute()
|
||||
logger.info("pnl_recorded", market=market, symbol=symbol, realized_pnl=realized_pnl)
|
||||
|
||||
def get_realized_total(self) -> float:
|
||||
raw = self._redis.get(PNL_REALIZED_KEY)
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def get_daily_realized(self) -> float:
|
||||
raw = self._redis.get(_daily_key())
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def get_trade_count(self) -> int:
|
||||
raw = self._redis.get(PNL_TRADE_COUNT_KEY)
|
||||
return int(raw) if raw else 0
|
||||
|
||||
def get_symbol_realized(self, market: str, symbol: str) -> float:
|
||||
raw = self._redis.get(f"hydra:pnl:symbol:{market}:{symbol}")
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
# ─── 미실현 손익 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def calc_unrealized(position: dict) -> float:
|
||||
"""단일 포지션의 미실현 손익 계산.
|
||||
position dict: {qty, avg_price, mark_price, side, leverage}
|
||||
"""
|
||||
qty = float(position.get("qty", 0))
|
||||
avg_price = float(position.get("avg_price", 0))
|
||||
mark_price = float(position.get("mark_price", avg_price))
|
||||
side = position.get("side", "buy")
|
||||
leverage = float(position.get("leverage", 1))
|
||||
|
||||
if qty == 0 or avg_price == 0:
|
||||
return 0.0
|
||||
|
||||
price_diff = mark_price - avg_price
|
||||
if side == "sell":
|
||||
price_diff = -price_diff
|
||||
|
||||
return price_diff * qty * leverage
|
||||
|
||||
def get_unrealized_total(self, positions: list[dict]) -> float:
|
||||
return sum(self.calc_unrealized(p) for p in positions)
|
||||
|
||||
# ─── 요약 ─────────────────────────────────────────────────────
|
||||
|
||||
def get_summary(self, positions: list[dict]) -> dict:
|
||||
realized_total = self.get_realized_total()
|
||||
daily_realized = self.get_daily_realized()
|
||||
unrealized = self.get_unrealized_total(positions)
|
||||
trade_count = self.get_trade_count()
|
||||
|
||||
return {
|
||||
"realized_total": round(realized_total, 4),
|
||||
"daily_realized": round(daily_realized, 4),
|
||||
"unrealized": round(unrealized, 4),
|
||||
"total_pnl": round(realized_total + unrealized, 4),
|
||||
"trade_count": trade_count,
|
||||
"positions": [
|
||||
{
|
||||
"market": p.get("market"),
|
||||
"symbol": p.get("symbol"),
|
||||
"side": p.get("side"),
|
||||
"qty": p.get("qty"),
|
||||
"avg_price": p.get("avg_price"),
|
||||
"mark_price": p.get("mark_price", p.get("avg_price")),
|
||||
"leverage": p.get("leverage", 1),
|
||||
"unrealized_pnl": round(self.calc_unrealized(p), 4),
|
||||
}
|
||||
for p in positions
|
||||
],
|
||||
}
|
||||
|
||||
def reset_daily(self) -> None:
|
||||
"""일일 손익 초기화 (자정 실행용)."""
|
||||
self._redis.delete(_daily_key())
|
||||
logger.info("pnl_daily_reset")
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
POSITIONS_KEY = "hydra:positions"
|
||||
|
||||
|
||||
class PositionTracker:
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
|
||||
def update(self, market: str, symbol: str, qty: float, avg_price: float, side: str, leverage: int = 1, mark_price: float | None = None) -> None:
|
||||
key = f"{POSITIONS_KEY}:{market}:{symbol}"
|
||||
data = {
|
||||
"qty": qty,
|
||||
"avg_price": avg_price,
|
||||
"side": side,
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"leverage": leverage,
|
||||
"mark_price": mark_price or avg_price,
|
||||
}
|
||||
self._redis.set(key, json.dumps(data))
|
||||
logger.info("position_updated", market=market, symbol=symbol, qty=qty, leverage=leverage)
|
||||
|
||||
def get(self, market: str, symbol: str) -> Optional[dict]:
|
||||
key = f"{POSITIONS_KEY}:{market}:{symbol}"
|
||||
raw = self._redis.get(key)
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
def get_all(self) -> list[dict]:
|
||||
keys = self._redis.keys(f"{POSITIONS_KEY}:*")
|
||||
result = []
|
||||
for k in keys:
|
||||
raw = self._redis.get(k)
|
||||
if raw:
|
||||
result.append(json.loads(raw))
|
||||
return result
|
||||
|
||||
def clear(self, market: str, symbol: str) -> None:
|
||||
key = f"{POSITIONS_KEY}:{market}:{symbol}"
|
||||
self._redis.delete(key)
|
||||
logger.info("position_cleared", market=market, symbol=symbol)
|
||||
|
||||
async def snapshot(self) -> dict:
|
||||
return {"positions": self.get_all()}
|
||||
@@ -0,0 +1,38 @@
|
||||
from hydra.config.validation import RiskConfig
|
||||
from hydra.core.position_tracker import PositionTracker
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DAILY_PNL_KEY = "hydra:daily_pnl"
|
||||
|
||||
|
||||
class RiskEngine:
|
||||
def __init__(self, redis_client, position_tracker: PositionTracker, config: RiskConfig | None = None):
|
||||
self._redis = redis_client
|
||||
self._positions = position_tracker
|
||||
self._config = config or RiskConfig()
|
||||
|
||||
def get_daily_pnl_pct(self) -> float:
|
||||
raw = self._redis.get(DAILY_PNL_KEY)
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def update_daily_pnl(self, pnl_pct: float) -> None:
|
||||
self._redis.set(DAILY_PNL_KEY, str(pnl_pct))
|
||||
|
||||
def check_order_allowed(self, market: str, symbol: str, position_pct: float) -> tuple[bool, str]:
|
||||
"""주문 허용 여부 확인. (allowed, reason) 반환."""
|
||||
daily_pnl = self.get_daily_pnl_pct()
|
||||
if daily_pnl <= -self._config.daily_loss_kill_pct:
|
||||
return False, f"일일 손실 {daily_pnl*100:.1f}% — Kill Switch 레벨"
|
||||
if daily_pnl <= -self._config.daily_loss_limit_pct:
|
||||
return False, f"일일 손실 {daily_pnl*100:.1f}% — 신규 주문 중단"
|
||||
if position_pct > self._config.max_position_per_symbol_pct:
|
||||
return False, f"종목 포지션 {position_pct*100:.1f}% 초과 (최대 {self._config.max_position_per_symbol_pct*100:.0f}%)"
|
||||
return True, "ok"
|
||||
|
||||
def should_kill_switch(self) -> tuple[bool, str]:
|
||||
daily_pnl = self.get_daily_pnl_pct()
|
||||
if daily_pnl <= -self._config.daily_loss_kill_pct:
|
||||
return True, f"daily_loss:{daily_pnl*100:.1f}%"
|
||||
return False, ""
|
||||
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
STATE_KEY = "hydra:state"
|
||||
|
||||
|
||||
class StateManager:
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
|
||||
def save(self, key: str, value: Any) -> None:
|
||||
payload = json.dumps({"value": value, "ts": time.time()})
|
||||
self._redis.hset(STATE_KEY, key, payload)
|
||||
|
||||
def load(self, key: str, default: Any = None) -> Any:
|
||||
raw = self._redis.hget(STATE_KEY, key)
|
||||
if raw is None:
|
||||
return default
|
||||
return json.loads(raw)["value"]
|
||||
|
||||
def save_all(self, state: dict) -> None:
|
||||
for k, v in state.items():
|
||||
self.save(k, v)
|
||||
|
||||
def load_all(self) -> dict:
|
||||
raw = self._redis.hgetall(STATE_KEY)
|
||||
return {k: json.loads(v)["value"] for k, v in raw.items()}
|
||||
Reference in New Issue
Block a user