Initial public release

This commit is contained in:
sinmb79
2026-03-30 13:19:11 +09:00
commit 92a692b63c
116 changed files with 5822 additions and 0 deletions
View File
+92
View File
@@ -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))
+137
View File
@@ -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)
+114
View File
@@ -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")
+49
View File
@@ -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()}
+38
View File
@@ -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, ""
+32
View File
@@ -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()}