Initial public release
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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에서 구현 예정"}
|
||||
@@ -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}
|
||||
@@ -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"]}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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>'로 활성화하세요.")
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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("[오류] 서버 오프라인")
|
||||
@@ -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에서 구현 예정")
|
||||
@@ -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에서 구현 예정")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,92 @@
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
|
||||
DAILY_PNL_KEY = "hydra:daily_pnl"
|
||||
KILL_THRESHOLD = -0.05
|
||||
|
||||
|
||||
@dataclass
|
||||
class KillSwitchResult:
|
||||
success: bool
|
||||
source: str
|
||||
reason: str
|
||||
duration_ms: float
|
||||
closed_positions: list = field(default_factory=list)
|
||||
errors: list = field(default_factory=list)
|
||||
|
||||
|
||||
class KillSwitch:
|
||||
def __init__(self, exchanges: dict, position_tracker, telegram, redis_client):
|
||||
self._exchanges = exchanges
|
||||
self._positions = position_tracker
|
||||
self._telegram = telegram
|
||||
self._redis = redis_client
|
||||
|
||||
async def execute(self, reason: str, source: str) -> KillSwitchResult:
|
||||
t0 = time.monotonic()
|
||||
logger.warning("kill_switch_triggered", reason=reason, source=source)
|
||||
|
||||
# 1. 신규 주문 차단
|
||||
self._redis.set(KILL_BLOCKED_KEY, "1")
|
||||
|
||||
# 2. 전 거래소 미체결 취소
|
||||
errors = []
|
||||
for name, ex in self._exchanges.items():
|
||||
try:
|
||||
await ex.cancel_all()
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: cancel_all failed: {e}")
|
||||
logger.error("kill_switch_cancel_error", exchange=name, error=str(e))
|
||||
|
||||
# 3. 전 포지션 시장가 청산
|
||||
positions = self._positions.get_all()
|
||||
closed = []
|
||||
for pos in positions:
|
||||
market = pos["market"]
|
||||
ex = self._exchanges.get(market)
|
||||
if ex:
|
||||
try:
|
||||
close_side = "sell" if pos["side"] == "buy" else "buy"
|
||||
await ex.create_order(
|
||||
symbol=pos["symbol"],
|
||||
side=close_side,
|
||||
order_type="market",
|
||||
qty=pos["qty"],
|
||||
)
|
||||
closed.append(pos["symbol"])
|
||||
except Exception as e:
|
||||
errors.append(f"close {pos['symbol']}: {e}")
|
||||
|
||||
duration_ms = (time.monotonic() - t0) * 1000
|
||||
|
||||
# 4. Telegram 알림
|
||||
msg = f"🚨 Kill Switch 발동\n원인: {reason}\n경로: {source}\n청산: {len(closed)}개\n소요: {duration_ms:.0f}ms"
|
||||
try:
|
||||
await self._telegram.send_message(msg)
|
||||
except Exception as e:
|
||||
logger.error("kill_switch_telegram_error", error=str(e))
|
||||
|
||||
logger.warning("kill_switch_complete", closed=len(closed), errors=len(errors), duration_ms=duration_ms)
|
||||
return KillSwitchResult(
|
||||
success=len(errors) == 0,
|
||||
source=source,
|
||||
reason=reason,
|
||||
duration_ms=duration_ms,
|
||||
closed_positions=closed,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def check_auto_triggers(self) -> tuple[bool, str]:
|
||||
raw = self._redis.get(DAILY_PNL_KEY)
|
||||
daily_pnl = float(raw) if raw else 0.0
|
||||
if daily_pnl <= KILL_THRESHOLD:
|
||||
return True, f"daily_loss:{daily_pnl*100:.1f}%"
|
||||
return False, ""
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return bool(self._redis.get(KILL_BLOCKED_KEY))
|
||||
@@ -0,0 +1,137 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
LOCK_TIMEOUT = 10
|
||||
IDEMPOTENCY_TTL = 86400 # 24 hours
|
||||
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
|
||||
|
||||
FUTURES_MARKETS = {"binance", "hl"} # 선물 거래를 지원하는 시장
|
||||
|
||||
|
||||
class OrderLockError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
market: Literal["kr", "us", "upbit", "binance", "hl", "poly"]
|
||||
symbol: str
|
||||
side: Literal["buy", "sell"]
|
||||
order_type: Literal["market", "limit"] = "market"
|
||||
qty: Optional[float] = None
|
||||
price: Optional[float] = None
|
||||
amount: Optional[float] = None
|
||||
idempotency_key: str = ""
|
||||
exchange: Optional[str] = None
|
||||
leverage: int = Field(default=1, ge=1, le=125, description="선물 레버리지 (1~125x, 현물은 1로 고정)")
|
||||
is_futures: bool = Field(default=False, description="선물 주문 여부")
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
if not self.idempotency_key:
|
||||
self.idempotency_key = str(uuid4())
|
||||
# 선물 지원 시장인데 leverage > 1이면 자동으로 is_futures=True
|
||||
if self.leverage > 1 and self.market in FUTURES_MARKETS:
|
||||
object.__setattr__(self, "is_futures", True)
|
||||
|
||||
@field_validator("qty")
|
||||
@classmethod
|
||||
def validate_qty(cls, v):
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError("수량은 양수여야 합니다")
|
||||
return v
|
||||
|
||||
@field_validator("leverage")
|
||||
@classmethod
|
||||
def validate_leverage(cls, v):
|
||||
if v < 1 or v > 125:
|
||||
raise ValueError("레버리지는 1~125 사이여야 합니다")
|
||||
return v
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderResult:
|
||||
order_id: str
|
||||
status: str
|
||||
symbol: str
|
||||
market: str
|
||||
|
||||
|
||||
class OrderQueue:
|
||||
def __init__(self, redis_client, risk_engine, position_tracker, exchanges: dict):
|
||||
self._redis = redis_client
|
||||
self._risk = risk_engine
|
||||
self._positions = position_tracker
|
||||
self._exchanges = exchanges
|
||||
self._blocked = False
|
||||
|
||||
def block_new_orders(self) -> None:
|
||||
self._blocked = True
|
||||
|
||||
async def submit(self, order: OrderRequest) -> OrderResult:
|
||||
# 멱등성 체크 (Kill Switch보다 먼저 — 캐시된 결과는 항상 반환)
|
||||
cached_raw = self._redis.get(f"idem:{order.idempotency_key}")
|
||||
if cached_raw:
|
||||
cached = json.loads(cached_raw)
|
||||
return OrderResult(
|
||||
order_id=cached["order_id"],
|
||||
status=cached["status"],
|
||||
symbol=order.symbol,
|
||||
market=order.market,
|
||||
)
|
||||
|
||||
# Kill Switch 체크
|
||||
if self._blocked or self._redis.get(KILL_BLOCKED_KEY):
|
||||
raise OrderLockError("Kill Switch 활성 — 신규 주문 불가")
|
||||
|
||||
# Redis 락 획득
|
||||
lock_key = f"order_lock:{order.market}:{order.symbol}:{order.side}"
|
||||
acquired = self._redis.set(lock_key, "1", nx=True, ex=LOCK_TIMEOUT)
|
||||
if not acquired:
|
||||
raise OrderLockError(f"주문 락 획득 실패: {order.symbol} {order.side}")
|
||||
|
||||
try:
|
||||
# 리스크 검증
|
||||
allowed, reason = self._risk.check_order_allowed(order.market, order.symbol, 0.0)
|
||||
if not allowed:
|
||||
raise OrderLockError(f"리스크 검증 실패: {reason}")
|
||||
|
||||
# 거래소 주문
|
||||
ex = self._exchanges.get(order.market)
|
||||
if not ex:
|
||||
raise OrderLockError(f"비활성 시장: {order.market}")
|
||||
|
||||
# 선물 레버리지 설정 (주문 전)
|
||||
if order.is_futures and order.leverage > 1:
|
||||
await ex.set_leverage(order.symbol, order.leverage)
|
||||
|
||||
raw = await ex.create_order(
|
||||
symbol=order.symbol,
|
||||
side=order.side,
|
||||
order_type=order.order_type,
|
||||
qty=order.qty,
|
||||
price=order.price,
|
||||
)
|
||||
result = OrderResult(
|
||||
order_id=raw.get("order_id", str(uuid4())),
|
||||
status=raw.get("status", "submitted"),
|
||||
symbol=order.symbol,
|
||||
market=order.market,
|
||||
)
|
||||
|
||||
# 멱등성 캐시 저장
|
||||
self._redis.set(
|
||||
f"idem:{order.idempotency_key}",
|
||||
json.dumps({"order_id": result.order_id, "status": result.status}),
|
||||
ex=IDEMPOTENCY_TTL,
|
||||
)
|
||||
logger.info("order_submitted", order_id=result.order_id, symbol=order.symbol, side=order.side, leverage=order.leverage)
|
||||
return result
|
||||
finally:
|
||||
self._redis.delete(lock_key)
|
||||
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
PNL_REALIZED_KEY = "hydra:pnl:realized:total"
|
||||
PNL_TRADE_COUNT_KEY = "hydra:pnl:trade_count"
|
||||
|
||||
|
||||
def _daily_key() -> str:
|
||||
return f"hydra:pnl:daily:{date.today().isoformat()}"
|
||||
|
||||
|
||||
class PnlTracker:
|
||||
"""
|
||||
시스템 전체 손익 추적.
|
||||
- 실현 손익: 포지션 청산 시 record_trade()로 기록
|
||||
- 미실현 손익: 포지션 데이터(mark_price)로 계산
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
|
||||
# ─── 실현 손익 ───────────────────────────────────────────────
|
||||
|
||||
def record_trade(self, market: str, symbol: str, realized_pnl: float) -> None:
|
||||
"""포지션 청산 시 실현 손익 기록."""
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.incrbyfloat(PNL_REALIZED_KEY, realized_pnl)
|
||||
pipe.incrbyfloat(_daily_key(), realized_pnl)
|
||||
pipe.incr(PNL_TRADE_COUNT_KEY)
|
||||
# 개별 심볼 실현 손익
|
||||
pipe.incrbyfloat(f"hydra:pnl:symbol:{market}:{symbol}", realized_pnl)
|
||||
pipe.execute()
|
||||
logger.info("pnl_recorded", market=market, symbol=symbol, realized_pnl=realized_pnl)
|
||||
|
||||
def get_realized_total(self) -> float:
|
||||
raw = self._redis.get(PNL_REALIZED_KEY)
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def get_daily_realized(self) -> float:
|
||||
raw = self._redis.get(_daily_key())
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def get_trade_count(self) -> int:
|
||||
raw = self._redis.get(PNL_TRADE_COUNT_KEY)
|
||||
return int(raw) if raw else 0
|
||||
|
||||
def get_symbol_realized(self, market: str, symbol: str) -> float:
|
||||
raw = self._redis.get(f"hydra:pnl:symbol:{market}:{symbol}")
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
# ─── 미실현 손익 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def calc_unrealized(position: dict) -> float:
|
||||
"""단일 포지션의 미실현 손익 계산.
|
||||
position dict: {qty, avg_price, mark_price, side, leverage}
|
||||
"""
|
||||
qty = float(position.get("qty", 0))
|
||||
avg_price = float(position.get("avg_price", 0))
|
||||
mark_price = float(position.get("mark_price", avg_price))
|
||||
side = position.get("side", "buy")
|
||||
leverage = float(position.get("leverage", 1))
|
||||
|
||||
if qty == 0 or avg_price == 0:
|
||||
return 0.0
|
||||
|
||||
price_diff = mark_price - avg_price
|
||||
if side == "sell":
|
||||
price_diff = -price_diff
|
||||
|
||||
return price_diff * qty * leverage
|
||||
|
||||
def get_unrealized_total(self, positions: list[dict]) -> float:
|
||||
return sum(self.calc_unrealized(p) for p in positions)
|
||||
|
||||
# ─── 요약 ─────────────────────────────────────────────────────
|
||||
|
||||
def get_summary(self, positions: list[dict]) -> dict:
|
||||
realized_total = self.get_realized_total()
|
||||
daily_realized = self.get_daily_realized()
|
||||
unrealized = self.get_unrealized_total(positions)
|
||||
trade_count = self.get_trade_count()
|
||||
|
||||
return {
|
||||
"realized_total": round(realized_total, 4),
|
||||
"daily_realized": round(daily_realized, 4),
|
||||
"unrealized": round(unrealized, 4),
|
||||
"total_pnl": round(realized_total + unrealized, 4),
|
||||
"trade_count": trade_count,
|
||||
"positions": [
|
||||
{
|
||||
"market": p.get("market"),
|
||||
"symbol": p.get("symbol"),
|
||||
"side": p.get("side"),
|
||||
"qty": p.get("qty"),
|
||||
"avg_price": p.get("avg_price"),
|
||||
"mark_price": p.get("mark_price", p.get("avg_price")),
|
||||
"leverage": p.get("leverage", 1),
|
||||
"unrealized_pnl": round(self.calc_unrealized(p), 4),
|
||||
}
|
||||
for p in positions
|
||||
],
|
||||
}
|
||||
|
||||
def reset_daily(self) -> None:
|
||||
"""일일 손익 초기화 (자정 실행용)."""
|
||||
self._redis.delete(_daily_key())
|
||||
logger.info("pnl_daily_reset")
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
POSITIONS_KEY = "hydra:positions"
|
||||
|
||||
|
||||
class PositionTracker:
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
|
||||
def update(self, market: str, symbol: str, qty: float, avg_price: float, side: str, leverage: int = 1, mark_price: float | None = None) -> None:
|
||||
key = f"{POSITIONS_KEY}:{market}:{symbol}"
|
||||
data = {
|
||||
"qty": qty,
|
||||
"avg_price": avg_price,
|
||||
"side": side,
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"leverage": leverage,
|
||||
"mark_price": mark_price or avg_price,
|
||||
}
|
||||
self._redis.set(key, json.dumps(data))
|
||||
logger.info("position_updated", market=market, symbol=symbol, qty=qty, leverage=leverage)
|
||||
|
||||
def get(self, market: str, symbol: str) -> Optional[dict]:
|
||||
key = f"{POSITIONS_KEY}:{market}:{symbol}"
|
||||
raw = self._redis.get(key)
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
def get_all(self) -> list[dict]:
|
||||
keys = self._redis.keys(f"{POSITIONS_KEY}:*")
|
||||
result = []
|
||||
for k in keys:
|
||||
raw = self._redis.get(k)
|
||||
if raw:
|
||||
result.append(json.loads(raw))
|
||||
return result
|
||||
|
||||
def clear(self, market: str, symbol: str) -> None:
|
||||
key = f"{POSITIONS_KEY}:{market}:{symbol}"
|
||||
self._redis.delete(key)
|
||||
logger.info("position_cleared", market=market, symbol=symbol)
|
||||
|
||||
async def snapshot(self) -> dict:
|
||||
return {"positions": self.get_all()}
|
||||
@@ -0,0 +1,38 @@
|
||||
from hydra.config.validation import RiskConfig
|
||||
from hydra.core.position_tracker import PositionTracker
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DAILY_PNL_KEY = "hydra:daily_pnl"
|
||||
|
||||
|
||||
class RiskEngine:
|
||||
def __init__(self, redis_client, position_tracker: PositionTracker, config: RiskConfig | None = None):
|
||||
self._redis = redis_client
|
||||
self._positions = position_tracker
|
||||
self._config = config or RiskConfig()
|
||||
|
||||
def get_daily_pnl_pct(self) -> float:
|
||||
raw = self._redis.get(DAILY_PNL_KEY)
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def update_daily_pnl(self, pnl_pct: float) -> None:
|
||||
self._redis.set(DAILY_PNL_KEY, str(pnl_pct))
|
||||
|
||||
def check_order_allowed(self, market: str, symbol: str, position_pct: float) -> tuple[bool, str]:
|
||||
"""주문 허용 여부 확인. (allowed, reason) 반환."""
|
||||
daily_pnl = self.get_daily_pnl_pct()
|
||||
if daily_pnl <= -self._config.daily_loss_kill_pct:
|
||||
return False, f"일일 손실 {daily_pnl*100:.1f}% — Kill Switch 레벨"
|
||||
if daily_pnl <= -self._config.daily_loss_limit_pct:
|
||||
return False, f"일일 손실 {daily_pnl*100:.1f}% — 신규 주문 중단"
|
||||
if position_pct > self._config.max_position_per_symbol_pct:
|
||||
return False, f"종목 포지션 {position_pct*100:.1f}% 초과 (최대 {self._config.max_position_per_symbol_pct*100:.0f}%)"
|
||||
return True, "ok"
|
||||
|
||||
def should_kill_switch(self) -> tuple[bool, str]:
|
||||
daily_pnl = self.get_daily_pnl_pct()
|
||||
if daily_pnl <= -self._config.daily_loss_kill_pct:
|
||||
return True, f"daily_loss:{daily_pnl*100:.1f}%"
|
||||
return False, ""
|
||||
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
STATE_KEY = "hydra:state"
|
||||
|
||||
|
||||
class StateManager:
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
|
||||
def save(self, key: str, value: Any) -> None:
|
||||
payload = json.dumps({"value": value, "ts": time.time()})
|
||||
self._redis.hset(STATE_KEY, key, payload)
|
||||
|
||||
def load(self, key: str, default: Any = None) -> Any:
|
||||
raw = self._redis.hget(STATE_KEY, key)
|
||||
if raw is None:
|
||||
return default
|
||||
return json.loads(raw)["value"]
|
||||
|
||||
def save_all(self, state: dict) -> None:
|
||||
for k, v in state.items():
|
||||
self.save(k, v)
|
||||
|
||||
def load_all(self) -> dict:
|
||||
raw = self._redis.hgetall(STATE_KEY)
|
||||
return {k: json.loads(v)["value"] for k, v in raw.items()}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user