Initial public release

This commit is contained in:
sinmb79
2026-03-30 13:19:11 +09:00
commit 92a692b63c
116 changed files with 5822 additions and 0 deletions

15
.env.example Normal file
View File

@@ -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

43
.gitignore vendored Normal file
View File

@@ -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

14
DISCLAIMER.md Normal file
View File

@@ -0,0 +1,14 @@
# 면책조항 / Disclaimer
본 소프트웨어(HYDRA)는 교육 및 연구 목적으로 제공됩니다.
1. 투자 조언이 아닙니다. 매매 결정은 전적으로 사용자의 판단과 책임입니다.
2. 과거 백테스트 수익률은 미래 수익을 보장하지 않습니다.
3. 자동매매 시스템은 기술적 장애, 네트워크 오류, 거래소 문제 등으로
예기치 않은 손실이 발생할 수 있습니다.
4. 개발자 및 기여자는 본 소프트웨어 사용으로 인한 어떠한 손실에 대해서도
책임을 지지 않습니다.
5. 실제 자금을 투자하기 전에 반드시 모의투자(페이퍼 트레이딩)로
충분히 검증하십시오.
이 소프트웨어를 사용함으로써 위 내용에 동의하는 것으로 간주합니다.

21
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

223
README.md Normal file
View File

@@ -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)를 따릅니다.

0
config/.gitkeep Normal file
View File

38
config/data.yaml Normal file
View File

@@ -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]

26
config/markets.yaml Normal file
View File

@@ -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

185
docker-compose.expert.yml Normal file
View File

@@ -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:

172
docker-compose.lite.yml Normal file
View File

@@ -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:

185
docker-compose.pro.yml Normal file
View File

@@ -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:

154
docs/API_REFERENCE_KO.md Normal file
View File

@@ -0,0 +1,154 @@
# HYDRA Engine API 레퍼런스
모든 보호된 엔드포인트는 아래 헤더를 요구합니다.
```http
X-HYDRA-KEY: <HYDRA_API_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`
실거래 관련 동작은 충분한 검증 이후에만 진행하세요.

116
docs/QUICKSTART_KO.md Normal file
View File

@@ -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`를 참고하세요.

1
hydra/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

0
hydra/api/__init__.py Normal file
View File

11
hydra/api/auth.py Normal file
View File

@@ -0,0 +1,11 @@
from fastapi import HTTPException, Header
from hydra.config.settings import get_settings
API_KEY_HEADER = "X-HYDRA-KEY"
async def verify_api_key(x_hydra_key: str = Header(..., alias=API_KEY_HEADER)) -> str:
settings = get_settings()
if x_hydra_key != settings.hydra_api_key:
raise HTTPException(status_code=403, detail="Invalid API key")
return x_hydra_key

61
hydra/api/backtest.py Normal file
View File

@@ -0,0 +1,61 @@
# hydra/api/backtest.py
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from hydra.api.auth import verify_api_key
from hydra.backtest.runner import BacktestRunner
from hydra.indicator.calculator import IndicatorCalculator
from hydra.regime.detector import RegimeDetector
from hydra.strategy.signal import SignalGenerator
router = APIRouter(prefix="/backtest")
_store = None
def set_store_for_backtest(store) -> None:
global _store
_store = store
class BacktestRequest(BaseModel):
market: str
symbol: str
timeframe: str
since: int
until: int
initial_capital: float = 10000.0
trade_amount_usd: float = 100.0
commission_pct: float = 0.001
@router.post("/run")
async def run_backtest(
req: BacktestRequest,
_: str = Depends(verify_api_key),
):
if _store is None:
raise HTTPException(status_code=503, detail="Store not initialized")
if req.since >= req.until:
raise HTTPException(status_code=400, detail="since must be less than until")
runner = BacktestRunner(
store=_store,
calculator=IndicatorCalculator(),
detector=RegimeDetector(),
generator=SignalGenerator(),
initial_capital=req.initial_capital,
trade_amount_usd=req.trade_amount_usd,
commission_pct=req.commission_pct,
)
try:
result = await runner.run(
market=req.market,
symbol=req.symbol,
timeframe=req.timeframe,
since=req.since,
until=req.until,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return asdict(result)

50
hydra/api/data.py Normal file
View File

@@ -0,0 +1,50 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from hydra.api.auth import verify_api_key
from hydra.data.storage.base import OhlcvStore
router = APIRouter(prefix="/data")
_store: Optional[OhlcvStore] = None
def set_store(store: OhlcvStore) -> None:
global _store
_store = store
@router.get("/candles")
async def get_candles(
market: str,
symbol: str,
timeframe: str,
limit: int = Query(default=200, ge=1, le=1000),
since: Optional[int] = None,
_: str = Depends(verify_api_key),
):
"""Return OHLCV candles ordered by open_time ASC."""
if _store is None:
raise HTTPException(status_code=503, detail="Store not initialized")
candles = await _store.query(market, symbol, timeframe, limit=limit, since=since)
return [
{
"market": c.market,
"symbol": c.symbol,
"timeframe": c.timeframe,
"open_time": c.open_time,
"open": c.open,
"high": c.high,
"low": c.low,
"close": c.close,
"volume": c.volume,
"close_time": c.close_time,
}
for c in candles
]
@router.get("/symbols")
async def get_symbols(_: str = Depends(verify_api_key)):
"""Return distinct {market, symbol, timeframe} records being collected."""
if _store is None:
raise HTTPException(status_code=503, detail="Store not initialized")
return await _store.get_symbols()

30
hydra/api/health.py Normal file
View File

@@ -0,0 +1,30 @@
import time
from fastapi import APIRouter
router = APIRouter()
_START_TIME = time.time()
_redis = None
def set_redis(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/health")
async def health():
result = {
"status": "ok",
"uptime_seconds": int(time.time() - _START_TIME),
}
if _redis:
keys = _redis.keys("hydra:collector:*:status")
collectors = {}
for key in keys:
market = key.split(":")[2]
collectors[market] = _redis.get(key)
if collectors:
result["collectors"] = collectors
if any(v and v.startswith("error:") for v in collectors.values()):
result["status"] = "degraded"
return result

56
hydra/api/indicators.py Normal file
View File

@@ -0,0 +1,56 @@
import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_indicators(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/indicators")
async def get_indicators(
market: str,
symbol: str,
timeframe: str,
_: str = Depends(verify_api_key),
):
"""Return the latest cached indicator values for a symbol."""
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:indicator:{market}:{symbol}:{timeframe}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No indicators cached for this symbol")
return {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"indicators": json.loads(raw),
}
@router.get("/indicators/list")
async def list_indicators(_: str = Depends(verify_api_key)):
"""Return all (market, symbol, timeframe) tuples that have cached indicators."""
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
keys = _redis.keys("hydra:indicator:*")
result = []
for key in keys:
parts = key.split(":")
# key format: hydra:indicator:{market}:{symbol}:{timeframe}
# symbol may contain ":" (e.g. BTC/USDT:USDT for futures)
# parts[0]="hydra", parts[1]="indicator", parts[2]=market,
# parts[3:-1]=symbol (joined), parts[-1]=timeframe
if len(parts) >= 5:
result.append({
"market": parts[2],
"symbol": ":".join(parts[3:-1]),
"timeframe": parts[-1],
})
return result

27
hydra/api/markets.py Normal file
View File

@@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_market_manager = None
def set_market_manager(mm) -> None:
global _market_manager
_market_manager = mm
@router.get("/markets")
async def get_markets(_: str = Depends(verify_api_key)):
return {"active": _market_manager.get_active_markets()}
@router.post("/markets/{market_id}/enable")
async def enable_market(market_id: str, _: str = Depends(verify_api_key)):
_market_manager.enable(market_id)
return {"status": "enabled", "market": market_id}
@router.post("/markets/{market_id}/disable")
async def disable_market(market_id: str, _: str = Depends(verify_api_key)):
_market_manager.disable(market_id)
return {"status": "disabled", "market": market_id}

16
hydra/api/orders.py Normal file
View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
from hydra.core.order_queue import OrderRequest
router = APIRouter()
_order_queue = None
def set_order_queue(queue) -> None:
global _order_queue
_order_queue = queue
@router.post("/orders")
async def create_order(order: OrderRequest, _: str = Depends(verify_api_key)):
return await _order_queue.submit(order)

26
hydra/api/pnl.py Normal file
View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_pnl_tracker = None
_position_tracker = None
def set_pnl_dependencies(pnl_tracker, position_tracker) -> None:
global _pnl_tracker, _position_tracker
_pnl_tracker = pnl_tracker
_position_tracker = position_tracker
@router.get("/pnl")
async def get_pnl(_: str = Depends(verify_api_key)):
"""전체 시스템 손익 현황."""
positions = _position_tracker.get_all()
return _pnl_tracker.get_summary(positions)
@router.post("/pnl/reset-daily")
async def reset_daily_pnl(_: str = Depends(verify_api_key)):
"""일일 손익 초기화."""
_pnl_tracker.reset_daily()
return {"status": "reset"}

15
hydra/api/positions.py Normal file
View File

@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_position_tracker = None
def set_position_tracker(tracker) -> None:
global _position_tracker
_position_tracker = tracker
@router.get("/positions")
async def get_positions(_: str = Depends(verify_api_key)):
return {"positions": _position_tracker.get_all()}

52
hydra/api/regime.py Normal file
View File

@@ -0,0 +1,52 @@
# hydra/api/regime.py
import json
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_regime(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/regime")
async def get_regime(
market: str,
symbol: str,
timeframe: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:regime:{market}:{symbol}:{timeframe}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No regime cached for this symbol")
data = json.loads(raw)
return {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"regime": data["regime"],
"detected_at": data["detected_at"],
}
@router.get("/regime/list")
async def list_regimes(_: str = Depends(verify_api_key)):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
keys = _redis.keys("hydra:regime:*")
result = []
for key in keys:
parts = key.split(":")
if len(parts) >= 5:
result.append({
"market": parts[2],
"symbol": ":".join(parts[3:-1]),
"timeframe": parts[-1],
})
return result

26
hydra/api/risk.py Normal file
View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
_kill_switch = None
_risk_engine = None
def set_dependencies(kill_switch, risk_engine) -> None:
global _kill_switch, _risk_engine
_kill_switch = kill_switch
_risk_engine = risk_engine
@router.get("/risk")
async def get_risk(_: str = Depends(verify_api_key)):
return {
"daily_pnl_pct": _risk_engine.get_daily_pnl_pct(),
"kill_switch_active": _kill_switch.is_active(),
}
@router.post("/killswitch")
async def killswitch(reason: str = "manual", _: str = Depends(verify_api_key)):
result = await _kill_switch.execute(reason=reason, source="api")
return {"success": result.success, "closed": result.closed_positions, "errors": result.errors}

54
hydra/api/signals.py Normal file
View File

@@ -0,0 +1,54 @@
# hydra/api/signals.py
import json
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_signals(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/signal")
async def get_signal(
market: str,
symbol: str,
timeframe: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:signal:{market}:{symbol}:{timeframe}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No signal cached for this symbol")
data = json.loads(raw)
return {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"signal": data["signal"],
"reason": data["reason"],
"price": data["price"],
"ts": data["ts"],
}
@router.get("/signal/list")
async def list_signals(_: str = Depends(verify_api_key)):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
keys = _redis.keys("hydra:signal:*")
result = []
for key in keys:
parts = key.split(":")
if len(parts) >= 5:
result.append({
"market": parts[2],
"symbol": ":".join(parts[3:-1]),
"timeframe": parts[-1],
})
return result

14
hydra/api/strategies.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
router = APIRouter()
@router.get("/strategies")
async def list_strategies(_: str = Depends(verify_api_key)):
return {"strategies": [], "note": "Phase 1에서 구현 예정"}
@router.post("/strategies/{name}/start")
async def start_strategy(name: str, _: str = Depends(verify_api_key)):
return {"status": "not_implemented", "note": "Phase 1에서 구현 예정"}

53
hydra/api/supplemental.py Normal file
View File

@@ -0,0 +1,53 @@
# hydra/api/supplemental.py
import json
from fastapi import APIRouter, Depends, HTTPException
from hydra.api.auth import verify_api_key
router = APIRouter(prefix="/data")
_redis = None
def set_redis_for_supplemental(redis_client) -> None:
global _redis
_redis = redis_client
@router.get("/orderbook")
async def get_orderbook(
market: str,
symbol: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:orderbook:{market}:{symbol}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No orderbook cached for this symbol")
data = json.loads(raw)
return {"market": market, "symbol": symbol, **data}
@router.get("/events")
async def get_events(_: str = Depends(verify_api_key)):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
raw = _redis.get("hydra:events:upcoming")
if raw is None:
return []
return json.loads(raw)
@router.get("/sentiment")
async def get_sentiment(
symbol: str,
_: str = Depends(verify_api_key),
):
if _redis is None:
raise HTTPException(status_code=503, detail="Redis not initialized")
key = f"hydra:sentiment:{symbol}"
raw = _redis.get(key)
if raw is None:
raise HTTPException(status_code=404, detail="No sentiment cached for this symbol")
data = json.loads(raw)
return {"symbol": symbol, **data}

20
hydra/api/system.py Normal file
View File

@@ -0,0 +1,20 @@
import hydra
from fastapi import APIRouter, Depends
from hydra.api.auth import verify_api_key
from hydra.config.settings import get_settings
router = APIRouter()
@router.get("/status")
async def status(_: str = Depends(verify_api_key)):
settings = get_settings()
return {
"version": hydra.__version__,
"profile": settings.hydra_profile,
}
@router.get("/modules")
async def modules(_: str = Depends(verify_api_key)):
return {"modules": ["core", "resilience", "exchange", "notify"]}

View File

77
hydra/backtest/broker.py Normal file
View File

@@ -0,0 +1,77 @@
# hydra/backtest/broker.py
from hydra.backtest.result import Trade
from hydra.data.models import Candle
from hydra.strategy.signal import Signal
class BacktestBroker:
def __init__(
self,
initial_capital: float,
trade_amount_usd: float,
commission_pct: float = 0.001,
):
self._capital = initial_capital
self._trade_amount = trade_amount_usd
self._commission = commission_pct
self._position: dict | None = None # {entry_price, qty, entry_ts, entry_reason}
self._trades: list[Trade] = []
self._equity_curve: list[dict] = []
def on_signal(self, signal: Signal, candle: Candle) -> None:
if signal.signal == "BUY" and self._position is None:
qty = self._trade_amount / candle.close
commission = self._trade_amount * self._commission
self._capital -= commission
self._position = {
"entry_price": candle.close,
"qty": qty,
"entry_ts": candle.open_time,
"entry_reason": signal.reason,
}
elif signal.signal == "SELL" and self._position is not None:
self.close_open_position(
price=candle.close,
ts=candle.open_time,
reason=signal.reason,
)
self._equity_curve.append({"ts": candle.open_time, "equity": self.equity})
def close_open_position(
self, price: float, ts: int, reason: str = "backtest_end"
) -> None:
if self._position is None:
return
pos = self._position
gross_pnl = (price - pos["entry_price"]) * pos["qty"]
commission = price * pos["qty"] * self._commission
net_pnl = gross_pnl - commission
pnl_pct = (price - pos["entry_price"]) / pos["entry_price"] * 100
self._capital += net_pnl
self._trades.append(Trade(
market="",
symbol="",
entry_price=pos["entry_price"],
exit_price=price,
qty=pos["qty"],
pnl_usd=round(net_pnl, 6),
pnl_pct=round(pnl_pct, 4),
entry_ts=pos["entry_ts"],
exit_ts=ts,
entry_reason=pos["entry_reason"],
exit_reason=reason,
))
self._position = None
self._equity_curve.append({"ts": ts, "equity": self.equity})
@property
def trades(self) -> list[Trade]:
return self._trades
@property
def equity_curve(self) -> list[dict]:
return self._equity_curve
@property
def equity(self) -> float:
return self._capital

87
hydra/backtest/result.py Normal file
View File

@@ -0,0 +1,87 @@
# hydra/backtest/result.py
from dataclasses import dataclass, field
@dataclass
class Trade:
market: str
symbol: str
entry_price: float
exit_price: float
qty: float
pnl_usd: float
pnl_pct: float
entry_ts: int
exit_ts: int
entry_reason: str
exit_reason: str
@dataclass
class BacktestResult:
market: str
symbol: str
timeframe: str
since: int
until: int
initial_capital: float
final_equity: float
trades: list[Trade] = field(default_factory=list)
equity_curve: list[dict] = field(default_factory=list)
metrics: dict = field(default_factory=dict)
def compute_metrics(
trades: list[Trade],
equity_curve: list[dict],
initial_capital: float,
final_equity: float,
) -> dict:
total_return_pct = (final_equity - initial_capital) / initial_capital * 100
total_trades = len(trades)
if total_trades == 0:
return {
"total_return_pct": round(total_return_pct, 4),
"total_trades": 0,
"win_rate": 0.0,
"max_drawdown_pct": 0.0,
"sharpe_ratio": 0.0,
"avg_pnl_usd": 0.0,
}
wins = sum(1 for t in trades if t.pnl_usd > 0)
win_rate = wins / total_trades * 100
avg_pnl_usd = sum(t.pnl_usd for t in trades) / total_trades
# Max drawdown from equity curve
max_drawdown_pct = 0.0
if equity_curve:
peak = equity_curve[0]["equity"]
for point in equity_curve:
eq = point["equity"]
if eq > peak:
peak = eq
dd = (peak - eq) / peak * 100 if peak > 0 else 0.0
if dd > max_drawdown_pct:
max_drawdown_pct = dd
# Sharpe ratio (annualized, trade-based)
sharpe_ratio = 0.0
if total_trades >= 2:
import math
pnls = [t.pnl_usd for t in trades]
mean = sum(pnls) / len(pnls)
variance = sum((p - mean) ** 2 for p in pnls) / (len(pnls) - 1)
std = math.sqrt(variance)
if std > 0:
sharpe_ratio = round((mean / std) * math.sqrt(252), 4)
return {
"total_return_pct": round(total_return_pct, 4),
"total_trades": total_trades,
"win_rate": round(win_rate, 2),
"max_drawdown_pct": round(max_drawdown_pct, 4),
"sharpe_ratio": sharpe_ratio,
"avg_pnl_usd": round(avg_pnl_usd, 4),
}

118
hydra/backtest/runner.py Normal file
View File

@@ -0,0 +1,118 @@
# hydra/backtest/runner.py
from hydra.backtest.broker import BacktestBroker
from hydra.backtest.result import BacktestResult, compute_metrics
from hydra.data.storage.base import OhlcvStore
from hydra.indicator.calculator import IndicatorCalculator
from hydra.regime.detector import RegimeDetector
from hydra.strategy.signal import SignalGenerator
_WARMUP = 210 # IndicatorCalculator._MIN_CANDLES
class BacktestRunner:
def __init__(
self,
store: OhlcvStore,
calculator: IndicatorCalculator,
detector: RegimeDetector,
generator: SignalGenerator,
initial_capital: float = 10000.0,
trade_amount_usd: float = 100.0,
commission_pct: float = 0.001,
):
self._store = store
self._calculator = calculator
self._detector = detector
self._generator = generator
self._initial_capital = initial_capital
self._trade_amount = trade_amount_usd
self._commission = commission_pct
async def run(
self,
market: str,
symbol: str,
timeframe: str,
since: int,
until: int,
) -> BacktestResult:
if since >= until:
raise ValueError(f"since ({since}) must be less than until ({until})")
candles = await self._store.query(
market=market,
symbol=symbol,
timeframe=timeframe,
limit=100_000,
since=None,
)
# Filter to candles up to 'until'
candles = [c for c in candles if c.open_time <= until]
broker = BacktestBroker(
initial_capital=self._initial_capital,
trade_amount_usd=self._trade_amount,
commission_pct=self._commission,
)
# Find the first index where trading starts: open_time >= since AND index >= WARMUP
trading_start_idx = None
for i, c in enumerate(candles):
if c.open_time >= since and i >= _WARMUP:
trading_start_idx = i
break
if trading_start_idx is None:
return BacktestResult(
market=market,
symbol=symbol,
timeframe=timeframe,
since=since,
until=until,
initial_capital=self._initial_capital,
final_equity=self._initial_capital,
trades=[],
equity_curve=[],
metrics=compute_metrics([], [], self._initial_capital, self._initial_capital),
)
for i in range(trading_start_idx, len(candles)):
window = candles[i - _WARMUP + 1: i + 1]
indicators = self._calculator.compute(window)
if not indicators:
continue
candle = candles[i]
close = candle.close
indicators["close"] = close
regime = self._detector.detect(indicators, close)
signal = self._generator.generate(indicators, regime, close)
broker.on_signal(signal, candle)
# Close any open position at end of backtest
if candles:
last = candles[-1]
broker.close_open_position(
price=last.close, ts=last.open_time, reason="backtest_end"
)
trades = broker.trades
for t in trades:
t.market = market
t.symbol = symbol
equity_curve = broker.equity_curve
final_equity = broker.equity
metrics = compute_metrics(trades, equity_curve, self._initial_capital, final_equity)
return BacktestResult(
market=market,
symbol=symbol,
timeframe=timeframe,
since=since,
until=until,
initial_capital=self._initial_capital,
final_equity=round(final_equity, 6),
trades=trades,
equity_curve=equity_curve,
metrics=metrics,
)

0
hydra/cli/__init__.py Normal file
View File

16
hydra/cli/app.py Normal file
View File

@@ -0,0 +1,16 @@
import typer
from hydra.cli import kill, status, trade, market, strategy, module
from hydra.cli import setup_wizard
app = typer.Typer(name="hydra", help="HYDRA 자동매매 시스템")
app.add_typer(kill.app, name="kill")
app.add_typer(status.app, name="status")
app.add_typer(trade.app, name="trade")
app.add_typer(market.app, name="market")
app.add_typer(strategy.app, name="strategy")
app.add_typer(module.app, name="module")
app.command("setup")(setup_wizard.run_setup)
if __name__ == "__main__":
app()

30
hydra/cli/kill.py Normal file
View File

@@ -0,0 +1,30 @@
import asyncio
import typer
import httpx
from hydra.config.settings import get_settings
app = typer.Typer(help="Kill Switch - 전 포지션 즉시 청산")
@app.callback(invoke_without_command=True)
def kill(reason: str = typer.Option("cli_manual", help="청산 사유")):
"""전 포지션 즉시 시장가 청산."""
confirm = typer.confirm("[주의] 전 포지션을 청산합니다. 계속하시겠습니까?")
if not confirm:
typer.echo("취소됨.")
raise typer.Exit()
settings = get_settings()
try:
resp = httpx.post(
"http://127.0.0.1:8000/killswitch",
params={"reason": reason},
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
typer.echo(f"[완료] Kill Switch 실행. 청산: {len(data.get('closed', []))}")
except httpx.ConnectError:
typer.echo("[오류] HYDRA 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하세요.")
raise typer.Exit(1)

31
hydra/cli/market.py Normal file
View File

@@ -0,0 +1,31 @@
import typer
from hydra.config.markets import MarketManager
app = typer.Typer(help="시장 활성화/비활성화")
@app.command()
def enable(market: str, mode: str = typer.Option("paper", help="paper / live")):
"""시장 활성화."""
mm = MarketManager()
mm.enable(market, mode)
typer.echo(f"[완료] {market} 활성화 ({mode} 모드)")
@app.command()
def disable(market: str):
"""시장 비활성화."""
mm = MarketManager()
mm.disable(market)
typer.echo(f"[완료] {market} 비활성화")
@app.command()
def list_markets():
"""활성화된 시장 목록."""
mm = MarketManager()
active = mm.get_active_markets()
if active:
typer.echo("활성 시장: " + ", ".join(active))
else:
typer.echo("활성화된 시장 없음. 'hydra market enable <market>'로 활성화하세요.")

24
hydra/cli/module.py Normal file
View File

@@ -0,0 +1,24 @@
import typer
app = typer.Typer(help="AI 모듈 관리")
MODULES = ["regime_detection", "signal_scoring", "feature_selection", "adaptive_retrain",
"crash_detection", "dynamic_sizing", "sentiment"]
@app.command()
def enable(name: str):
if name not in MODULES:
typer.echo(f"[오류] 알 수 없는 모듈: {name}. 사용 가능: {MODULES}")
raise typer.Exit(1)
typer.echo(f"모듈 활성화: {name} - Phase 1에서 구현 예정")
@app.command()
def disable(name: str):
typer.echo(f"모듈 비활성화: {name} - Phase 1에서 구현 예정")
@app.command()
def list_modules():
typer.echo("AI 모듈 목록:\n" + "\n".join(f" - {m}" for m in MODULES))

122
hydra/cli/setup_wizard.py Normal file
View File

@@ -0,0 +1,122 @@
import sys
import psutil
import typer
from pathlib import Path
from hydra.config.keys import KeyManager
from hydra.config.markets import MarketManager
from hydra.logging.setup import configure_logging
MARKETS = {
"kr": "한국 주식 (KIS)",
"us": "미국 주식 (KIS)",
"upbit": "업비트 (암호화폐)",
"binance": "바이낸스 (암호화폐)",
"hl": "Hyperliquid ([주의] 고위험)",
"poly": "Polymarket 예측시장 ([주의] 고위험)",
}
def detect_hardware() -> dict:
return {
"cpu_cores": psutil.cpu_count(logical=False),
"ram_gb": round(psutil.virtual_memory().total / 1024**3),
"disk_gb": round(psutil.disk_usage("/").total / 1024**3),
}
def recommend_profile(hw: dict) -> str:
if hw["ram_gb"] >= 32 and hw["cpu_cores"] >= 16:
return "expert"
elif hw["ram_gb"] >= 16 and hw["cpu_cores"] >= 8:
return "pro"
return "lite"
def run_setup():
"""7단계 HYDRA 초기 설정 위자드."""
configure_logging()
typer.echo("\nHYDRA 설정 위자드에 오신 것을 환영합니다.\n")
# Step 1: 하드웨어 감지
typer.echo("-- Step 1/7: 하드웨어 감지 --")
hw = detect_hardware()
typer.echo(f" CPU: {hw['cpu_cores']}코어 RAM: {hw['ram_gb']}GB Disk: {hw['disk_gb']}GB")
# Step 2: 프로필 추천
typer.echo("\n-- Step 2/7: 프로필 선택 --")
recommended = recommend_profile(hw)
typer.echo(f" 추천 프로필: {recommended.upper()}")
profile = typer.prompt(" 프로필 선택 [lite/pro/expert]", default=recommended)
if profile not in ("lite", "pro", "expert"):
typer.echo("[오류] 잘못된 프로필입니다. lite, pro, expert 중 선택하세요.")
raise typer.Exit(1)
# Step 3: AI 선택
typer.echo("\n-- Step 3/7: AI 모드 선택 --")
typer.echo(" [1] OFF (규칙 기반만) [2] 경량 CPU [3] GPU [4] 커스텀")
ai_choice = typer.prompt(" 선택", default="1")
ai_mode = {"1": "off", "2": "cpu", "3": "gpu", "4": "custom"}.get(ai_choice, "off")
# Step 4: 인터페이스
typer.echo("\n-- Step 4/7: 인터페이스 선택 --")
typer.echo(" [1] CLI+Telegram [2] Dashboard+Telegram [3] 전부 [4] Telegram만")
interface = typer.prompt(" 선택", default="1")
# Step 5: 면책조항 동의
typer.echo("\n-- Step 5/7: 면책조항 동의 --")
disclaimer = Path("DISCLAIMER.md").read_text(encoding="utf-8") if Path("DISCLAIMER.md").exists() else ""
typer.echo(disclaimer)
accepted = typer.confirm("\n위 면책조항에 동의하십니까?")
if not accepted:
typer.echo("면책조항에 동의하지 않으면 설치를 진행할 수 없습니다.")
sys.exit(1)
# Step 5b: 시장 선택 + API 키
typer.echo("\n-- 시장 선택 --")
selected_markets = []
mm = MarketManager()
km = KeyManager()
for market_id, label in MARKETS.items():
if typer.confirm(f" {label} 사용?", default=False):
selected_markets.append(market_id)
mode = typer.prompt(f" {label} 모드 [paper/live]", default="paper")
mm.enable(market_id, mode)
if market_id in ("kr", "us"):
app_key = typer.prompt(f" KIS App Key ({label})", hide_input=True)
secret = typer.prompt(f" KIS App Secret ({label})", hide_input=True)
km.store(f"kis_{market_id}", app_key, secret)
account_no = typer.prompt(" KIS 계좌번호 (예: 50123456-01)")
km.store("kis_account", account_no, "")
elif market_id in ("upbit", "binance", "hl"):
api_key = typer.prompt(f" {label} API Key", hide_input=True)
secret = typer.prompt(f" {label} Secret", hide_input=True)
km.store(market_id, api_key, secret)
# Step 6: 벤치마크
typer.echo("\n-- Step 6/7: 성능 벤치마크 실행 --")
typer.echo(" (잠시 기다려 주세요...)")
import subprocess
try:
subprocess.run(["python", "scripts/benchmark.py", "--profile", profile], timeout=30)
except Exception:
typer.echo(" 벤치마크 스킵 (scripts/benchmark.py 없음)")
# Step 7: 설정 저장
typer.echo("\n-- Step 7/7: 설정 완료 --")
env_content = f"""HYDRA_PROFILE={profile}
HYDRA_API_KEY={_generate_api_key()}
REDIS_URL=redis://localhost:6379
"""
Path(".env").write_text(env_content)
typer.echo("\n[완료] 설정이 저장되었습니다.")
typer.echo(f" 프로필: {profile.upper()} | AI: {ai_mode} | 시장: {', '.join(selected_markets) or '없음'}")
typer.echo(" hydra start 또는 docker compose -f docker-compose.lite.yml up 으로 시작하세요.")
def _generate_api_key() -> str:
import secrets
return secrets.token_urlsafe(32)

60
hydra/cli/status.py Normal file
View File

@@ -0,0 +1,60 @@
import typer
import httpx
from hydra.config.settings import get_settings
app = typer.Typer(help="시스템 상태 확인")
@app.callback(invoke_without_command=True)
def status():
"""HYDRA 상태 확인."""
settings = get_settings()
try:
h = httpx.get("http://127.0.0.1:8000/health", timeout=5)
s = httpx.get(
"http://127.0.0.1:8000/status",
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=5,
)
r = httpx.get(
"http://127.0.0.1:8000/risk",
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=5,
)
p = httpx.get(
"http://127.0.0.1:8000/pnl",
headers={"X-HYDRA-KEY": settings.hydra_api_key},
timeout=5,
)
typer.echo(f"[정상] 서버 상태 정상 | 프로필: {s.json()['profile']} | 가동시간: {h.json()['uptime_seconds']}")
risk = r.json()
ks = "ACTIVE" if risk["kill_switch_active"] else "NORMAL"
typer.echo(f"Kill Switch: {ks} | 일일 손익(리스크): {risk['daily_pnl_pct']*100:.2f}%")
pnl = p.json()
sign = lambda v: "+" if v >= 0 else ""
typer.echo(
f"\n=== 손익 현황 ===\n"
f" 실현 손익 (누적): {sign(pnl['realized_total'])}{pnl['realized_total']:,.4f} USDT\n"
f" 실현 손익 (오늘): {sign(pnl['daily_realized'])}{pnl['daily_realized']:,.4f} USDT\n"
f" 미실현 손익: {sign(pnl['unrealized'])}{pnl['unrealized']:,.4f} USDT\n"
f" 총 손익: {sign(pnl['total_pnl'])}{pnl['total_pnl']:,.4f} USDT\n"
f" 체결 거래 수: {pnl['trade_count']}"
)
if pnl["positions"]:
typer.echo("\n -- 오픈 포지션 --")
for pos in pnl["positions"]:
lev = f" {pos['leverage']}x" if pos.get("leverage", 1) > 1 else ""
upnl = pos.get("unrealized_pnl", 0)
typer.echo(
f" [{pos['market']}] {pos['symbol']} {pos['side'].upper()}{lev} "
f" 수량: {pos['qty']} 평균단가: {pos['avg_price']} "
f"미실현: {sign(upnl)}{upnl:,.4f}"
)
else:
typer.echo("\n 오픈 포지션 없음")
except httpx.ConnectError:
typer.echo("[오류] 서버 오프라인")

18
hydra/cli/strategy.py Normal file
View File

@@ -0,0 +1,18 @@
import typer
app = typer.Typer(help="전략 관리 (Phase 2에서 구현)")
@app.command()
def list_strategies():
typer.echo("전략 목록 - Phase 2에서 구현 예정")
@app.command()
def start(name: str):
typer.echo(f"전략 시작: {name} - Phase 2에서 구현 예정")
@app.command()
def stop(name: str):
typer.echo(f"전략 중지: {name} - Phase 2에서 구현 예정")

40
hydra/cli/trade.py Normal file
View File

@@ -0,0 +1,40 @@
import typer
from typing import Optional
app = typer.Typer(help="수동 매매 명령 (Phase 1에서 전략 연동)")
@app.command()
def kr(symbol: str, side: str, qty: float):
"""한국 주식 수동 주문."""
typer.echo(f"[한국주식] {side} {symbol} {qty}주 - Phase 1에서 구현 예정")
@app.command()
def us(symbol: str, side: str, qty: float):
"""미국 주식 수동 주문."""
typer.echo(f"[미국주식] {side} {symbol} {qty}주 - Phase 1에서 구현 예정")
@app.command()
def crypto(
exchange: str,
symbol: str,
side: str,
qty: float,
leverage: int = typer.Option(1, "--leverage", "-l", help="선물 레버리지 배수 (1~125x). 현물은 1로 고정.", min=1, max=125),
futures: bool = typer.Option(False, "--futures", help="선물 주문 여부"),
):
"""암호화폐 수동 주문. 선물은 --futures --leverage N 으로 레버리지 지정."""
if futures and leverage > 1:
typer.echo(f"[{exchange}] {side} {symbol} {qty} x {leverage} 레버리지 (선물) - Phase 1에서 구현 예정")
elif futures:
typer.echo(f"[{exchange}] {side} {symbol} {qty} (선물) - Phase 1에서 구현 예정")
else:
typer.echo(f"[{exchange}] {side} {symbol} {qty} (현물) - Phase 1에서 구현 예정")
@app.command()
def poly(market_id: str, side: str, amount: float):
"""Polymarket 예측시장 주문."""
typer.echo(f"[Polymarket] {side} {market_id} ${amount} - Phase 1에서 구현 예정")

0
hydra/config/__init__.py Normal file
View File

52
hydra/config/keys.py Normal file
View File

@@ -0,0 +1,52 @@
import json
import os
from pathlib import Path
from cryptography.fernet import Fernet
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class KeyManager:
def __init__(self, master_key_path: str = "~/.hydra/master.key"):
self._master_path = Path(master_key_path).expanduser()
self._key_dir = self._master_path.parent
self._fernet = self._load_or_create_fernet()
def _load_or_create_fernet(self) -> Fernet:
self._key_dir.mkdir(parents=True, exist_ok=True)
if self._master_path.exists():
raw = self._master_path.read_bytes()
else:
raw = Fernet.generate_key()
self._master_path.write_bytes(raw)
self._master_path.chmod(0o600)
logger.info("master_key_created", path=str(self._master_path))
return Fernet(raw)
def encrypt(self, plain: str) -> bytes:
return self._fernet.encrypt(plain.encode())
def decrypt(self, token: bytes) -> str:
return self._fernet.decrypt(token).decode()
def store(self, exchange: str, api_key: str, secret: str) -> None:
payload = json.dumps({"api_key": api_key, "secret": secret})
encrypted = self.encrypt(payload)
out = self._key_dir / f"{exchange}.enc"
out.write_bytes(encrypted)
out.chmod(0o600)
logger.info("key_stored", exchange=exchange)
def load(self, exchange: str) -> tuple[str, str]:
path = self._key_dir / f"{exchange}.enc"
if not path.exists():
raise FileNotFoundError(f"No stored key for exchange '{exchange}'")
payload = json.loads(self.decrypt(path.read_bytes()))
return payload["api_key"], payload["secret"]
def check_withdrawal_permission(self, exchange: str) -> bool:
"""Placeholder — subclasses override for exchange-specific checks."""
return False

52
hydra/config/markets.py Normal file
View File

@@ -0,0 +1,52 @@
from pathlib import Path
from typing import Optional
import yaml
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
VALID_MARKETS = {"kr", "us", "upbit", "binance", "hl", "poly"}
class MarketManager:
def __init__(self, config_path: str = "config/markets.yaml"):
self._path = Path(config_path)
self._data: dict = self._load()
def _load(self) -> dict:
if not self._path.exists():
return {"markets": {m: {"enabled": False, "mode": "paper"} for m in VALID_MARKETS}}
with self._path.open() as f:
return yaml.safe_load(f) or {"markets": {}}
def _save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._path.open("w") as f:
yaml.dump(self._data, f, allow_unicode=True)
def get_active_markets(self) -> list[str]:
return [k for k, v in self._data.get("markets", {}).items() if v.get("enabled")]
def is_active(self, market_id: str) -> bool:
return self._data.get("markets", {}).get(market_id, {}).get("enabled", False)
def enable(self, market_id: str, mode: str = "paper") -> None:
if market_id not in VALID_MARKETS:
raise ValueError(f"Unknown market '{market_id}'. Valid: {VALID_MARKETS}")
markets = self._data.setdefault("markets", {})
markets.setdefault(market_id, {})["enabled"] = True
markets[market_id]["mode"] = mode
self._save()
logger.info("market_enabled", market=market_id, mode=mode)
def disable(self, market_id: str) -> None:
markets = self._data.get("markets", {})
if market_id in markets:
markets[market_id]["enabled"] = False
self._save()
logger.info("market_disabled", market=market_id)
def get_mode(self, market_id: str) -> str:
return self._data.get("markets", {}).get(market_id, {}).get("mode", "paper")

49
hydra/config/profiles.py Normal file
View File

@@ -0,0 +1,49 @@
from dataclasses import dataclass
@dataclass
class ProfileLimits:
name: str
core_mem_gb: int
redis_mem_gb: int
db_mem_gb: int
cpus: int
ai_enabled: bool
db_backend: str # "sqlite" or "timescaledb"
PROFILES: dict[str, ProfileLimits] = {
"lite": ProfileLimits(
name="lite",
core_mem_gb=2,
redis_mem_gb=1,
db_mem_gb=0,
cpus=2,
ai_enabled=False,
db_backend="sqlite",
),
"pro": ProfileLimits(
name="pro",
core_mem_gb=4,
redis_mem_gb=2,
db_mem_gb=4,
cpus=4,
ai_enabled=True,
db_backend="timescaledb",
),
"expert": ProfileLimits(
name="expert",
core_mem_gb=8,
redis_mem_gb=4,
db_mem_gb=8,
cpus=8,
ai_enabled=True,
db_backend="timescaledb",
),
}
def get_profile(name: str) -> ProfileLimits:
if name not in PROFILES:
raise ValueError(f"Unknown profile '{name}'. Choose: {list(PROFILES)}")
return PROFILES[name]

19
hydra/config/settings.py Normal file
View File

@@ -0,0 +1,19 @@
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
hydra_api_key: str = "change-me"
hydra_profile: str = "lite"
redis_url: str = "redis://localhost:6379"
db_url: str = "sqlite:///data/hydra.db"
telegram_bot_token: str = ""
telegram_chat_id: str = ""
log_level: str = "INFO"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,33 @@
from pydantic import BaseModel, field_validator
class StrategyConfig(BaseModel):
stop_loss_pct: float = 0.02
take_profit_pct: float = 0.05
position_size_pct: float = 0.10
max_positions: int = 5
@field_validator("stop_loss_pct")
@classmethod
def validate_stop_loss(cls, v: float) -> float:
if v <= 0:
raise ValueError("손절은 양수여야 합니다")
if v > 0.20:
raise ValueError(f"손절 {v * 100:.1f}%는 너무 큽니다 (최대 20%)")
return v
@field_validator("position_size_pct")
@classmethod
def validate_position_size(cls, v: float) -> float:
if v > 0.50:
raise ValueError(f"포지션 사이즈 {v * 100:.1f}%는 너무 큽니다 (최대 50%)")
return v
class RiskConfig(BaseModel):
daily_loss_limit_pct: float = 0.03
daily_loss_kill_pct: float = 0.05
max_position_per_symbol_pct: float = 0.20
max_position_per_strategy_pct: float = 0.30
max_position_per_market_pct: float = 0.50
consecutive_loss_limit: int = 5

0
hydra/core/__init__.py Normal file
View File

92
hydra/core/kill_switch.py Normal file
View File

@@ -0,0 +1,92 @@
import time
from dataclasses import dataclass, field
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
DAILY_PNL_KEY = "hydra:daily_pnl"
KILL_THRESHOLD = -0.05
@dataclass
class KillSwitchResult:
success: bool
source: str
reason: str
duration_ms: float
closed_positions: list = field(default_factory=list)
errors: list = field(default_factory=list)
class KillSwitch:
def __init__(self, exchanges: dict, position_tracker, telegram, redis_client):
self._exchanges = exchanges
self._positions = position_tracker
self._telegram = telegram
self._redis = redis_client
async def execute(self, reason: str, source: str) -> KillSwitchResult:
t0 = time.monotonic()
logger.warning("kill_switch_triggered", reason=reason, source=source)
# 1. 신규 주문 차단
self._redis.set(KILL_BLOCKED_KEY, "1")
# 2. 전 거래소 미체결 취소
errors = []
for name, ex in self._exchanges.items():
try:
await ex.cancel_all()
except Exception as e:
errors.append(f"{name}: cancel_all failed: {e}")
logger.error("kill_switch_cancel_error", exchange=name, error=str(e))
# 3. 전 포지션 시장가 청산
positions = self._positions.get_all()
closed = []
for pos in positions:
market = pos["market"]
ex = self._exchanges.get(market)
if ex:
try:
close_side = "sell" if pos["side"] == "buy" else "buy"
await ex.create_order(
symbol=pos["symbol"],
side=close_side,
order_type="market",
qty=pos["qty"],
)
closed.append(pos["symbol"])
except Exception as e:
errors.append(f"close {pos['symbol']}: {e}")
duration_ms = (time.monotonic() - t0) * 1000
# 4. Telegram 알림
msg = f"🚨 Kill Switch 발동\n원인: {reason}\n경로: {source}\n청산: {len(closed)}\n소요: {duration_ms:.0f}ms"
try:
await self._telegram.send_message(msg)
except Exception as e:
logger.error("kill_switch_telegram_error", error=str(e))
logger.warning("kill_switch_complete", closed=len(closed), errors=len(errors), duration_ms=duration_ms)
return KillSwitchResult(
success=len(errors) == 0,
source=source,
reason=reason,
duration_ms=duration_ms,
closed_positions=closed,
errors=errors,
)
async def check_auto_triggers(self) -> tuple[bool, str]:
raw = self._redis.get(DAILY_PNL_KEY)
daily_pnl = float(raw) if raw else 0.0
if daily_pnl <= KILL_THRESHOLD:
return True, f"daily_loss:{daily_pnl*100:.1f}%"
return False, ""
def is_active(self) -> bool:
return bool(self._redis.get(KILL_BLOCKED_KEY))

137
hydra/core/order_queue.py Normal file
View File

@@ -0,0 +1,137 @@
import json
from dataclasses import dataclass
from typing import Literal, Optional
from uuid import uuid4
from pydantic import BaseModel, Field, field_validator
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
LOCK_TIMEOUT = 10
IDEMPOTENCY_TTL = 86400 # 24 hours
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
FUTURES_MARKETS = {"binance", "hl"} # 선물 거래를 지원하는 시장
class OrderLockError(Exception):
pass
class OrderRequest(BaseModel):
market: Literal["kr", "us", "upbit", "binance", "hl", "poly"]
symbol: str
side: Literal["buy", "sell"]
order_type: Literal["market", "limit"] = "market"
qty: Optional[float] = None
price: Optional[float] = None
amount: Optional[float] = None
idempotency_key: str = ""
exchange: Optional[str] = None
leverage: int = Field(default=1, ge=1, le=125, description="선물 레버리지 (1~125x, 현물은 1로 고정)")
is_futures: bool = Field(default=False, description="선물 주문 여부")
def model_post_init(self, __context) -> None:
if not self.idempotency_key:
self.idempotency_key = str(uuid4())
# 선물 지원 시장인데 leverage > 1이면 자동으로 is_futures=True
if self.leverage > 1 and self.market in FUTURES_MARKETS:
object.__setattr__(self, "is_futures", True)
@field_validator("qty")
@classmethod
def validate_qty(cls, v):
if v is not None and v <= 0:
raise ValueError("수량은 양수여야 합니다")
return v
@field_validator("leverage")
@classmethod
def validate_leverage(cls, v):
if v < 1 or v > 125:
raise ValueError("레버리지는 1~125 사이여야 합니다")
return v
@dataclass
class OrderResult:
order_id: str
status: str
symbol: str
market: str
class OrderQueue:
def __init__(self, redis_client, risk_engine, position_tracker, exchanges: dict):
self._redis = redis_client
self._risk = risk_engine
self._positions = position_tracker
self._exchanges = exchanges
self._blocked = False
def block_new_orders(self) -> None:
self._blocked = True
async def submit(self, order: OrderRequest) -> OrderResult:
# 멱등성 체크 (Kill Switch보다 먼저 — 캐시된 결과는 항상 반환)
cached_raw = self._redis.get(f"idem:{order.idempotency_key}")
if cached_raw:
cached = json.loads(cached_raw)
return OrderResult(
order_id=cached["order_id"],
status=cached["status"],
symbol=order.symbol,
market=order.market,
)
# Kill Switch 체크
if self._blocked or self._redis.get(KILL_BLOCKED_KEY):
raise OrderLockError("Kill Switch 활성 — 신규 주문 불가")
# Redis 락 획득
lock_key = f"order_lock:{order.market}:{order.symbol}:{order.side}"
acquired = self._redis.set(lock_key, "1", nx=True, ex=LOCK_TIMEOUT)
if not acquired:
raise OrderLockError(f"주문 락 획득 실패: {order.symbol} {order.side}")
try:
# 리스크 검증
allowed, reason = self._risk.check_order_allowed(order.market, order.symbol, 0.0)
if not allowed:
raise OrderLockError(f"리스크 검증 실패: {reason}")
# 거래소 주문
ex = self._exchanges.get(order.market)
if not ex:
raise OrderLockError(f"비활성 시장: {order.market}")
# 선물 레버리지 설정 (주문 전)
if order.is_futures and order.leverage > 1:
await ex.set_leverage(order.symbol, order.leverage)
raw = await ex.create_order(
symbol=order.symbol,
side=order.side,
order_type=order.order_type,
qty=order.qty,
price=order.price,
)
result = OrderResult(
order_id=raw.get("order_id", str(uuid4())),
status=raw.get("status", "submitted"),
symbol=order.symbol,
market=order.market,
)
# 멱등성 캐시 저장
self._redis.set(
f"idem:{order.idempotency_key}",
json.dumps({"order_id": result.order_id, "status": result.status}),
ex=IDEMPOTENCY_TTL,
)
logger.info("order_submitted", order_id=result.order_id, symbol=order.symbol, side=order.side, leverage=order.leverage)
return result
finally:
self._redis.delete(lock_key)

114
hydra/core/pnl_tracker.py Normal file
View File

@@ -0,0 +1,114 @@
import json
import time
from datetime import date
from typing import Optional
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
PNL_REALIZED_KEY = "hydra:pnl:realized:total"
PNL_TRADE_COUNT_KEY = "hydra:pnl:trade_count"
def _daily_key() -> str:
return f"hydra:pnl:daily:{date.today().isoformat()}"
class PnlTracker:
"""
시스템 전체 손익 추적.
- 실현 손익: 포지션 청산 시 record_trade()로 기록
- 미실현 손익: 포지션 데이터(mark_price)로 계산
"""
def __init__(self, redis_client):
self._redis = redis_client
# ─── 실현 손익 ───────────────────────────────────────────────
def record_trade(self, market: str, symbol: str, realized_pnl: float) -> None:
"""포지션 청산 시 실현 손익 기록."""
pipe = self._redis.pipeline()
pipe.incrbyfloat(PNL_REALIZED_KEY, realized_pnl)
pipe.incrbyfloat(_daily_key(), realized_pnl)
pipe.incr(PNL_TRADE_COUNT_KEY)
# 개별 심볼 실현 손익
pipe.incrbyfloat(f"hydra:pnl:symbol:{market}:{symbol}", realized_pnl)
pipe.execute()
logger.info("pnl_recorded", market=market, symbol=symbol, realized_pnl=realized_pnl)
def get_realized_total(self) -> float:
raw = self._redis.get(PNL_REALIZED_KEY)
return float(raw) if raw else 0.0
def get_daily_realized(self) -> float:
raw = self._redis.get(_daily_key())
return float(raw) if raw else 0.0
def get_trade_count(self) -> int:
raw = self._redis.get(PNL_TRADE_COUNT_KEY)
return int(raw) if raw else 0
def get_symbol_realized(self, market: str, symbol: str) -> float:
raw = self._redis.get(f"hydra:pnl:symbol:{market}:{symbol}")
return float(raw) if raw else 0.0
# ─── 미실현 손익 ──────────────────────────────────────────────
@staticmethod
def calc_unrealized(position: dict) -> float:
"""단일 포지션의 미실현 손익 계산.
position dict: {qty, avg_price, mark_price, side, leverage}
"""
qty = float(position.get("qty", 0))
avg_price = float(position.get("avg_price", 0))
mark_price = float(position.get("mark_price", avg_price))
side = position.get("side", "buy")
leverage = float(position.get("leverage", 1))
if qty == 0 or avg_price == 0:
return 0.0
price_diff = mark_price - avg_price
if side == "sell":
price_diff = -price_diff
return price_diff * qty * leverage
def get_unrealized_total(self, positions: list[dict]) -> float:
return sum(self.calc_unrealized(p) for p in positions)
# ─── 요약 ─────────────────────────────────────────────────────
def get_summary(self, positions: list[dict]) -> dict:
realized_total = self.get_realized_total()
daily_realized = self.get_daily_realized()
unrealized = self.get_unrealized_total(positions)
trade_count = self.get_trade_count()
return {
"realized_total": round(realized_total, 4),
"daily_realized": round(daily_realized, 4),
"unrealized": round(unrealized, 4),
"total_pnl": round(realized_total + unrealized, 4),
"trade_count": trade_count,
"positions": [
{
"market": p.get("market"),
"symbol": p.get("symbol"),
"side": p.get("side"),
"qty": p.get("qty"),
"avg_price": p.get("avg_price"),
"mark_price": p.get("mark_price", p.get("avg_price")),
"leverage": p.get("leverage", 1),
"unrealized_pnl": round(self.calc_unrealized(p), 4),
}
for p in positions
],
}
def reset_daily(self) -> None:
"""일일 손익 초기화 (자정 실행용)."""
self._redis.delete(_daily_key())
logger.info("pnl_daily_reset")

View File

@@ -0,0 +1,49 @@
import json
from typing import Optional
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
POSITIONS_KEY = "hydra:positions"
class PositionTracker:
def __init__(self, redis_client):
self._redis = redis_client
def update(self, market: str, symbol: str, qty: float, avg_price: float, side: str, leverage: int = 1, mark_price: float | None = None) -> None:
key = f"{POSITIONS_KEY}:{market}:{symbol}"
data = {
"qty": qty,
"avg_price": avg_price,
"side": side,
"market": market,
"symbol": symbol,
"leverage": leverage,
"mark_price": mark_price or avg_price,
}
self._redis.set(key, json.dumps(data))
logger.info("position_updated", market=market, symbol=symbol, qty=qty, leverage=leverage)
def get(self, market: str, symbol: str) -> Optional[dict]:
key = f"{POSITIONS_KEY}:{market}:{symbol}"
raw = self._redis.get(key)
return json.loads(raw) if raw else None
def get_all(self) -> list[dict]:
keys = self._redis.keys(f"{POSITIONS_KEY}:*")
result = []
for k in keys:
raw = self._redis.get(k)
if raw:
result.append(json.loads(raw))
return result
def clear(self, market: str, symbol: str) -> None:
key = f"{POSITIONS_KEY}:{market}:{symbol}"
self._redis.delete(key)
logger.info("position_cleared", market=market, symbol=symbol)
async def snapshot(self) -> dict:
return {"positions": self.get_all()}

38
hydra/core/risk_engine.py Normal file
View File

@@ -0,0 +1,38 @@
from hydra.config.validation import RiskConfig
from hydra.core.position_tracker import PositionTracker
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
DAILY_PNL_KEY = "hydra:daily_pnl"
class RiskEngine:
def __init__(self, redis_client, position_tracker: PositionTracker, config: RiskConfig | None = None):
self._redis = redis_client
self._positions = position_tracker
self._config = config or RiskConfig()
def get_daily_pnl_pct(self) -> float:
raw = self._redis.get(DAILY_PNL_KEY)
return float(raw) if raw else 0.0
def update_daily_pnl(self, pnl_pct: float) -> None:
self._redis.set(DAILY_PNL_KEY, str(pnl_pct))
def check_order_allowed(self, market: str, symbol: str, position_pct: float) -> tuple[bool, str]:
"""주문 허용 여부 확인. (allowed, reason) 반환."""
daily_pnl = self.get_daily_pnl_pct()
if daily_pnl <= -self._config.daily_loss_kill_pct:
return False, f"일일 손실 {daily_pnl*100:.1f}% — Kill Switch 레벨"
if daily_pnl <= -self._config.daily_loss_limit_pct:
return False, f"일일 손실 {daily_pnl*100:.1f}% — 신규 주문 중단"
if position_pct > self._config.max_position_per_symbol_pct:
return False, f"종목 포지션 {position_pct*100:.1f}% 초과 (최대 {self._config.max_position_per_symbol_pct*100:.0f}%)"
return True, "ok"
def should_kill_switch(self) -> tuple[bool, str]:
daily_pnl = self.get_daily_pnl_pct()
if daily_pnl <= -self._config.daily_loss_kill_pct:
return True, f"daily_loss:{daily_pnl*100:.1f}%"
return False, ""

View File

@@ -0,0 +1,32 @@
import json
import time
from typing import Any
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
STATE_KEY = "hydra:state"
class StateManager:
def __init__(self, redis_client):
self._redis = redis_client
def save(self, key: str, value: Any) -> None:
payload = json.dumps({"value": value, "ts": time.time()})
self._redis.hset(STATE_KEY, key, payload)
def load(self, key: str, default: Any = None) -> Any:
raw = self._redis.hget(STATE_KEY, key)
if raw is None:
return default
return json.loads(raw)["value"]
def save_all(self, state: dict) -> None:
for k, v in state.items():
self.save(k, v)
def load_all(self) -> dict:
raw = self._redis.hgetall(STATE_KEY)
return {k: json.loads(v)["value"] for k, v in raw.items()}

View File

22
hydra/exchange/base.py Normal file
View File

@@ -0,0 +1,22 @@
from abc import ABC, abstractmethod
class BaseExchange(ABC):
@abstractmethod
async def get_balance(self) -> dict: ...
@abstractmethod
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict: ...
@abstractmethod
async def cancel_order(self, order_id: str) -> dict: ...
@abstractmethod
async def cancel_all(self) -> list: ...
@abstractmethod
async def get_positions(self) -> list: ...
async def set_leverage(self, symbol: str, leverage: int) -> None:
"""레버리지 설정. 현물 거래소는 no-op (override 불필요)."""
pass

61
hydra/exchange/crypto.py Normal file
View File

@@ -0,0 +1,61 @@
import asyncio
import json
import subprocess
from pybreaker import CircuitBreaker
from hydra.exchange.base import BaseExchange
from hydra.resilience.circuit_breaker import create_breaker
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
VALID_LEVERAGE = range(1, 126) # 1x ~ 125x
class CryptoExchange(BaseExchange):
"""ccxt CLI를 subprocess로 호출. 서킷 브레이커 적용."""
def __init__(self, exchange_id: str, breaker: CircuitBreaker | None = None, is_futures: bool = False):
self.exchange_id = exchange_id
self.is_futures = is_futures
self._breaker = breaker or create_breaker(f"crypto:{exchange_id}")
async def _run(self, args: list[str]) -> dict:
cmd = ["ccxt", self.exchange_id] + args + ["--json"]
loop = asyncio.get_event_loop()
raw = await loop.run_in_executor(
None,
lambda: self._breaker.call(subprocess.check_output, cmd, text=True, timeout=15),
)
return json.loads(raw)
async def get_balance(self) -> dict:
return await self._run(["fetchBalance"])
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict:
args = ["createOrder", symbol, order_type, side, str(qty)]
if price:
args.append(str(price))
return await self._run(args)
async def cancel_order(self, order_id: str) -> dict:
return await self._run(["cancelOrder", order_id])
async def cancel_all(self) -> list:
result = await self._run(["cancelAllOrders"])
return result if isinstance(result, list) else []
async def get_positions(self) -> list:
result = await self._run(["fetchPositions"])
return result if isinstance(result, list) else []
async def set_leverage(self, symbol: str, leverage: int) -> None:
"""선물 레버리지 설정. 현물 모드에서는 no-op."""
if not self.is_futures:
logger.debug("set_leverage_skipped_spot", exchange=self.exchange_id, symbol=symbol)
return
if leverage not in VALID_LEVERAGE:
raise ValueError(f"레버리지는 1~125 사이여야 합니다. 입력값: {leverage}")
await self._run(["setLeverage", str(leverage), symbol])
logger.info("leverage_set", exchange=self.exchange_id, symbol=symbol, leverage=leverage)

41
hydra/exchange/factory.py Normal file
View File

@@ -0,0 +1,41 @@
from hydra.config.markets import MarketManager
from hydra.config.keys import KeyManager
from hydra.exchange.base import BaseExchange
from hydra.exchange.crypto import CryptoExchange
from hydra.exchange.kis import KISExchange
from hydra.exchange.polymarket import PolymarketExchange
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
def create_exchanges(market_manager: MarketManager, key_manager: KeyManager) -> dict[str, BaseExchange]:
"""활성화된 시장의 거래소 커넥터만 생성."""
exchanges: dict[str, BaseExchange] = {}
active = market_manager.get_active_markets()
for market in active:
mode = market_manager.get_mode(market)
is_paper = mode == "paper"
try:
if market == "kr":
app_key, secret = key_manager.load("kis_kr")
account_no, _ = key_manager.load("kis_account")
exchanges["kr"] = KISExchange(app_key, secret, account_no, is_paper=is_paper)
elif market == "us":
app_key, secret = key_manager.load("kis_us")
account_no, _ = key_manager.load("kis_account")
exchanges["us"] = KISExchange(app_key, secret, account_no, is_paper=is_paper)
elif market == "upbit":
exchanges["upbit"] = CryptoExchange("upbit")
elif market == "binance":
exchanges["binance"] = CryptoExchange("binance")
elif market == "hl":
exchanges["hl"] = CryptoExchange("hyperliquid")
elif market == "poly":
exchanges["poly"] = PolymarketExchange()
logger.info("exchange_created", market=market, mode=mode)
except Exception as e:
logger.error("exchange_create_failed", market=market, error=str(e))
return exchanges

63
hydra/exchange/kis.py Normal file
View File

@@ -0,0 +1,63 @@
from hydra.exchange.base import BaseExchange
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class KISExchange(BaseExchange):
"""python-kis 래핑. 한국/미국 주식."""
def __init__(self, app_key: str, app_secret: str, account_no: str, is_paper: bool = True):
self._app_key = app_key
self._app_secret = app_secret
self._account_no = account_no
self._is_paper = is_paper
self._client = None
def _get_client(self):
if self._client is None:
import pykis
self._client = pykis.PyKis(
id=self._app_key,
account=self._account_no,
appkey=self._app_key,
appsecret=self._app_secret,
virtual_account=self._is_paper,
)
return self._client
async def get_balance(self) -> dict:
client = self._get_client()
account = client.account()
return {"balance": float(account.balance)}
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict:
client = self._get_client()
stock = client.stock(symbol)
if side == "buy":
order = stock.buy(qty=int(qty), price=price)
else:
order = stock.sell(qty=int(qty), price=price)
return {"order_id": str(order.number), "status": "submitted"}
async def cancel_order(self, order_id: str) -> dict:
client = self._get_client()
client.cancel_order(order_id)
return {"status": "canceled"}
async def cancel_all(self) -> list:
client = self._get_client()
orders = client.pending_orders()
canceled = []
for o in orders:
o.cancel()
canceled.append(str(o.number))
return canceled
async def get_positions(self) -> list:
client = self._get_client()
account = client.account()
return [
{"symbol": s.symbol, "qty": s.qty, "avg_price": float(s.purchase_price), "side": "buy"}
for s in account.stocks
]

View File

@@ -0,0 +1,41 @@
import asyncio
import json
import subprocess
from hydra.exchange.base import BaseExchange
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class PolymarketExchange(BaseExchange):
"""polymarket-cli subprocess 래핑."""
async def _run(self, args: list[str]) -> dict:
cmd = ["polymarket"] + args + ["--json"]
loop = asyncio.get_event_loop()
raw = await loop.run_in_executor(
None,
lambda: subprocess.check_output(cmd, text=True, timeout=15),
)
return json.loads(raw)
async def get_balance(self) -> dict:
return await self._run(["balance"])
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict:
args = ["order", "create", "--market", symbol, "--side", side, "--amount", str(qty)]
if price:
args += ["--price", str(price)]
return await self._run(args)
async def cancel_order(self, order_id: str) -> dict:
return await self._run(["order", "cancel", "--id", order_id])
async def cancel_all(self) -> list:
result = await self._run(["order", "cancel-all"])
return result if isinstance(result, list) else []
async def get_positions(self) -> list:
result = await self._run(["positions"])
return result if isinstance(result, list) else []

View File

View File

@@ -0,0 +1,82 @@
import time
import pandas as pd
import pandas_ta as ta
from hydra.data.models import Candle
_MIN_CANDLES = 210 # EMA_200 requires at least 200 candles; 210 gives buffer
# Common technical indicators used for trading signals.
# Uses ta.Study (the current pandas-ta API) instead of the deprecated
# ta.Strategy("All") which is not available in newer pandas-ta versions.
_DEFAULT_STUDY = ta.Study(
name="hydra",
ta=[
{"kind": "rsi", "length": 14},
{"kind": "ema", "length": 9},
{"kind": "ema", "length": 20},
{"kind": "ema", "length": 50},
{"kind": "ema", "length": 200},
{"kind": "sma", "length": 20},
{"kind": "sma", "length": 50},
{"kind": "macd"},
{"kind": "bbands"},
{"kind": "atr"},
{"kind": "adx"},
{"kind": "stoch"},
{"kind": "stochrsi"},
{"kind": "cci"},
{"kind": "willr"},
{"kind": "obv"},
{"kind": "mfi"},
{"kind": "mom"},
{"kind": "roc"},
{"kind": "tsi"},
{"kind": "vwap"},
{"kind": "supertrend"},
{"kind": "kc"},
{"kind": "donchian"},
{"kind": "aroon"},
{"kind": "ao"},
{"kind": "er"},
],
)
class IndicatorCalculator:
"""Compute technical indicators for a candle list using pandas-ta."""
def compute(self, candles: list[Candle]) -> dict:
"""
Run a comprehensive set of pandas-ta indicators on candles.
Returns {} if fewer than _MIN_CANDLES candles provided.
NaN values are converted to None.
"""
if len(candles) < _MIN_CANDLES:
return {}
df = pd.DataFrame([
{
"open": c.open, "high": c.high,
"low": c.low, "close": c.close,
"volume": c.volume,
}
for c in candles
])
# cores=0 disables multiprocessing (avoids overhead for small DataFrames)
df.ta.study(_DEFAULT_STUDY, cores=0)
last = df.iloc[-1].to_dict()
result: dict = {}
for key, val in last.items():
if key in ("open", "high", "low", "close", "volume"):
continue
if isinstance(val, float) and pd.isna(val):
result[key] = None
elif hasattr(val, "item"): # numpy scalar → Python native
result[key] = val.item()
else:
result[key] = val
result["calculated_at"] = int(time.time() * 1000)
return result

85
hydra/indicator/engine.py Normal file
View File

@@ -0,0 +1,85 @@
import asyncio
import json
from hydra.data.storage.base import OhlcvStore
from hydra.indicator.calculator import IndicatorCalculator
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_CHANNEL = "hydra:candle:new"
_KEY_PREFIX = "hydra:indicator"
class IndicatorEngine:
def __init__(
self,
store: OhlcvStore,
redis_client,
calculator: IndicatorCalculator,
):
self._store = store
self._redis = redis_client
self._calculator = calculator
async def _handle_event(
self, market: str, symbol: str, timeframe: str
) -> None:
"""Compute indicators for one (market, symbol, timeframe) and cache in Redis."""
try:
candles = await self._store.query(market, symbol, timeframe, limit=250)
result = self._calculator.compute(candles)
if not result:
return
key = f"{_KEY_PREFIX}:{market}:{symbol}:{timeframe}"
await self._redis.set(key, json.dumps(result))
logger.debug("indicator_cached", market=market, symbol=symbol, tf=timeframe)
except Exception as e:
logger.warning(
"indicator_error",
market=market, symbol=symbol, tf=timeframe, error=str(e),
)
async def cold_start(self) -> None:
"""On startup, compute indicators for all symbols already in the DB."""
symbols = await self._store.get_symbols()
logger.info("indicator_cold_start", count=len(symbols))
for row in symbols:
await self._handle_event(row["market"], row["symbol"], row["timeframe"])
async def run(self) -> None:
"""Subscribe to hydra:candle:new and process events."""
pubsub = self._redis.pubsub()
await pubsub.subscribe(_CHANNEL)
logger.info("indicator_engine_subscribed", channel=_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
payload = json.loads(message["data"])
await self._handle_event(
payload["market"], payload["symbol"], payload["timeframe"]
)
except Exception as e:
logger.warning("indicator_subscribe_error", error=str(e))
async def main() -> None:
import redis.asyncio as aioredis
from hydra.data.storage import create_store
from hydra.config.settings import get_settings
settings = get_settings()
store = create_store()
await store.init()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
calculator = IndicatorCalculator()
engine = IndicatorEngine(store=store, redis_client=r, calculator=calculator)
try:
await engine.cold_start()
await engine.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())

View File

34
hydra/logging/setup.py Normal file
View File

@@ -0,0 +1,34 @@
import logging
import structlog
def _mask_secrets(_, __, event_dict: dict) -> dict:
"""API 키 등 민감 정보를 로그에서 마스킹"""
sensitive = ("api_key", "secret", "token", "password", "key")
for k in list(event_dict.keys()):
if any(s in k.lower() for s in sensitive):
event_dict[k] = "***MASKED***"
return event_dict
def configure_logging(level: str = "INFO") -> None:
"""structlog JSON 로깅 설정. 애플리케이션 시작 시 1회 호출."""
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
_mask_secrets,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, level.upper(), logging.INFO)
),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
)
def get_logger(name: str):
return structlog.get_logger(name)

132
hydra/main.py Normal file
View File

@@ -0,0 +1,132 @@
import asyncio
import redis as redis_lib
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from hydra.config.settings import get_settings
from hydra.config.markets import MarketManager
from hydra.config.keys import KeyManager
from hydra.core.kill_switch import KillSwitch
from hydra.core.order_queue import OrderQueue
from hydra.core.position_tracker import PositionTracker
from hydra.core.pnl_tracker import PnlTracker
from hydra.core.risk_engine import RiskEngine
from hydra.core.state_manager import StateManager
from hydra.exchange.factory import create_exchanges
from hydra.logging.setup import configure_logging, get_logger
from hydra.notify.telegram import TelegramNotifier
from hydra.resilience.graceful import GracefulManager
from hydra.api import health, orders, positions, risk, markets, system, strategies, pnl as pnl_api
from hydra.api.health import set_redis
from hydra.api.orders import set_order_queue
from hydra.api.positions import set_position_tracker
from hydra.api.risk import set_dependencies as set_risk_deps
from hydra.api.markets import set_market_manager
from hydra.api.pnl import set_pnl_dependencies
from hydra.api import data as data_api
from hydra.api.data import set_store
from hydra.api import indicators as indicators_api
from hydra.api.indicators import set_redis_for_indicators
from hydra.api import regime as regime_api
from hydra.api.regime import set_redis_for_regime
from hydra.api import signals as signals_api
from hydra.api.signals import set_redis_for_signals
from hydra.api import supplemental as supplemental_api
from hydra.api.supplemental import set_redis_for_supplemental
from hydra.api import backtest as backtest_api
from hydra.api.backtest import set_store_for_backtest
from hydra.data.storage import create_store
logger = get_logger(__name__)
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
configure_logging(settings.log_level)
logger.info("hydra_starting", profile=settings.hydra_profile)
r = redis_lib.Redis.from_url(settings.redis_url, decode_responses=True)
set_redis(r)
market_manager = MarketManager()
key_manager = KeyManager()
telegram = TelegramNotifier(settings.telegram_bot_token, settings.telegram_chat_id)
position_tracker = PositionTracker(r)
state_manager = StateManager(r)
risk_engine = RiskEngine(r, position_tracker)
pnl_tracker = PnlTracker(r)
ohlcv_store = create_store()
await ohlcv_store.init()
exchanges = create_exchanges(market_manager, key_manager)
kill_switch = KillSwitch(
exchanges=exchanges,
position_tracker=position_tracker,
telegram=telegram,
redis_client=r,
)
order_queue = OrderQueue(
redis_client=r,
risk_engine=risk_engine,
position_tracker=position_tracker,
exchanges=exchanges,
)
graceful = GracefulManager(order_queue, position_tracker, r)
graceful.register_signals()
set_order_queue(order_queue)
set_position_tracker(position_tracker)
set_risk_deps(kill_switch, risk_engine)
set_market_manager(market_manager)
set_pnl_dependencies(pnl_tracker, position_tracker)
set_store(ohlcv_store)
set_redis_for_indicators(r)
set_redis_for_regime(r)
set_redis_for_signals(r)
set_redis_for_supplemental(r)
set_store_for_backtest(ohlcv_store)
logger.info("hydra_started")
try:
yield
finally:
logger.info("hydra_stopping")
await ohlcv_store.close()
def create_app() -> FastAPI:
app = FastAPI(title="HYDRA", version="0.1.0", docs_url=None, redoc_url=None, lifespan=lifespan)
@app.middleware("http")
async def auth_guard(request: Request, call_next):
if request.url.path == "/health":
return await call_next(request)
return await call_next(request)
app.include_router(health.router)
app.include_router(orders.router)
app.include_router(positions.router)
app.include_router(risk.router)
app.include_router(markets.router)
app.include_router(system.router)
app.include_router(strategies.router)
app.include_router(pnl_api.router)
app.include_router(data_api.router)
app.include_router(indicators_api.router)
app.include_router(regime_api.router)
app.include_router(signals_api.router)
app.include_router(supplemental_api.router)
app.include_router(backtest_api.router)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run("hydra.main:app", host="127.0.0.1", port=8000, reload=False)

0
hydra/notify/__init__.py Normal file
View File

42
hydra/notify/telegram.py Normal file
View File

@@ -0,0 +1,42 @@
import asyncio
from telegram import Bot
from telegram.error import TelegramError
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
class TelegramNotifier:
def __init__(self, token: str, chat_id: str):
self._token = token
self._chat_id = chat_id
self._bot: Bot | None = None
def _get_bot(self) -> Bot:
if self._bot is None:
self._bot = Bot(token=self._token)
return self._bot
async def send_message(self, text: str) -> None:
if not self._token or not self._chat_id:
logger.warning("telegram_not_configured")
return
try:
await self._get_bot().send_message(chat_id=self._chat_id, text=text)
except TelegramError as e:
logger.error("telegram_send_error", error=str(e))
async def run_bot(self, kill_switch_callback) -> None:
"""Telegram 명령어 수신 루프 (별도 프로세스에서 실행)."""
from telegram.ext import ApplicationBuilder, CommandHandler
app = ApplicationBuilder().token(self._token).build()
async def handle_killswitch(update, context):
await update.message.reply_text("⚠️ Kill Switch 발동 중...")
await kill_switch_callback(reason="telegram_command", source="telegram")
await update.message.reply_text("✅ 전 포지션 청산 완료")
app.add_handler(CommandHandler("killswitch", handle_killswitch))
await app.run_polling()

0
hydra/regime/__init__.py Normal file
View File

17
hydra/regime/detector.py Normal file
View File

@@ -0,0 +1,17 @@
# hydra/regime/detector.py
_VOLATILE_BBB_THRESHOLD = 0.08
_TRENDING_ADX_THRESHOLD = 25.0
class RegimeDetector:
def detect(self, indicators: dict, close: float) -> str:
bbb = indicators.get("BBB_5_2.0_2.0")
adx = indicators.get("ADX_14")
ema50 = indicators.get("EMA_50")
if bbb is not None and bbb > _VOLATILE_BBB_THRESHOLD:
return "volatile"
if adx is not None and adx > _TRENDING_ADX_THRESHOLD:
if ema50 is not None:
return "trending_up" if close > ema50 else "trending_down"
return "ranging"

85
hydra/regime/engine.py Normal file
View File

@@ -0,0 +1,85 @@
# hydra/regime/engine.py
import asyncio
import json
import time
from hydra.regime.detector import RegimeDetector
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_CANDLE_CHANNEL = "hydra:candle:new"
_INDICATOR_PREFIX = "hydra:indicator"
_REGIME_PREFIX = "hydra:regime"
class RegimeEngine:
def __init__(self, redis_client, detector: RegimeDetector):
self._redis = redis_client
self._detector = detector
async def _handle_event(
self, market: str, symbol: str, timeframe: str
) -> None:
try:
indicator_key = f"{_INDICATOR_PREFIX}:{market}:{symbol}:{timeframe}"
raw = self._redis.get(indicator_key)
if raw is None:
return
indicators = json.loads(raw)
close = float(indicators.get("close") or 0.0)
regime = self._detector.detect(indicators, close)
regime_key = f"{_REGIME_PREFIX}:{market}:{symbol}:{timeframe}"
await self._redis.set(regime_key, json.dumps({
"regime": regime,
"detected_at": int(time.time() * 1000),
}))
logger.debug("regime_cached", market=market, symbol=symbol,
tf=timeframe, regime=regime)
except Exception as e:
logger.warning("regime_error", market=market, symbol=symbol,
tf=timeframe, error=str(e))
async def cold_start(self) -> None:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
logger.info("regime_cold_start", count=len(keys))
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
market = parts[2]
symbol = ":".join(parts[3:-1])
timeframe = parts[-1]
await self._handle_event(market, symbol, timeframe)
async def run(self) -> None:
pubsub = self._redis.pubsub()
await pubsub.subscribe(_CANDLE_CHANNEL)
logger.info("regime_engine_subscribed", channel=_CANDLE_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
payload = json.loads(message["data"])
await self._handle_event(
payload["market"], payload["symbol"], payload["timeframe"],
)
except Exception as e:
logger.warning("regime_subscribe_error", error=str(e))
async def main() -> None:
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
detector = RegimeDetector()
engine = RegimeEngine(redis_client=r, detector=detector)
try:
await engine.cold_start()
await engine.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())

View File

View File

@@ -0,0 +1,6 @@
from pybreaker import CircuitBreaker
def create_breaker(name: str, fail_max: int = 5, reset_timeout: int = 30) -> CircuitBreaker:
"""거래소/API별 독립 서킷 브레이커 생성."""
return CircuitBreaker(fail_max=fail_max, reset_timeout=reset_timeout, name=name)

View File

@@ -0,0 +1,35 @@
import asyncio
import signal
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_SHUTDOWN_TIMEOUT = 30
class GracefulManager:
def __init__(self, order_queue, position_tracker, redis_client):
self._order_queue = order_queue
self._positions = position_tracker
self._redis = redis_client
self._shutting_down = False
def register_signals(self) -> None:
"""메인 이벤트 루프에서 호출. SIGTERM/SIGINT 핸들러 등록."""
loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self.shutdown(s.name)))
async def shutdown(self, reason: str = "unknown") -> None:
if self._shutting_down:
return
self._shutting_down = True
logger.info("graceful_shutdown_start", reason=reason)
try:
async with asyncio.timeout(_SHUTDOWN_TIMEOUT):
self._order_queue.block_new_orders()
snapshot = await self._positions.snapshot()
self._redis.set("hydra:last_snapshot", str(snapshot))
logger.info("graceful_shutdown_complete")
except TimeoutError:
logger.error("graceful_shutdown_timeout", seconds=_SHUTDOWN_TIMEOUT)

View File

@@ -0,0 +1,28 @@
import asyncio
import time
class TokenBucketRateLimiter:
"""거래소별 API 호출 제한. 우선순위: 0=주문, 1=시세, 2=조회."""
def __init__(self, rate: float, capacity: int):
self.rate = rate # tokens/second
self.capacity = capacity
self._tokens = float(capacity)
self._last_refill = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self, priority: int = 2, tokens: int = 1) -> None:
async with self._lock:
self._refill()
while self._tokens < tokens:
wait = (tokens - self._tokens) / self.rate
await asyncio.sleep(wait)
self._refill()
self._tokens -= tokens
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
self._last_refill = now

11
hydra/resilience/retry.py Normal file
View File

@@ -0,0 +1,11 @@
from functools import wraps
from tenacity import retry, stop_after_attempt, wait_exponential_jitter
def with_retry(func):
"""지수 백오프 + 지터 재시도 데코레이터 (최대 3회)."""
return retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=1, max=10, jitter=2),
reraise=True,
)(func)

View File

155
hydra/strategy/engine.py Normal file
View File

@@ -0,0 +1,155 @@
# hydra/strategy/engine.py
import asyncio
import json
import os
from hydra.strategy.signal import Signal, SignalGenerator
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_CANDLE_CHANNEL = "hydra:candle:new"
_INDICATOR_PREFIX = "hydra:indicator"
_REGIME_PREFIX = "hydra:regime"
_SIGNAL_PREFIX = "hydra:signal"
class StrategyEngine:
def __init__(
self,
redis_client,
generator: SignalGenerator,
dry_run: bool = True,
order_queue=None,
risk_engine=None,
trade_amount_usd: float = 100.0,
):
self._redis = redis_client
self._generator = generator
self._dry_run = dry_run
self._order_queue = order_queue
self._risk_engine = risk_engine
self._trade_amount_usd = trade_amount_usd
async def _handle_event(
self, market: str, symbol: str, timeframe: str
) -> None:
try:
raw_indicator = self._redis.get(
f"{_INDICATOR_PREFIX}:{market}:{symbol}:{timeframe}"
)
if raw_indicator is None:
return
indicators = json.loads(raw_indicator)
raw_regime = self._redis.get(
f"{_REGIME_PREFIX}:{market}:{symbol}:{timeframe}"
)
if raw_regime is None:
return
regime = json.loads(raw_regime).get("regime", "ranging")
close = float(indicators.get("close") or 0.0)
signal = self._generator.generate(indicators, regime, close)
await self._redis.set(
f"{_SIGNAL_PREFIX}:{market}:{symbol}:{timeframe}",
json.dumps({
"signal": signal.signal,
"reason": signal.reason,
"price": signal.price,
"ts": signal.ts,
}),
)
logger.debug("signal_cached", market=market, symbol=symbol,
tf=timeframe, signal=signal.signal, reason=signal.reason)
if signal.signal in ("BUY", "SELL") and not self._dry_run:
await self._submit_order(market, symbol, signal)
except Exception as e:
logger.warning("strategy_error", market=market, symbol=symbol,
tf=timeframe, error=str(e))
async def _submit_order(self, market: str, symbol: str, signal: Signal) -> None:
from hydra.core.order_queue import OrderRequest
allowed, reason = self._risk_engine.check_order_allowed(market, symbol, 0.0)
if not allowed:
logger.info("order_blocked_by_risk", market=market, symbol=symbol,
reason=reason)
return
order = OrderRequest(
market=market,
symbol=symbol,
side="buy" if signal.signal == "BUY" else "sell",
order_type="market",
amount=self._trade_amount_usd,
)
result = await self._order_queue.submit(order)
logger.info("order_submitted", market=market, symbol=symbol,
signal=signal.signal, order_id=result.order_id)
async def cold_start(self) -> None:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
logger.info("strategy_cold_start", count=len(keys))
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
market = parts[2]
symbol = ":".join(parts[3:-1])
timeframe = parts[-1]
await self._handle_event(market, symbol, timeframe)
async def run(self) -> None:
pubsub = self._redis.pubsub()
await pubsub.subscribe(_CANDLE_CHANNEL)
logger.info("strategy_engine_subscribed", channel=_CANDLE_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
payload = json.loads(message["data"])
await self._handle_event(
payload["market"], payload["symbol"], payload["timeframe"],
)
except Exception as e:
logger.warning("strategy_subscribe_error", error=str(e))
async def main() -> None:
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
dry_run = os.environ.get("STRATEGY_DRY_RUN", "true").lower() != "false"
trade_amount = float(os.environ.get("STRATEGY_TRADE_AMOUNT_USD", "100"))
r = aioredis.from_url(settings.redis_url, decode_responses=True)
order_queue = None
risk_engine = None
if not dry_run:
from hydra.core.order_queue import OrderQueue
from hydra.core.risk_engine import RiskEngine
from hydra.core.position_tracker import PositionTracker
position_tracker = PositionTracker(r)
risk_engine = RiskEngine(r, position_tracker)
order_queue = OrderQueue(r, risk_engine, position_tracker, exchanges={})
generator = SignalGenerator()
engine = StrategyEngine(
redis_client=r,
generator=generator,
dry_run=dry_run,
order_queue=order_queue,
risk_engine=risk_engine,
trade_amount_usd=trade_amount,
)
try:
await engine.cold_start()
await engine.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())

57
hydra/strategy/signal.py Normal file
View File

@@ -0,0 +1,57 @@
# hydra/strategy/signal.py
import time
from dataclasses import dataclass
_EMA_FAST = "EMA_9"
_EMA_SLOW = "EMA_20"
_RSI = "RSI_14"
_RSI_OVERSOLD = 30.0
_RSI_OVERBOUGHT = 70.0
_TREND_UP_RSI_LOW = 45.0
_TREND_UP_RSI_HIGH = 75.0
_TREND_DOWN_RSI_LOW = 25.0
_TREND_DOWN_RSI_HIGH = 55.0
@dataclass
class Signal:
signal: str # "BUY" | "SELL" | "HOLD"
reason: str
price: float
ts: int
class SignalGenerator:
def generate(self, indicators: dict, regime: str, close: float) -> Signal:
ema9 = indicators.get(_EMA_FAST)
ema20 = indicators.get(_EMA_SLOW)
rsi = indicators.get(_RSI)
ts = int(time.time() * 1000)
if regime == "volatile":
return Signal(signal="HOLD", reason="volatile: skip", price=close, ts=ts)
if regime == "trending_up":
if ema9 is not None and ema20 is not None and ema9 > ema20:
if rsi is not None and _TREND_UP_RSI_LOW < rsi < _TREND_UP_RSI_HIGH:
return Signal(signal="BUY", reason="trend_up: ema_cross+rsi",
price=close, ts=ts)
return Signal(signal="HOLD", reason="trend_up: no_entry", price=close, ts=ts)
if regime == "trending_down":
if ema9 is not None and ema20 is not None and ema9 < ema20:
if rsi is not None and _TREND_DOWN_RSI_LOW < rsi < _TREND_DOWN_RSI_HIGH:
return Signal(signal="SELL", reason="trend_down: ema_cross+rsi",
price=close, ts=ts)
return Signal(signal="HOLD", reason="trend_down: no_entry", price=close, ts=ts)
# ranging (default)
if rsi is not None:
if rsi < _RSI_OVERSOLD:
return Signal(signal="BUY", reason="ranging: rsi_oversold",
price=close, ts=ts)
if rsi > _RSI_OVERBOUGHT:
return Signal(signal="SELL", reason="ranging: rsi_overbought",
price=close, ts=ts)
return Signal(signal="HOLD", reason="ranging: neutral", price=close, ts=ts)

View File

View File

@@ -0,0 +1,73 @@
# hydra/supplemental/events.py
import asyncio
import json
import httpx
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_EVENTS_KEY = "hydra:events:upcoming"
_API_URL = "https://api.coinmarketcal.com/v1/events"
_DEFAULT_INTERVAL = 3600
class EventCalendarPoller:
def __init__(self, redis_client, api_key: str = "",
interval_sec: int = _DEFAULT_INTERVAL):
self._redis = redis_client
self._api_key = api_key
self._interval = interval_sec
async def _fetch(self) -> list[dict]:
if not self._api_key:
return []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
_API_URL,
headers={"x-api-key": self._api_key},
params={"max": 10, "dateRangeEnd": "+24h"},
)
resp.raise_for_status()
body = resp.json()
events = []
for item in body.get("body", []):
coins = item.get("coins") or []
symbol = coins[0].get("symbol", "") if coins else ""
events.append({
"title": item.get("title", ""),
"symbol": symbol,
"date_event": item.get("date_event", ""),
"source": "coinmarketcal",
})
return events
except Exception as e:
logger.warning("events_fetch_error", error=str(e))
return []
async def run(self) -> None:
logger.info("events_poller_started", interval=self._interval,
has_key=bool(self._api_key))
while True:
events = await self._fetch()
await self._redis.set(_EVENTS_KEY, json.dumps(events))
logger.debug("events_cached", count=len(events))
await asyncio.sleep(self._interval)
async def main() -> None:
import os
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
api_key = os.environ.get("COINMARKETCAL_API_KEY", "")
poller = EventCalendarPoller(r, api_key=api_key)
try:
await poller.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,90 @@
# hydra/supplemental/orderbook.py
import asyncio
import json
import time
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_INDICATOR_PREFIX = "hydra:indicator"
_ORDERBOOK_PREFIX = "hydra:orderbook"
_DEFAULT_INTERVAL = 30
class OrderBookPoller:
def __init__(self, redis_client, interval_sec: int = _DEFAULT_INTERVAL):
self._redis = redis_client
self._interval = interval_sec
def _get_active_symbols(self) -> list[tuple[str, str]]:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
seen = set()
result = []
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
market = parts[2]
symbol = ":".join(parts[3:-1])
if (market, symbol) not in seen:
seen.add((market, symbol))
result.append((market, symbol))
return result
def _get_exchange(self, market: str):
import ccxt
exchange_class = getattr(ccxt, market, None)
if exchange_class is None:
return None
return exchange_class()
def _fetch_one(self, market: str, symbol: str) -> dict | None:
try:
exchange = self._get_exchange(market)
if exchange is None:
return None
ob = exchange.fetch_order_book(symbol, limit=5)
bid = ob["bids"][0][0] if ob.get("bids") else None
ask = ob["asks"][0][0] if ob.get("asks") else None
if bid is None or ask is None:
return None
spread_pct = round((ask - bid) / ask * 100, 4)
return {
"bid": bid,
"ask": ask,
"spread_pct": spread_pct,
"bids": ob["bids"][:5],
"asks": ob["asks"][:5],
"ts": int(time.time() * 1000),
}
except Exception as e:
logger.warning("orderbook_fetch_error", market=market,
symbol=symbol, error=str(e))
return None
async def run(self) -> None:
logger.info("orderbook_poller_started", interval=self._interval)
while True:
for market, symbol in self._get_active_symbols():
data = self._fetch_one(market, symbol)
if data:
key = f"{_ORDERBOOK_PREFIX}:{market}:{symbol}"
await self._redis.set(key, json.dumps(data))
logger.debug("orderbook_cached", market=market, symbol=symbol)
await asyncio.sleep(self._interval)
async def main() -> None:
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
poller = OrderBookPoller(r)
try:
await poller.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,88 @@
# hydra/supplemental/sentiment.py
import asyncio
import json
import time
import httpx
from hydra.logging.setup import get_logger
logger = get_logger(__name__)
_INDICATOR_PREFIX = "hydra:indicator"
_SENTIMENT_PREFIX = "hydra:sentiment"
_API_URL = "https://cryptopanic.com/api/v1/posts/"
_DEFAULT_INTERVAL = 300
class SentimentPoller:
def __init__(self, redis_client, api_key: str = "",
interval_sec: int = _DEFAULT_INTERVAL):
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
self._redis = redis_client
self._api_key = api_key
self._interval = interval_sec
self._analyzer = SentimentIntensityAnalyzer()
def _get_active_symbols(self) -> list[str]:
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
bases = set()
for key in keys:
parts = key.split(":")
if len(parts) < 5:
continue
symbol = ":".join(parts[3:-1])
base = symbol.split("/")[0] if "/" in symbol else symbol
bases.add(base)
return list(bases)
async def _fetch_news(self, symbol: str) -> list[str]:
try:
params: dict = {"currencies": symbol, "public": "true"}
if self._api_key:
params["auth_token"] = self._api_key
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(_API_URL, params=params)
resp.raise_for_status()
results = resp.json().get("results", [])
return [item["title"] for item in results if item.get("title")]
except Exception as e:
logger.warning("sentiment_fetch_error", symbol=symbol, error=str(e))
return []
def _score(self, headlines: list[str]) -> float:
if not headlines:
return 0.0
scores = [self._analyzer.polarity_scores(h)["compound"] for h in headlines]
return round(sum(scores) / len(scores), 4)
async def run(self) -> None:
logger.info("sentiment_poller_started", interval=self._interval)
while True:
for symbol in self._get_active_symbols():
headlines = await self._fetch_news(symbol)
score = self._score(headlines)
key = f"{_SENTIMENT_PREFIX}:{symbol}"
await self._redis.set(key, json.dumps({
"score": score,
"article_count": len(headlines),
"ts": int(time.time() * 1000),
}))
logger.debug("sentiment_cached", symbol=symbol, score=score)
await asyncio.sleep(self._interval)
async def main() -> None:
import os
import redis.asyncio as aioredis
from hydra.config.settings import get_settings
settings = get_settings()
r = aioredis.from_url(settings.redis_url, decode_responses=True)
api_key = os.environ.get("CRYPTOPANIC_API_KEY", "")
poller = SentimentPoller(r, api_key=api_key)
try:
await poller.run()
finally:
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())

48
pyproject.toml Normal file
View File

@@ -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"]

0
scripts/__init__.py Normal file
View File

36
scripts/benchmark.py Normal file
View File

@@ -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)

72
scripts/dr_watchdog.py Normal file
View File

@@ -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}

17
scripts/hydra.service Normal file
View File

@@ -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

0
tests/__init__.py Normal file
View File

20
tests/conftest.py Normal file
View File

@@ -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

64
tests/test_backfill.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

27
tests/test_cli_help.py Normal file
View File

@@ -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

41
tests/test_dr.py Normal file
View File

@@ -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"]

View File

@@ -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()

Some files were not shown because too many files have changed in this diff Show More