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
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
View File
+11
View File
@@ -0,0 +1,11 @@
from fastapi import HTTPException, Header
from hydra.config.settings import get_settings
API_KEY_HEADER = "X-HYDRA-KEY"
async def verify_api_key(x_hydra_key: str = Header(..., alias=API_KEY_HEADER)) -> str:
settings = get_settings()
if x_hydra_key != settings.hydra_api_key:
raise HTTPException(status_code=403, detail="Invalid API key")
return x_hydra_key
+61
View File
@@ -0,0 +1,61 @@
# hydra/api/backtest.py
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from hydra.api.auth import verify_api_key
from hydra.backtest.runner import BacktestRunner
from hydra.indicator.calculator import IndicatorCalculator
from hydra.regime.detector import RegimeDetector
from hydra.strategy.signal import SignalGenerator
router = APIRouter(prefix="/backtest")
_store = None
def set_store_for_backtest(store) -> None:
global _store
_store = store
class BacktestRequest(BaseModel):
market: str
symbol: str
timeframe: str
since: int
until: int
initial_capital: float = 10000.0
trade_amount_usd: float = 100.0
commission_pct: float = 0.001
@router.post("/run")
async def run_backtest(
req: BacktestRequest,
_: str = Depends(verify_api_key),
):
if _store is None:
raise HTTPException(status_code=503, detail="Store not initialized")
if req.since >= req.until:
raise HTTPException(status_code=400, detail="since must be less than until")
runner = BacktestRunner(
store=_store,
calculator=IndicatorCalculator(),
detector=RegimeDetector(),
generator=SignalGenerator(),
initial_capital=req.initial_capital,
trade_amount_usd=req.trade_amount_usd,
commission_pct=req.commission_pct,
)
try:
result = await runner.run(
market=req.market,
symbol=req.symbol,
timeframe=req.timeframe,
since=req.since,
until=req.until,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return asdict(result)
+50
View File
@@ -0,0 +1,50 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from hydra.api.auth import verify_api_key
from hydra.data.storage.base import OhlcvStore
router = APIRouter(prefix="/data")
_store: Optional[OhlcvStore] = None
def set_store(store: OhlcvStore) -> None:
global _store
_store = store
@router.get("/candles")
async def get_candles(
market: str,
symbol: str,
timeframe: str,
limit: int = Query(default=200, ge=1, le=1000),
since: Optional[int] = None,
_: str = Depends(verify_api_key),
):
"""Return OHLCV candles ordered by open_time ASC."""
if _store is None:
raise HTTPException(status_code=503, detail="Store not initialized")
candles = await _store.query(market, symbol, timeframe, limit=limit, since=since)
return [
{
"market": c.market,
"symbol": c.symbol,
"timeframe": c.timeframe,
"open_time": c.open_time,
"open": c.open,
"high": c.high,
"low": c.low,
"close": c.close,
"volume": c.volume,
"close_time": c.close_time,
}
for c in candles
]
@router.get("/symbols")
async def get_symbols(_: str = Depends(verify_api_key)):
"""Return distinct {market, symbol, timeframe} records being collected."""
if _store is None:
raise HTTPException(status_code=503, detail="Store not initialized")
return await _store.get_symbols()
+30
View File
@@ -0,0 +1,30 @@
import time
from fastapi import APIRouter
router = APIRouter()
_START_TIME = time.time()
_redis = None
def set_redis(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/health")
async def health():
result = {
"status": "ok",
"uptime_seconds": int(time.time() - _START_TIME),
}
if _redis:
keys = _redis.keys("hydra:collector:*:status")
collectors = {}
for key in keys:
market = key.split(":")[2]
collectors[market] = _redis.get(key)
if collectors:
result["collectors"] = collectors
if any(v and v.startswith("error:") for v in collectors.values()):
result["status"] = "degraded"
return result
+56
View File
@@ -0,0 +1,56 @@
import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_indicators(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/indicators")
async def get_indicators(
market: str,
symbol: str,
timeframe: str,
_: str = Depends(verify_api_key),
):
"""Return the latest cached indicator values for a symbol."""
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:indicator:{market}:{symbol}:{timeframe}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No indicators cached for this symbol")
return {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"indicators": json.loads(raw),
}
@router.get("/indicators/list")
async def list_indicators(_: str = Depends(verify_api_key)):
"""Return all (market, symbol, timeframe) tuples that have cached indicators."""
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
keys = _redis.keys("hydra:indicator:*")
result = []
for key in keys:
parts = key.split(":")
# key format: hydra:indicator:{market}:{symbol}:{timeframe}
# symbol may contain ":" (e.g. BTC/USDT:USDT for futures)
# parts[0]="hydra", parts[1]="indicator", parts[2]=market,
# parts[3:-1]=symbol (joined), parts[-1]=timeframe
if len(parts) >= 5:
result.append({
"market": parts[2],
"symbol": ":".join(parts[3:-1]),
"timeframe": parts[-1],
})
return result
+27
View File
@@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_market_manager = None
def set_market_manager(mm) -> None:
global _market_manager
_market_manager = mm
@router.get("/markets")
async def get_markets(_: str = Depends(verify_api_key)):
return {"active": _market_manager.get_active_markets()}
@router.post("/markets/{market_id}/enable")
async def enable_market(market_id: str, _: str = Depends(verify_api_key)):
_market_manager.enable(market_id)
return {"status": "enabled", "market": market_id}
@router.post("/markets/{market_id}/disable")
async def disable_market(market_id: str, _: str = Depends(verify_api_key)):
_market_manager.disable(market_id)
return {"status": "disabled", "market": market_id}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
from hydra.core.order_queue import OrderRequest
router = APIRouter()
_order_queue = None
def set_order_queue(queue) -> None:
global _order_queue
_order_queue = queue
@router.post("/orders")
async def create_order(order: OrderRequest, _: str = Depends(verify_api_key)):
return await _order_queue.submit(order)
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_pnl_tracker = None
_position_tracker = None
def set_pnl_dependencies(pnl_tracker, position_tracker) -> None:
global _pnl_tracker, _position_tracker
_pnl_tracker = pnl_tracker
_position_tracker = position_tracker
@router.get("/pnl")
async def get_pnl(_: str = Depends(verify_api_key)):
"""전체 시스템 손익 현황."""
positions = _position_tracker.get_all()
return _pnl_tracker.get_summary(positions)
@router.post("/pnl/reset-daily")
async def reset_daily_pnl(_: str = Depends(verify_api_key)):
"""일일 손익 초기화."""
_pnl_tracker.reset_daily()
return {"status": "reset"}
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_position_tracker = None
def set_position_tracker(tracker) -> None:
global _position_tracker
_position_tracker = tracker
@router.get("/positions")
async def get_positions(_: str = Depends(verify_api_key)):
return {"positions": _position_tracker.get_all()}
+52
View File
@@ -0,0 +1,52 @@
# hydra/api/regime.py
import json
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_regime(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/regime")
async def get_regime(
market: str,
symbol: str,
timeframe: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:regime:{market}:{symbol}:{timeframe}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No regime cached for this symbol")
data = json.loads(raw)
return {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"regime": data["regime"],
"detected_at": data["detected_at"],
}
@router.get("/regime/list")
async def list_regimes(_: str = Depends(verify_api_key)):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
keys = _redis.keys("hydra:regime:*")
result = []
for key in keys:
parts = key.split(":")
if len(parts) >= 5:
result.append({
"market": parts[2],
"symbol": ":".join(parts[3:-1]),
"timeframe": parts[-1],
})
return result
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_kill_switch = None
_risk_engine = None
def set_dependencies(kill_switch, risk_engine) -> None:
global _kill_switch, _risk_engine
_kill_switch = kill_switch
_risk_engine = risk_engine
@router.get("/risk")
async def get_risk(_: str = Depends(verify_api_key)):
return {
"daily_pnl_pct": _risk_engine.get_daily_pnl_pct(),
"kill_switch_active": _kill_switch.is_active(),
}
@router.post("/killswitch")
async def killswitch(reason: str = "manual", _: str = Depends(verify_api_key)):
result = await _kill_switch.execute(reason=reason, source="api")
return {"success": result.success, "closed": result.closed_positions, "errors": result.errors}
+54
View File
@@ -0,0 +1,54 @@
# hydra/api/signals.py
import json
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_signals(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/signal")
async def get_signal(
market: str,
symbol: str,
timeframe: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:signal:{market}:{symbol}:{timeframe}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No signal cached for this symbol")
data = json.loads(raw)
return {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"signal": data["signal"],
"reason": data["reason"],
"price": data["price"],
"ts": data["ts"],
}
@router.get("/signal/list")
async def list_signals(_: str = Depends(verify_api_key)):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
keys = _redis.keys("hydra:signal:*")
result = []
for key in keys:
parts = key.split(":")
if len(parts) >= 5:
result.append({
"market": parts[2],
"symbol": ":".join(parts[3:-1]),
"timeframe": parts[-1],
})
return result
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
@router.get("/strategies")
async def list_strategies(_: str = Depends(verify_api_key)):
return {"strategies": [], "note": "Phase 1에서 구현 예정"}
@router.post("/strategies/{name}/start")
async def start_strategy(name: str, _: str = Depends(verify_api_key)):
return {"status": "not_implemented", "note": "Phase 1에서 구현 예정"}
+53
View File
@@ -0,0 +1,53 @@
# hydra/api/supplemental.py
import json
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_supplemental(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/orderbook")
async def get_orderbook(
market: str,
symbol: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:orderbook:{market}:{symbol}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No orderbook cached for this symbol")
data = json.loads(raw)
return {"market": market, "symbol": symbol, **data}
@router.get("/events")
async def get_events(_: str = Depends(verify_api_key)):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
raw = _redis.get("hydra:events:upcoming")
if raw is None:
return []
return json.loads(raw)
@router.get("/sentiment")
async def get_sentiment(
symbol: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:sentiment:{symbol}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No sentiment cached for this symbol")
data = json.loads(raw)
return {"symbol": symbol, **data}
+20
View File
@@ -0,0 +1,20 @@
import hydra
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
from hydra.config.settings import get_settings
router = APIRouter()
@router.get("/status")
async def status(_: str = Depends(verify_api_key)):
settings = get_settings()
return {
"version": hydra.__version__,
"profile": settings.hydra_profile,
}
@router.get("/modules")
async def modules(_: str = Depends(verify_api_key)):
return {"modules": ["core", "resilience", "exchange", "notify"]}
View File
+77
View File
@@ -0,0 +1,77 @@
# hydra/backtest/broker.py
from hydra.backtest.result import Trade
from hydra.data.models import Candle
from hydra.strategy.signal import Signal
class BacktestBroker:
def __init__(
self,
initial_capital: float,
trade_amount_usd: float,
commission_pct: float = 0.001,
):
self._capital = initial_capital
self._trade_amount = trade_amount_usd
self._commission = commission_pct
self._position: dict | None = None # {entry_price, qty, entry_ts, entry_reason}
self._trades: list[Trade] = []
self._equity_curve: list[dict] = []
def on_signal(self, signal: Signal, candle: Candle) -> None:
if signal.signal == "BUY" and self._position is None:
qty = self._trade_amount / candle.close
commission = self._trade_amount * self._commission
self._capital -= commission
self._position = {
"entry_price": candle.close,
"qty": qty,
"entry_ts": candle.open_time,
"entry_reason": signal.reason,
}
elif signal.signal == "SELL" and self._position is not None:
self.close_open_position(
price=candle.close,
ts=candle.open_time,
reason=signal.reason,
)
self._equity_curve.append({"ts": candle.open_time, "equity": self.equity})
def close_open_position(
self, price: float, ts: int, reason: str = "backtest_end"
) -> None:
if self._position is None:
return
pos = self._position
gross_pnl = (price - pos["entry_price"]) * pos["qty"]
commission = price * pos["qty"] * self._commission
net_pnl = gross_pnl - commission
pnl_pct = (price - pos["entry_price"]) / pos["entry_price"] * 100
self._capital += net_pnl
self._trades.append(Trade(
market="",
symbol="",
entry_price=pos["entry_price"],
exit_price=price,
qty=pos["qty"],
pnl_usd=round(net_pnl, 6),
pnl_pct=round(pnl_pct, 4),
entry_ts=pos["entry_ts"],
exit_ts=ts,
entry_reason=pos["entry_reason"],
exit_reason=reason,
))
self._position = None
self._equity_curve.append({"ts": ts, "equity": self.equity})
@property
def trades(self) -> list[Trade]:
return self._trades
@property
def equity_curve(self) -> list[dict]:
return self._equity_curve
@property
def equity(self) -> float:
return self._capital
+87
View File
@@ -0,0 +1,87 @@
# hydra/backtest/result.py
from dataclasses import dataclass, field
@dataclass
class Trade:
market: str
symbol: str
entry_price: float
exit_price: float
qty: float
pnl_usd: float
pnl_pct: float
entry_ts: int
exit_ts: int
entry_reason: str
exit_reason: str
@dataclass
class BacktestResult:
market: str
symbol: str
timeframe: str
since: int
until: int
initial_capital: float
final_equity: float
trades: list[Trade] = field(default_factory=list)
equity_curve: list[dict] = field(default_factory=list)
metrics: dict = field(default_factory=dict)
def compute_metrics(
trades: list[Trade],
equity_curve: list[dict],
initial_capital: float,
final_equity: float,
) -> dict:
total_return_pct = (final_equity - initial_capital) / initial_capital * 100
total_trades = len(trades)
if total_trades == 0:
return {
"total_return_pct": round(total_return_pct, 4),
"total_trades": 0,
"win_rate": 0.0,
"max_drawdown_pct": 0.0,
"sharpe_ratio": 0.0,
"avg_pnl_usd": 0.0,
}
wins = sum(1 for t in trades if t.pnl_usd > 0)
win_rate = wins / total_trades * 100
avg_pnl_usd = sum(t.pnl_usd for t in trades) / total_trades
# Max drawdown from equity curve
max_drawdown_pct = 0.0
if equity_curve:
peak = equity_curve[0]["equity"]
for point in equity_curve:
eq = point["equity"]
if eq > peak:
peak = eq
dd = (peak - eq) / peak * 100 if peak > 0 else 0.0
if dd > max_drawdown_pct:
max_drawdown_pct = dd
# Sharpe ratio (annualized, trade-based)
sharpe_ratio = 0.0
if total_trades >= 2:
import math
pnls = [t.pnl_usd for t in trades]
mean = sum(pnls) / len(pnls)
variance = sum((p - mean) ** 2 for p in pnls) / (len(pnls) - 1)
std = math.sqrt(variance)
if std > 0:
sharpe_ratio = round((mean / std) * math.sqrt(252), 4)
return {
"total_return_pct": round(total_return_pct, 4),
"total_trades": total_trades,
"win_rate": round(win_rate, 2),
"max_drawdown_pct": round(max_drawdown_pct, 4),
"sharpe_ratio": sharpe_ratio,
"avg_pnl_usd": round(avg_pnl_usd, 4),
}
+118
View File
@@ -0,0 +1,118 @@
# hydra/backtest/runner.py
from hydra.backtest.broker import BacktestBroker
from hydra.backtest.result import BacktestResult, compute_metrics
from hydra.data.storage.base import OhlcvStore
from hydra.indicator.calculator import IndicatorCalculator
from hydra.regime.detector import RegimeDetector
from hydra.strategy.signal import SignalGenerator
_WARMUP = 210 # IndicatorCalculator._MIN_CANDLES
class BacktestRunner:
def __init__(
self,
store: OhlcvStore,
calculator: IndicatorCalculator,
detector: RegimeDetector,
generator: SignalGenerator,
initial_capital: float = 10000.0,
trade_amount_usd: float = 100.0,
commission_pct: float = 0.001,
):
self._store = store
self._calculator = calculator
self._detector = detector
self._generator = generator
self._initial_capital = initial_capital
self._trade_amount = trade_amount_usd
self._commission = commission_pct
async def run(
self,
market: str,
symbol: str,
timeframe: str,
since: int,
until: int,
) -> BacktestResult:
if since >= until:
raise ValueError(f"since ({since}) must be less than until ({until})")
candles = await self._store.query(
market=market,
symbol=symbol,
timeframe=timeframe,
limit=100_000,
since=None,
)
# Filter to candles up to 'until'
candles = [c for c in candles if c.open_time <= until]
broker = BacktestBroker(
initial_capital=self._initial_capital,
trade_amount_usd=self._trade_amount,
commission_pct=self._commission,
)
# Find the first index where trading starts: open_time >= since AND index >= WARMUP
trading_start_idx = None
for i, c in enumerate(candles):
if c.open_time >= since and i >= _WARMUP:
trading_start_idx = i
break
if trading_start_idx is None:
return BacktestResult(
market=market,
symbol=symbol,
timeframe=timeframe,
since=since,
until=until,
initial_capital=self._initial_capital,
final_equity=self._initial_capital,
trades=[],
equity_curve=[],
metrics=compute_metrics([], [], self._initial_capital, self._initial_capital),
)
for i in range(trading_start_idx, len(candles)):
window = candles[i - _WARMUP + 1: i + 1]
indicators = self._calculator.compute(window)
if not indicators:
continue
candle = candles[i]
close = candle.close
indicators["close"] = close
regime = self._detector.detect(indicators, close)
signal = self._generator.generate(indicators, regime, close)
broker.on_signal(signal, candle)
# Close any open position at end of backtest
if candles:
last = candles[-1]
broker.close_open_position(
price=last.close, ts=last.open_time, reason="backtest_end"
)
trades = broker.trades
for t in trades:
t.market = market
t.symbol = symbol
equity_curve = broker.equity_curve
final_equity = broker.equity
metrics = compute_metrics(trades, equity_curve, self._initial_capital, final_equity)
return BacktestResult(
market=market,
symbol=symbol,
timeframe=timeframe,
since=since,
until=until,
initial_capital=self._initial_capital,
final_equity=round(final_equity, 6),
trades=trades,
equity_curve=equity_curve,
metrics=metrics,
)
View File
+16
View File
@@ -0,0 +1,16 @@
import typer
from hydra.cli import kill, status, trade, market, strategy, module
from hydra.cli import setup_wizard
app = typer.Typer(name="hydra", help="HYDRA 자동매매 시스템")
app.add_typer(kill.app, name="kill")
app.add_typer(status.app, name="status")
app.add_typer(trade.app, name="trade")
app.add_typer(market.app, name="market")
app.add_typer(strategy.app, name="strategy")
app.add_typer(module.app, name="module")
app.command("setup")(setup_wizard.run_setup)
if __name__ == "__main__":
app()
+30
View File
@@ -0,0 +1,30 @@
import asyncio
import typer
import httpx
from hydra.config.settings import get_settings
app = typer.Typer(help="Kill Switch - 전 포지션 즉시 청산")
@app.callback(invoke_without_command=True)
def kill(reason: str = typer.Option("cli_manual", help="청산 사유")):
"""전 포지션 즉시 시장가 청산."""
confirm = typer.confirm("[주의] 전 포지션을 청산합니다. 계속하시겠습니까?")
if not confirm:
typer.echo("취소됨.")
raise typer.Exit()
settings = get_settings()
try:
resp = httpx.post(
"http://127.0.0.1:8000/killswitch",
params={"reason": reason},
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
typer.echo(f"[완료] Kill Switch 실행. 청산: {len(data.get('closed', []))}")
except httpx.ConnectError:
typer.echo("[오류] HYDRA 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하세요.")
raise typer.Exit(1)
+31
View File
@@ -0,0 +1,31 @@
import typer
from hydra.config.markets import MarketManager
app = typer.Typer(help="시장 활성화/비활성화")
@app.command()
def enable(market: str, mode: str = typer.Option("paper", help="paper / live")):
"""시장 활성화."""
mm = MarketManager()
mm.enable(market, mode)
typer.echo(f"[완료] {market} 활성화 ({mode} 모드)")
@app.command()
def disable(market: str):
"""시장 비활성화."""
mm = MarketManager()
mm.disable(market)
typer.echo(f"[완료] {market} 비활성화")
@app.command()
def list_markets():
"""활성화된 시장 목록."""
mm = MarketManager()
active = mm.get_active_markets()
if active:
typer.echo("활성 시장: " + ", ".join(active))
else:
typer.echo("활성화된 시장 없음. 'hydra market enable <market>'로 활성화하세요.")
+24
View File
@@ -0,0 +1,24 @@
import typer
app = typer.Typer(help="AI 모듈 관리")
MODULES = ["regime_detection", "signal_scoring", "feature_selection", "adaptive_retrain",
"crash_detection", "dynamic_sizing", "sentiment"]
@app.command()
def enable(name: str):
if name not in MODULES:
typer.echo(f"[오류] 알 수 없는 모듈: {name}. 사용 가능: {MODULES}")
raise typer.Exit(1)
typer.echo(f"모듈 활성화: {name} - Phase 1에서 구현 예정")
@app.command()
def disable(name: str):
typer.echo(f"모듈 비활성화: {name} - Phase 1에서 구현 예정")
@app.command()
def list_modules():
typer.echo("AI 모듈 목록:\n" + "\n".join(f" - {m}" for m in MODULES))
+122
View File
@@ -0,0 +1,122 @@
import sys
import psutil
import typer
from pathlib import Path
from hydra.config.keys import KeyManager
from hydra.config.markets import MarketManager
from hydra.logging.setup import configure_logging
MARKETS = {
"kr": "한국 주식 (KIS)",
"us": "미국 주식 (KIS)",
"upbit": "업비트 (암호화폐)",
"binance": "바이낸스 (암호화폐)",
"hl": "Hyperliquid ([주의] 고위험)",
"poly": "Polymarket 예측시장 ([주의] 고위험)",
}
def detect_hardware() -> dict:
return {
"cpu_cores": psutil.cpu_count(logical=False),
"ram_gb": round(psutil.virtual_memory().total / 1024**3),
"disk_gb": round(psutil.disk_usage("/").total / 1024**3),
}
def recommend_profile(hw: dict) -> str:
if hw["ram_gb"] >= 32 and hw["cpu_cores"] >= 16:
return "expert"
elif hw["ram_gb"] >= 16 and hw["cpu_cores"] >= 8:
return "pro"
return "lite"
def run_setup():
"""7단계 HYDRA 초기 설정 위자드."""
configure_logging()
typer.echo("\nHYDRA 설정 위자드에 오신 것을 환영합니다.\n")
# Step 1: 하드웨어 감지
typer.echo("-- Step 1/7: 하드웨어 감지 --")
hw = detect_hardware()
typer.echo(f" CPU: {hw['cpu_cores']}코어 RAM: {hw['ram_gb']}GB Disk: {hw['disk_gb']}GB")
# Step 2: 프로필 추천
typer.echo("\n-- Step 2/7: 프로필 선택 --")
recommended = recommend_profile(hw)
typer.echo(f" 추천 프로필: {recommended.upper()}")
profile = typer.prompt(" 프로필 선택 [lite/pro/expert]", default=recommended)
if profile not in ("lite", "pro", "expert"):
typer.echo("[오류] 잘못된 프로필입니다. lite, pro, expert 중 선택하세요.")
raise typer.Exit(1)
# Step 3: AI 선택
typer.echo("\n-- Step 3/7: AI 모드 선택 --")
typer.echo(" [1] OFF (규칙 기반만) [2] 경량 CPU [3] GPU [4] 커스텀")
ai_choice = typer.prompt(" 선택", default="1")
ai_mode = {"1": "off", "2": "cpu", "3": "gpu", "4": "custom"}.get(ai_choice, "off")
# Step 4: 인터페이스
typer.echo("\n-- Step 4/7: 인터페이스 선택 --")
typer.echo(" [1] CLI+Telegram [2] Dashboard+Telegram [3] 전부 [4] Telegram만")
interface = typer.prompt(" 선택", default="1")
# Step 5: 면책조항 동의
typer.echo("\n-- Step 5/7: 면책조항 동의 --")
disclaimer = Path("DISCLAIMER.md").read_text(encoding="utf-8") if Path("DISCLAIMER.md").exists() else ""
typer.echo(disclaimer)
accepted = typer.confirm("\n위 면책조항에 동의하십니까?")
if not accepted:
typer.echo("면책조항에 동의하지 않으면 설치를 진행할 수 없습니다.")
sys.exit(1)
# Step 5b: 시장 선택 + API 키
typer.echo("\n-- 시장 선택 --")
selected_markets = []
mm = MarketManager()
km = KeyManager()
for market_id, label in MARKETS.items():
if typer.confirm(f" {label} 사용?", default=False):
selected_markets.append(market_id)
mode = typer.prompt(f" {label} 모드 [paper/live]", default="paper")
mm.enable(market_id, mode)
if market_id in ("kr", "us"):
app_key = typer.prompt(f" KIS App Key ({label})", hide_input=True)
secret = typer.prompt(f" KIS App Secret ({label})", hide_input=True)
km.store(f"kis_{market_id}", app_key, secret)
account_no = typer.prompt(" KIS 계좌번호 (예: 50123456-01)")
km.store("kis_account", account_no, "")
elif market_id in ("upbit", "binance", "hl"):
api_key = typer.prompt(f" {label} API Key", hide_input=True)
secret = typer.prompt(f" {label} Secret", hide_input=True)
km.store(market_id, api_key, secret)
# Step 6: 벤치마크
typer.echo("\n-- Step 6/7: 성능 벤치마크 실행 --")
typer.echo(" (잠시 기다려 주세요...)")
import subprocess
try:
subprocess.run(["python", "scripts/benchmark.py", "--profile", profile], timeout=30)
except Exception:
typer.echo(" 벤치마크 스킵 (scripts/benchmark.py 없음)")
# Step 7: 설정 저장
typer.echo("\n-- Step 7/7: 설정 완료 --")
env_content = f"""HYDRA_PROFILE={profile}
HYDRA_API_KEY={_generate_api_key()}
REDIS_URL=redis://localhost:6379
"""
Path(".env").write_text(env_content)
typer.echo("\n[완료] 설정이 저장되었습니다.")
typer.echo(f" 프로필: {profile.upper()} | AI: {ai_mode} | 시장: {', '.join(selected_markets) or '없음'}")
typer.echo(" hydra start 또는 docker compose -f docker-compose.lite.yml up 으로 시작하세요.")
def _generate_api_key() -> str:
import secrets
return secrets.token_urlsafe(32)
+60
View File
@@ -0,0 +1,60 @@
import typer
import httpx
from hydra.config.settings import get_settings
app = typer.Typer(help="시스템 상태 확인")
@app.callback(invoke_without_command=True)
def status():
"""HYDRA 상태 확인."""
settings = get_settings()
try:
h = httpx.get("http://127.0.0.1:8000/health", timeout=5)
s = httpx.get(
"http://127.0.0.1:8000/status",
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=5,
)
r = httpx.get(
"http://127.0.0.1:8000/risk",
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=5,
)
p = httpx.get(
"http://127.0.0.1:8000/pnl",
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=5,
)
typer.echo(f"[정상] 서버 상태 정상 | 프로필: {s.json()['profile']} | 가동시간: {h.json()['uptime_seconds']}")
risk = r.json()
ks = "ACTIVE" if risk["kill_switch_active"] else "NORMAL"
typer.echo(f"Kill Switch: {ks} | 일일 손익(리스크): {risk['daily_pnl_pct']*100:.2f}%")
pnl = p.json()
sign = lambda v: "+" if v >= 0 else ""
typer.echo(
f"\n=== 손익 현황 ===\n"
f" 실현 손익 (누적): {sign(pnl['realized_total'])}{pnl['realized_total']:,.4f} USDT\n"
f" 실현 손익 (오늘): {sign(pnl['daily_realized'])}{pnl['daily_realized']:,.4f} USDT\n"
f" 미실현 손익: {sign(pnl['unrealized'])}{pnl['unrealized']:,.4f} USDT\n"
f" 총 손익: {sign(pnl['total_pnl'])}{pnl['total_pnl']:,.4f} USDT\n"
f" 체결 거래 수: {pnl['trade_count']}"
)
if pnl["positions"]:
typer.echo("\n -- 오픈 포지션 --")
for pos in pnl["positions"]:
lev = f" {pos['leverage']}x" if pos.get("leverage", 1) > 1 else ""
upnl = pos.get("unrealized_pnl", 0)
typer.echo(
f" [{pos['market']}] {pos['symbol']} {pos['side'].upper()}{lev} "
f" 수량: {pos['qty']} 평균단가: {pos['avg_price']} "
f"미실현: {sign(upnl)}{upnl:,.4f}"
)
else:
typer.echo("\n 오픈 포지션 없음")
except httpx.ConnectError:
typer.echo("[오류] 서버 오프라인")
+18
View File
@@ -0,0 +1,18 @@
import typer
app = typer.Typer(help="전략 관리 (Phase 2에서 구현)")
@app.command()
def list_strategies():
typer.echo("전략 목록 - Phase 2에서 구현 예정")
@app.command()
def start(name: str):
typer.echo(f"전략 시작: {name} - Phase 2에서 구현 예정")
@app.command()
def stop(name: str):
typer.echo(f"전략 중지: {name} - Phase 2에서 구현 예정")
+40
View File
@@ -0,0 +1,40 @@
import typer
from typing import Optional
app = typer.Typer(help="수동 매매 명령 (Phase 1에서 전략 연동)")
@app.command()
def kr(symbol: str, side: str, qty: float):
"""한국 주식 수동 주문."""
typer.echo(f"[한국주식] {side} {symbol} {qty}주 - Phase 1에서 구현 예정")
@app.command()
def us(symbol: str, side: str, qty: float):
"""미국 주식 수동 주문."""
typer.echo(f"[미국주식] {side} {symbol} {qty}주 - Phase 1에서 구현 예정")
@app.command()
def crypto(
exchange: str,
symbol: str,
side: str,
qty: float,
leverage: int = typer.Option(1, "--leverage", "-l", help="선물 레버리지 배수 (1~125x). 현물은 1로 고정.", min=1, max=125),
futures: bool = typer.Option(False, "--futures", help="선물 주문 여부"),
):
"""암호화폐 수동 주문. 선물은 --futures --leverage N 으로 레버리지 지정."""
if futures and leverage > 1:
typer.echo(f"[{exchange}] {side} {symbol} {qty} x {leverage} 레버리지 (선물) - Phase 1에서 구현 예정")
elif futures:
typer.echo(f"[{exchange}] {side} {symbol} {qty} (선물) - Phase 1에서 구현 예정")
else:
typer.echo(f"[{exchange}] {side} {symbol} {qty} (현물) - Phase 1에서 구현 예정")
@app.command()
def poly(market_id: str, side: str, amount: float):
"""Polymarket 예측시장 주문."""
typer.echo(f"[Polymarket] {side} {market_id} ${amount} - Phase 1에서 구현 예정")
View File
+52
View File
@@ -0,0 +1,52 @@
import json
import os
from pathlib import Path
from cryptography.fernet import Fernet
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class KeyManager:
def __init__(self, master_key_path: str = "~/.hydra/master.key"):
self._master_path = Path(master_key_path).expanduser()
self._key_dir = self._master_path.parent
self._fernet = self._load_or_create_fernet()
def _load_or_create_fernet(self) -> Fernet:
self._key_dir.mkdir(parents=True, exist_ok=True)
if self._master_path.exists():
raw = self._master_path.read_bytes()
else:
raw = Fernet.generate_key()
self._master_path.write_bytes(raw)
self._master_path.chmod(0o600)
logger.info("master_key_created", path=str(self._master_path))
return Fernet(raw)
def encrypt(self, plain: str) -> bytes:
return self._fernet.encrypt(plain.encode())
def decrypt(self, token: bytes) -> str:
return self._fernet.decrypt(token).decode()
def store(self, exchange: str, api_key: str, secret: str) -> None:
payload = json.dumps({"api_key": api_key, "secret": secret})
encrypted = self.encrypt(payload)
out = self._key_dir / f"{exchange}.enc"
out.write_bytes(encrypted)
out.chmod(0o600)
logger.info("key_stored", exchange=exchange)
def load(self, exchange: str) -> tuple[str, str]:
path = self._key_dir / f"{exchange}.enc"
if not path.exists():
raise FileNotFoundError(f"No stored key for exchange '{exchange}'")
payload = json.loads(self.decrypt(path.read_bytes()))
return payload["api_key"], payload["secret"]
def check_withdrawal_permission(self, exchange: str) -> bool:
"""Placeholder — subclasses override for exchange-specific checks."""
return False
+52
View File
@@ -0,0 +1,52 @@
from pathlib import Path
from typing import Optional
import yaml
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
VALID_MARKETS = {"kr", "us", "upbit", "binance", "hl", "poly"}
class MarketManager:
def __init__(self, config_path: str = "config/markets.yaml"):
self._path = Path(config_path)
self._data: dict = self._load()
def _load(self) -> dict:
if not self._path.exists():
return {"markets": {m: {"enabled": False, "mode": "paper"} for m in VALID_MARKETS}}
with self._path.open() as f:
return yaml.safe_load(f) or {"markets": {}}
def _save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._path.open("w") as f:
yaml.dump(self._data, f, allow_unicode=True)
def get_active_markets(self) -> list[str]:
return [k for k, v in self._data.get("markets", {}).items() if v.get("enabled")]
def is_active(self, market_id: str) -> bool:
return self._data.get("markets", {}).get(market_id, {}).get("enabled", False)
def enable(self, market_id: str, mode: str = "paper") -> None:
if market_id not in VALID_MARKETS:
raise ValueError(f"Unknown market '{market_id}'. Valid: {VALID_MARKETS}")
markets = self._data.setdefault("markets", {})
markets.setdefault(market_id, {})["enabled"] = True
markets[market_id]["mode"] = mode
self._save()
logger.info("market_enabled", market=market_id, mode=mode)
def disable(self, market_id: str) -> None:
markets = self._data.get("markets", {})
if market_id in markets:
markets[market_id]["enabled"] = False
self._save()
logger.info("market_disabled", market=market_id)
def get_mode(self, market_id: str) -> str:
return self._data.get("markets", {}).get(market_id, {}).get("mode", "paper")
+49
View File
@@ -0,0 +1,49 @@
from dataclasses import dataclass
@dataclass
class ProfileLimits:
name: str
core_mem_gb: int
redis_mem_gb: int
db_mem_gb: int
cpus: int
ai_enabled: bool
db_backend: str # "sqlite" or "timescaledb"
PROFILES: dict[str, ProfileLimits] = {
"lite": ProfileLimits(
name="lite",
core_mem_gb=2,
redis_mem_gb=1,
db_mem_gb=0,
cpus=2,
ai_enabled=False,
db_backend="sqlite",
),
"pro": ProfileLimits(
name="pro",
core_mem_gb=4,
redis_mem_gb=2,
db_mem_gb=4,
cpus=4,
ai_enabled=True,
db_backend="timescaledb",
),
"expert": ProfileLimits(
name="expert",
core_mem_gb=8,
redis_mem_gb=4,
db_mem_gb=8,
cpus=8,
ai_enabled=True,
db_backend="timescaledb",
),
}
def get_profile(name: str) -> ProfileLimits:
if name not in PROFILES:
raise ValueError(f"Unknown profile '{name}'. Choose: {list(PROFILES)}")
return PROFILES[name]
+19
View File
@@ -0,0 +1,19 @@
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
hydra_api_key: str = "change-me"
hydra_profile: str = "lite"
redis_url: str = "redis://localhost:6379"
db_url: str = "sqlite:///data/hydra.db"
telegram_bot_token: str = ""
telegram_chat_id: str = ""
log_level: str = "INFO"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@lru_cache
def get_settings() -> Settings:
return Settings()
+33
View File
@@ -0,0 +1,33 @@
from pydantic import BaseModel, field_validator
class StrategyConfig(BaseModel):
stop_loss_pct: float = 0.02
take_profit_pct: float = 0.05
position_size_pct: float = 0.10
max_positions: int = 5
@field_validator("stop_loss_pct")
@classmethod
def validate_stop_loss(cls, v: float) -> float:
if v <= 0:
raise ValueError("손절은 양수여야 합니다")
if v > 0.20:
raise ValueError(f"손절 {v * 100:.1f}%는 너무 큽니다 (최대 20%)")
return v
@field_validator("position_size_pct")
@classmethod
def validate_position_size(cls, v: float) -> float:
if v > 0.50:
raise ValueError(f"포지션 사이즈 {v * 100:.1f}%는 너무 큽니다 (최대 50%)")
return v
class RiskConfig(BaseModel):
daily_loss_limit_pct: float = 0.03
daily_loss_kill_pct: float = 0.05
max_position_per_symbol_pct: float = 0.20
max_position_per_strategy_pct: float = 0.30
max_position_per_market_pct: float = 0.50
consecutive_loss_limit: int = 5
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()}
View File
+22
View File
@@ -0,0 +1,22 @@
from abc import ABC, abstractmethod
class BaseExchange(ABC):
@abstractmethod
async def get_balance(self) -> dict: ...
@abstractmethod
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict: ...
@abstractmethod
async def cancel_order(self, order_id: str) -> dict: ...
@abstractmethod
async def cancel_all(self) -> list: ...
@abstractmethod
async def get_positions(self) -> list: ...
async def set_leverage(self, symbol: str, leverage: int) -> None:
"""레버리지 설정. 현물 거래소는 no-op (override 불필요)."""
pass
+61
View File
@@ -0,0 +1,61 @@
import asyncio
import json
import subprocess
from pybreaker import CircuitBreaker
from hydra.exchange.base import BaseExchange
from hydra.resilience.circuit_breaker import create_breaker
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
VALID_LEVERAGE = range(1, 126) # 1x ~ 125x
class CryptoExchange(BaseExchange):
"""ccxt CLI를 subprocess로 호출. 서킷 브레이커 적용."""
def __init__(self, exchange_id: str, breaker: CircuitBreaker | None = None, is_futures: bool = False):
self.exchange_id = exchange_id
self.is_futures = is_futures
self._breaker = breaker or create_breaker(f"crypto:{exchange_id}")
async def _run(self, args: list[str]) -> dict:
cmd = ["ccxt", self.exchange_id] + args + ["--json"]
loop = asyncio.get_event_loop()
raw = await loop.run_in_executor(
None,
lambda: self._breaker.call(subprocess.check_output, cmd, text=True, timeout=15),
)
return json.loads(raw)
async def get_balance(self) -> dict:
return await self._run(["fetchBalance"])
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict:
args = ["createOrder", symbol, order_type, side, str(qty)]
if price:
args.append(str(price))
return await self._run(args)
async def cancel_order(self, order_id: str) -> dict:
return await self._run(["cancelOrder", order_id])
async def cancel_all(self) -> list:
result = await self._run(["cancelAllOrders"])
return result if isinstance(result, list) else []
async def get_positions(self) -> list:
result = await self._run(["fetchPositions"])
return result if isinstance(result, list) else []
async def set_leverage(self, symbol: str, leverage: int) -> None:
"""선물 레버리지 설정. 현물 모드에서는 no-op."""
if not self.is_futures:
logger.debug("set_leverage_skipped_spot", exchange=self.exchange_id, symbol=symbol)
return
if leverage not in VALID_LEVERAGE:
raise ValueError(f"레버리지는 1~125 사이여야 합니다. 입력값: {leverage}")
await self._run(["setLeverage", str(leverage), symbol])
logger.info("leverage_set", exchange=self.exchange_id, symbol=symbol, leverage=leverage)
+41
View File
@@ -0,0 +1,41 @@
from hydra.config.markets import MarketManager
from hydra.config.keys import KeyManager
from hydra.exchange.base import BaseExchange
from hydra.exchange.crypto import CryptoExchange
from hydra.exchange.kis import KISExchange
from hydra.exchange.polymarket import PolymarketExchange
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
def create_exchanges(market_manager: MarketManager, key_manager: KeyManager) -> dict[str, BaseExchange]:
"""활성화된 시장의 거래소 커넥터만 생성."""
exchanges: dict[str, BaseExchange] = {}
active = market_manager.get_active_markets()
for market in active:
mode = market_manager.get_mode(market)
is_paper = mode == "paper"
try:
if market == "kr":
app_key, secret = key_manager.load("kis_kr")
account_no, _ = key_manager.load("kis_account")
exchanges["kr"] = KISExchange(app_key, secret, account_no, is_paper=is_paper)
elif market == "us":
app_key, secret = key_manager.load("kis_us")
account_no, _ = key_manager.load("kis_account")
exchanges["us"] = KISExchange(app_key, secret, account_no, is_paper=is_paper)
elif market == "upbit":
exchanges["upbit"] = CryptoExchange("upbit")
elif market == "binance":
exchanges["binance"] = CryptoExchange("binance")
elif market == "hl":
exchanges["hl"] = CryptoExchange("hyperliquid")
elif market == "poly":
exchanges["poly"] = PolymarketExchange()
logger.info("exchange_created", market=market, mode=mode)
except Exception as e:
logger.error("exchange_create_failed", market=market, error=str(e))
return exchanges
+63
View File
@@ -0,0 +1,63 @@
from hydra.exchange.base import BaseExchange
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class KISExchange(BaseExchange):
"""python-kis 래핑. 한국/미국 주식."""
def __init__(self, app_key: str, app_secret: str, account_no: str, is_paper: bool = True):
self._app_key = app_key
self._app_secret = app_secret
self._account_no = account_no
self._is_paper = is_paper
self._client = None
def _get_client(self):
if self._client is None:
import pykis
self._client = pykis.PyKis(
id=self._app_key,
account=self._account_no,
appkey=self._app_key,
appsecret=self._app_secret,
virtual_account=self._is_paper,
)
return self._client
async def get_balance(self) -> dict:
client = self._get_client()
account = client.account()
return {"balance": float(account.balance)}
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict:
client = self._get_client()
stock = client.stock(symbol)
if side == "buy":
order = stock.buy(qty=int(qty), price=price)
else:
order = stock.sell(qty=int(qty), price=price)
return {"order_id": str(order.number), "status": "submitted"}
async def cancel_order(self, order_id: str) -> dict:
client = self._get_client()
client.cancel_order(order_id)
return {"status": "canceled"}
async def cancel_all(self) -> list:
client = self._get_client()
orders = client.pending_orders()
canceled = []
for o in orders:
o.cancel()
canceled.append(str(o.number))
return canceled
async def get_positions(self) -> list:
client = self._get_client()
account = client.account()
return [
{"symbol": s.symbol, "qty": s.qty, "avg_price": float(s.purchase_price), "side": "buy"}
for s in account.stocks
]
+41
View File
@@ -0,0 +1,41 @@
import asyncio
import json
import subprocess
from hydra.exchange.base import BaseExchange
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class PolymarketExchange(BaseExchange):
"""polymarket-cli subprocess 래핑."""
async def _run(self, args: list[str]) -> dict:
cmd = ["polymarket"] + args + ["--json"]
loop = asyncio.get_event_loop()
raw = await loop.run_in_executor(
None,
lambda: subprocess.check_output(cmd, text=True, timeout=15),
)
return json.loads(raw)
async def get_balance(self) -> dict:
return await self._run(["balance"])
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict:
args = ["order", "create", "--market", symbol, "--side", side, "--amount", str(qty)]
if price:
args += ["--price", str(price)]
return await self._run(args)
async def cancel_order(self, order_id: str) -> dict:
return await self._run(["order", "cancel", "--id", order_id])
async def cancel_all(self) -> list:
result = await self._run(["order", "cancel-all"])
return result if isinstance(result, list) else []
async def get_positions(self) -> list:
result = await self._run(["positions"])
return result if isinstance(result, list) else []
View File
+82
View File
@@ -0,0 +1,82 @@
import time
import pandas as pd
import pandas_ta as ta
from hydra.data.models import Candle
_MIN_CANDLES = 210 # EMA_200 requires at least 200 candles; 210 gives buffer
# Common technical indicators used for trading signals.
# Uses ta.Study (the current pandas-ta API) instead of the deprecated
# ta.Strategy("All") which is not available in newer pandas-ta versions.
_DEFAULT_STUDY = ta.Study(
name="hydra",
ta=[
{"kind": "rsi", "length": 14},
{"kind": "ema", "length": 9},
{"kind": "ema", "length": 20},
{"kind": "ema", "length": 50},
{"kind": "ema", "length": 200},
{"kind": "sma", "length": 20},
{"kind": "sma", "length": 50},
{"kind": "macd"},
{"kind": "bbands"},
{"kind": "atr"},
{"kind": "adx"},
{"kind": "stoch"},
{"kind": "stochrsi"},
{"kind": "cci"},
{"kind": "willr"},
{"kind": "obv"},
{"kind": "mfi"},
{"kind": "mom"},
{"kind": "roc"},
{"kind": "tsi"},
{"kind": "vwap"},
{"kind": "supertrend"},
{"kind": "kc"},
{"kind": "donchian"},
{"kind": "aroon"},
{"kind": "ao"},
{"kind": "er"},
],
)
class IndicatorCalculator:
"""Compute technical indicators for a candle list using pandas-ta."""
def compute(self, candles: list[Candle]) -> dict:
"""
Run a comprehensive set of pandas-ta indicators on candles.
Returns {} if fewer than _MIN_CANDLES candles provided.
NaN values are converted to None.
"""
if len(candles) < _MIN_CANDLES:
return {}
df = pd.DataFrame([
{
"open": c.open, "high": c.high,
"low": c.low, "close": c.close,
"volume": c.volume,
}
for c in candles
])
# cores=0 disables multiprocessing (avoids overhead for small DataFrames)
df.ta.study(_DEFAULT_STUDY, cores=0)
last = df.iloc[-1].to_dict()
result: dict = {}
for key, val in last.items():
if key in ("open", "high", "low", "close", "volume"):
continue
if isinstance(val, float) and pd.isna(val):
result[key] = None
elif hasattr(val, "item"): # numpy scalar → Python native
result[key] = val.item()
else:
result[key] = val
result["calculated_at"] = int(time.time() * 1000)
return result
+85
View File
@@ -0,0 +1,85 @@
import asyncio
import json
from hydra.data.storage.base import OhlcvStore
from hydra.indicator.calculator import IndicatorCalculator
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_CHANNEL = "hydra:candle:new"
_KEY_PREFIX = "hydra:indicator"
class IndicatorEngine:
def __init__(
self,
store: OhlcvStore,
redis_client,
calculator: IndicatorCalculator,
):
self._store = store
self._redis = redis_client
self._calculator = calculator
async def _handle_event(
self, market: str, symbol: str, timeframe: str
) -> None:
"""Compute indicators for one (market, symbol, timeframe) and cache in Redis."""
try:
candles = await self._store.query(market, symbol, timeframe, limit=250)
result = self._calculator.compute(candles)
if not result:
return
key = f"{_KEY_PREFIX}:{market}:{symbol}:{timeframe}"
await self._redis.set(key, json.dumps(result))
logger.debug("indicator_cached", market=market, symbol=symbol, tf=timeframe)
except Exception as e:
logger.warning(
"indicator_error",
market=market, symbol=symbol, tf=timeframe, error=str(e),
)
async def cold_start(self) -> None:
"""On startup, compute indicators for all symbols already in the DB."""
symbols = await self._store.get_symbols()
logger.info("indicator_cold_start", count=len(symbols))
for row in symbols:
await self._handle_event(row["market"], row["symbol"], row["timeframe"])
async def run(self) -> None:
"""Subscribe to hydra:candle:new and process events."""
pubsub = self._redis.pubsub()
await pubsub.subscribe(_CHANNEL)
logger.info("indicator_engine_subscribed", channel=_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
payload = json.loads(message["data"])
await self._handle_event(
payload["market"], payload["symbol"], payload["timeframe"]
)
except Exception as e:
logger.warning("indicator_subscribe_error", error=str(e))
async def main() -> None:
import redis.asyncio as aioredis
from hydra.data.storage import create_store
from hydra.config.settings import get_settings
settings = get_settings()
store = create_store()
await store.init()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
calculator = IndicatorCalculator()
engine = IndicatorEngine(store=store, redis_client=r, calculator=calculator)
try:
await engine.cold_start()
await engine.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())
View File
+34
View File
@@ -0,0 +1,34 @@
import logging
import structlog
def _mask_secrets(_, __, event_dict: dict) -> dict:
"""API 키 등 민감 정보를 로그에서 마스킹"""
sensitive = ("api_key", "secret", "token", "password", "key")
for k in list(event_dict.keys()):
if any(s in k.lower() for s in sensitive):
event_dict[k] = "***MASKED***"
return event_dict
def configure_logging(level: str = "INFO") -> None:
"""structlog JSON 로깅 설정. 애플리케이션 시작 시 1회 호출."""
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
_mask_secrets,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, level.upper(), logging.INFO)
),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
)
def get_logger(name: str):
return structlog.get_logger(name)
+132
View File
@@ -0,0 +1,132 @@
import asyncio
import redis as redis_lib
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from hydra.config.settings import get_settings
from hydra.config.markets import MarketManager
from hydra.config.keys import KeyManager
from hydra.core.kill_switch import KillSwitch
from hydra.core.order_queue import OrderQueue
from hydra.core.position_tracker import PositionTracker
from hydra.core.pnl_tracker import PnlTracker
from hydra.core.risk_engine import RiskEngine
from hydra.core.state_manager import StateManager
from hydra.exchange.factory import create_exchanges
from hydra.logging.setup import configure_logging, get_logger
from hydra.notify.telegram import TelegramNotifier
from hydra.resilience.graceful import GracefulManager
from hydra.api import health, orders, positions, risk, markets, system, strategies, pnl as pnl_api
from hydra.api.health import set_redis
from hydra.api.orders import set_order_queue
from hydra.api.positions import set_position_tracker
from hydra.api.risk import set_dependencies as set_risk_deps
from hydra.api.markets import set_market_manager
from hydra.api.pnl import set_pnl_dependencies
from hydra.api import data as data_api
from hydra.api.data import set_store
from hydra.api import indicators as indicators_api
from hydra.api.indicators import set_redis_for_indicators
from hydra.api import regime as regime_api
from hydra.api.regime import set_redis_for_regime
from hydra.api import signals as signals_api
from hydra.api.signals import set_redis_for_signals
from hydra.api import supplemental as supplemental_api
from hydra.api.supplemental import set_redis_for_supplemental
from hydra.api import backtest as backtest_api
from hydra.api.backtest import set_store_for_backtest
from hydra.data.storage import create_store
logger = get_logger(__name__)
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
configure_logging(settings.log_level)
logger.info("hydra_starting", profile=settings.hydra_profile)
r = redis_lib.Redis.from_url(settings.redis_url, decode_responses=True)
set_redis(r)
market_manager = MarketManager()
key_manager = KeyManager()
telegram = TelegramNotifier(settings.telegram_bot_token, settings.telegram_chat_id)
position_tracker = PositionTracker(r)
state_manager = StateManager(r)
risk_engine = RiskEngine(r, position_tracker)
pnl_tracker = PnlTracker(r)
ohlcv_store = create_store()
await ohlcv_store.init()
exchanges = create_exchanges(market_manager, key_manager)
kill_switch = KillSwitch(
exchanges=exchanges,
position_tracker=position_tracker,
telegram=telegram,
redis_client=r,
)
order_queue = OrderQueue(
redis_client=r,
risk_engine=risk_engine,
position_tracker=position_tracker,
exchanges=exchanges,
)
graceful = GracefulManager(order_queue, position_tracker, r)
graceful.register_signals()
set_order_queue(order_queue)
set_position_tracker(position_tracker)
set_risk_deps(kill_switch, risk_engine)
set_market_manager(market_manager)
set_pnl_dependencies(pnl_tracker, position_tracker)
set_store(ohlcv_store)
set_redis_for_indicators(r)
set_redis_for_regime(r)
set_redis_for_signals(r)
set_redis_for_supplemental(r)
set_store_for_backtest(ohlcv_store)
logger.info("hydra_started")
try:
yield
finally:
logger.info("hydra_stopping")
await ohlcv_store.close()
def create_app() -> FastAPI:
app = FastAPI(title="HYDRA", version="0.1.0", docs_url=None, redoc_url=None, lifespan=lifespan)
@app.middleware("http")
async def auth_guard(request: Request, call_next):
if request.url.path == "/health":
return await call_next(request)
return await call_next(request)
app.include_router(health.router)
app.include_router(orders.router)
app.include_router(positions.router)
app.include_router(risk.router)
app.include_router(markets.router)
app.include_router(system.router)
app.include_router(strategies.router)
app.include_router(pnl_api.router)
app.include_router(data_api.router)
app.include_router(indicators_api.router)
app.include_router(regime_api.router)
app.include_router(signals_api.router)
app.include_router(supplemental_api.router)
app.include_router(backtest_api.router)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run("hydra.main:app", host="127.0.0.1", port=8000, reload=False)
View File
+42
View File
@@ -0,0 +1,42 @@
import asyncio
from telegram import Bot
from telegram.error import TelegramError
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class TelegramNotifier:
def __init__(self, token: str, chat_id: str):
self._token = token
self._chat_id = chat_id
self._bot: Bot | None = None
def _get_bot(self) -> Bot:
if self._bot is None:
self._bot = Bot(token=self._token)
return self._bot
async def send_message(self, text: str) -> None:
if not self._token or not self._chat_id:
logger.warning("telegram_not_configured")
return
try:
await self._get_bot().send_message(chat_id=self._chat_id, text=text)
except TelegramError as e:
logger.error("telegram_send_error", error=str(e))
async def run_bot(self, kill_switch_callback) -> None:
"""Telegram 명령어 수신 루프 (별도 프로세스에서 실행)."""
from telegram.ext import ApplicationBuilder, CommandHandler
app = ApplicationBuilder().token(self._token).build()
async def handle_killswitch(update, context):
await update.message.reply_text("⚠️ Kill Switch 발동 중...")
await kill_switch_callback(reason="telegram_command", source="telegram")
await update.message.reply_text("✅ 전 포지션 청산 완료")
app.add_handler(CommandHandler("killswitch", handle_killswitch))
await app.run_polling()
View File
+17
View File
@@ -0,0 +1,17 @@
# hydra/regime/detector.py
_VOLATILE_BBB_THRESHOLD = 0.08
_TRENDING_ADX_THRESHOLD = 25.0
class RegimeDetector:
def detect(self, indicators: dict, close: float) -> str:
bbb = indicators.get("BBB_5_2.0_2.0")
adx = indicators.get("ADX_14")
ema50 = indicators.get("EMA_50")
if bbb is not None and bbb > _VOLATILE_BBB_THRESHOLD:
return "volatile"
if adx is not None and adx > _TRENDING_ADX_THRESHOLD:
if ema50 is not None:
return "trending_up" if close > ema50 else "trending_down"
return "ranging"
+85
View File
@@ -0,0 +1,85 @@
# hydra/regime/engine.py
import asyncio
import json
import time
from hydra.regime.detector import RegimeDetector
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_CANDLE_CHANNEL = "hydra:candle:new"
_INDICATOR_PREFIX = "hydra:indicator"
_REGIME_PREFIX = "hydra:regime"
class RegimeEngine:
def __init__(self, redis_client, detector: RegimeDetector):
self._redis = redis_client
self._detector = detector
async def _handle_event(
self, market: str, symbol: str, timeframe: str
) -> None:
try:
indicator_key = f"{_INDICATOR_PREFIX}:{market}:{symbol}:{timeframe}"
raw = self._redis.get(indicator_key)
if raw is None:
return
indicators = json.loads(raw)
close = float(indicators.get("close") or 0.0)
regime = self._detector.detect(indicators, close)
regime_key = f"{_REGIME_PREFIX}:{market}:{symbol}:{timeframe}"
await self._redis.set(regime_key, json.dumps({
"regime": regime,
"detected_at": int(time.time() * 1000),
}))
logger.debug("regime_cached", market=market, symbol=symbol,
tf=timeframe, regime=regime)
except Exception as e:
logger.warning("regime_error", market=market, symbol=symbol,
tf=timeframe, error=str(e))
async def cold_start(self) -> None:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
logger.info("regime_cold_start", count=len(keys))
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
market = parts[2]
symbol = ":".join(parts[3:-1])
timeframe = parts[-1]
await self._handle_event(market, symbol, timeframe)
async def run(self) -> None:
pubsub = self._redis.pubsub()
await pubsub.subscribe(_CANDLE_CHANNEL)
logger.info("regime_engine_subscribed", channel=_CANDLE_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
payload = json.loads(message["data"])
await self._handle_event(
payload["market"], payload["symbol"], payload["timeframe"],
)
except Exception as e:
logger.warning("regime_subscribe_error", error=str(e))
async def main() -> None:
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
detector = RegimeDetector()
engine = RegimeEngine(redis_client=r, detector=detector)
try:
await engine.cold_start()
await engine.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())
View File
+6
View File
@@ -0,0 +1,6 @@
from pybreaker import CircuitBreaker
def create_breaker(name: str, fail_max: int = 5, reset_timeout: int = 30) -> CircuitBreaker:
"""거래소/API별 독립 서킷 브레이커 생성."""
return CircuitBreaker(fail_max=fail_max, reset_timeout=reset_timeout, name=name)
+35
View File
@@ -0,0 +1,35 @@
import asyncio
import signal
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_SHUTDOWN_TIMEOUT = 30
class GracefulManager:
def __init__(self, order_queue, position_tracker, redis_client):
self._order_queue = order_queue
self._positions = position_tracker
self._redis = redis_client
self._shutting_down = False
def register_signals(self) -> None:
"""메인 이벤트 루프에서 호출. SIGTERM/SIGINT 핸들러 등록."""
loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self.shutdown(s.name)))
async def shutdown(self, reason: str = "unknown") -> None:
if self._shutting_down:
return
self._shutting_down = True
logger.info("graceful_shutdown_start", reason=reason)
try:
async with asyncio.timeout(_SHUTDOWN_TIMEOUT):
self._order_queue.block_new_orders()
snapshot = await self._positions.snapshot()
self._redis.set("hydra:last_snapshot", str(snapshot))
logger.info("graceful_shutdown_complete")
except TimeoutError:
logger.error("graceful_shutdown_timeout", seconds=_SHUTDOWN_TIMEOUT)
+28
View File
@@ -0,0 +1,28 @@
import asyncio
import time
class TokenBucketRateLimiter:
"""거래소별 API 호출 제한. 우선순위: 0=주문, 1=시세, 2=조회."""
def __init__(self, rate: float, capacity: int):
self.rate = rate # tokens/second
self.capacity = capacity
self._tokens = float(capacity)
self._last_refill = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self, priority: int = 2, tokens: int = 1) -> None:
async with self._lock:
self._refill()
while self._tokens < tokens:
wait = (tokens - self._tokens) / self.rate
await asyncio.sleep(wait)
self._refill()
self._tokens -= tokens
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
self._last_refill = now
+11
View File
@@ -0,0 +1,11 @@
from functools import wraps
from tenacity import retry, stop_after_attempt, wait_exponential_jitter
def with_retry(func):
"""지수 백오프 + 지터 재시도 데코레이터 (최대 3회)."""
return retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=1, max=10, jitter=2),
reraise=True,
)(func)
View File
+155
View File
@@ -0,0 +1,155 @@
# hydra/strategy/engine.py
import asyncio
import json
import os
from hydra.strategy.signal import Signal, SignalGenerator
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_CANDLE_CHANNEL = "hydra:candle:new"
_INDICATOR_PREFIX = "hydra:indicator"
_REGIME_PREFIX = "hydra:regime"
_SIGNAL_PREFIX = "hydra:signal"
class StrategyEngine:
def __init__(
self,
redis_client,
generator: SignalGenerator,
dry_run: bool = True,
order_queue=None,
risk_engine=None,
trade_amount_usd: float = 100.0,
):
self._redis = redis_client
self._generator = generator
self._dry_run = dry_run
self._order_queue = order_queue
self._risk_engine = risk_engine
self._trade_amount_usd = trade_amount_usd
async def _handle_event(
self, market: str, symbol: str, timeframe: str
) -> None:
try:
raw_indicator = self._redis.get(
f"{_INDICATOR_PREFIX}:{market}:{symbol}:{timeframe}"
)
if raw_indicator is None:
return
indicators = json.loads(raw_indicator)
raw_regime = self._redis.get(
f"{_REGIME_PREFIX}:{market}:{symbol}:{timeframe}"
)
if raw_regime is None:
return
regime = json.loads(raw_regime).get("regime", "ranging")
close = float(indicators.get("close") or 0.0)
signal = self._generator.generate(indicators, regime, close)
await self._redis.set(
f"{_SIGNAL_PREFIX}:{market}:{symbol}:{timeframe}",
json.dumps({
"signal": signal.signal,
"reason": signal.reason,
"price": signal.price,
"ts": signal.ts,
}),
)
logger.debug("signal_cached", market=market, symbol=symbol,
tf=timeframe, signal=signal.signal, reason=signal.reason)
if signal.signal in ("BUY", "SELL") and not self._dry_run:
await self._submit_order(market, symbol, signal)
except Exception as e:
logger.warning("strategy_error", market=market, symbol=symbol,
tf=timeframe, error=str(e))
async def _submit_order(self, market: str, symbol: str, signal: Signal) -> None:
from hydra.core.order_queue import OrderRequest
allowed, reason = self._risk_engine.check_order_allowed(market, symbol, 0.0)
if not allowed:
logger.info("order_blocked_by_risk", market=market, symbol=symbol,
reason=reason)
return
order = OrderRequest(
market=market,
symbol=symbol,
side="buy" if signal.signal == "BUY" else "sell",
order_type="market",
amount=self._trade_amount_usd,
)
result = await self._order_queue.submit(order)
logger.info("order_submitted", market=market, symbol=symbol,
signal=signal.signal, order_id=result.order_id)
async def cold_start(self) -> None:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
logger.info("strategy_cold_start", count=len(keys))
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
market = parts[2]
symbol = ":".join(parts[3:-1])
timeframe = parts[-1]
await self._handle_event(market, symbol, timeframe)
async def run(self) -> None:
pubsub = self._redis.pubsub()
await pubsub.subscribe(_CANDLE_CHANNEL)
logger.info("strategy_engine_subscribed", channel=_CANDLE_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
payload = json.loads(message["data"])
await self._handle_event(
payload["market"], payload["symbol"], payload["timeframe"],
)
except Exception as e:
logger.warning("strategy_subscribe_error", error=str(e))
async def main() -> None:
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
dry_run = os.environ.get("STRATEGY_DRY_RUN", "true").lower() != "false"
trade_amount = float(os.environ.get("STRATEGY_TRADE_AMOUNT_USD", "100"))
r = aioredis.from_url(settings.redis_url, decode_responses=True)
order_queue = None
risk_engine = None
if not dry_run:
from hydra.core.order_queue import OrderQueue
from hydra.core.risk_engine import RiskEngine
from hydra.core.position_tracker import PositionTracker
position_tracker = PositionTracker(r)
risk_engine = RiskEngine(r, position_tracker)
order_queue = OrderQueue(r, risk_engine, position_tracker, exchanges={})
generator = SignalGenerator()
engine = StrategyEngine(
redis_client=r,
generator=generator,
dry_run=dry_run,
order_queue=order_queue,
risk_engine=risk_engine,
trade_amount_usd=trade_amount,
)
try:
await engine.cold_start()
await engine.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())
+57
View File
@@ -0,0 +1,57 @@
# hydra/strategy/signal.py
import time
from dataclasses import dataclass
_EMA_FAST = "EMA_9"
_EMA_SLOW = "EMA_20"
_RSI = "RSI_14"
_RSI_OVERSOLD = 30.0
_RSI_OVERBOUGHT = 70.0
_TREND_UP_RSI_LOW = 45.0
_TREND_UP_RSI_HIGH = 75.0
_TREND_DOWN_RSI_LOW = 25.0
_TREND_DOWN_RSI_HIGH = 55.0
@dataclass
class Signal:
signal: str # "BUY" | "SELL" | "HOLD"
reason: str
price: float
ts: int
class SignalGenerator:
def generate(self, indicators: dict, regime: str, close: float) -> Signal:
ema9 = indicators.get(_EMA_FAST)
ema20 = indicators.get(_EMA_SLOW)
rsi = indicators.get(_RSI)
ts = int(time.time() * 1000)
if regime == "volatile":
return Signal(signal="HOLD", reason="volatile: skip", price=close, ts=ts)
if regime == "trending_up":
if ema9 is not None and ema20 is not None and ema9 > ema20:
if rsi is not None and _TREND_UP_RSI_LOW < rsi < _TREND_UP_RSI_HIGH:
return Signal(signal="BUY", reason="trend_up: ema_cross+rsi",
price=close, ts=ts)
return Signal(signal="HOLD", reason="trend_up: no_entry", price=close, ts=ts)
if regime == "trending_down":
if ema9 is not None and ema20 is not None and ema9 < ema20:
if rsi is not None and _TREND_DOWN_RSI_LOW < rsi < _TREND_DOWN_RSI_HIGH:
return Signal(signal="SELL", reason="trend_down: ema_cross+rsi",
price=close, ts=ts)
return Signal(signal="HOLD", reason="trend_down: no_entry", price=close, ts=ts)
# ranging (default)
if rsi is not None:
if rsi < _RSI_OVERSOLD:
return Signal(signal="BUY", reason="ranging: rsi_oversold",
price=close, ts=ts)
if rsi > _RSI_OVERBOUGHT:
return Signal(signal="SELL", reason="ranging: rsi_overbought",
price=close, ts=ts)
return Signal(signal="HOLD", reason="ranging: neutral", price=close, ts=ts)
View File
+73
View File
@@ -0,0 +1,73 @@
# hydra/supplemental/events.py
import asyncio
import json
import httpx
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_EVENTS_KEY = "hydra:events:upcoming"
_API_URL = "https://api.coinmarketcal.com/v1/events"
_DEFAULT_INTERVAL = 3600
class EventCalendarPoller:
def __init__(self, redis_client, api_key: str = "",
interval_sec: int = _DEFAULT_INTERVAL):
self._redis = redis_client
self._api_key = api_key
self._interval = interval_sec
async def _fetch(self) -> list[dict]:
if not self._api_key:
return []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
_API_URL,
headers={"x-api-key": self._api_key},
params={"max": 10, "dateRangeEnd": "+24h"},
)
resp.raise_for_status()
body = resp.json()
events = []
for item in body.get("body", []):
coins = item.get("coins") or []
symbol = coins[0].get("symbol", "") if coins else ""
events.append({
"title": item.get("title", ""),
"symbol": symbol,
"date_event": item.get("date_event", ""),
"source": "coinmarketcal",
})
return events
except Exception as e:
logger.warning("events_fetch_error", error=str(e))
return []
async def run(self) -> None:
logger.info("events_poller_started", interval=self._interval,
has_key=bool(self._api_key))
while True:
events = await self._fetch()
await self._redis.set(_EVENTS_KEY, json.dumps(events))
logger.debug("events_cached", count=len(events))
await asyncio.sleep(self._interval)
async def main() -> None:
import os
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
api_key = os.environ.get("COINMARKETCAL_API_KEY", "")
poller = EventCalendarPoller(r, api_key=api_key)
try:
await poller.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())
+90
View File
@@ -0,0 +1,90 @@
# hydra/supplemental/orderbook.py
import asyncio
import json
import time
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_INDICATOR_PREFIX = "hydra:indicator"
_ORDERBOOK_PREFIX = "hydra:orderbook"
_DEFAULT_INTERVAL = 30
class OrderBookPoller:
def __init__(self, redis_client, interval_sec: int = _DEFAULT_INTERVAL):
self._redis = redis_client
self._interval = interval_sec
def _get_active_symbols(self) -> list[tuple[str, str]]:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
seen = set()
result = []
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
market = parts[2]
symbol = ":".join(parts[3:-1])
if (market, symbol) not in seen:
seen.add((market, symbol))
result.append((market, symbol))
return result
def _get_exchange(self, market: str):
import ccxt
exchange_class = getattr(ccxt, market, None)
if exchange_class is None:
return None
return exchange_class()
def _fetch_one(self, market: str, symbol: str) -> dict | None:
try:
exchange = self._get_exchange(market)
if exchange is None:
return None
ob = exchange.fetch_order_book(symbol, limit=5)
bid = ob["bids"][0][0] if ob.get("bids") else None
ask = ob["asks"][0][0] if ob.get("asks") else None
if bid is None or ask is None:
return None
spread_pct = round((ask - bid) / ask * 100, 4)
return {
"bid": bid,
"ask": ask,
"spread_pct": spread_pct,
"bids": ob["bids"][:5],
"asks": ob["asks"][:5],
"ts": int(time.time() * 1000),
}
except Exception as e:
logger.warning("orderbook_fetch_error", market=market,
symbol=symbol, error=str(e))
return None
async def run(self) -> None:
logger.info("orderbook_poller_started", interval=self._interval)
while True:
for market, symbol in self._get_active_symbols():
data = self._fetch_one(market, symbol)
if data:
key = f"{_ORDERBOOK_PREFIX}:{market}:{symbol}"
await self._redis.set(key, json.dumps(data))
logger.debug("orderbook_cached", market=market, symbol=symbol)
await asyncio.sleep(self._interval)
async def main() -> None:
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
poller = OrderBookPoller(r)
try:
await poller.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())
+88
View File
@@ -0,0 +1,88 @@
# hydra/supplemental/sentiment.py
import asyncio
import json
import time
import httpx
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_INDICATOR_PREFIX = "hydra:indicator"
_SENTIMENT_PREFIX = "hydra:sentiment"
_API_URL = "https://cryptopanic.com/api/v1/posts/"
_DEFAULT_INTERVAL = 300
class SentimentPoller:
def __init__(self, redis_client, api_key: str = "",
interval_sec: int = _DEFAULT_INTERVAL):
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
self._redis = redis_client
self._api_key = api_key
self._interval = interval_sec
self._analyzer = SentimentIntensityAnalyzer()
def _get_active_symbols(self) -> list[str]:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
bases = set()
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
symbol = ":".join(parts[3:-1])
base = symbol.split("/")[0] if "/" in symbol else symbol
bases.add(base)
return list(bases)
async def _fetch_news(self, symbol: str) -> list[str]:
try:
params: dict = {"currencies": symbol, "public": "true"}
if self._api_key:
params["auth_token"] = self._api_key
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(_API_URL, params=params)
resp.raise_for_status()
results = resp.json().get("results", [])
return [item["title"] for item in results if item.get("title")]
except Exception as e:
logger.warning("sentiment_fetch_error", symbol=symbol, error=str(e))
return []
def _score(self, headlines: list[str]) -> float:
if not headlines:
return 0.0
scores = [self._analyzer.polarity_scores(h)["compound"] for h in headlines]
return round(sum(scores) / len(scores), 4)
async def run(self) -> None:
logger.info("sentiment_poller_started", interval=self._interval)
while True:
for symbol in self._get_active_symbols():
headlines = await self._fetch_news(symbol)
score = self._score(headlines)
key = f"{_SENTIMENT_PREFIX}:{symbol}"
await self._redis.set(key, json.dumps({
"score": score,
"article_count": len(headlines),
"ts": int(time.time() * 1000),
}))
logger.debug("sentiment_cached", symbol=symbol, score=score)
await asyncio.sleep(self._interval)
async def main() -> None:
import os
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
api_key = os.environ.get("CRYPTOPANIC_API_KEY", "")
poller = SentimentPoller(r, api_key=api_key)
try:
await poller.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())