From 92a692b63ce0430a84a2a475df4a2d70ac6e87b3 Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Mon, 30 Mar 2026 13:19:11 +0900 Subject: [PATCH] Initial public release --- .env.example | 15 ++ .gitignore | 43 ++++++ DISCLAIMER.md | 14 ++ Dockerfile | 21 +++ LICENSE | 21 +++ README.md | 223 +++++++++++++++++++++++++++ config/.gitkeep | 0 config/data.yaml | 38 +++++ config/markets.yaml | 26 ++++ docker-compose.expert.yml | 185 ++++++++++++++++++++++ docker-compose.lite.yml | 172 +++++++++++++++++++++ docker-compose.pro.yml | 185 ++++++++++++++++++++++ docs/API_REFERENCE_KO.md | 154 ++++++++++++++++++ docs/QUICKSTART_KO.md | 116 ++++++++++++++ hydra/__init__.py | 1 + hydra/api/__init__.py | 0 hydra/api/auth.py | 11 ++ hydra/api/backtest.py | 61 ++++++++ hydra/api/data.py | 50 ++++++ hydra/api/health.py | 30 ++++ hydra/api/indicators.py | 56 +++++++ hydra/api/markets.py | 27 ++++ hydra/api/orders.py | 16 ++ hydra/api/pnl.py | 26 ++++ hydra/api/positions.py | 15 ++ hydra/api/regime.py | 52 +++++++ hydra/api/risk.py | 26 ++++ hydra/api/signals.py | 54 +++++++ hydra/api/strategies.py | 14 ++ hydra/api/supplemental.py | 53 +++++++ hydra/api/system.py | 20 +++ hydra/backtest/__init__.py | 0 hydra/backtest/broker.py | 77 +++++++++ hydra/backtest/result.py | 87 +++++++++++ hydra/backtest/runner.py | 118 ++++++++++++++ hydra/cli/__init__.py | 0 hydra/cli/app.py | 16 ++ hydra/cli/kill.py | 30 ++++ hydra/cli/market.py | 31 ++++ hydra/cli/module.py | 24 +++ hydra/cli/setup_wizard.py | 122 +++++++++++++++ hydra/cli/status.py | 60 +++++++ hydra/cli/strategy.py | 18 +++ hydra/cli/trade.py | 40 +++++ hydra/config/__init__.py | 0 hydra/config/keys.py | 52 +++++++ hydra/config/markets.py | 52 +++++++ hydra/config/profiles.py | 49 ++++++ hydra/config/settings.py | 19 +++ hydra/config/validation.py | 33 ++++ hydra/core/__init__.py | 0 hydra/core/kill_switch.py | 92 +++++++++++ hydra/core/order_queue.py | 137 ++++++++++++++++ hydra/core/pnl_tracker.py | 114 ++++++++++++++ hydra/core/position_tracker.py | 49 ++++++ hydra/core/risk_engine.py | 38 +++++ hydra/core/state_manager.py | 32 ++++ hydra/exchange/__init__.py | 0 hydra/exchange/base.py | 22 +++ hydra/exchange/crypto.py | 61 ++++++++ hydra/exchange/factory.py | 41 +++++ hydra/exchange/kis.py | 63 ++++++++ hydra/exchange/polymarket.py | 41 +++++ hydra/indicator/__init__.py | 0 hydra/indicator/calculator.py | 82 ++++++++++ hydra/indicator/engine.py | 85 ++++++++++ hydra/logging/__init__.py | 0 hydra/logging/setup.py | 34 ++++ hydra/main.py | 132 ++++++++++++++++ hydra/notify/__init__.py | 0 hydra/notify/telegram.py | 42 +++++ hydra/regime/__init__.py | 0 hydra/regime/detector.py | 17 ++ hydra/regime/engine.py | 85 ++++++++++ hydra/resilience/__init__.py | 0 hydra/resilience/circuit_breaker.py | 6 + hydra/resilience/graceful.py | 35 +++++ hydra/resilience/rate_limiter.py | 28 ++++ hydra/resilience/retry.py | 11 ++ hydra/strategy/__init__.py | 0 hydra/strategy/engine.py | 155 +++++++++++++++++++ hydra/strategy/signal.py | 57 +++++++ hydra/supplemental/__init__.py | 0 hydra/supplemental/events.py | 73 +++++++++ hydra/supplemental/orderbook.py | 90 +++++++++++ hydra/supplemental/sentiment.py | 88 +++++++++++ pyproject.toml | 48 ++++++ scripts/__init__.py | 0 scripts/benchmark.py | 36 +++++ scripts/dr_watchdog.py | 72 +++++++++ scripts/hydra.service | 17 ++ tests/__init__.py | 0 tests/conftest.py | 20 +++ tests/test_backfill.py | 64 ++++++++ tests/test_backtest_broker.py | 56 +++++++ tests/test_backtest_runner.py | 175 +++++++++++++++++++++ tests/test_circuit_breaker.py | 26 ++++ tests/test_cli_help.py | 27 ++++ tests/test_dr.py | 41 +++++ tests/test_graceful_shutdown.py | 19 +++ tests/test_indicator_calculator.py | 57 +++++++ tests/test_indicator_engine.py | 81 ++++++++++ tests/test_key_security.py | 34 ++++ tests/test_kill_switch.py | 61 ++++++++ tests/test_oom_protection.py | 29 ++++ tests/test_order_queue.py | 49 ++++++ tests/test_regime_detector.py | 27 ++++ tests/test_regime_engine.py | 59 +++++++ tests/test_storage.py | 85 ++++++++++ tests/test_strategy_engine.py | 70 +++++++++ tests/test_strategy_signal.py | 63 ++++++++ tests/test_supplemental_events.py | 59 +++++++ tests/test_supplemental_orderbook.py | 80 ++++++++++ tests/test_supplemental_sentiment.py | 70 +++++++++ tests/test_validation.py | 33 ++++ tests/test_websocket_reconnect.py | 101 ++++++++++++ 116 files changed, 5822 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DISCLAIMER.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/.gitkeep create mode 100644 config/data.yaml create mode 100644 config/markets.yaml create mode 100644 docker-compose.expert.yml create mode 100644 docker-compose.lite.yml create mode 100644 docker-compose.pro.yml create mode 100644 docs/API_REFERENCE_KO.md create mode 100644 docs/QUICKSTART_KO.md create mode 100644 hydra/__init__.py create mode 100644 hydra/api/__init__.py create mode 100644 hydra/api/auth.py create mode 100644 hydra/api/backtest.py create mode 100644 hydra/api/data.py create mode 100644 hydra/api/health.py create mode 100644 hydra/api/indicators.py create mode 100644 hydra/api/markets.py create mode 100644 hydra/api/orders.py create mode 100644 hydra/api/pnl.py create mode 100644 hydra/api/positions.py create mode 100644 hydra/api/regime.py create mode 100644 hydra/api/risk.py create mode 100644 hydra/api/signals.py create mode 100644 hydra/api/strategies.py create mode 100644 hydra/api/supplemental.py create mode 100644 hydra/api/system.py create mode 100644 hydra/backtest/__init__.py create mode 100644 hydra/backtest/broker.py create mode 100644 hydra/backtest/result.py create mode 100644 hydra/backtest/runner.py create mode 100644 hydra/cli/__init__.py create mode 100644 hydra/cli/app.py create mode 100644 hydra/cli/kill.py create mode 100644 hydra/cli/market.py create mode 100644 hydra/cli/module.py create mode 100644 hydra/cli/setup_wizard.py create mode 100644 hydra/cli/status.py create mode 100644 hydra/cli/strategy.py create mode 100644 hydra/cli/trade.py create mode 100644 hydra/config/__init__.py create mode 100644 hydra/config/keys.py create mode 100644 hydra/config/markets.py create mode 100644 hydra/config/profiles.py create mode 100644 hydra/config/settings.py create mode 100644 hydra/config/validation.py create mode 100644 hydra/core/__init__.py create mode 100644 hydra/core/kill_switch.py create mode 100644 hydra/core/order_queue.py create mode 100644 hydra/core/pnl_tracker.py create mode 100644 hydra/core/position_tracker.py create mode 100644 hydra/core/risk_engine.py create mode 100644 hydra/core/state_manager.py create mode 100644 hydra/exchange/__init__.py create mode 100644 hydra/exchange/base.py create mode 100644 hydra/exchange/crypto.py create mode 100644 hydra/exchange/factory.py create mode 100644 hydra/exchange/kis.py create mode 100644 hydra/exchange/polymarket.py create mode 100644 hydra/indicator/__init__.py create mode 100644 hydra/indicator/calculator.py create mode 100644 hydra/indicator/engine.py create mode 100644 hydra/logging/__init__.py create mode 100644 hydra/logging/setup.py create mode 100644 hydra/main.py create mode 100644 hydra/notify/__init__.py create mode 100644 hydra/notify/telegram.py create mode 100644 hydra/regime/__init__.py create mode 100644 hydra/regime/detector.py create mode 100644 hydra/regime/engine.py create mode 100644 hydra/resilience/__init__.py create mode 100644 hydra/resilience/circuit_breaker.py create mode 100644 hydra/resilience/graceful.py create mode 100644 hydra/resilience/rate_limiter.py create mode 100644 hydra/resilience/retry.py create mode 100644 hydra/strategy/__init__.py create mode 100644 hydra/strategy/engine.py create mode 100644 hydra/strategy/signal.py create mode 100644 hydra/supplemental/__init__.py create mode 100644 hydra/supplemental/events.py create mode 100644 hydra/supplemental/orderbook.py create mode 100644 hydra/supplemental/sentiment.py create mode 100644 pyproject.toml create mode 100644 scripts/__init__.py create mode 100644 scripts/benchmark.py create mode 100644 scripts/dr_watchdog.py create mode 100644 scripts/hydra.service create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_backfill.py create mode 100644 tests/test_backtest_broker.py create mode 100644 tests/test_backtest_runner.py create mode 100644 tests/test_circuit_breaker.py create mode 100644 tests/test_cli_help.py create mode 100644 tests/test_dr.py create mode 100644 tests/test_graceful_shutdown.py create mode 100644 tests/test_indicator_calculator.py create mode 100644 tests/test_indicator_engine.py create mode 100644 tests/test_key_security.py create mode 100644 tests/test_kill_switch.py create mode 100644 tests/test_oom_protection.py create mode 100644 tests/test_order_queue.py create mode 100644 tests/test_regime_detector.py create mode 100644 tests/test_regime_engine.py create mode 100644 tests/test_storage.py create mode 100644 tests/test_strategy_engine.py create mode 100644 tests/test_strategy_signal.py create mode 100644 tests/test_supplemental_events.py create mode 100644 tests/test_supplemental_orderbook.py create mode 100644 tests/test_supplemental_sentiment.py create mode 100644 tests/test_validation.py create mode 100644 tests/test_websocket_reconnect.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..024c58d --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# HYDRA Environment Variables +# Copy to .env and fill in your values. NEVER commit .env to git. + +HYDRA_API_KEY=change-me-to-a-random-secret +HYDRA_PROFILE=lite + +# Database (Pro/Expert only) +DB_PASSWORD=change-me + +# Telegram +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# Redis +REDIS_URL=redis://localhost:6379 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91688ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Secrets +.env +.env.* +!.env.example +config/keys/ +*.key +~/.hydra/ + +# Database +*.db +*.sqlite + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +.venv/ +venv/ +data/ + +# IDE +.idea/ +.vscode/ + +# Logs +logs/ +*.log + +# Docker volumes +redis-data/ +db-data/ + +# Git worktrees +.worktrees/ + +# Internal planning artifacts kept local only +docs/superpowers/ +hydra-phase0-spec.md +hydra-v32-final.jsx diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..6b3b1d9 --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,14 @@ +# 면책조항 / Disclaimer + +본 소프트웨어(HYDRA)는 교육 및 연구 목적으로 제공됩니다. + +1. 투자 조언이 아닙니다. 매매 결정은 전적으로 사용자의 판단과 책임입니다. +2. 과거 백테스트 수익률은 미래 수익을 보장하지 않습니다. +3. 자동매매 시스템은 기술적 장애, 네트워크 오류, 거래소 문제 등으로 + 예기치 않은 손실이 발생할 수 있습니다. +4. 개발자 및 기여자는 본 소프트웨어 사용으로 인한 어떠한 손실에 대해서도 + 책임을 지지 않습니다. +5. 실제 자금을 투자하기 전에 반드시 모의투자(페이퍼 트레이딩)로 + 충분히 검증하십시오. + +이 소프트웨어를 사용함으로써 위 내용에 동의하는 것으로 간주합니다. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8732aa1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml . +RUN pip install --no-cache-dir -e . + +COPY hydra/ hydra/ +COPY config/ config/ +COPY scripts/ scripts/ +COPY DISCLAIMER.md . + +RUN useradd -m hydra && chown -R hydra:hydra /app +USER hydra + +EXPOSE 8000 +CMD ["uvicorn", "hydra.main:app", "--host", "127.0.0.1", "--port", "8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1972165 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sinmb79 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..645ce30 --- /dev/null +++ b/README.md @@ -0,0 +1,223 @@ +# HYDRA Engine + +HYDRA Engine은 로컬 우선(Local-first) 철학으로 설계된 자동매매 엔진 프로젝트입니다. +데이터 수집, 지표 계산, 레짐 분류, 시그널 생성, 보조 데이터 수집, 백테스트, FastAPI API, Typer CLI를 하나의 저장소에서 관리합니다. + +이 저장소는 "실거래 수익 보장"을 목표로 하지 않습니다. 먼저 안전하게 실행하고, 충분히 검증하고, 필요할 때만 확장하는 것을 목표로 합니다. + +## 1. 현재 포함된 기능 + +- FastAPI 기반 제어/API 서버 +- Redis 기반 상태 공유 +- OHLCV 수집기와 SQLite/TimescaleDB 저장소 +- 지표 계산 엔진 +- 레짐(Regime) 분류 엔진 +- 전략 시그널 엔진 +- 오더북 / 이벤트 / 감성 보조 데이터 수집 +- 인메모리 백테스트 엔진 +- Kill Switch, 주문 큐, 리스크 엔진, 설정 검증 +- CLI 도구와 Docker Compose 프로필(lite / pro / expert) + +## 2. 이 프로젝트를 어떻게 이해하면 좋은가 + +HYDRA는 한 번에 모든 기능을 다 켜는 프로젝트가 아닙니다. + +1. 먼저 데이터를 수집합니다. +2. 지표와 레짐, 시그널을 계산합니다. +3. API나 CLI로 상태를 확인합니다. +4. 백테스트로 전략을 검증합니다. +5. 실거래는 마지막 단계에서 매우 조심스럽게 붙입니다. + +현재 저장소에는 실거래로 연결되는 기반 코드가 일부 포함되어 있지만, 여러 CLI 명령은 아직 "예정" 상태의 placeholder를 포함합니다. 공개 배포용으로는 안전하게 시험, 학습, 백테스트, 데이터 파이프라인 검증부터 시작하는 것을 권장합니다. + +## 3. 빠른 시작 + +가장 쉬운 시작 방법은 아래 두 가지입니다. + +- 로컬 Python 환경에서 테스트부터 실행 +- Docker Compose Lite 프로필로 전체 파이프라인 기동 + +### 3.1 요구 사항 + +- Python 3.11 이상 +- Docker / Docker Compose +- Redis +- Git + +### 3.2 저장소 준비 + +```bash +git clone https://github.com/sinmb79/Hydra-Engine.git +cd Hydra-Engine +python -m venv .venv +``` + +Windows PowerShell: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +macOS / Linux: + +```bash +source .venv/bin/activate +``` + +패키지 설치: + +```bash +pip install -e .[dev] +``` + +### 3.3 환경 변수 설정 + +`.env.example`를 복사해서 `.env`를 만드세요. + +```bash +cp .env.example .env +``` + +Windows PowerShell: + +```powershell +Copy-Item .env.example .env +``` + +최소 필수 항목: + +```env +HYDRA_API_KEY=change-me-to-a-random-secret +HYDRA_PROFILE=lite +REDIS_URL=redis://localhost:6379 +``` + +`pro` 또는 `expert` 프로필에서는 `DB_PASSWORD`가 필요합니다. + +## 4. 가장 먼저 해볼 것 + +### 4.1 테스트 실행 + +```bash +pytest -q +``` + +정상이라면 전체 테스트가 통과해야 합니다. + +### 4.2 Lite 프로필로 실행 + +```bash +docker compose -f docker-compose.lite.yml up --build +``` + +서버 헬스체크: + +```bash +curl http://127.0.0.1:8000/health +``` + +## 5. 주요 사용 흐름 + +### 5.1 활성 시장 확인 + +```bash +curl -H "X-HYDRA-KEY: change-me-to-a-random-secret" \ + http://127.0.0.1:8000/markets +``` + +### 5.2 수집 중인 심볼 확인 + +```bash +curl -H "X-HYDRA-KEY: change-me-to-a-random-secret" \ + http://127.0.0.1:8000/data/symbols +``` + +### 5.3 캔들 조회 + +```bash +curl -G http://127.0.0.1:8000/data/candles \ + -H "X-HYDRA-KEY: change-me-to-a-random-secret" \ + --data-urlencode "market=binance" \ + --data-urlencode "symbol=BTC/USDT" \ + --data-urlencode "timeframe=1h" \ + --data-urlencode "limit=200" +``` + +### 5.4 백테스트 실행 + +```bash +curl -X POST http://127.0.0.1:8000/backtest/run \ + -H "Content-Type: application/json" \ + -H "X-HYDRA-KEY: change-me-to-a-random-secret" \ + -d '{ + "market": "binance", + "symbol": "BTC/USDT", + "timeframe": "1h", + "since": 1704067200000, + "until": 1706745600000, + "initial_capital": 10000, + "trade_amount_usd": 100, + "commission_pct": 0.001 + }' +``` + +### 5.5 Kill Switch + +Kill Switch는 전 포지션 청산을 시도하는 고위험 명령입니다. 테스트 목적이 아니라면 함부로 사용하지 마세요. + +CLI: + +```bash +python -m hydra.cli.app kill +``` + +API: + +```bash +curl -X POST "http://127.0.0.1:8000/killswitch?reason=manual_test" \ + -H "X-HYDRA-KEY: change-me-to-a-random-secret" +``` + +## 6. CLI 예시 + +```bash +python -m hydra.cli.app --help +python -m hydra.cli.app setup +python -m hydra.cli.app status +python -m hydra.cli.app market list-markets +python -m hydra.cli.app market enable binance --mode paper +python -m hydra.cli.app trade crypto binance BTC/USDT buy 0.01 +``` + +주의: + +- `trade`, `strategy`, `module` 일부 명령은 아직 placeholder 메시지를 출력합니다. +- 공개 버전 기준으로는 데이터 수집, 관찰, 백테스트 중심으로 사용하는 것이 안전합니다. + +## 7. Docker 프로필 + +- `docker-compose.lite.yml` + - SQLite 사용 + - 개인 PC / 테스트 / 입문용 +- `docker-compose.pro.yml` + - TimescaleDB + Redis + - 중간 규모 수집/분석용 +- `docker-compose.expert.yml` + - 고사양 장비 / 확장 시나리오용 + +## 8. 문서 + +- 자세한 시작 가이드: [docs/QUICKSTART_KO.md](docs/QUICKSTART_KO.md) +- API 레퍼런스: [docs/API_REFERENCE_KO.md](docs/API_REFERENCE_KO.md) +- 법적 고지: [DISCLAIMER.md](DISCLAIMER.md) + +## 9. 안전 안내 + +- 이 저장소는 교육, 연구, 실험용입니다. +- 실거래 전에는 반드시 paper 모드와 백테스트로 먼저 검증하세요. +- `.env`, API 키, 계정 정보는 절대 Git에 올리지 마세요. +- 기본적으로 로컬/사설 네트워크에서만 운용하는 것을 권장합니다. + +## 10. 라이선스 + +이 저장소는 [MIT License](LICENSE)를 따릅니다. diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/data.yaml b/config/data.yaml new file mode 100644 index 0000000..0f11ea7 --- /dev/null +++ b/config/data.yaml @@ -0,0 +1,38 @@ +# OHLCV collection targets per market. +# Disabled markets (config/markets.yaml) are skipped by the collector. + +binance: + symbols: + - BTC/USDT + - ETH/USDT + - BTC/USDT:USDT # futures + - ETH/USDT:USDT # futures + timeframes: [1m, 5m, 15m, 1h, 4h, 1d] + +upbit: + symbols: + - BTC/KRW + - ETH/KRW + - SOL/KRW + timeframes: [1m, 5m, 15m, 1h, 4h, 1d] + +hl: + symbols: + - BTC-PERP + - ETH-PERP + timeframes: [1m, 5m, 15m, 1h, 4h, 1d] + +kr: + symbols: + - "005930" # Samsung Electronics + - "000660" # SK Hynix + - "035420" # NAVER + timeframes: [1m, 5m, 15m, 1h, 1d] + +us: + symbols: + - AAPL + - TSLA + - NVDA + - SPY + timeframes: [1m, 5m, 15m, 1h, 1d] diff --git a/config/markets.yaml b/config/markets.yaml new file mode 100644 index 0000000..040d484 --- /dev/null +++ b/config/markets.yaml @@ -0,0 +1,26 @@ +# config/markets.yaml +markets: + kr: + enabled: false + mode: paper + exchange: kis + us: + enabled: false + mode: paper + exchange: kis + upbit: + enabled: false + mode: paper + exchange: upbit + binance: + enabled: false + mode: paper + exchange: binance + hl: + enabled: false + mode: paper + exchange: hyperliquid + poly: + enabled: false + mode: paper + exchange: polymarket diff --git a/docker-compose.expert.yml b/docker-compose.expert.yml new file mode 100644 index 0000000..d3f390f --- /dev/null +++ b/docker-compose.expert.yml @@ -0,0 +1,185 @@ +version: "3.9" + +services: + hydra-core: + build: . + command: uvicorn hydra.main:app --host 127.0.0.1 --port 8000 --workers 2 + mem_limit: 8g + cpus: 8 + restart: always + ports: + - "127.0.0.1:8000:8000" + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + env_file: + - .env + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + mem_limit: 4g + restart: always + volumes: + - redis-data:/data + command: redis-server --appendonly yes --maxmemory 3gb --maxmemory-policy allkeys-lru + + timescaledb: + image: timescale/timescaledb:latest-pg16 + mem_limit: 8g + shm_size: 512m + restart: always + environment: + - POSTGRES_USER=hydra + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=hydra + volumes: + - db-data:/var/lib/postgresql/data + + hydra-collector: + build: . + command: python -m hydra.data.collector + mem_limit: 2g + restart: always + env_file: + - .env + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + + hydra-indicator: + build: . + command: python -m hydra.indicator.engine + restart: always + mem_limit: 2g + env_file: + - .env + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-collector + + hydra-regime: + build: . + command: python -m hydra.regime.engine + restart: always + mem_limit: 1g + env_file: + - .env + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-indicator + + hydra-strategy: + build: . + command: python -m hydra.strategy.engine + restart: always + mem_limit: 1g + env_file: + - .env + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + - STRATEGY_DRY_RUN=true + - STRATEGY_TRADE_AMOUNT_USD=100 + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-regime + + hydra-orderbook: + build: . + command: python -m hydra.supplemental.orderbook + restart: always + mem_limit: 128m + env_file: + - .env + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-indicator + + hydra-events: + build: . + command: python -m hydra.supplemental.events + restart: always + mem_limit: 128m + env_file: + - .env + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - COINMARKETCAL_API_KEY=${COINMARKETCAL_API_KEY:-} + volumes: + - ./config:/app/config + depends_on: + - redis + + hydra-sentiment: + build: . + command: python -m hydra.supplemental.sentiment + restart: always + mem_limit: 256m + env_file: + - .env + environment: + - HYDRA_PROFILE=expert + - REDIS_URL=redis://redis:6379 + - CRYPTOPANIC_API_KEY=${CRYPTOPANIC_API_KEY:-} + volumes: + - ./config:/app/config + depends_on: + - redis + - hydra-indicator + + telegram-bot: + build: . + command: python -m hydra.notify.telegram + mem_limit: 512m + restart: always + env_file: + - .env + depends_on: + - hydra-core + +volumes: + redis-data: + db-data: diff --git a/docker-compose.lite.yml b/docker-compose.lite.yml new file mode 100644 index 0000000..45d21fb --- /dev/null +++ b/docker-compose.lite.yml @@ -0,0 +1,172 @@ +version: "3.9" + +services: + hydra-core: + build: . + command: uvicorn hydra.main:app --host 127.0.0.1 --port 8000 + mem_limit: 2g + cpus: 2 + restart: always + ports: + - "127.0.0.1:8000:8000" + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - DB_URL=sqlite:///data/hydra.db + env_file: + - .env + volumes: + - ./data:/app/data + - ./config:/app/config + depends_on: + - redis + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + mem_limit: 1g + restart: always + volumes: + - redis-data:/data + command: redis-server --appendonly yes + + hydra-collector: + build: . + command: python -m hydra.data.collector + mem_limit: 512m + restart: always + env_file: + - .env + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - DB_URL=sqlite:///data/hydra.db + volumes: + - ./data:/app/data + - ./config:/app/config + depends_on: + - redis + + hydra-indicator: + build: . + command: python -m hydra.indicator.engine + restart: always + mem_limit: 512m + env_file: + - .env + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - DB_URL=sqlite:///data/hydra.db + volumes: + - ./data:/app/data + - ./config:/app/config + depends_on: + - redis + - hydra-collector + + hydra-regime: + build: . + command: python -m hydra.regime.engine + restart: always + mem_limit: 256m + env_file: + - .env + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - DB_URL=sqlite:///data/hydra.db + volumes: + - ./data:/app/data + - ./config:/app/config + depends_on: + - redis + - hydra-indicator + + hydra-strategy: + build: . + command: python -m hydra.strategy.engine + restart: always + mem_limit: 256m + env_file: + - .env + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - DB_URL=sqlite:///data/hydra.db + - STRATEGY_DRY_RUN=true + - STRATEGY_TRADE_AMOUNT_USD=100 + volumes: + - ./data:/app/data + - ./config:/app/config + depends_on: + - redis + - hydra-regime + + hydra-orderbook: + build: . + command: python -m hydra.supplemental.orderbook + restart: always + mem_limit: 128m + env_file: + - .env + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - DB_URL=sqlite:///data/hydra.db + volumes: + - ./data:/app/data + - ./config:/app/config + depends_on: + - redis + - hydra-indicator + + hydra-events: + build: . + command: python -m hydra.supplemental.events + restart: always + mem_limit: 128m + env_file: + - .env + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - COINMARKETCAL_API_KEY=${COINMARKETCAL_API_KEY:-} + volumes: + - ./config:/app/config + depends_on: + - redis + + hydra-sentiment: + build: . + command: python -m hydra.supplemental.sentiment + restart: always + mem_limit: 256m + env_file: + - .env + environment: + - HYDRA_PROFILE=lite + - REDIS_URL=redis://redis:6379 + - CRYPTOPANIC_API_KEY=${CRYPTOPANIC_API_KEY:-} + volumes: + - ./config:/app/config + depends_on: + - redis + - hydra-indicator + + telegram-bot: + build: . + command: python -m hydra.notify.telegram + mem_limit: 256m + restart: always + env_file: + - .env + depends_on: + - hydra-core + +volumes: + redis-data: diff --git a/docker-compose.pro.yml b/docker-compose.pro.yml new file mode 100644 index 0000000..d143a3f --- /dev/null +++ b/docker-compose.pro.yml @@ -0,0 +1,185 @@ +version: "3.9" + +services: + hydra-core: + build: . + command: uvicorn hydra.main:app --host 127.0.0.1 --port 8000 + mem_limit: 4g + cpus: 4 + restart: always + ports: + - "127.0.0.1:8000:8000" + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + env_file: + - .env + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + mem_limit: 2g + restart: always + volumes: + - redis-data:/data + command: redis-server --appendonly yes + + timescaledb: + image: timescale/timescaledb:latest-pg16 + mem_limit: 4g + shm_size: 256m + restart: always + environment: + - POSTGRES_USER=hydra + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=hydra + volumes: + - db-data:/var/lib/postgresql/data + + hydra-collector: + build: . + command: python -m hydra.data.collector + mem_limit: 1g + restart: always + env_file: + - .env + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + + hydra-indicator: + build: . + command: python -m hydra.indicator.engine + restart: always + mem_limit: 1g + env_file: + - .env + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-collector + + hydra-regime: + build: . + command: python -m hydra.regime.engine + restart: always + mem_limit: 512m + env_file: + - .env + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-indicator + + hydra-strategy: + build: . + command: python -m hydra.strategy.engine + restart: always + mem_limit: 512m + env_file: + - .env + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + - STRATEGY_DRY_RUN=true + - STRATEGY_TRADE_AMOUNT_USD=100 + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-regime + + hydra-orderbook: + build: . + command: python -m hydra.supplemental.orderbook + restart: always + mem_limit: 128m + env_file: + - .env + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - DB_URL=postgresql://hydra:${DB_PASSWORD}@timescaledb:5432/hydra + volumes: + - ./config:/app/config + depends_on: + - redis + - timescaledb + - hydra-indicator + + hydra-events: + build: . + command: python -m hydra.supplemental.events + restart: always + mem_limit: 128m + env_file: + - .env + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - COINMARKETCAL_API_KEY=${COINMARKETCAL_API_KEY:-} + volumes: + - ./config:/app/config + depends_on: + - redis + + hydra-sentiment: + build: . + command: python -m hydra.supplemental.sentiment + restart: always + mem_limit: 256m + env_file: + - .env + environment: + - HYDRA_PROFILE=pro + - REDIS_URL=redis://redis:6379 + - CRYPTOPANIC_API_KEY=${CRYPTOPANIC_API_KEY:-} + volumes: + - ./config:/app/config + depends_on: + - redis + - hydra-indicator + + telegram-bot: + build: . + command: python -m hydra.notify.telegram + mem_limit: 512m + restart: always + env_file: + - .env + depends_on: + - hydra-core + +volumes: + redis-data: + db-data: diff --git a/docs/API_REFERENCE_KO.md b/docs/API_REFERENCE_KO.md new file mode 100644 index 0000000..f332b31 --- /dev/null +++ b/docs/API_REFERENCE_KO.md @@ -0,0 +1,154 @@ +# HYDRA Engine API 레퍼런스 + +모든 보호된 엔드포인트는 아래 헤더를 요구합니다. + +```http +X-HYDRA-KEY: +``` + +## 1. 헬스체크 + +### `GET /health` + +인증 없이 호출할 수 있습니다. + +```bash +curl http://127.0.0.1:8000/health +``` + +## 2. 시스템 + +### `GET /status` + +서버 프로필과 상태를 조회합니다. + +### `GET /modules` + +활성 모듈 상태를 조회합니다. + +## 3. 시장 관리 + +### `GET /markets` + +현재 활성 시장 목록을 반환합니다. + +### `POST /markets/{market_id}/enable` + +시장 활성화 + +예시: + +```bash +curl -X POST \ + -H "X-HYDRA-KEY: my-local-demo-key" \ + http://127.0.0.1:8000/markets/binance/enable +``` + +### `POST /markets/{market_id}/disable` + +시장 비활성화 + +## 4. 데이터 + +### `GET /data/symbols` + +수집 중이거나 저장된 시장/심볼/타임프레임 목록을 반환합니다. + +### `GET /data/candles` + +쿼리 파라미터: + +- `market` +- `symbol` +- `timeframe` +- `limit` (기본 200, 최대 1000) +- `since` (선택) + +예시: + +```bash +curl -G http://127.0.0.1:8000/data/candles \ + -H "X-HYDRA-KEY: my-local-demo-key" \ + --data-urlencode "market=binance" \ + --data-urlencode "symbol=BTC/USDT" \ + --data-urlencode "timeframe=1h" \ + --data-urlencode "limit=100" +``` + +## 5. 지표 / 레짐 / 시그널 + +### `GET /indicators` +### `GET /indicators/list` +### `GET /regime` +### `GET /regime/list` +### `GET /signal` +### `GET /signal/list` + +이 엔드포인트들은 최신 계산 결과를 확인할 때 사용합니다. + +## 6. 포지션 / 손익 / 리스크 + +### `GET /positions` +### `GET /pnl` +### `POST /pnl/reset-daily` +### `GET /risk` +### `POST /killswitch` + +`POST /killswitch`는 매우 위험한 명령이므로 테스트 목적이 아니라면 호출하지 마세요. + +예시: + +```bash +curl -X POST "http://127.0.0.1:8000/killswitch?reason=manual_test" \ + -H "X-HYDRA-KEY: my-local-demo-key" +``` + +## 7. 백테스트 + +### `POST /backtest/run` + +요청 본문: + +```json +{ + "market": "binance", + "symbol": "BTC/USDT", + "timeframe": "1h", + "since": 1704067200000, + "until": 1706745600000, + "initial_capital": 10000, + "trade_amount_usd": 100, + "commission_pct": 0.001 +} +``` + +응답에는 아래 항목이 포함됩니다. + +- 시장 / 심볼 / 타임프레임 +- 초기 자본 / 최종 자본 +- 체결 트레이드 목록 +- equity curve +- 성과 지표(`total_return_pct`, `total_trades`, `win_rate`, `max_drawdown_pct`, `sharpe_ratio`, `avg_pnl_usd`) + +## 8. 보조 데이터 + +### `GET /orderbook` +### `GET /events` +### `GET /sentiment` + +각 엔드포인트는 최근 오더북, 이벤트 일정, 감성 점수를 조회하는 데 사용합니다. + +## 9. 인증 실패 시 + +- 잘못된 API 키: `403 Invalid API key` +- 내부 초기화 전 호출: `503 Store not initialized` 등 + +## 10. 권장 호출 순서 + +1. `/health` +2. `/markets` +3. `/data/symbols` +4. `/data/candles` +5. `/backtest/run` + +실거래 관련 동작은 충분한 검증 이후에만 진행하세요. diff --git a/docs/QUICKSTART_KO.md b/docs/QUICKSTART_KO.md new file mode 100644 index 0000000..bd9e5f5 --- /dev/null +++ b/docs/QUICKSTART_KO.md @@ -0,0 +1,116 @@ +# HYDRA Engine 빠른 시작 가이드 + +이 문서는 "처음 받은 사람이 10분 안에 테스트와 기본 실행까지 해보는 것"을 목표로 합니다. + +## 1. 준비물 + +- Python 3.11 이상 +- Git +- Docker / Docker Compose + +## 2. 설치 + +```bash +git clone https://github.com/sinmb79/Hydra-Engine.git +cd Hydra-Engine +python -m venv .venv +``` + +Windows PowerShell: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +패키지 설치: + +```bash +pip install -e .[dev] +``` + +## 3. 환경 변수 + +```bash +cp .env.example .env +``` + +최소 예시: + +```env +HYDRA_API_KEY=my-local-demo-key +HYDRA_PROFILE=lite +REDIS_URL=redis://localhost:6379 +``` + +## 4. 정상 동작 확인 + +```bash +pytest -q +``` + +테스트가 모두 통과하면 기본 코드 상태는 정상입니다. + +## 5. Lite 프로필 실행 + +```bash +docker compose -f docker-compose.lite.yml up --build +``` + +다른 터미널에서 헬스체크: + +```bash +curl http://127.0.0.1:8000/health +``` + +## 6. 필수 API 예제 + +### 6.1 활성 시장 확인 + +```bash +curl -H "X-HYDRA-KEY: my-local-demo-key" http://127.0.0.1:8000/markets +``` + +### 6.2 저장된 심볼 목록 확인 + +```bash +curl -H "X-HYDRA-KEY: my-local-demo-key" http://127.0.0.1:8000/data/symbols +``` + +### 6.3 백테스트 실행 + +```bash +curl -X POST http://127.0.0.1:8000/backtest/run \ + -H "Content-Type: application/json" \ + -H "X-HYDRA-KEY: my-local-demo-key" \ + -d '{ + "market": "binance", + "symbol": "BTC/USDT", + "timeframe": "1h", + "since": 1704067200000, + "until": 1706745600000 + }' +``` + +## 7. CLI 예제 + +```bash +python -m hydra.cli.app status +python -m hydra.cli.app market list-markets +python -m hydra.cli.app market enable binance --mode paper +python -m hydra.cli.app kill +``` + +## 8. 추천 사용 순서 + +1. 테스트 통과 확인 +2. Lite 프로필 실행 +3. 시장 설정 확인 +4. 데이터 조회 +5. 백테스트 실행 +6. 전략/실거래 확장 여부 판단 + +## 9. 주의사항 + +- 실거래 키를 넣기 전에 먼저 paper 모드로 확인하세요. +- API 키, 계정번호, 개인 설정은 `.env` 또는 로컬 전용 파일에만 저장하세요. +- FastAPI Swagger UI는 기본 비활성화 상태입니다. API 사용법은 `docs/API_REFERENCE_KO.md`를 참고하세요. diff --git a/hydra/__init__.py b/hydra/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/hydra/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/hydra/api/__init__.py b/hydra/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/api/auth.py b/hydra/api/auth.py new file mode 100644 index 0000000..b3b6276 --- /dev/null +++ b/hydra/api/auth.py @@ -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 diff --git a/hydra/api/backtest.py b/hydra/api/backtest.py new file mode 100644 index 0000000..9a1efa0 --- /dev/null +++ b/hydra/api/backtest.py @@ -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) diff --git a/hydra/api/data.py b/hydra/api/data.py new file mode 100644 index 0000000..11b42c1 --- /dev/null +++ b/hydra/api/data.py @@ -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() diff --git a/hydra/api/health.py b/hydra/api/health.py new file mode 100644 index 0000000..fe2d3c2 --- /dev/null +++ b/hydra/api/health.py @@ -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 diff --git a/hydra/api/indicators.py b/hydra/api/indicators.py new file mode 100644 index 0000000..81c0941 --- /dev/null +++ b/hydra/api/indicators.py @@ -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 diff --git a/hydra/api/markets.py b/hydra/api/markets.py new file mode 100644 index 0000000..e6f9d45 --- /dev/null +++ b/hydra/api/markets.py @@ -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} diff --git a/hydra/api/orders.py b/hydra/api/orders.py new file mode 100644 index 0000000..4d328ee --- /dev/null +++ b/hydra/api/orders.py @@ -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) diff --git a/hydra/api/pnl.py b/hydra/api/pnl.py new file mode 100644 index 0000000..028e487 --- /dev/null +++ b/hydra/api/pnl.py @@ -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"} diff --git a/hydra/api/positions.py b/hydra/api/positions.py new file mode 100644 index 0000000..0e7ed9f --- /dev/null +++ b/hydra/api/positions.py @@ -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()} diff --git a/hydra/api/regime.py b/hydra/api/regime.py new file mode 100644 index 0000000..6a1a81c --- /dev/null +++ b/hydra/api/regime.py @@ -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 diff --git a/hydra/api/risk.py b/hydra/api/risk.py new file mode 100644 index 0000000..dd45299 --- /dev/null +++ b/hydra/api/risk.py @@ -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} diff --git a/hydra/api/signals.py b/hydra/api/signals.py new file mode 100644 index 0000000..4e4f93f --- /dev/null +++ b/hydra/api/signals.py @@ -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 diff --git a/hydra/api/strategies.py b/hydra/api/strategies.py new file mode 100644 index 0000000..7eb4008 --- /dev/null +++ b/hydra/api/strategies.py @@ -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에서 구현 예정"} diff --git a/hydra/api/supplemental.py b/hydra/api/supplemental.py new file mode 100644 index 0000000..9252aa0 --- /dev/null +++ b/hydra/api/supplemental.py @@ -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} diff --git a/hydra/api/system.py b/hydra/api/system.py new file mode 100644 index 0000000..79a8f54 --- /dev/null +++ b/hydra/api/system.py @@ -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"]} diff --git a/hydra/backtest/__init__.py b/hydra/backtest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/backtest/broker.py b/hydra/backtest/broker.py new file mode 100644 index 0000000..5ac407f --- /dev/null +++ b/hydra/backtest/broker.py @@ -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 diff --git a/hydra/backtest/result.py b/hydra/backtest/result.py new file mode 100644 index 0000000..33c0360 --- /dev/null +++ b/hydra/backtest/result.py @@ -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), + } diff --git a/hydra/backtest/runner.py b/hydra/backtest/runner.py new file mode 100644 index 0000000..1ea6ae0 --- /dev/null +++ b/hydra/backtest/runner.py @@ -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, + ) diff --git a/hydra/cli/__init__.py b/hydra/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/cli/app.py b/hydra/cli/app.py new file mode 100644 index 0000000..2816554 --- /dev/null +++ b/hydra/cli/app.py @@ -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() diff --git a/hydra/cli/kill.py b/hydra/cli/kill.py new file mode 100644 index 0000000..a85a638 --- /dev/null +++ b/hydra/cli/kill.py @@ -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) diff --git a/hydra/cli/market.py b/hydra/cli/market.py new file mode 100644 index 0000000..a30c320 --- /dev/null +++ b/hydra/cli/market.py @@ -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 '로 활성화하세요.") diff --git a/hydra/cli/module.py b/hydra/cli/module.py new file mode 100644 index 0000000..da6267e --- /dev/null +++ b/hydra/cli/module.py @@ -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)) diff --git a/hydra/cli/setup_wizard.py b/hydra/cli/setup_wizard.py new file mode 100644 index 0000000..dcccc10 --- /dev/null +++ b/hydra/cli/setup_wizard.py @@ -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) diff --git a/hydra/cli/status.py b/hydra/cli/status.py new file mode 100644 index 0000000..367ff43 --- /dev/null +++ b/hydra/cli/status.py @@ -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("[오류] 서버 오프라인") diff --git a/hydra/cli/strategy.py b/hydra/cli/strategy.py new file mode 100644 index 0000000..e4ac78b --- /dev/null +++ b/hydra/cli/strategy.py @@ -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에서 구현 예정") diff --git a/hydra/cli/trade.py b/hydra/cli/trade.py new file mode 100644 index 0000000..be9fdb4 --- /dev/null +++ b/hydra/cli/trade.py @@ -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에서 구현 예정") diff --git a/hydra/config/__init__.py b/hydra/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/config/keys.py b/hydra/config/keys.py new file mode 100644 index 0000000..fdcc039 --- /dev/null +++ b/hydra/config/keys.py @@ -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 diff --git a/hydra/config/markets.py b/hydra/config/markets.py new file mode 100644 index 0000000..da600e7 --- /dev/null +++ b/hydra/config/markets.py @@ -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") diff --git a/hydra/config/profiles.py b/hydra/config/profiles.py new file mode 100644 index 0000000..6d126fe --- /dev/null +++ b/hydra/config/profiles.py @@ -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] diff --git a/hydra/config/settings.py b/hydra/config/settings.py new file mode 100644 index 0000000..c725844 --- /dev/null +++ b/hydra/config/settings.py @@ -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() diff --git a/hydra/config/validation.py b/hydra/config/validation.py new file mode 100644 index 0000000..b9a2e23 --- /dev/null +++ b/hydra/config/validation.py @@ -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 diff --git a/hydra/core/__init__.py b/hydra/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/core/kill_switch.py b/hydra/core/kill_switch.py new file mode 100644 index 0000000..6d55a5e --- /dev/null +++ b/hydra/core/kill_switch.py @@ -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)) diff --git a/hydra/core/order_queue.py b/hydra/core/order_queue.py new file mode 100644 index 0000000..0a0214a --- /dev/null +++ b/hydra/core/order_queue.py @@ -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) diff --git a/hydra/core/pnl_tracker.py b/hydra/core/pnl_tracker.py new file mode 100644 index 0000000..eac17f5 --- /dev/null +++ b/hydra/core/pnl_tracker.py @@ -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") diff --git a/hydra/core/position_tracker.py b/hydra/core/position_tracker.py new file mode 100644 index 0000000..0dc604f --- /dev/null +++ b/hydra/core/position_tracker.py @@ -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()} diff --git a/hydra/core/risk_engine.py b/hydra/core/risk_engine.py new file mode 100644 index 0000000..b637fa1 --- /dev/null +++ b/hydra/core/risk_engine.py @@ -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, "" diff --git a/hydra/core/state_manager.py b/hydra/core/state_manager.py new file mode 100644 index 0000000..bc1a8c9 --- /dev/null +++ b/hydra/core/state_manager.py @@ -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()} diff --git a/hydra/exchange/__init__.py b/hydra/exchange/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/exchange/base.py b/hydra/exchange/base.py new file mode 100644 index 0000000..ddcd4e4 --- /dev/null +++ b/hydra/exchange/base.py @@ -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 diff --git a/hydra/exchange/crypto.py b/hydra/exchange/crypto.py new file mode 100644 index 0000000..c9d2c29 --- /dev/null +++ b/hydra/exchange/crypto.py @@ -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) diff --git a/hydra/exchange/factory.py b/hydra/exchange/factory.py new file mode 100644 index 0000000..d93c3b1 --- /dev/null +++ b/hydra/exchange/factory.py @@ -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 diff --git a/hydra/exchange/kis.py b/hydra/exchange/kis.py new file mode 100644 index 0000000..108919d --- /dev/null +++ b/hydra/exchange/kis.py @@ -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 + ] diff --git a/hydra/exchange/polymarket.py b/hydra/exchange/polymarket.py new file mode 100644 index 0000000..da2c933 --- /dev/null +++ b/hydra/exchange/polymarket.py @@ -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 [] diff --git a/hydra/indicator/__init__.py b/hydra/indicator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/indicator/calculator.py b/hydra/indicator/calculator.py new file mode 100644 index 0000000..325ae3c --- /dev/null +++ b/hydra/indicator/calculator.py @@ -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 diff --git a/hydra/indicator/engine.py b/hydra/indicator/engine.py new file mode 100644 index 0000000..6c96d87 --- /dev/null +++ b/hydra/indicator/engine.py @@ -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()) diff --git a/hydra/logging/__init__.py b/hydra/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/logging/setup.py b/hydra/logging/setup.py new file mode 100644 index 0000000..c2e5e52 --- /dev/null +++ b/hydra/logging/setup.py @@ -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) diff --git a/hydra/main.py b/hydra/main.py new file mode 100644 index 0000000..ac139b7 --- /dev/null +++ b/hydra/main.py @@ -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) diff --git a/hydra/notify/__init__.py b/hydra/notify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/notify/telegram.py b/hydra/notify/telegram.py new file mode 100644 index 0000000..6ad6193 --- /dev/null +++ b/hydra/notify/telegram.py @@ -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() diff --git a/hydra/regime/__init__.py b/hydra/regime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/regime/detector.py b/hydra/regime/detector.py new file mode 100644 index 0000000..1ac006d --- /dev/null +++ b/hydra/regime/detector.py @@ -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" diff --git a/hydra/regime/engine.py b/hydra/regime/engine.py new file mode 100644 index 0000000..7d5efb0 --- /dev/null +++ b/hydra/regime/engine.py @@ -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()) diff --git a/hydra/resilience/__init__.py b/hydra/resilience/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/resilience/circuit_breaker.py b/hydra/resilience/circuit_breaker.py new file mode 100644 index 0000000..90be673 --- /dev/null +++ b/hydra/resilience/circuit_breaker.py @@ -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) diff --git a/hydra/resilience/graceful.py b/hydra/resilience/graceful.py new file mode 100644 index 0000000..06d9fe5 --- /dev/null +++ b/hydra/resilience/graceful.py @@ -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) diff --git a/hydra/resilience/rate_limiter.py b/hydra/resilience/rate_limiter.py new file mode 100644 index 0000000..7374106 --- /dev/null +++ b/hydra/resilience/rate_limiter.py @@ -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 diff --git a/hydra/resilience/retry.py b/hydra/resilience/retry.py new file mode 100644 index 0000000..0bdf177 --- /dev/null +++ b/hydra/resilience/retry.py @@ -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) diff --git a/hydra/strategy/__init__.py b/hydra/strategy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/strategy/engine.py b/hydra/strategy/engine.py new file mode 100644 index 0000000..bca4c36 --- /dev/null +++ b/hydra/strategy/engine.py @@ -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()) diff --git a/hydra/strategy/signal.py b/hydra/strategy/signal.py new file mode 100644 index 0000000..a7d9a2d --- /dev/null +++ b/hydra/strategy/signal.py @@ -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) diff --git a/hydra/supplemental/__init__.py b/hydra/supplemental/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydra/supplemental/events.py b/hydra/supplemental/events.py new file mode 100644 index 0000000..73e3a7f --- /dev/null +++ b/hydra/supplemental/events.py @@ -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()) diff --git a/hydra/supplemental/orderbook.py b/hydra/supplemental/orderbook.py new file mode 100644 index 0000000..73b8eda --- /dev/null +++ b/hydra/supplemental/orderbook.py @@ -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()) diff --git a/hydra/supplemental/sentiment.py b/hydra/supplemental/sentiment.py new file mode 100644 index 0000000..5753419 --- /dev/null +++ b/hydra/supplemental/sentiment.py @@ -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()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ad292a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hydra" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi==0.115.*", + "uvicorn[standard]==0.34.*", + "redis==5.2.*", + "pydantic==2.10.*", + "pydantic-settings==2.7.*", + "cryptography==44.*", + "pybreaker==1.2.*", + "tenacity==9.0.*", + "structlog==24.*", + "typer==0.15.*", + "python-telegram-bot==21.*", + "python-kis==1.*", + "ccxt==4.*", + "psutil==6.*", + "prometheus-client==0.21.*", + "httpx==0.28.*", + "pyyaml==6.*", + "websockets==12.*", + "aiosqlite==0.20.*", + "asyncpg==0.30.*", + "pandas-ta>=0.3.14b", + "pandas>=2.0", + "numpy>=1.26", + "vaderSentiment>=3.3", +] + +[project.optional-dependencies] +dev = [ + "pytest==8.*", + "pytest-asyncio==0.24.*", + "pytest-cov==6.*", +] + +[project.scripts] +hydra = "hydra.cli.app:app" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..f6b1e63 --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""HYDRA 하드웨어 벤치마크.""" +import time +import math +import typer +import psutil + + +def cpu_benchmark(seconds: int = 3) -> float: + """단순 연산 벤치마크. ops/sec 반환.""" + count = 0 + end = time.monotonic() + seconds + while time.monotonic() < end: + math.sqrt(2 ** 32) + count += 1 + return count / seconds + + +def main(profile: str = "lite"): + typer.echo(f"🔬 벤치마크 시작 (프로필: {profile})") + mem = psutil.virtual_memory() + typer.echo(f" RAM: {mem.total // 1024**3}GB 사용 가능: {mem.available // 1024**3}GB") + + ops = cpu_benchmark(2) + typer.echo(f" CPU 연산: {ops/1e6:.1f}M ops/sec") + + thresholds = {"lite": 50, "pro": 100, "expert": 200} + min_ops = thresholds.get(profile, 50) * 1e6 + if ops >= min_ops: + typer.echo(f" ✅ 벤치마크 통과") + else: + typer.echo(f" ⚠️ 성능 미달 (권장: {min_ops/1e6:.0f}M ops/sec)") + + +if __name__ == "__main__": + typer.run(main) diff --git a/scripts/dr_watchdog.py b/scripts/dr_watchdog.py new file mode 100644 index 0000000..2c92825 --- /dev/null +++ b/scripts/dr_watchdog.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +AWS Lambda 배포용 DR L2 워치독. +1분마다 헬스체크. 3회 연속 실패 시 비상 청산 API 호출. +""" +import os +import json +import boto3 +import requests + +HEALTH_URL = os.environ.get("HYDRA_HEALTH_URL", "http://YOUR_MINI_PC_IP:8000/health") +KILL_URL = os.environ.get("HYDRA_KILL_URL", "http://YOUR_MINI_PC_IP:8000/killswitch") +HYDRA_API_KEY = os.environ.get("HYDRA_API_KEY", "") +TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") +MAX_FAILURES = 3 +FAILURE_KEY = "hydra_watchdog_failures" + + +def get_failure_count() -> int: + dynamo = boto3.resource("dynamodb") + table = dynamo.Table("hydra_watchdog") + resp = table.get_item(Key={"id": FAILURE_KEY}) + return int(resp.get("Item", {}).get("count", 0)) + + +def set_failure_count(count: int) -> None: + dynamo = boto3.resource("dynamodb") + table = dynamo.Table("hydra_watchdog") + table.put_item(Item={"id": FAILURE_KEY, "count": count}) + + +def reset_failure_count() -> None: + set_failure_count(0) + + +def emergency_close_all() -> dict: + resp = requests.post( + KILL_URL, + params={"reason": "dr_l2_watchdog"}, + headers={"X-HYDRA-KEY": HYDRA_API_KEY}, + timeout=30, + ) + return resp.json() + + +def send_telegram_alert(message: str) -> None: + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: + return + url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" + requests.post(url, json={"chat_id": TELEGRAM_CHAT_ID, "text": message}, timeout=10) + + +def lambda_handler(event, context) -> dict: + failure_count = get_failure_count() + try: + r = requests.get(HEALTH_URL, timeout=5) + if r.status_code == 200: + reset_failure_count() + return {"status": "ok", "failures": 0} + except Exception: + pass + + failure_count += 1 + set_failure_count(failure_count) + + if failure_count >= MAX_FAILURES: + result = emergency_close_all() + send_telegram_alert(f"⚠️ DR L2 발동: 미니PC {MAX_FAILURES}회 무응답.\n전 포지션 청산: {result}") + reset_failure_count() + + return {"status": "failure", "failures": failure_count} diff --git a/scripts/hydra.service b/scripts/hydra.service new file mode 100644 index 0000000..990d942 --- /dev/null +++ b/scripts/hydra.service @@ -0,0 +1,17 @@ +[Unit] +Description=HYDRA Trading System +After=docker.service +Requires=docker.service + +[Service] +Type=simple +Restart=always +RestartSec=10 +WorkingDirectory=/opt/hydra +ExecStart=/usr/bin/docker compose -f docker-compose.lite.yml up +ExecStop=/usr/bin/docker compose -f docker-compose.lite.yml down +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..77024ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + + +@pytest.fixture +def mock_redis(): + r = MagicMock() + r.set.return_value = True + r.get.return_value = None + r.delete.return_value = True + return r + + +@pytest.fixture +def mock_exchange(): + ex = AsyncMock() + ex.cancel_all.return_value = [] + ex.get_positions.return_value = [] + ex.cancel_order.return_value = {"status": "canceled"} + return ex diff --git a/tests/test_backfill.py b/tests/test_backfill.py new file mode 100644 index 0000000..5ee4cf1 --- /dev/null +++ b/tests/test_backfill.py @@ -0,0 +1,64 @@ +import pytest +from unittest.mock import AsyncMock +from hydra.data.models import Candle +from hydra.data.backfill import Backfiller +from hydra.data.storage.base import OhlcvStore + + +def make_raw_ohlcv(base_time: int, count: int) -> list: + """Return ccxt fetch_ohlcv-style rows: [ts_ms, o, h, l, c, v].""" + return [ + [base_time + i * 60_000, 50000.0, 50100.0, 49900.0, 50050.0, 1.5] + for i in range(count) + ] + + +@pytest.fixture +def mock_store(): + store = AsyncMock(spec=OhlcvStore) + store.get_last_time.return_value = None + store.save.return_value = None + return store + + +async def test_backfill_one_saves_candles(mock_store): + fetch_fn = AsyncMock(return_value=make_raw_ohlcv(1_000_000, 5)) + filler = Backfiller(mock_store) + await filler._backfill_one("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn, limit=5) + + mock_store.save.assert_awaited_once() + saved: list[Candle] = mock_store.save.call_args[0][0] + assert len(saved) == 5 + assert isinstance(saved[0], Candle) + assert saved[0].open_time == 1_000_000 + assert saved[0].market == "binance" + assert saved[0].symbol == "BTC/USDT" + assert saved[0].timeframe == "1m" + assert saved[0].close_time == 1_000_000 + 60_000 - 1 + + +async def test_backfill_one_skips_empty_response(mock_store): + fetch_fn = AsyncMock(return_value=[]) + filler = Backfiller(mock_store) + await filler._backfill_one("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn) + mock_store.save.assert_not_awaited() + + +async def test_gap_backfill_uses_last_time(mock_store): + mock_store.get_last_time.return_value = 1_060_000 + fetch_fn = AsyncMock(return_value=make_raw_ohlcv(1_060_000, 3)) + filler = Backfiller(mock_store) + await filler.gap_backfill("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn) + + kwargs = fetch_fn.call_args[1] + assert kwargs.get("since") == 1_060_000 + + +async def test_gap_backfill_fetches_500_when_no_history(mock_store): + mock_store.get_last_time.return_value = None + fetch_fn = AsyncMock(return_value=make_raw_ohlcv(1_000_000, 500)) + filler = Backfiller(mock_store) + await filler.gap_backfill("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn) + + kwargs = fetch_fn.call_args[1] + assert kwargs.get("limit") == 500 diff --git a/tests/test_backtest_broker.py b/tests/test_backtest_broker.py new file mode 100644 index 0000000..324f7fe --- /dev/null +++ b/tests/test_backtest_broker.py @@ -0,0 +1,56 @@ +# tests/test_backtest_broker.py +import pytest +from hydra.backtest.broker import BacktestBroker +from hydra.backtest.result import Trade +from hydra.data.models import Candle +from hydra.strategy.signal import Signal + + +def _make_candle(close: float, ts: int = 1000) -> Candle: + return Candle( + market="binance", symbol="BTC/USDT", timeframe="1h", + open_time=ts, open=close, high=close, low=close, + close=close, volume=1.0, close_time=ts + 3600000, + ) + + +def _make_signal(sig: str, price: float, reason: str = "test") -> Signal: + return Signal(signal=sig, reason=reason, price=price, ts=1000) + + +def test_buy_opens_position(): + broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0, + commission_pct=0.0) + broker.on_signal(_make_signal("BUY", 100.0), _make_candle(100.0, ts=1000)) + assert len(broker.trades) == 0 # trade recorded only on close + assert broker.equity == pytest.approx(10000.0, abs=0.01) + + +def test_sell_closes_position_and_records_trade(): + broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0, + commission_pct=0.0) + broker.on_signal(_make_signal("BUY", 100.0), _make_candle(100.0, ts=1000)) + broker.on_signal(_make_signal("SELL", 110.0), _make_candle(110.0, ts=2000)) + assert len(broker.trades) == 1 + trade = broker.trades[0] + assert trade.entry_price == 100.0 + assert trade.exit_price == 110.0 + assert trade.pnl_usd == pytest.approx(10.0, abs=0.01) # 1.0 qty * 10 price diff + assert broker.equity == pytest.approx(10010.0, abs=0.01) + + +def test_sell_without_position_is_ignored(): + broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0) + broker.on_signal(_make_signal("SELL", 110.0), _make_candle(110.0)) + assert len(broker.trades) == 0 + assert broker.equity == pytest.approx(10000.0, abs=0.01) + + +def test_force_close_records_trade(): + broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0, + commission_pct=0.0) + broker.on_signal(_make_signal("BUY", 100.0), _make_candle(100.0, ts=1000)) + broker.close_open_position(price=120.0, ts=9000, reason="backtest_end") + assert len(broker.trades) == 1 + assert broker.trades[0].exit_price == 120.0 + assert broker.trades[0].pnl_usd == pytest.approx(20.0, abs=0.01) diff --git a/tests/test_backtest_runner.py b/tests/test_backtest_runner.py new file mode 100644 index 0000000..7dd22d4 --- /dev/null +++ b/tests/test_backtest_runner.py @@ -0,0 +1,175 @@ +# tests/test_backtest_runner.py +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock +from hydra.backtest.result import Trade, BacktestResult, compute_metrics + + +def _make_trade(pnl_usd: float, entry_price: float = 100.0, + exit_price: float = 110.0) -> Trade: + return Trade( + market="binance", + symbol="BTC/USDT", + entry_price=entry_price, + exit_price=exit_price, + qty=1.0, + pnl_usd=pnl_usd, + pnl_pct=(exit_price - entry_price) / entry_price * 100, + entry_ts=1000, + exit_ts=2000, + entry_reason="trend_up: ema_cross+rsi", + exit_reason="trend_down: ema_cross+rsi", + ) + + +def test_compute_metrics_no_trades(): + metrics = compute_metrics( + trades=[], + equity_curve=[{"ts": 1000, "equity": 10000.0}], + initial_capital=10000.0, + final_equity=10000.0, + ) + assert metrics["total_return_pct"] == 0.0 + assert metrics["total_trades"] == 0 + assert metrics["win_rate"] == 0.0 + assert metrics["max_drawdown_pct"] == 0.0 + assert metrics["sharpe_ratio"] == 0.0 + assert metrics["avg_pnl_usd"] == 0.0 + + +def test_compute_metrics_with_trades(): + trades = [_make_trade(50.0), _make_trade(-20.0), _make_trade(30.0)] + equity_curve = [ + {"ts": 1000, "equity": 10000.0}, + {"ts": 2000, "equity": 10050.0}, + {"ts": 3000, "equity": 10030.0}, + {"ts": 4000, "equity": 10060.0}, + ] + metrics = compute_metrics( + trades=trades, + equity_curve=equity_curve, + initial_capital=10000.0, + final_equity=10060.0, + ) + assert metrics["total_trades"] == 3 + assert metrics["total_return_pct"] == pytest.approx(0.6, abs=0.01) + assert metrics["win_rate"] == pytest.approx(66.67, abs=0.1) + assert metrics["avg_pnl_usd"] == pytest.approx(20.0, abs=0.01) + assert metrics["max_drawdown_pct"] >= 0.0 + assert isinstance(metrics["sharpe_ratio"], float) + + +from hydra.backtest.runner import BacktestRunner +from hydra.data.models import Candle +from hydra.indicator.calculator import IndicatorCalculator +from hydra.regime.detector import RegimeDetector +from hydra.strategy.signal import SignalGenerator + + +def _make_candles(n: int, base_close: float = 100.0) -> list[Candle]: + """Generate n synthetic candles with monotonically increasing timestamps.""" + candles = [] + for i in range(n): + close = base_close + i * 0.1 + candles.append(Candle( + market="binance", symbol="BTC/USDT", timeframe="1h", + open_time=1_000_000 + i * 3_600_000, + open=close, high=close + 1, low=close - 1, + close=close, volume=100.0, + close_time=1_000_000 + i * 3_600_000 + 3_599_000, + )) + return candles + + +@pytest.mark.asyncio +async def test_runner_returns_backtest_result(): + candles = _make_candles(230) + calculator = MagicMock(spec=IndicatorCalculator) + calculator.compute = MagicMock(return_value={ + "EMA_9": 101.0, "EMA_20": 100.0, "RSI_14": 55.0, + "BBB_5_2.0_2.0": 0.05, "ADX_14": 30.0, "EMA_50": 99.0, + }) + + runner = BacktestRunner( + store=MagicMock(query=AsyncMock(return_value=candles)), + calculator=calculator, + detector=RegimeDetector(), + generator=SignalGenerator(), + initial_capital=10000.0, + trade_amount_usd=100.0, + ) + since = candles[210].open_time + until = candles[-1].open_time + result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until) + + from hydra.backtest.result import BacktestResult + assert isinstance(result, BacktestResult) + assert result.market == "binance" + assert result.symbol == "BTC/USDT" + assert result.initial_capital == 10000.0 + assert "total_return_pct" in result.metrics + + +@pytest.mark.asyncio +async def test_runner_insufficient_data_returns_empty_result(): + candles = _make_candles(50) # less than 210 warmup + runner = BacktestRunner( + store=MagicMock(query=AsyncMock(return_value=candles)), + calculator=IndicatorCalculator(), + detector=RegimeDetector(), + generator=SignalGenerator(), + ) + since = candles[0].open_time + until = candles[-1].open_time + result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until) + assert result.metrics["total_trades"] == 0 + + +@pytest.mark.asyncio +async def test_runner_validates_since_until(): + runner = BacktestRunner( + store=MagicMock(), + calculator=IndicatorCalculator(), + detector=RegimeDetector(), + generator=SignalGenerator(), + ) + with pytest.raises(ValueError, match="since"): + await runner.run("binance", "BTC/USDT", "1h", since=2000, until=1000) + + +@pytest.mark.asyncio +async def test_runner_applies_commission(): + candles = _make_candles(230) + call_count = 0 + base_indicators = { + "EMA_9": 101.0, "EMA_20": 100.0, + "BBB_5_2.0_2.0": 0.05, "ADX_14": 30.0, "EMA_50": 99.0, + } + + def mock_compute(candles_window): + nonlocal call_count + call_count += 1 + if call_count % 2 == 1: + return {**base_indicators, "RSI_14": 55.0} # trending_up BUY + else: + return {**base_indicators, "RSI_14": 25.0, + "EMA_9": 99.0, "EMA_20": 100.0} # trending_down SELL + + calculator = MagicMock(spec=IndicatorCalculator) + calculator.compute = MagicMock(side_effect=mock_compute) + + runner = BacktestRunner( + store=MagicMock(query=AsyncMock(return_value=candles)), + calculator=calculator, + detector=RegimeDetector(), + generator=SignalGenerator(), + initial_capital=10000.0, + trade_amount_usd=100.0, + commission_pct=0.001, + ) + since = candles[210].open_time + until = candles[-1].open_time + result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until) + # With commission, final_equity should differ from initial if there were trades + assert isinstance(result.final_equity, float) + assert result.metrics["total_trades"] >= 0 diff --git a/tests/test_circuit_breaker.py b/tests/test_circuit_breaker.py new file mode 100644 index 0000000..a8e5535 --- /dev/null +++ b/tests/test_circuit_breaker.py @@ -0,0 +1,26 @@ +import pytest +from pybreaker import CircuitBreakerError +from hydra.resilience.circuit_breaker import create_breaker + + +def failing_fn(): + raise ConnectionError("exchange down") + + +def test_breaker_open_after_failures(): + breaker = create_breaker("test", fail_max=3, reset_timeout=60) + for _ in range(2): + with pytest.raises(ConnectionError): + breaker.call(failing_fn) + # 3번째 실패에서 circuit이 open된다 + with pytest.raises(CircuitBreakerError): + breaker.call(failing_fn) + # circuit이 open되었으므로 이후 호출도 CircuitBreakerError + with pytest.raises(CircuitBreakerError): + breaker.call(failing_fn) + + +def test_breaker_passes_success(): + breaker = create_breaker("test2", fail_max=5, reset_timeout=60) + result = breaker.call(lambda: "ok") + assert result == "ok" diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py new file mode 100644 index 0000000..071e7aa --- /dev/null +++ b/tests/test_cli_help.py @@ -0,0 +1,27 @@ +import os +import subprocess +import sys + + +def _run_cli_help(*args: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PYTHONIOENCODING"] = "cp949" + env["PYTHONUTF8"] = "0" + return subprocess.run( + [sys.executable, "-m", "hydra.cli.app", *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_root_help_renders_without_unicode_error(): + result = _run_cli_help("--help") + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stdout + + +def test_kill_help_renders_without_unicode_error(): + result = _run_cli_help("kill", "--help") + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stdout diff --git a/tests/test_dr.py b/tests/test_dr.py new file mode 100644 index 0000000..45c6d82 --- /dev/null +++ b/tests/test_dr.py @@ -0,0 +1,41 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock +from hydra.resilience.graceful import GracefulManager + + +@pytest.mark.asyncio +async def test_l1_graceful_shutdown_saves_state(): + """프로세스 종료 전 상태 저장 확인 (systemd Restart=always가 L1 보장).""" + order_queue = MagicMock() + order_queue.block_new_orders = MagicMock() + position_tracker = AsyncMock() + position_tracker.snapshot.return_value = {"positions": [{"symbol": "005930"}]} + redis_client = MagicMock() + + manager = GracefulManager(order_queue, position_tracker, redis_client) + await manager.shutdown("SIGTERM") + + order_queue.block_new_orders.assert_called_once() + redis_client.set.assert_called_once() + args = redis_client.set.call_args[0] + assert "last_snapshot" in args[0] + + +def test_l2_watchdog_health_url_configurable(): + """Lambda 워치독 환경변수 설정 확인.""" + import importlib + import os + import sys + + os.environ["HYDRA_HEALTH_URL"] = "http://192.168.1.100:8000/health" + + # boto3/requests가 없을 경우 mock 처리 + mock_boto3 = MagicMock() + mock_requests = MagicMock() + sys.modules.setdefault("boto3", mock_boto3) + sys.modules.setdefault("requests", mock_requests) + + import scripts.dr_watchdog as wd + importlib.reload(wd) + assert "192.168.1.100" in wd.HEALTH_URL + del os.environ["HYDRA_HEALTH_URL"] diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py new file mode 100644 index 0000000..41e3d47 --- /dev/null +++ b/tests/test_graceful_shutdown.py @@ -0,0 +1,19 @@ +import asyncio +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from hydra.resilience.graceful import GracefulManager + + +@pytest.mark.asyncio +async def test_shutdown_saves_state(): + order_queue = MagicMock() + order_queue.block_new_orders = MagicMock() + position_tracker = AsyncMock() + position_tracker.snapshot.return_value = {"positions": []} + redis_client = MagicMock() + + manager = GracefulManager(order_queue, position_tracker, redis_client) + await manager.shutdown("SIGTERM") + + order_queue.block_new_orders.assert_called_once() + position_tracker.snapshot.assert_called_once() diff --git a/tests/test_indicator_calculator.py b/tests/test_indicator_calculator.py new file mode 100644 index 0000000..ede7433 --- /dev/null +++ b/tests/test_indicator_calculator.py @@ -0,0 +1,57 @@ +import math +import pytest +from hydra.data.models import Candle +from hydra.indicator.calculator import IndicatorCalculator + + +def _make_candles(n: int) -> list[Candle]: + candles = [] + price = 50000.0 + for i in range(n): + open_ = price + high = price * 1.001 + low = price * 0.999 + close = price * (1 + (0.001 if i % 2 == 0 else -0.001)) + price = close + candles.append(Candle( + market="binance", symbol="BTC/USDT", timeframe="1m", + open_time=1_000_000 + i * 60_000, + open=open_, high=high, low=low, close=close, + volume=100.0 + i, + close_time=1_000_000 + i * 60_000 + 59_999, + )) + return candles + + +def test_compute_returns_dict_with_rsi(): + calc = IndicatorCalculator() + candles = _make_candles(250) + result = calc.compute(candles) + assert isinstance(result, dict) + assert "RSI_14" in result + assert isinstance(result["RSI_14"], float), "RSI_14 must be a valid float with 250 candles" + + +def test_compute_no_nan_values(): + calc = IndicatorCalculator() + candles = _make_candles(250) + result = calc.compute(candles) + for key, val in result.items(): + if val is not None: + assert not (isinstance(val, float) and math.isnan(val)), \ + f"NaN found for key {key}" + + +def test_compute_returns_empty_for_insufficient_candles(): + calc = IndicatorCalculator() + candles = _make_candles(100) # fewer than 210 + result = calc.compute(candles) + assert result == {} + + +def test_compute_includes_calculated_at(): + calc = IndicatorCalculator() + candles = _make_candles(250) + result = calc.compute(candles) + assert "calculated_at" in result + assert isinstance(result["calculated_at"], int) diff --git a/tests/test_indicator_engine.py b/tests/test_indicator_engine.py new file mode 100644 index 0000000..d44f1cc --- /dev/null +++ b/tests/test_indicator_engine.py @@ -0,0 +1,81 @@ +import json +import pytest +from unittest.mock import AsyncMock, MagicMock +from hydra.data.models import Candle +from hydra.indicator.engine import IndicatorEngine +from hydra.indicator.calculator import IndicatorCalculator + + +def _make_candles(n: int = 250) -> list[Candle]: + candles = [] + price = 50000.0 + for i in range(n): + close = price * (1 + (0.001 if i % 2 == 0 else -0.001)) + price = close + candles.append(Candle( + market="binance", symbol="BTC/USDT", timeframe="1m", + open_time=1_000_000 + i * 60_000, + open=price, high=price * 1.001, low=price * 0.999, close=close, + volume=100.0, close_time=1_000_000 + i * 60_000 + 59_999, + )) + return candles + + +@pytest.fixture +def mock_store(): + store = AsyncMock() + store.query = AsyncMock(return_value=_make_candles(250)) + store.get_symbols = AsyncMock(return_value=[ + {"market": "binance", "symbol": "BTC/USDT", "timeframe": "1m"} + ]) + return store + + +@pytest.fixture +def mock_redis(): + r = MagicMock() + r.set = AsyncMock() + r.keys = MagicMock(return_value=[]) + return r + + +@pytest.fixture +def engine(mock_store, mock_redis): + calc = IndicatorCalculator() + return IndicatorEngine(store=mock_store, redis_client=mock_redis, calculator=calc) + + +@pytest.mark.asyncio +async def test_handle_event_writes_to_redis(engine, mock_redis): + await engine._handle_event("binance", "BTC/USDT", "1m") + mock_redis.set.assert_called_once() + key, value = mock_redis.set.call_args[0] + assert key == "hydra:indicator:binance:BTC/USDT:1m" + data = json.loads(value) + assert "RSI_14" in data + assert "calculated_at" in data + + +@pytest.mark.asyncio +async def test_handle_event_skips_empty_result(engine, mock_redis, mock_store): + # Only 5 candles → calculator returns {} + mock_store.query = AsyncMock(return_value=_make_candles(5)) + await engine._handle_event("binance", "BTC/USDT", "1m") + mock_redis.set.assert_not_called() + + +@pytest.mark.asyncio +async def test_handle_event_does_not_raise_on_exception(engine, mock_store): + mock_store.query = AsyncMock(side_effect=RuntimeError("db error")) + # Should not raise — failures must be isolated + await engine._handle_event("binance", "BTC/USDT", "1m") + + +@pytest.mark.asyncio +async def test_cold_start_processes_all_symbols(engine, mock_redis, mock_store): + mock_store.get_symbols = AsyncMock(return_value=[ + {"market": "binance", "symbol": "BTC/USDT", "timeframe": "1m"}, + {"market": "upbit", "symbol": "BTC/KRW", "timeframe": "1h"}, + ]) + await engine.cold_start() + assert mock_redis.set.call_count == 2 diff --git a/tests/test_key_security.py b/tests/test_key_security.py new file mode 100644 index 0000000..5f7c4cf --- /dev/null +++ b/tests/test_key_security.py @@ -0,0 +1,34 @@ +import os +import pytest +from pathlib import Path +from hydra.config.keys import KeyManager + + +def test_gitignore_contains_env(): + content = Path(".gitignore").read_text() + assert ".env" in content + assert "config/keys/" in content + assert "*.key" in content + + +def test_key_roundtrip(tmp_path): + km = KeyManager(master_key_path=str(tmp_path / "master.key")) + km.store("binance", "my_api_key", "my_secret") + api_key, secret = km.load("binance") + assert api_key == "my_api_key" + assert secret == "my_secret" + + +def test_stored_file_is_not_plaintext(tmp_path): + km = KeyManager(master_key_path=str(tmp_path / "master.key")) + km.store("upbit", "plain_key", "plain_secret") + key_file = tmp_path / "upbit.enc" + assert key_file.exists() + raw = key_file.read_bytes() + assert b"plain_key" not in raw + assert b"plain_secret" not in raw + + +def test_withdrawal_check_no_permission(tmp_path): + km = KeyManager(master_key_path=str(tmp_path / "master.key")) + assert km.check_withdrawal_permission("binance") is False diff --git a/tests/test_kill_switch.py b/tests/test_kill_switch.py new file mode 100644 index 0000000..30a90d1 --- /dev/null +++ b/tests/test_kill_switch.py @@ -0,0 +1,61 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch, call +from hydra.core.kill_switch import KillSwitch, KillSwitchResult + +KILL_BLOCKED_KEY = "hydra:kill_switch_active" + + +@pytest.fixture +def ks(mock_redis, mock_exchange): + telegram = AsyncMock() + telegram.send_message = AsyncMock() + positions = MagicMock() + positions.get_all.return_value = [ + {"market": "kr", "symbol": "005930", "qty": 10, "side": "buy"} + ] + return KillSwitch( + exchanges={"kr": mock_exchange}, + position_tracker=positions, + telegram=telegram, + redis_client=mock_redis, + ) + + +@pytest.mark.asyncio +async def test_kill_via_cli(ks, mock_exchange): + result = await ks.execute(reason="test", source="cli") + assert result.success + mock_exchange.cancel_all.assert_called_once() + + +@pytest.mark.asyncio +async def test_kill_via_api(ks, mock_exchange): + result = await ks.execute(reason="manual", source="api") + assert result.success + + +@pytest.mark.asyncio +async def test_kill_blocks_new_orders(ks, mock_redis): + await ks.execute(reason="test", source="cli") + mock_redis.set.assert_any_call(KILL_BLOCKED_KEY, "1") + + +@pytest.mark.asyncio +async def test_kill_sends_telegram(ks): + await ks.execute(reason="test", source="cli") + ks._telegram.send_message.assert_called_once() + + +@pytest.mark.asyncio +async def test_auto_trigger_daily_loss(ks, mock_redis): + mock_redis.get.return_value = "-0.06" + triggered, reason = await ks.check_auto_triggers() + assert triggered + assert "daily_loss" in reason + + +@pytest.mark.asyncio +async def test_auto_trigger_no_trigger(ks, mock_redis): + mock_redis.get.return_value = "-0.01" + triggered, _ = await ks.check_auto_triggers() + assert not triggered diff --git a/tests/test_oom_protection.py b/tests/test_oom_protection.py new file mode 100644 index 0000000..ba1b486 --- /dev/null +++ b/tests/test_oom_protection.py @@ -0,0 +1,29 @@ +import pytest +from hydra.config.profiles import get_profile + + +def test_lite_profile_has_memory_limits(): + profile = get_profile("lite") + assert profile.core_mem_gb <= 4 + assert profile.redis_mem_gb <= 2 + + +def test_expert_profile_higher_than_lite(): + lite = get_profile("lite") + expert = get_profile("expert") + assert expert.core_mem_gb > lite.core_mem_gb + + +def test_invalid_profile_raises(): + with pytest.raises(ValueError, match="Unknown profile"): + get_profile("invalid") + + +def test_lite_no_ai(): + profile = get_profile("lite") + assert profile.ai_enabled is False + + +def test_pro_expert_have_ai(): + for name in ("pro", "expert"): + assert get_profile(name).ai_enabled is True diff --git a/tests/test_order_queue.py b/tests/test_order_queue.py new file mode 100644 index 0000000..4195bcd --- /dev/null +++ b/tests/test_order_queue.py @@ -0,0 +1,49 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +from hydra.core.order_queue import OrderQueue, OrderRequest, OrderLockError + + +@pytest.fixture +def queue(mock_redis, mock_exchange): + risk = MagicMock() + risk.check_order_allowed.return_value = (True, "ok") + positions = MagicMock() + exchanges = {"kr": mock_exchange} + mock_exchange.create_order = AsyncMock(return_value={"order_id": "ORD123", "status": "filled"}) + return OrderQueue(redis_client=mock_redis, risk_engine=risk, position_tracker=positions, exchanges=exchanges) + + +@pytest.mark.asyncio +async def test_order_submitted_successfully(queue, mock_redis): + mock_redis.set.return_value = True # lock acquired + mock_redis.get.return_value = None # no idempotency hit + order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10) + result = await queue.submit(order) + assert result.order_id is not None + + +@pytest.mark.asyncio +async def test_duplicate_lock_raises(queue, mock_redis): + mock_redis.set.return_value = False # lock already held + mock_redis.get.return_value = None + order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10) + with pytest.raises(OrderLockError): + await queue.submit(order) + + +@pytest.mark.asyncio +async def test_idempotency_returns_cached(queue, mock_redis): + import json + cached = json.dumps({"order_id": "CACHED_ID", "status": "filled"}) + mock_redis.get.return_value = cached + order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10, idempotency_key="fixed-key") + result = await queue.submit(order) + assert result.order_id == "CACHED_ID" + + +@pytest.mark.asyncio +async def test_blocked_when_kill_switch_active(queue, mock_redis): + mock_redis.get.side_effect = lambda k: "1" if "kill_switch" in k else None + order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10) + with pytest.raises(OrderLockError, match="Kill Switch"): + await queue.submit(order) diff --git a/tests/test_regime_detector.py b/tests/test_regime_detector.py new file mode 100644 index 0000000..9754aaf --- /dev/null +++ b/tests/test_regime_detector.py @@ -0,0 +1,27 @@ +# tests/test_regime_detector.py +from hydra.regime.detector import RegimeDetector + +def test_volatile_regime(): + det = RegimeDetector() + indicators = {"BBB_5_2.0_2.0": 0.10, "ADX_14": 30.0, "EMA_50": 45000.0} + assert det.detect(indicators, close=50000.0) == "volatile" + +def test_trending_up(): + det = RegimeDetector() + indicators = {"BBB_5_2.0_2.0": 0.02, "ADX_14": 30.0, "EMA_50": 45000.0} + assert det.detect(indicators, close=50000.0) == "trending_up" + +def test_trending_down(): + det = RegimeDetector() + indicators = {"BBB_5_2.0_2.0": 0.02, "ADX_14": 30.0, "EMA_50": 55000.0} + assert det.detect(indicators, close=50000.0) == "trending_down" + +def test_ranging(): + det = RegimeDetector() + indicators = {"BBB_5_2.0_2.0": 0.02, "ADX_14": 15.0, "EMA_50": 45000.0} + assert det.detect(indicators, close=50000.0) == "ranging" + +def test_none_indicators_return_ranging(): + det = RegimeDetector() + indicators = {"BBB_5_2.0_2.0": None, "ADX_14": None, "EMA_50": None} + assert det.detect(indicators, close=50000.0) == "ranging" diff --git a/tests/test_regime_engine.py b/tests/test_regime_engine.py new file mode 100644 index 0000000..83a82ac --- /dev/null +++ b/tests/test_regime_engine.py @@ -0,0 +1,59 @@ +# tests/test_regime_engine.py +import json +import pytest +from unittest.mock import AsyncMock, MagicMock +from hydra.regime.engine import RegimeEngine +from hydra.regime.detector import RegimeDetector + + +@pytest.fixture +def mock_redis(): + r = MagicMock() + r.get = MagicMock(return_value=json.dumps({ + "BBB_5_2.0_2.0": 0.02, "ADX_14": 30.0, "EMA_50": 45000.0, + "close": 50000.0, + "calculated_at": 1000000 + })) + r.set = AsyncMock() + r.keys = MagicMock(return_value=["hydra:indicator:binance:BTC/USDT:1m"]) + r.pubsub = MagicMock() + return r + + +@pytest.fixture +def engine(mock_redis): + return RegimeEngine(redis_client=mock_redis, detector=RegimeDetector()) + + +@pytest.mark.asyncio +async def test_handle_event_writes_regime(engine, mock_redis): + await engine._handle_event("binance", "BTC/USDT", "1m") + mock_redis.set.assert_called_once() + key, value = mock_redis.set.call_args[0] + assert key == "hydra:regime:binance:BTC/USDT:1m" + data = json.loads(value) + assert data["regime"] == "trending_up" + assert "detected_at" in data + + +@pytest.mark.asyncio +async def test_handle_event_skips_when_no_indicator(engine, mock_redis): + mock_redis.get = MagicMock(return_value=None) + await engine._handle_event("binance", "BTC/USDT", "1m") + mock_redis.set.assert_not_called() + + +@pytest.mark.asyncio +async def test_handle_event_does_not_raise(engine, mock_redis): + mock_redis.get = MagicMock(side_effect=RuntimeError("redis error")) + await engine._handle_event("binance", "BTC/USDT", "1m") + + +@pytest.mark.asyncio +async def test_cold_start_processes_all_keys(engine, mock_redis): + mock_redis.keys = MagicMock(return_value=[ + "hydra:indicator:binance:BTC/USDT:1m", + "hydra:indicator:upbit:BTC/KRW:1h", + ]) + await engine.cold_start() + assert mock_redis.set.call_count == 2 diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..663a870 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,85 @@ +import pytest +from hydra.data.models import Candle +from hydra.data.storage.sqlite import SqliteStore + + +def test_store_exposes_close(): + assert hasattr(SqliteStore, "close") + + +def make_candle(open_time: int, market="binance", symbol="BTC/USDT", tf="1m") -> Candle: + return Candle( + market=market, symbol=symbol, timeframe=tf, + open_time=open_time, open=50000.0, high=50100.0, + low=49900.0, close=50050.0, volume=1.5, + close_time=open_time + 59999, + ) + + +@pytest.fixture +async def store(tmp_path): + s = SqliteStore(db_path=str(tmp_path / "test.db")) + await s.init() + try: + yield s + finally: + await s.close() + + +async def test_save_and_query(store): + candles = [make_candle(1_000_000 + i * 60_000) for i in range(5)] + await store.save(candles) + result = await store.query("binance", "BTC/USDT", "1m", limit=10) + assert len(result) == 5 + assert result[0].open_time == 1_000_000 + assert result[-1].open_time == 1_000_000 + 4 * 60_000 + + +async def test_upsert_updates_close(store): + await store.save([make_candle(1_000_000)]) + updated = Candle( + market="binance", symbol="BTC/USDT", timeframe="1m", + open_time=1_000_000, open=50000.0, high=50200.0, + low=49800.0, close=50150.0, volume=2.0, + close_time=1_059_999, + ) + await store.save([updated]) + result = await store.query("binance", "BTC/USDT", "1m") + assert len(result) == 1 + assert result[0].close == 50150.0 + + +async def test_get_last_time_empty(store): + assert await store.get_last_time("binance", "BTC/USDT", "1m") is None + + +async def test_get_last_time(store): + candles = [make_candle(1_000_000 + i * 60_000) for i in range(3)] + await store.save(candles) + result = await store.get_last_time("binance", "BTC/USDT", "1m") + assert result == 1_000_000 + 2 * 60_000 + + +async def test_query_with_since(store): + candles = [make_candle(1_000_000 + i * 60_000) for i in range(5)] + await store.save(candles) + result = await store.query("binance", "BTC/USDT", "1m", since=1_000_000 + 2 * 60_000) + assert len(result) == 3 + assert result[0].open_time == 1_000_000 + 2 * 60_000 + + +async def test_get_symbols(store): + await store.save([make_candle(1_000_000, market="binance", symbol="BTC/USDT")]) + await store.save([make_candle(1_000_000, market="upbit", symbol="BTC/KRW")]) + symbols = await store.get_symbols() + assert len(symbols) == 2 + markets = {s["market"] for s in symbols} + assert "binance" in markets + assert "upbit" in markets + + +async def test_close_is_idempotent(tmp_path): + store = SqliteStore(db_path=str(tmp_path / "test-close.db")) + await store.init() + await store.close() + await store.close() diff --git a/tests/test_strategy_engine.py b/tests/test_strategy_engine.py new file mode 100644 index 0000000..6d17b9a --- /dev/null +++ b/tests/test_strategy_engine.py @@ -0,0 +1,70 @@ +# tests/test_strategy_engine.py +import json +import pytest +from unittest.mock import AsyncMock, MagicMock +from hydra.strategy.engine import StrategyEngine +from hydra.strategy.signal import SignalGenerator + + +@pytest.fixture +def mock_redis(): + r = MagicMock() + r.get = MagicMock(side_effect=lambda key: ( + json.dumps({"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 55.0, "close": 50000.0}) + if ":indicator:" in key + else json.dumps({"regime": "trending_up"}) + )) + r.set = AsyncMock() + r.keys = MagicMock(return_value=["hydra:indicator:binance:BTC/USDT:1m"]) + r.pubsub = MagicMock() + return r + + +@pytest.fixture +def engine(mock_redis): + return StrategyEngine( + redis_client=mock_redis, + generator=SignalGenerator(), + dry_run=True, + ) + + +@pytest.mark.asyncio +async def test_handle_event_writes_signal(engine, mock_redis): + await engine._handle_event("binance", "BTC/USDT", "1m") + mock_redis.set.assert_called_once() + key, value = mock_redis.set.call_args[0] + assert key == "hydra:signal:binance:BTC/USDT:1m" + data = json.loads(value) + assert data["signal"] == "BUY" + assert "reason" in data + assert "price" in data + assert "ts" in data + + +@pytest.mark.asyncio +async def test_handle_event_skips_when_no_indicator(engine, mock_redis): + mock_redis.get = MagicMock(return_value=None) + await engine._handle_event("binance", "BTC/USDT", "1m") + mock_redis.set.assert_not_called() + + +@pytest.mark.asyncio +async def test_handle_event_does_not_raise(engine, mock_redis): + mock_redis.get = MagicMock(side_effect=RuntimeError("redis error")) + await engine._handle_event("binance", "BTC/USDT", "1m") + + +@pytest.mark.asyncio +async def test_cold_start_processes_all_keys(engine, mock_redis): + mock_redis.keys = MagicMock(return_value=[ + "hydra:indicator:binance:BTC/USDT:1m", + "hydra:indicator:upbit:BTC/KRW:1h", + ]) + mock_redis.get = MagicMock(side_effect=lambda key: ( + json.dumps({"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 55.0, "close": 50000.0}) + if ":indicator:" in key + else json.dumps({"regime": "trending_up"}) + )) + await engine.cold_start() + assert mock_redis.set.call_count == 2 diff --git a/tests/test_strategy_signal.py b/tests/test_strategy_signal.py new file mode 100644 index 0000000..98a2a90 --- /dev/null +++ b/tests/test_strategy_signal.py @@ -0,0 +1,63 @@ +# tests/test_strategy_signal.py +from hydra.strategy.signal import SignalGenerator + + +def test_volatile_returns_hold(): + gen = SignalGenerator() + indicators = {"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 60.0} + sig = gen.generate(indicators, "volatile", 50000.0) + assert sig.signal == "HOLD" + assert sig.reason == "volatile: skip" + + +def test_trending_up_buy(): + gen = SignalGenerator() + indicators = {"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 55.0} + sig = gen.generate(indicators, "trending_up", 50000.0) + assert sig.signal == "BUY" + assert sig.reason == "trend_up: ema_cross+rsi" + + +def test_trending_up_hold_no_cross(): + gen = SignalGenerator() + indicators = {"EMA_9": 0.9, "EMA_20": 1.0, "RSI_14": 55.0} + sig = gen.generate(indicators, "trending_up", 50000.0) + assert sig.signal == "HOLD" + + +def test_trending_down_sell(): + gen = SignalGenerator() + indicators = {"EMA_9": 0.9, "EMA_20": 1.0, "RSI_14": 40.0} + sig = gen.generate(indicators, "trending_down", 50000.0) + assert sig.signal == "SELL" + assert sig.reason == "trend_down: ema_cross+rsi" + + +def test_trending_down_hold_no_cross(): + gen = SignalGenerator() + indicators = {"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 40.0} + sig = gen.generate(indicators, "trending_down", 50000.0) + assert sig.signal == "HOLD" + + +def test_ranging_buy_oversold(): + gen = SignalGenerator() + indicators = {"EMA_9": 1.0, "EMA_20": 1.0, "RSI_14": 25.0} + sig = gen.generate(indicators, "ranging", 50000.0) + assert sig.signal == "BUY" + assert sig.reason == "ranging: rsi_oversold" + + +def test_ranging_sell_overbought(): + gen = SignalGenerator() + indicators = {"EMA_9": 1.0, "EMA_20": 1.0, "RSI_14": 75.0} + sig = gen.generate(indicators, "ranging", 50000.0) + assert sig.signal == "SELL" + assert sig.reason == "ranging: rsi_overbought" + + +def test_none_indicators_return_hold(): + gen = SignalGenerator() + indicators = {"EMA_9": None, "EMA_20": None, "RSI_14": None} + sig = gen.generate(indicators, "trending_up", 50000.0) + assert sig.signal == "HOLD" diff --git a/tests/test_supplemental_events.py b/tests/test_supplemental_events.py new file mode 100644 index 0000000..4288474 --- /dev/null +++ b/tests/test_supplemental_events.py @@ -0,0 +1,59 @@ +# tests/test_supplemental_events.py +import asyncio +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from hydra.supplemental.events import EventCalendarPoller + + +@pytest.mark.asyncio +async def test_fetch_returns_empty_without_api_key(): + r = MagicMock() + poller = EventCalendarPoller(redis_client=r, api_key="") + result = await poller._fetch() + assert result == [] + + +@pytest.mark.asyncio +async def test_fetch_parses_response(): + r = MagicMock() + poller = EventCalendarPoller(redis_client=r, api_key="test_key") + mock_response = { + "body": [ + { + "title": "Bitcoin Halving", + "coins": [{"symbol": "BTC"}], + "date_event": "2024-04-20T00:00:00Z", + } + ] + } + mock_resp = MagicMock() + mock_resp.json.return_value = mock_response + mock_resp.raise_for_status = MagicMock() + mock_http = AsyncMock() + mock_http.get = AsyncMock(return_value=mock_resp) + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + result = await poller._fetch() + assert len(result) == 1 + assert result[0]["title"] == "Bitcoin Halving" + assert result[0]["symbol"] == "BTC" + assert result[0]["source"] == "coinmarketcal" + + +@pytest.mark.asyncio +async def test_run_writes_redis(): + r = MagicMock() + r.set = AsyncMock() + poller = EventCalendarPoller(redis_client=r, api_key="") + with patch("hydra.supplemental.events.asyncio.sleep", + side_effect=asyncio.CancelledError): + try: + await poller.run() + except asyncio.CancelledError: + pass + r.set.assert_called_once() + key, value = r.set.call_args[0] + assert key == "hydra:events:upcoming" + assert json.loads(value) == [] diff --git a/tests/test_supplemental_orderbook.py b/tests/test_supplemental_orderbook.py new file mode 100644 index 0000000..c680620 --- /dev/null +++ b/tests/test_supplemental_orderbook.py @@ -0,0 +1,80 @@ +# tests/test_supplemental_orderbook.py +import asyncio +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from hydra.supplemental.orderbook import OrderBookPoller + + +@pytest.fixture +def mock_redis(): + r = MagicMock() + r.keys = MagicMock(return_value=[ + "hydra:indicator:binance:BTC/USDT:1m", + "hydra:indicator:binance:ETH/USDT:1h", + ]) + r.set = AsyncMock() + return r + + +@pytest.fixture +def poller(mock_redis): + return OrderBookPoller(redis_client=mock_redis, interval_sec=1) + + +def test_get_active_symbols_deduplicates(poller, mock_redis): + mock_redis.keys = MagicMock(return_value=[ + "hydra:indicator:binance:BTC/USDT:1m", + "hydra:indicator:binance:BTC/USDT:1h", + "hydra:indicator:binance:ETH/USDT:1m", + ]) + symbols = poller._get_active_symbols() + assert ("binance", "BTC/USDT") in symbols + assert ("binance", "ETH/USDT") in symbols + assert len(symbols) == 2 + + +def test_fetch_one_returns_orderbook(poller): + mock_ob = { + "bids": [[49998.0, 1.5], [49997.0, 2.0]], + "asks": [[50002.0, 1.2], [50003.0, 1.8]], + } + mock_exchange = MagicMock() + mock_exchange.fetch_order_book.return_value = mock_ob + poller._get_exchange = MagicMock(return_value=mock_exchange) + result = poller._fetch_one("binance", "BTC/USDT") + assert result is not None + assert result["bid"] == 49998.0 + assert result["ask"] == 50002.0 + assert "spread_pct" in result + assert "ts" in result + + +def test_fetch_one_returns_none_on_error(poller): + mock_exchange = MagicMock() + mock_exchange.fetch_order_book.side_effect = Exception("connection error") + poller._get_exchange = MagicMock(return_value=mock_exchange) + result = poller._fetch_one("binance", "BTC/USDT") + assert result is None + + +@pytest.mark.asyncio +async def test_run_writes_redis(poller, mock_redis): + mock_ob = { + "bids": [[49998.0, 1.5]], + "asks": [[50002.0, 1.2]], + } + mock_exchange = MagicMock() + mock_exchange.fetch_order_book.return_value = mock_ob + poller._get_exchange = MagicMock(return_value=mock_exchange) + with patch("hydra.supplemental.orderbook.asyncio.sleep", + side_effect=asyncio.CancelledError): + try: + await poller.run() + except asyncio.CancelledError: + pass + assert mock_redis.set.call_count == 2 # BTC/USDT and ETH/USDT + key = mock_redis.set.call_args_list[0][0][0] + assert key.startswith("hydra:orderbook:binance:") + data = json.loads(mock_redis.set.call_args_list[0][0][1]) + assert "bid" in data diff --git a/tests/test_supplemental_sentiment.py b/tests/test_supplemental_sentiment.py new file mode 100644 index 0000000..ea7dbdd --- /dev/null +++ b/tests/test_supplemental_sentiment.py @@ -0,0 +1,70 @@ +# tests/test_supplemental_sentiment.py +import asyncio +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from hydra.supplemental.sentiment import SentimentPoller + + +@pytest.fixture +def mock_redis(): + r = MagicMock() + r.keys = MagicMock(return_value=["hydra:indicator:binance:BTC/USDT:1m"]) + r.set = AsyncMock() + return r + + +def test_score_positive_headline(): + r = MagicMock() + poller = SentimentPoller(redis_client=r) + score = poller._score(["Bitcoin is great and prices are rising strongly today!"]) + assert score > 0.0 + + +def test_score_empty_headlines_returns_zero(): + r = MagicMock() + poller = SentimentPoller(redis_client=r) + score = poller._score([]) + assert score == 0.0 + + +@pytest.mark.asyncio +async def test_fetch_news_returns_headlines(): + r = MagicMock() + poller = SentimentPoller(redis_client=r, api_key="test_key") + mock_response = { + "results": [ + {"title": "Bitcoin price rises strongly"}, + {"title": "Market looks very bullish today"}, + ] + } + mock_resp = MagicMock() + mock_resp.json.return_value = mock_response + mock_resp.raise_for_status = MagicMock() + mock_http = AsyncMock() + mock_http.get = AsyncMock(return_value=mock_resp) + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + headlines = await poller._fetch_news("BTC") + assert len(headlines) == 2 + assert "Bitcoin price rises strongly" in headlines + + +@pytest.mark.asyncio +async def test_run_writes_redis(mock_redis): + poller = SentimentPoller(redis_client=mock_redis) + poller._fetch_news = AsyncMock(return_value=["Bitcoin is surging higher today!"]) + with patch("hydra.supplemental.sentiment.asyncio.sleep", + side_effect=asyncio.CancelledError): + try: + await poller.run() + except asyncio.CancelledError: + pass + assert mock_redis.set.call_count >= 1 + key, value = mock_redis.set.call_args_list[0][0] + assert key == "hydra:sentiment:BTC" + data = json.loads(value) + assert "score" in data + assert "article_count" in data + assert data["article_count"] == 1 diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..a6842fb --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,33 @@ +import pytest +from hydra.config.validation import StrategyConfig, RiskConfig + + +def test_stop_loss_too_high(): + with pytest.raises(ValueError, match="너무 큽니다"): + StrategyConfig(stop_loss_pct=0.99) + + +def test_stop_loss_zero(): + with pytest.raises(ValueError): + StrategyConfig(stop_loss_pct=0.0) + + +def test_stop_loss_valid(): + cfg = StrategyConfig(stop_loss_pct=0.05) + assert cfg.stop_loss_pct == 0.05 + + +def test_position_size_too_high(): + with pytest.raises(ValueError, match="너무 큽니다"): + StrategyConfig(position_size_pct=0.99) + + +def test_position_size_valid(): + cfg = StrategyConfig(position_size_pct=0.10) + assert cfg.position_size_pct == 0.10 + + +def test_risk_config_defaults(): + cfg = RiskConfig() + assert cfg.daily_loss_limit_pct == 0.03 + assert cfg.daily_loss_kill_pct == 0.05 diff --git a/tests/test_websocket_reconnect.py b/tests/test_websocket_reconnect.py new file mode 100644 index 0000000..407b6a9 --- /dev/null +++ b/tests/test_websocket_reconnect.py @@ -0,0 +1,101 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +from hydra.data.models import Candle +from hydra.data.collector import ExchangeCollector + + +def make_candle() -> Candle: + return Candle( + market="binance", symbol="BTC/USDT", timeframe="1m", + open_time=1_000_000, open=50000.0, high=50100.0, + low=49900.0, close=50050.0, volume=1.5, + close_time=1_059_999, + ) + + +async def _gen_one(candle: Candle): + yield candle + + +async def _gen_raise(exc: Exception): + raise exc + yield # makes this an async generator + + +@pytest.fixture +def mock_store(): + s = AsyncMock() + s.save = AsyncMock() + s.get_last_time = AsyncMock(return_value=None) + return s + + +@pytest.fixture +def mock_telegram(): + t = AsyncMock() + t.send_message = AsyncMock() + return t + + +async def test_saves_candle_on_receive(mock_store, mock_telegram): + """Received candle is forwarded to store.save.""" + candle = make_candle() + handler = MagicMock() + handler.listen = MagicMock(return_value=_gen_one(candle)) + + collector = ExchangeCollector( + market="binance", handler=handler, store=mock_store, + backfiller=AsyncMock(), telegram=mock_telegram, + ) + await collector._run_once() + mock_store.save.assert_awaited_once_with([candle]) + + +async def test_telegram_alert_after_3_consecutive_failures(mock_store, mock_telegram): + """Three consecutive disconnects trigger a Telegram alert.""" + handler = MagicMock() + handler.listen = MagicMock( + side_effect=lambda: _gen_raise(ConnectionError("down")) + ) + backfiller = AsyncMock() + + collector = ExchangeCollector( + market="binance", handler=handler, store=mock_store, + backfiller=backfiller, telegram=mock_telegram, + max_delay=0.001, + ) + await collector._run_with_retry(max_attempts=3) + + mock_telegram.send_message.assert_awaited_once() + assert "binance" in mock_telegram.send_message.call_args[0][0] + + +async def test_gap_backfill_called_on_reconnect(mock_store, mock_telegram): + """After a disconnect, gap_backfill is called on the next connection.""" + candle = make_candle() + call_count = 0 + + async def listen_impl(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ConnectionError("first attempt fails") + yield candle + + handler = MagicMock() + handler.listen = MagicMock(side_effect=listen_impl) + + backfiller = AsyncMock() + backfiller.gap_backfill = AsyncMock() + fetch_fn_factory = MagicMock(return_value=AsyncMock(return_value=[])) + + collector = ExchangeCollector( + market="binance", handler=handler, store=mock_store, + backfiller=backfiller, telegram=mock_telegram, + symbols=["BTC/USDT"], timeframes=["1m"], + fetch_fn_factory=fetch_fn_factory, + max_delay=0.001, + ) + await collector._run_with_retry(max_attempts=2) + + backfiller.gap_backfill.assert_awaited()