Initial public release
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal 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
43
.gitignore
vendored
Normal 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
14
DISCLAIMER.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 면책조항 / Disclaimer
|
||||
|
||||
본 소프트웨어(HYDRA)는 교육 및 연구 목적으로 제공됩니다.
|
||||
|
||||
1. 투자 조언이 아닙니다. 매매 결정은 전적으로 사용자의 판단과 책임입니다.
|
||||
2. 과거 백테스트 수익률은 미래 수익을 보장하지 않습니다.
|
||||
3. 자동매매 시스템은 기술적 장애, 네트워크 오류, 거래소 문제 등으로
|
||||
예기치 않은 손실이 발생할 수 있습니다.
|
||||
4. 개발자 및 기여자는 본 소프트웨어 사용으로 인한 어떠한 손실에 대해서도
|
||||
책임을 지지 않습니다.
|
||||
5. 실제 자금을 투자하기 전에 반드시 모의투자(페이퍼 트레이딩)로
|
||||
충분히 검증하십시오.
|
||||
|
||||
이 소프트웨어를 사용함으로써 위 내용에 동의하는 것으로 간주합니다.
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
21
LICENSE
Normal 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
223
README.md
Normal 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
0
config/.gitkeep
Normal file
38
config/data.yaml
Normal file
38
config/data.yaml
Normal 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
26
config/markets.yaml
Normal 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
185
docker-compose.expert.yml
Normal 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
172
docker-compose.lite.yml
Normal 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
185
docker-compose.pro.yml
Normal 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
154
docs/API_REFERENCE_KO.md
Normal 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
116
docs/QUICKSTART_KO.md
Normal 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
1
hydra/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
0
hydra/api/__init__.py
Normal file
0
hydra/api/__init__.py
Normal file
11
hydra/api/auth.py
Normal file
11
hydra/api/auth.py
Normal 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
61
hydra/api/backtest.py
Normal 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
50
hydra/api/data.py
Normal 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
30
hydra/api/health.py
Normal 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
56
hydra/api/indicators.py
Normal 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
27
hydra/api/markets.py
Normal 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
16
hydra/api/orders.py
Normal 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
26
hydra/api/pnl.py
Normal 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
15
hydra/api/positions.py
Normal 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
52
hydra/api/regime.py
Normal 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
26
hydra/api/risk.py
Normal 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
54
hydra/api/signals.py
Normal 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
14
hydra/api/strategies.py
Normal 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
53
hydra/api/supplemental.py
Normal 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
20
hydra/api/system.py
Normal 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"]}
|
||||
0
hydra/backtest/__init__.py
Normal file
0
hydra/backtest/__init__.py
Normal file
77
hydra/backtest/broker.py
Normal file
77
hydra/backtest/broker.py
Normal 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
87
hydra/backtest/result.py
Normal 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
118
hydra/backtest/runner.py
Normal 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
0
hydra/cli/__init__.py
Normal file
16
hydra/cli/app.py
Normal file
16
hydra/cli/app.py
Normal 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
30
hydra/cli/kill.py
Normal 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
31
hydra/cli/market.py
Normal 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
24
hydra/cli/module.py
Normal 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
122
hydra/cli/setup_wizard.py
Normal 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
60
hydra/cli/status.py
Normal 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
18
hydra/cli/strategy.py
Normal 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
40
hydra/cli/trade.py
Normal 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
0
hydra/config/__init__.py
Normal file
52
hydra/config/keys.py
Normal file
52
hydra/config/keys.py
Normal 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
52
hydra/config/markets.py
Normal 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
49
hydra/config/profiles.py
Normal 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
19
hydra/config/settings.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
hydra_api_key: str = "change-me"
|
||||
hydra_profile: str = "lite"
|
||||
redis_url: str = "redis://localhost:6379"
|
||||
db_url: str = "sqlite:///data/hydra.db"
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
33
hydra/config/validation.py
Normal file
33
hydra/config/validation.py
Normal 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
0
hydra/core/__init__.py
Normal file
92
hydra/core/kill_switch.py
Normal file
92
hydra/core/kill_switch.py
Normal 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
137
hydra/core/order_queue.py
Normal 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
114
hydra/core/pnl_tracker.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
PNL_REALIZED_KEY = "hydra:pnl:realized:total"
|
||||
PNL_TRADE_COUNT_KEY = "hydra:pnl:trade_count"
|
||||
|
||||
|
||||
def _daily_key() -> str:
|
||||
return f"hydra:pnl:daily:{date.today().isoformat()}"
|
||||
|
||||
|
||||
class PnlTracker:
|
||||
"""
|
||||
시스템 전체 손익 추적.
|
||||
- 실현 손익: 포지션 청산 시 record_trade()로 기록
|
||||
- 미실현 손익: 포지션 데이터(mark_price)로 계산
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client):
|
||||
self._redis = redis_client
|
||||
|
||||
# ─── 실현 손익 ───────────────────────────────────────────────
|
||||
|
||||
def record_trade(self, market: str, symbol: str, realized_pnl: float) -> None:
|
||||
"""포지션 청산 시 실현 손익 기록."""
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.incrbyfloat(PNL_REALIZED_KEY, realized_pnl)
|
||||
pipe.incrbyfloat(_daily_key(), realized_pnl)
|
||||
pipe.incr(PNL_TRADE_COUNT_KEY)
|
||||
# 개별 심볼 실현 손익
|
||||
pipe.incrbyfloat(f"hydra:pnl:symbol:{market}:{symbol}", realized_pnl)
|
||||
pipe.execute()
|
||||
logger.info("pnl_recorded", market=market, symbol=symbol, realized_pnl=realized_pnl)
|
||||
|
||||
def get_realized_total(self) -> float:
|
||||
raw = self._redis.get(PNL_REALIZED_KEY)
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def get_daily_realized(self) -> float:
|
||||
raw = self._redis.get(_daily_key())
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def get_trade_count(self) -> int:
|
||||
raw = self._redis.get(PNL_TRADE_COUNT_KEY)
|
||||
return int(raw) if raw else 0
|
||||
|
||||
def get_symbol_realized(self, market: str, symbol: str) -> float:
|
||||
raw = self._redis.get(f"hydra:pnl:symbol:{market}:{symbol}")
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
# ─── 미실현 손익 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def calc_unrealized(position: dict) -> float:
|
||||
"""단일 포지션의 미실현 손익 계산.
|
||||
position dict: {qty, avg_price, mark_price, side, leverage}
|
||||
"""
|
||||
qty = float(position.get("qty", 0))
|
||||
avg_price = float(position.get("avg_price", 0))
|
||||
mark_price = float(position.get("mark_price", avg_price))
|
||||
side = position.get("side", "buy")
|
||||
leverage = float(position.get("leverage", 1))
|
||||
|
||||
if qty == 0 or avg_price == 0:
|
||||
return 0.0
|
||||
|
||||
price_diff = mark_price - avg_price
|
||||
if side == "sell":
|
||||
price_diff = -price_diff
|
||||
|
||||
return price_diff * qty * leverage
|
||||
|
||||
def get_unrealized_total(self, positions: list[dict]) -> float:
|
||||
return sum(self.calc_unrealized(p) for p in positions)
|
||||
|
||||
# ─── 요약 ─────────────────────────────────────────────────────
|
||||
|
||||
def get_summary(self, positions: list[dict]) -> dict:
|
||||
realized_total = self.get_realized_total()
|
||||
daily_realized = self.get_daily_realized()
|
||||
unrealized = self.get_unrealized_total(positions)
|
||||
trade_count = self.get_trade_count()
|
||||
|
||||
return {
|
||||
"realized_total": round(realized_total, 4),
|
||||
"daily_realized": round(daily_realized, 4),
|
||||
"unrealized": round(unrealized, 4),
|
||||
"total_pnl": round(realized_total + unrealized, 4),
|
||||
"trade_count": trade_count,
|
||||
"positions": [
|
||||
{
|
||||
"market": p.get("market"),
|
||||
"symbol": p.get("symbol"),
|
||||
"side": p.get("side"),
|
||||
"qty": p.get("qty"),
|
||||
"avg_price": p.get("avg_price"),
|
||||
"mark_price": p.get("mark_price", p.get("avg_price")),
|
||||
"leverage": p.get("leverage", 1),
|
||||
"unrealized_pnl": round(self.calc_unrealized(p), 4),
|
||||
}
|
||||
for p in positions
|
||||
],
|
||||
}
|
||||
|
||||
def reset_daily(self) -> None:
|
||||
"""일일 손익 초기화 (자정 실행용)."""
|
||||
self._redis.delete(_daily_key())
|
||||
logger.info("pnl_daily_reset")
|
||||
49
hydra/core/position_tracker.py
Normal file
49
hydra/core/position_tracker.py
Normal 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
38
hydra/core/risk_engine.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from hydra.config.validation import RiskConfig
|
||||
from hydra.core.position_tracker import PositionTracker
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DAILY_PNL_KEY = "hydra:daily_pnl"
|
||||
|
||||
|
||||
class RiskEngine:
|
||||
def __init__(self, redis_client, position_tracker: PositionTracker, config: RiskConfig | None = None):
|
||||
self._redis = redis_client
|
||||
self._positions = position_tracker
|
||||
self._config = config or RiskConfig()
|
||||
|
||||
def get_daily_pnl_pct(self) -> float:
|
||||
raw = self._redis.get(DAILY_PNL_KEY)
|
||||
return float(raw) if raw else 0.0
|
||||
|
||||
def update_daily_pnl(self, pnl_pct: float) -> None:
|
||||
self._redis.set(DAILY_PNL_KEY, str(pnl_pct))
|
||||
|
||||
def check_order_allowed(self, market: str, symbol: str, position_pct: float) -> tuple[bool, str]:
|
||||
"""주문 허용 여부 확인. (allowed, reason) 반환."""
|
||||
daily_pnl = self.get_daily_pnl_pct()
|
||||
if daily_pnl <= -self._config.daily_loss_kill_pct:
|
||||
return False, f"일일 손실 {daily_pnl*100:.1f}% — Kill Switch 레벨"
|
||||
if daily_pnl <= -self._config.daily_loss_limit_pct:
|
||||
return False, f"일일 손실 {daily_pnl*100:.1f}% — 신규 주문 중단"
|
||||
if position_pct > self._config.max_position_per_symbol_pct:
|
||||
return False, f"종목 포지션 {position_pct*100:.1f}% 초과 (최대 {self._config.max_position_per_symbol_pct*100:.0f}%)"
|
||||
return True, "ok"
|
||||
|
||||
def should_kill_switch(self) -> tuple[bool, str]:
|
||||
daily_pnl = self.get_daily_pnl_pct()
|
||||
if daily_pnl <= -self._config.daily_loss_kill_pct:
|
||||
return True, f"daily_loss:{daily_pnl*100:.1f}%"
|
||||
return False, ""
|
||||
32
hydra/core/state_manager.py
Normal file
32
hydra/core/state_manager.py
Normal 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()}
|
||||
0
hydra/exchange/__init__.py
Normal file
0
hydra/exchange/__init__.py
Normal file
22
hydra/exchange/base.py
Normal file
22
hydra/exchange/base.py
Normal 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
61
hydra/exchange/crypto.py
Normal 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
41
hydra/exchange/factory.py
Normal 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
63
hydra/exchange/kis.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from hydra.exchange.base import BaseExchange
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class KISExchange(BaseExchange):
|
||||
"""python-kis 래핑. 한국/미국 주식."""
|
||||
|
||||
def __init__(self, app_key: str, app_secret: str, account_no: str, is_paper: bool = True):
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
self._account_no = account_no
|
||||
self._is_paper = is_paper
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import pykis
|
||||
self._client = pykis.PyKis(
|
||||
id=self._app_key,
|
||||
account=self._account_no,
|
||||
appkey=self._app_key,
|
||||
appsecret=self._app_secret,
|
||||
virtual_account=self._is_paper,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def get_balance(self) -> dict:
|
||||
client = self._get_client()
|
||||
account = client.account()
|
||||
return {"balance": float(account.balance)}
|
||||
|
||||
async def create_order(self, symbol: str, side: str, order_type: str, qty: float, price: float | None = None) -> dict:
|
||||
client = self._get_client()
|
||||
stock = client.stock(symbol)
|
||||
if side == "buy":
|
||||
order = stock.buy(qty=int(qty), price=price)
|
||||
else:
|
||||
order = stock.sell(qty=int(qty), price=price)
|
||||
return {"order_id": str(order.number), "status": "submitted"}
|
||||
|
||||
async def cancel_order(self, order_id: str) -> dict:
|
||||
client = self._get_client()
|
||||
client.cancel_order(order_id)
|
||||
return {"status": "canceled"}
|
||||
|
||||
async def cancel_all(self) -> list:
|
||||
client = self._get_client()
|
||||
orders = client.pending_orders()
|
||||
canceled = []
|
||||
for o in orders:
|
||||
o.cancel()
|
||||
canceled.append(str(o.number))
|
||||
return canceled
|
||||
|
||||
async def get_positions(self) -> list:
|
||||
client = self._get_client()
|
||||
account = client.account()
|
||||
return [
|
||||
{"symbol": s.symbol, "qty": s.qty, "avg_price": float(s.purchase_price), "side": "buy"}
|
||||
for s in account.stocks
|
||||
]
|
||||
41
hydra/exchange/polymarket.py
Normal file
41
hydra/exchange/polymarket.py
Normal 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 []
|
||||
0
hydra/indicator/__init__.py
Normal file
0
hydra/indicator/__init__.py
Normal file
82
hydra/indicator/calculator.py
Normal file
82
hydra/indicator/calculator.py
Normal 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
85
hydra/indicator/engine.py
Normal 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())
|
||||
0
hydra/logging/__init__.py
Normal file
0
hydra/logging/__init__.py
Normal file
34
hydra/logging/setup.py
Normal file
34
hydra/logging/setup.py
Normal 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
132
hydra/main.py
Normal 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
0
hydra/notify/__init__.py
Normal file
42
hydra/notify/telegram.py
Normal file
42
hydra/notify/telegram.py
Normal 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
0
hydra/regime/__init__.py
Normal file
17
hydra/regime/detector.py
Normal file
17
hydra/regime/detector.py
Normal 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
85
hydra/regime/engine.py
Normal 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())
|
||||
0
hydra/resilience/__init__.py
Normal file
0
hydra/resilience/__init__.py
Normal file
6
hydra/resilience/circuit_breaker.py
Normal file
6
hydra/resilience/circuit_breaker.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pybreaker import CircuitBreaker
|
||||
|
||||
|
||||
def create_breaker(name: str, fail_max: int = 5, reset_timeout: int = 30) -> CircuitBreaker:
|
||||
"""거래소/API별 독립 서킷 브레이커 생성."""
|
||||
return CircuitBreaker(fail_max=fail_max, reset_timeout=reset_timeout, name=name)
|
||||
35
hydra/resilience/graceful.py
Normal file
35
hydra/resilience/graceful.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import asyncio
|
||||
import signal
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_SHUTDOWN_TIMEOUT = 30
|
||||
|
||||
|
||||
class GracefulManager:
|
||||
def __init__(self, order_queue, position_tracker, redis_client):
|
||||
self._order_queue = order_queue
|
||||
self._positions = position_tracker
|
||||
self._redis = redis_client
|
||||
self._shutting_down = False
|
||||
|
||||
def register_signals(self) -> None:
|
||||
"""메인 이벤트 루프에서 호출. SIGTERM/SIGINT 핸들러 등록."""
|
||||
loop = asyncio.get_event_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self.shutdown(s.name)))
|
||||
|
||||
async def shutdown(self, reason: str = "unknown") -> None:
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._shutting_down = True
|
||||
logger.info("graceful_shutdown_start", reason=reason)
|
||||
try:
|
||||
async with asyncio.timeout(_SHUTDOWN_TIMEOUT):
|
||||
self._order_queue.block_new_orders()
|
||||
snapshot = await self._positions.snapshot()
|
||||
self._redis.set("hydra:last_snapshot", str(snapshot))
|
||||
logger.info("graceful_shutdown_complete")
|
||||
except TimeoutError:
|
||||
logger.error("graceful_shutdown_timeout", seconds=_SHUTDOWN_TIMEOUT)
|
||||
28
hydra/resilience/rate_limiter.py
Normal file
28
hydra/resilience/rate_limiter.py
Normal 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
11
hydra/resilience/retry.py
Normal 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)
|
||||
0
hydra/strategy/__init__.py
Normal file
0
hydra/strategy/__init__.py
Normal file
155
hydra/strategy/engine.py
Normal file
155
hydra/strategy/engine.py
Normal 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
57
hydra/strategy/signal.py
Normal 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)
|
||||
0
hydra/supplemental/__init__.py
Normal file
0
hydra/supplemental/__init__.py
Normal file
73
hydra/supplemental/events.py
Normal file
73
hydra/supplemental/events.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# hydra/supplemental/events.py
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_EVENTS_KEY = "hydra:events:upcoming"
|
||||
_API_URL = "https://api.coinmarketcal.com/v1/events"
|
||||
_DEFAULT_INTERVAL = 3600
|
||||
|
||||
|
||||
class EventCalendarPoller:
|
||||
def __init__(self, redis_client, api_key: str = "",
|
||||
interval_sec: int = _DEFAULT_INTERVAL):
|
||||
self._redis = redis_client
|
||||
self._api_key = api_key
|
||||
self._interval = interval_sec
|
||||
|
||||
async def _fetch(self) -> list[dict]:
|
||||
if not self._api_key:
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
_API_URL,
|
||||
headers={"x-api-key": self._api_key},
|
||||
params={"max": 10, "dateRangeEnd": "+24h"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
events = []
|
||||
for item in body.get("body", []):
|
||||
coins = item.get("coins") or []
|
||||
symbol = coins[0].get("symbol", "") if coins else ""
|
||||
events.append({
|
||||
"title": item.get("title", ""),
|
||||
"symbol": symbol,
|
||||
"date_event": item.get("date_event", ""),
|
||||
"source": "coinmarketcal",
|
||||
})
|
||||
return events
|
||||
except Exception as e:
|
||||
logger.warning("events_fetch_error", error=str(e))
|
||||
return []
|
||||
|
||||
async def run(self) -> None:
|
||||
logger.info("events_poller_started", interval=self._interval,
|
||||
has_key=bool(self._api_key))
|
||||
while True:
|
||||
events = await self._fetch()
|
||||
await self._redis.set(_EVENTS_KEY, json.dumps(events))
|
||||
logger.debug("events_cached", count=len(events))
|
||||
await asyncio.sleep(self._interval)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
import os
|
||||
import redis.asyncio as aioredis
|
||||
from hydra.config.settings import get_settings
|
||||
settings = get_settings()
|
||||
r = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
api_key = os.environ.get("COINMARKETCAL_API_KEY", "")
|
||||
poller = EventCalendarPoller(r, api_key=api_key)
|
||||
try:
|
||||
await poller.run()
|
||||
finally:
|
||||
await r.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
90
hydra/supplemental/orderbook.py
Normal file
90
hydra/supplemental/orderbook.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# hydra/supplemental/orderbook.py
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_INDICATOR_PREFIX = "hydra:indicator"
|
||||
_ORDERBOOK_PREFIX = "hydra:orderbook"
|
||||
_DEFAULT_INTERVAL = 30
|
||||
|
||||
|
||||
class OrderBookPoller:
|
||||
def __init__(self, redis_client, interval_sec: int = _DEFAULT_INTERVAL):
|
||||
self._redis = redis_client
|
||||
self._interval = interval_sec
|
||||
|
||||
def _get_active_symbols(self) -> list[tuple[str, str]]:
|
||||
keys = self._redis.keys(f"{_INDICATOR_PREFIX}:*")
|
||||
seen = set()
|
||||
result = []
|
||||
for key in keys:
|
||||
parts = key.split(":")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
market = parts[2]
|
||||
symbol = ":".join(parts[3:-1])
|
||||
if (market, symbol) not in seen:
|
||||
seen.add((market, symbol))
|
||||
result.append((market, symbol))
|
||||
return result
|
||||
|
||||
def _get_exchange(self, market: str):
|
||||
import ccxt
|
||||
exchange_class = getattr(ccxt, market, None)
|
||||
if exchange_class is None:
|
||||
return None
|
||||
return exchange_class()
|
||||
|
||||
def _fetch_one(self, market: str, symbol: str) -> dict | None:
|
||||
try:
|
||||
exchange = self._get_exchange(market)
|
||||
if exchange is None:
|
||||
return None
|
||||
ob = exchange.fetch_order_book(symbol, limit=5)
|
||||
bid = ob["bids"][0][0] if ob.get("bids") else None
|
||||
ask = ob["asks"][0][0] if ob.get("asks") else None
|
||||
if bid is None or ask is None:
|
||||
return None
|
||||
spread_pct = round((ask - bid) / ask * 100, 4)
|
||||
return {
|
||||
"bid": bid,
|
||||
"ask": ask,
|
||||
"spread_pct": spread_pct,
|
||||
"bids": ob["bids"][:5],
|
||||
"asks": ob["asks"][:5],
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("orderbook_fetch_error", market=market,
|
||||
symbol=symbol, error=str(e))
|
||||
return None
|
||||
|
||||
async def run(self) -> None:
|
||||
logger.info("orderbook_poller_started", interval=self._interval)
|
||||
while True:
|
||||
for market, symbol in self._get_active_symbols():
|
||||
data = self._fetch_one(market, symbol)
|
||||
if data:
|
||||
key = f"{_ORDERBOOK_PREFIX}:{market}:{symbol}"
|
||||
await self._redis.set(key, json.dumps(data))
|
||||
logger.debug("orderbook_cached", market=market, symbol=symbol)
|
||||
await asyncio.sleep(self._interval)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
import redis.asyncio as aioredis
|
||||
from hydra.config.settings import get_settings
|
||||
settings = get_settings()
|
||||
r = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
poller = OrderBookPoller(r)
|
||||
try:
|
||||
await poller.run()
|
||||
finally:
|
||||
await r.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
88
hydra/supplemental/sentiment.py
Normal file
88
hydra/supplemental/sentiment.py
Normal 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
48
pyproject.toml
Normal 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
0
scripts/__init__.py
Normal file
36
scripts/benchmark.py
Normal file
36
scripts/benchmark.py
Normal 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
72
scripts/dr_watchdog.py
Normal 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
17
scripts/hydra.service
Normal 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
0
tests/__init__.py
Normal file
20
tests/conftest.py
Normal file
20
tests/conftest.py
Normal 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
64
tests/test_backfill.py
Normal 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
|
||||
56
tests/test_backtest_broker.py
Normal file
56
tests/test_backtest_broker.py
Normal 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)
|
||||
175
tests/test_backtest_runner.py
Normal file
175
tests/test_backtest_runner.py
Normal 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
|
||||
26
tests/test_circuit_breaker.py
Normal file
26
tests/test_circuit_breaker.py
Normal 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
27
tests/test_cli_help.py
Normal 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
41
tests/test_dr.py
Normal 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"]
|
||||
19
tests/test_graceful_shutdown.py
Normal file
19
tests/test_graceful_shutdown.py
Normal 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
Reference in New Issue
Block a user